diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 0a99f1fa0..9bc3b6898 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -27,6 +27,7 @@ import ipaddress import socket import urllib.parse import urllib.request +import tempfile from contextlib import contextmanager from pathlib import Path from datetime import datetime, timedelta, timezone @@ -1323,6 +1324,106 @@ def _show_promotion_candidates(project: dict) -> None: print(f" Run `instinct-cli.py promote` to promote these to global scope.\n") +def _frontmatter_scalar(lines: list[str], key: str) -> Optional[str]: + """Extract a simple scalar value from frontmatter lines.""" + for line in lines: + if ':' not in line: + continue + parsed_key, value = line.split(':', 1) + if parsed_key.strip() != key: + continue + value = value.strip() + if value.startswith('"') and value.endswith('"'): + return value[1:-1].replace('\\"', '"').replace('\\\\', '\\') + if value.startswith("'") and value.endswith("'"): + return value[1:-1].replace("''", "'") + return value + return None + + +def _remove_instinct_blocks(content: str, instinct_id: str) -> tuple[str, int]: + """Remove raw frontmatter blocks with a matching instinct ID.""" + lines = content.splitlines(keepends=True) + retained = [] + removed = 0 + index = 0 + + while index < len(lines): + if lines[index].strip() != '---': + retained.append(lines[index]) + index += 1 + continue + + block_start = index + frontmatter_end = index + 1 + while frontmatter_end < len(lines) and lines[frontmatter_end].strip() != '---': + frontmatter_end += 1 + + if frontmatter_end >= len(lines): + retained.extend(lines[block_start:]) + break + + next_block_start = frontmatter_end + 1 + while next_block_start < len(lines) and lines[next_block_start].strip() != '---': + next_block_start += 1 + + block_id = _frontmatter_scalar(lines[block_start + 1:frontmatter_end], 'id') + if block_id == instinct_id: + removed += 1 + else: + retained.extend(lines[block_start:next_block_start]) + index = next_block_start + + return ''.join(retained), removed + + +def _write_text_atomic(file_path: Path, content: str) -> None: + """Replace a text file via same-directory temp file.""" + temp_fd, temp_name = tempfile.mkstemp( + prefix=f".{file_path.name}.", + suffix=".tmp", + dir=file_path.parent, + text=True, + ) + temp_file = Path(temp_name) + try: + with os.fdopen(temp_fd, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_file, file_path) + finally: + try: + temp_file.unlink() + except FileNotFoundError: + pass + + +def _remove_instinct_from_source(source_file_str: str, instinct_id: str) -> None: + """Strip promoted instinct blocks from the project-scoped source file.""" + source_file = Path(source_file_str) + if not source_file.exists(): + return + + try: + content = source_file.read_text(encoding="utf-8") + except OSError as exc: + print(f"Warning: Failed to read promoted instinct source {source_file}: {exc}", file=sys.stderr) + return + + remaining_content, removed = _remove_instinct_blocks(content, instinct_id) + if removed == 0: + return + + try: + if remaining_content: + _write_text_atomic(source_file, remaining_content) + else: + source_file.unlink() + except OSError as exc: + print(f"Warning: Failed to remove promoted instinct from {source_file}: {exc}", file=sys.stderr) + + def cmd_promote(args) -> int: """Promote project-scoped instincts to global scope.""" project = detect_project() @@ -1385,6 +1486,9 @@ 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 @@ -1458,6 +1562,10 @@ 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.") diff --git a/tests/scripts/instinct-cli-projects.test.js b/tests/scripts/instinct-cli-projects.test.js index 73289f73a..ae0ff27f2 100644 --- a/tests/scripts/instinct-cli-projects.test.js +++ b/tests/scripts/instinct-cli-projects.test.js @@ -63,6 +63,10 @@ function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function normalizeLineEndings(value) { + return value.replace(/\r\n/g, '\n'); +} + function writeInstinct(filePath, id, confidence = 0.9) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync( @@ -117,6 +121,13 @@ function runGit(cwd, args) { return result.stdout.trim(); } +function initGitProject(parentDir, name = 'repo') { + const repoDir = path.join(parentDir, name); + fs.mkdirSync(repoDir, { recursive: true }); + runGit(repoDir, ['init']); + return repoDir; +} + function runCli(root, args, options = {}) { return spawnSync(PYTHON3, [cliPath, ...args], { cwd: options.cwd || repoRoot, @@ -330,6 +341,143 @@ test('status migrates legacy no-remote linked worktree project dirs to main work } }); +test('promote removes only the promoted instinct block from project source', () => { + const root = createTempDir(); + const repoParent = createTempDir(); + try { + const repoDir = initGitProject(repoParent); + const projectId = projectHash(runGit(repoDir, ['rev-parse', '--show-toplevel'])); + const sourceFile = path.join(root, 'projects', projectId, 'instincts', 'personal', 'mixed.yaml'); + const retainedBlock = [ + '---', + 'id: keep-me', + 'trigger: "when value: contains colon"', + 'confidence: 0.72', + 'domain: workflow', + 'tags: [alpha, beta]', + '---', + '', + 'Keep this block exactly.', + '', + ].join('\n'); + fs.mkdirSync(path.dirname(sourceFile), { recursive: true }); + fs.writeFileSync( + sourceFile, + [ + '---', + 'id: promote-me', + 'trigger: "when promoting"', + 'confidence: 0.91', + 'domain: workflow', + '---', + '', + 'Promote this block.', + '', + retainedBlock, + ].join('\n') + ); + + const result = runCli(root, ['promote', 'promote-me', '--force'], { cwd: repoDir }); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'promote-me.yaml'))); + assert.strictEqual(normalizeLineEndings(fs.readFileSync(sourceFile, 'utf8')), retainedBlock); + } finally { + cleanupDir(root); + cleanupDir(repoParent); + } +}); + +test('promote deletes project source file when it only contained the promoted instinct', () => { + const root = createTempDir(); + const repoParent = createTempDir(); + try { + const repoDir = initGitProject(repoParent); + const projectId = projectHash(runGit(repoDir, ['rev-parse', '--show-toplevel'])); + const sourceFile = path.join(root, 'projects', projectId, 'instincts', 'personal', 'single.yaml'); + writeInstinct(sourceFile, 'promote-single', 0.93); + + const result = runCli(root, ['promote', 'promote-single', '--force'], { cwd: repoDir }); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'promote-single.yaml'))); + assert.ok(!fs.existsSync(sourceFile)); + } finally { + cleanupDir(root); + cleanupDir(repoParent); + } +}); + +test('promote preserves malformed and foreign source blocks while removing target', () => { + const root = createTempDir(); + const repoParent = createTempDir(); + try { + const repoDir = initGitProject(repoParent); + const projectId = projectHash(runGit(repoDir, ['rev-parse', '--show-toplevel'])); + const sourceFile = path.join(root, 'projects', projectId, 'instincts', 'personal', 'foreign.yaml'); + const foreignContent = [ + '---', + 'title: foreign block without id', + '---', + '', + 'Do not drop this content.', + '', + '---', + 'id: keep-foreign-neighbor', + 'trigger: "when nearby"', + 'confidence: not-a-float', + '---', + '', + 'This parse-tolerated block must also stay raw.', + '', + ].join('\n'); + fs.mkdirSync(path.dirname(sourceFile), { recursive: true }); + fs.writeFileSync( + sourceFile, + [ + foreignContent, + '---', + 'id: promote-foreign', + 'trigger: "when target appears"', + 'confidence: 0.95', + 'domain: workflow', + '---', + '', + 'Only this block should be removed.', + '', + ].join('\n') + ); + + const result = runCli(root, ['promote', 'promote-foreign', '--force'], { cwd: repoDir }); + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'promote-foreign.yaml'))); + assert.strictEqual(normalizeLineEndings(fs.readFileSync(sourceFile, 'utf8')), `${foreignContent}\n`); + } finally { + cleanupDir(root); + cleanupDir(repoParent); + } +}); + +test('auto-promote removes promoted source copies from every contributing project', () => { + const root = createTempDir(); + try { + const registryPath = path.join(root, 'projects.json'); + const projectOne = seedProject(root, 'proj111', { personal: ['shared-auto'] }); + const projectTwo = seedProject(root, 'proj222', { personal: ['shared-auto'] }); + writeJson(registryPath, { + proj111: { name: 'one', root: '/repo/one', remote: '', last_seen: '2026-01-01T00:00:00Z' }, + proj222: { name: 'two', root: '/repo/two', remote: '', last_seen: '2026-01-02T00:00:00Z' }, + }); + + const result = runCli(root, ['promote', '--force'], { cwd: root }); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Promoted 1 instincts to global scope/); + assert.ok(fs.existsSync(path.join(root, 'instincts', 'personal', 'shared-auto.yaml'))); + assert.ok(!fs.existsSync(path.join(projectOne, 'instincts', 'personal', 'shared-auto.yaml'))); + assert.ok(!fs.existsSync(path.join(projectTwo, 'instincts', 'personal', 'shared-auto.yaml'))); + } finally { + cleanupDir(root); + } +}); + console.log(`\nPassed: ${passed}`); console.log(`Failed: ${failed}`);