* test(clv2): cover instinct-cli prune, projects ops, promote dry-run, normalize-url
Add pytest coverage for previously-untested functions in
skills/continuous-learning-v2/scripts/instinct-cli.py:
- _normalize_remote_url: scp/https/file forms, credential + .git
stripping, network lowercasing, case-preserving local paths, idempotence
- _promote_specific dry-run: returns 0 and writes no global file
- projects delete/gc/merge: invalid-id, not-found, dry-run, and force
paths over registry + storage, asserting destructive ops are gated
- cmd_prune: dry-run keeps files; non-dry-run deletes only expired; quiet
Test-only change; no production code modified.
Fixes#2302
* test(clv2): assert dry-run storage no-op and quiet-mode stderr silence
Address CodeRabbit review on #2374:
- projects gc/merge dry-run tests now also assert on-disk storage is
untouched (empty1 project dir survives; nothing copied into dest
personal), closing the gap where a storage-mutating dry-run regression
would still pass.
- cmd_prune quiet test now asserts stderr is empty too, not just stdout.
* test(clv2): cover merge missing-destination and prune empty-pending branches
* fix(clv2): align Python _update_registry schema with shell counterpart
The Python `_update_registry` in instinct-cli.py wrote registry entries
without the `id` and `created_at` fields, while the shell counterpart in
detect-project.sh writes both. A projects.json entry could therefore have a
different shape depending on which path (Python CLI or shell hook) last
touched it.
Emit the same field set and order as the shell version: id, name, root,
remote, created_at (preserved from any existing entry), last_seen. Add
regression tests asserting field parity and created_at preservation.
Fixes#2299
* fix(clv2): guard _update_registry against a non-dict registry entry
A malformed projects.json (a non-dict value for the current project id, e.g.
null) would make existing.get("created_at", ...) raise and crash the update,
losing the old code's ability to self-heal a corrupt per-entry value. Normalize
existing to {} when it is not a dict so the entry is healed by the rewrite. Add
a regression test for the malformed-entry path.
* test(clv2): assert the first-write created_at == last_seen contract
The new _update_registry tests only checked both timestamps were truthy. On the
initial write both derive from the same `now`, so created_at must equal
last_seen; assert that explicitly so a later refactor that breaks the contract
is caught. Split the compound assertions into single-expression checks.
* fix(clv2): heal a non-dict top-level registry in _update_registry
A projects.json that is valid JSON but not a mapping (e.g. `[]` or a
string) previously crashed _update_registry on registry.get(), before
the per-entry guard could run, so the corrupt file could not be healed.
Guard the top-level shape right after the load and fall back to {} so the
rewrite repairs the file — matching the per-entry healing already in place.
Resolves the remaining CodeRabbit finding on #2299.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Two security-priority fixes in continuous-learning-v2/scripts/instinct-cli.py:
- #2294: _write_registry wrote projects.json without the advisory lock that
_update_registry holds, so concurrent 'projects delete/gc/merge' could race an
observe-time update and corrupt the registry. Extract the lock into a shared
_registry_lock() context manager and use it in both writers.
- #2297: _remove_project_storage called shutil.rmtree on PROJECTS_DIR/project_id
with no containment check. Add defense-in-depth: resolve the path and refuse to
delete anything that is not strictly inside PROJECTS_DIR (or is the root
itself), so a relaxed validator or future caller can never cause an
arbitrary-directory delete.
Adds 5 pytest regression tests (atomic write under lock, contained delete,
missing-dir no-op, traversal refused, root refused). Node integration suite
(tests/scripts/instinct-cli-projects.test.js) green 9/9.
parse_instinct_file() was appending the instinct and resetting state
when frontmatter ended (second ---), before any content lines could be
collected. This caused all content (Action, Evidence, Examples) to be
lost during import.
Fix: only set in_frontmatter=False when frontmatter ends. The existing
logic at the start of next frontmatter (or EOF) correctly appends the
instinct with its collected content.
Fixes#148