From f4b6f07decee12fd6649ca806af707470170c20c Mon Sep 17 00:00:00 2001 From: wouterschoonees <33550113+wouterschoonees@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:33:03 +0200 Subject: [PATCH 1/2] fix: remove project-scoped instinct copy after promote promote (both by-ID and auto) wrote the instinct to the global personal dir but never touched the project-scoped source file. Since load_all_instincts() dedupes by ID with project taking precedence over global, the leftover project copy silently shadowed the new global entry - `status` under-reported the global count until the stale file was removed by hand. Adds _remove_instinct_from_source(), which strips just the promoted instinct's block from its (possibly multi-instinct) source file, deleting the file only if it was the sole entry. Wired into both _promote_specific and _promote_auto (the latter cleans every project's copy that fed the promotion, not just the highest-confidence one used for the merged content). --- .../scripts/instinct-cli.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 2274a852b4..331132ff22 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -1314,6 +1314,39 @@ def _show_promotion_candidates(project: dict) -> None: print(f" Run `instinct-cli.py promote` to promote these to global scope.\n") +def _remove_instinct_from_source(source_file_str: str, instinct_id: str) -> None: + """Strip one instinct block from its project-scoped source file after promotion. + + Without this, the project copy keeps shadowing the new global copy in + load_all_instincts()'s dedup (project wins on ID collision), so `status` + silently under-reports the global count until the stale copy is removed by hand. + Deletes the file if it was the only instinct in it, otherwise rewrites the + remaining blocks in the same frontmatter format. + """ + source_file = Path(source_file_str) + if not source_file.exists(): + return + + remaining = [i for i in parse_instinct_file(source_file.read_text(encoding="utf-8")) + if i.get('id') != instinct_id] + + if not remaining: + source_file.unlink() + return + + blocks = [] + for inst in remaining: + block = "---\n" + for key, value in inst.items(): + if key == 'content' or key.startswith('_'): + continue + block += f"{key}: {_yaml_quote(value) if key == 'trigger' else value}\n" + block += "---\n\n" + block += inst.get('content', '') + "\n" + blocks.append(block) + source_file.write_text("\n".join(blocks), encoding="utf-8") + + def cmd_promote(args) -> int: """Promote project-scoped instincts to global scope.""" project = detect_project() @@ -1376,6 +1409,11 @@ def _promote_specific(project: dict, instinct_id: str, force: bool, dry_run: boo output_content += target.get('content', '') + "\n" output_file.write_text(output_content, encoding="utf-8") + + source_file = target.get('_source_file') + if source_file: + _remove_instinct_from_source(source_file, instinct_id) + print(f"\nPromoted '{instinct_id}' to global scope.") print(f" Saved to: {output_file}") return 0 @@ -1449,6 +1487,12 @@ def _promote_auto(project: dict, force: bool, dry_run: bool) -> int: output_content += inst.get('content', '') + "\n" output_file.write_text(output_content, encoding="utf-8") + + for _, _, entry_inst in cand['entries']: + entry_source = entry_inst.get('_source_file') + if entry_source: + _remove_instinct_from_source(entry_source, cand['id']) + promoted += 1 print(f"\nPromoted {promoted} instincts to global scope.") From 85da3dba3d77b9c39c96ea398f0c11fca9cb28ed Mon Sep 17 00:00:00 2001 From: wouterschoonees <33550113+wouterschoonees@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:22:05 +0200 Subject: [PATCH 2/2] fix: don't delete source file on failed instinct parse Address review feedback on #2549 (Greptile): _remove_instinct_from_source treated an empty parse_instinct_file() result as proof the source file was safe to delete. A malformed file or one modified concurrently by another session would also parse to [], so the guard was deleting content it never actually read. Now bails out unless the target ID is found in a successful parse - a parse failure is never treated as "nothing left to keep". --- skills/continuous-learning-v2/scripts/instinct-cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 331132ff22..7f19790fa7 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -1327,8 +1327,14 @@ def _remove_instinct_from_source(source_file_str: str, instinct_id: str) -> None if not source_file.exists(): return - remaining = [i for i in parse_instinct_file(source_file.read_text(encoding="utf-8")) - if i.get('id') != instinct_id] + parsed = parse_instinct_file(source_file.read_text(encoding="utf-8")) + if not any(i.get('id') == instinct_id for i in parsed): + # Target not found in a successful parse - either already removed, or the + # file is malformed/foreign content. Either way, don't touch it: a parse + # failure must never be treated as proof the file is safe to delete. + return + + remaining = [i for i in parsed if i.get('id') != instinct_id] if not remaining: source_file.unlink()