From 89bf930c9fd201a7f1e74ed03e03a7d4b99da0c9 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:27:43 +0200 Subject: [PATCH 01/28] docs(rules): fix the common coding-style link --- rules/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rules/README.md b/rules/README.md index 0a9f48e45..8f2b51b6f 100644 --- a/rules/README.md +++ b/rules/README.md @@ -136,7 +136,7 @@ When language-specific rules and common rules conflict, **language-specific rule `common/coding-style.md` recommends immutability as a default principle. A language-specific `golang/coding-style.md` can override this: -> Idiomatic Go uses pointer receivers for struct mutation — see [common/coding-style.md](../common/coding-style.md) for the general principle, but Go-idiomatic mutation is preferred here. +> Idiomatic Go uses pointer receivers for struct mutation — see [common/coding-style.md](common/coding-style.md) for the general principle, but Go-idiomatic mutation is preferred here. ### Common rules with override notes From 32bbddab1d21276abd4bcc5d3e30ec6674718bee Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:31:21 +0200 Subject: [PATCH 02/28] fix: keep unchanged skill rates stable at zero threshold --- scripts/lib/skill-evolution/health.js | 3 +++ tests/lib/skill-evolution.test.js | 30 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/scripts/lib/skill-evolution/health.js b/scripts/lib/skill-evolution/health.js index c57e23354..34caaf80b 100644 --- a/scripts/lib/skill-evolution/health.js +++ b/scripts/lib/skill-evolution/health.js @@ -121,6 +121,9 @@ function getFailureTrend(successRate7d, successRate30d, warnThreshold) { } const delta = roundRate(successRate7d - successRate30d); + if (delta === 0) { + return 'stable'; + } if (delta <= (-1 * warnThreshold)) { return 'worsening'; } diff --git a/tests/lib/skill-evolution.test.js b/tests/lib/skill-evolution.test.js index f2dd4e6ec..db2452023 100644 --- a/tests/lib/skill-evolution.test.js +++ b/tests/lib/skill-evolution.test.js @@ -580,6 +580,36 @@ function runTests() { ); })) passed++; else failed++; + if (test('zero warning threshold distinguishes unchanged rates from actual trends', () => { + const recent = '2026-03-14T10:00:00.000Z'; + const older = '2026-02-24T10:00:00.000Z'; + const records = [ + { skill_id: 'unchanged', outcome: 'success', recorded_at: recent }, + { skill_id: 'improving', outcome: 'failure', recorded_at: older }, + { skill_id: 'improving', outcome: 'success', recorded_at: recent }, + { skill_id: 'declining', outcome: 'success', recorded_at: older }, + { skill_id: 'declining', outcome: 'failure', recorded_at: recent }, + ]; + const report = health.collectSkillHealth({ + repoRoot, + homeDir, + now, + warnThreshold: 0, + stateStore: { listSkillExecutionRecords: () => records }, + }); + + assert.strictEqual(report.warn_threshold, 0); + for (const [skillId, expectedTrend] of [ + ['unchanged', 'stable'], + ['improving', 'improving'], + ['declining', 'worsening'], + ]) { + const skill = report.skills.find(entry => entry.skill_id === skillId); + assert.strictEqual(skill.failure_trend, expectedTrend); + assert.strictEqual(skill.declining, expectedTrend === 'worsening'); + } + })) passed++; else failed++; + if (test('does not report unmeasured skills as healthy (#2463)', () => { // A runs file that does not exist: skills are still discovered, so this // is exactly the "discovered but never executed" state. From 9f82a0c219113dd1d66750cead4dfecdb1bfe5cc Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:31:54 +0200 Subject: [PATCH 03/28] docs: fix Kiro license link --- .kiro/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.kiro/README.md b/.kiro/README.md index f13710241..b90c92a9b 100644 --- a/.kiro/README.md +++ b/.kiro/README.md @@ -638,4 +638,4 @@ For more detailed information, see the `docs/` directory: ## License -MIT — see [LICENSE](LICENSE) for details. +MIT — see [LICENSE](../LICENSE) for details. From 8b755a66df325575b096f87467a23d8323c08f03 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:31:56 +0200 Subject: [PATCH 04/28] fix: validate instinct confidence before status rendering RED: 9 failures for nonfinite and out-of-range confidence. GREEN: 27 targeted tests pass; parse_instinct_file line coverage 97.2%. Four unrelated Unix-system-path tests also fail unchanged on origin/main under Windows. --- .../scripts/instinct-cli.py | 3 ++- .../scripts/test_parse_instinct.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index f7f35abbb..baf02e9d9 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -551,7 +551,8 @@ def parse_instinct_file(content: str) -> list[dict]: value = value[1:-1].replace("''", "'") if key == 'confidence': try: - current[key] = float(value) + confidence = float(value) + current[key] = confidence if 0.0 <= confidence <= 1.0 else 0.5 except ValueError: current[key] = 0.5 # default on malformed confidence else: diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index f58c58853..8b895727f 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -252,6 +252,18 @@ Body. assert result[0]["confidence"] == pytest.approx(0.42) +@pytest.mark.parametrize("confidence", ["nan", "inf", "-inf", "1e309", "-0.1", "1.1", "invalid"]) +def test_parse_invalid_confidence_uses_default(confidence): + content = SAMPLE_INSTINCT_YAML.replace("confidence: 0.8", f"confidence: {confidence}") + assert parse_instinct_file(content)[0]["confidence"] == 0.5 + + +@pytest.mark.parametrize("confidence", [0.0, 0.42, 1.0]) +def test_parse_confidence_preserves_valid_range(confidence): + content = SAMPLE_INSTINCT_YAML.replace("confidence: 0.8", f"confidence: {confidence}") + assert parse_instinct_file(content)[0]["confidence"] == confidence + + def test_parse_trigger_strips_quotes(): content = """\ --- @@ -671,6 +683,17 @@ def test_cmd_status_with_instincts(patch_globals, monkeypatch, capsys): assert "GLOBAL" in out +@pytest.mark.parametrize("confidence", ["inf", "-inf", "nan"]) +def test_cmd_status_handles_nonfinite_confidence(patch_globals, monkeypatch, capsys, confidence): + project = _make_project(patch_globals) + monkeypatch.setattr(_mod, "detect_project", lambda: project) + content = SAMPLE_INSTINCT_YAML.replace("confidence: 0.8", f"confidence: {confidence}") + (project["instincts_personal"] / "nonfinite.yaml").write_text(content, encoding="utf-8") + + assert cmd_status(SimpleNamespace()) == 0 + assert "50%" in capsys.readouterr().out + + def test_confidence_bar_uses_unicode_when_supported(): """Confidence bars should retain block glyphs on UTF-8 streams.""" stream = SimpleNamespace(encoding="utf-8") From db05c048684be6928f29612f4a48923dc664761f Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:32:19 +0200 Subject: [PATCH 05/28] docs: fix translated rules coding-style link --- docs/zh-CN/rules/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zh-CN/rules/README.md b/docs/zh-CN/rules/README.md index 0e6618573..2a5e7e8f5 100644 --- a/docs/zh-CN/rules/README.md +++ b/docs/zh-CN/rules/README.md @@ -96,7 +96,7 @@ cp -r rules/php ~/.claude/rules/php `common/coding-style.md` 建议将不可变性作为默认原则。语言特定的 `golang/coding-style.md` 可以覆盖这一点: -> 符合 Go 语言习惯的做法是使用指针接收器进行结构体修改——关于通用原则请参阅 [common/coding-style.md](../../../common/coding-style.md),但此处更推荐符合 Go 语言习惯的修改方式。 +> 符合 Go 语言习惯的做法是使用指针接收器进行结构体修改——关于通用原则请参阅 [common/coding-style.md](common/coding-style.md),但此处更推荐符合 Go 语言习惯的修改方式。 ### 带有覆盖说明的通用规则 From acb75bc1edfaa73516cb276c7e9d4c23d3ec2412 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:33:41 +0200 Subject: [PATCH 06/28] fix: ignore directories when listing skill snapshots --- scripts/lib/skill-evolution/versioning.js | 3 +++ tests/lib/skill-evolution.test.js | 26 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 6aa7a932d..83a309d74 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -101,6 +101,9 @@ function listVersions(skillPath) { const filePath = path.join(versionsDir, fileName); const stats = fs.statSync(filePath); + if (!stats.isFile()) { + return null; + } return { version, diff --git a/tests/lib/skill-evolution.test.js b/tests/lib/skill-evolution.test.js index f2dd4e6ec..6cd18aebe 100644 --- a/tests/lib/skill-evolution.test.js +++ b/tests/lib/skill-evolution.test.js @@ -202,6 +202,32 @@ function runTests() { assert.strictEqual(amendments[1].version, 2); })) passed++; else failed++; + if (test('ignores directories named like version snapshots', () => { + const fixtureRoot = createTempDir('skill-version-directory-'); + try { + const skillDir = createSkill(fixtureRoot, 'demo', '# Demo\n'); + const snapshotDirectory = path.join(versioning.getVersionsDir(skillDir), 'v999.md'); + fs.mkdirSync(snapshotDirectory, { recursive: true }); + fs.writeFileSync(path.join(versioning.getVersionsDir(skillDir), 'notes.txt'), 'Not a snapshot'); + + assert.deepStrictEqual(versioning.listVersions(skillDir), []); + assert.strictEqual(versioning.getCurrentVersion(skillDir), 1); + const snapshot = versioning.createVersion(skillDir); + assert.strictEqual(snapshot.version, 1); + assert.deepStrictEqual(versioning.listVersions(skillDir).map(entry => entry.version), [1]); + assert.deepStrictEqual( + versioning.listVersions(path.join(skillDir, 'SKILL.md')).map(entry => entry.version), + [1] + ); + assert.strictEqual(versioning.getCurrentVersion(skillDir), 1); + assert.ok(fs.statSync(snapshotDirectory).isDirectory()); + assert.strictEqual(versioning.getCurrentVersion(path.join(fixtureRoot, 'missing-skill')), 0); + assert.throws(() => versioning.createVersion(snapshotDirectory), /Skill file not found/); + } finally { + cleanupTempDir(fixtureRoot); + } + })) passed++; else failed++; + if (test('rolls back to a previous snapshot without losing history', () => { const skillDir = path.join(skillsRoot, 'alpha'); From 0c28980bbd6f60baad5e37d90c7d90a021f02a7f Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:33:45 +0200 Subject: [PATCH 07/28] docs: fix migration guide README anchor --- docs/MIGRATION-1X-TO-2.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MIGRATION-1X-TO-2.0.md b/docs/MIGRATION-1X-TO-2.0.md index 24e3f1e4d..ed6b3d383 100644 --- a/docs/MIGRATION-1X-TO-2.0.md +++ b/docs/MIGRATION-1X-TO-2.0.md @@ -1,6 +1,6 @@ # Migrating From ECC 1.x To 2.0 -ECC 2.0 renamed the repo (`affaan-m/everything-claude-code` → `affaan-m/ECC`) and the plugin identifier (`everything-claude-code@everything-claude-code` → `ecc@ecc`). If you installed 1.x, follow this guide to upgrade cleanly. See also the [Naming + Migration Note](../README.md#naming--migration-note) in the README. +ECC 2.0 renamed the repo (`affaan-m/everything-claude-code` → `affaan-m/ECC`) and the plugin identifier (`everything-claude-code@everything-claude-code` → `ecc@ecc`). If you installed 1.x, follow this guide to upgrade cleanly. See also the [Naming + Migration Note](../README.md#claude-code-details) in the README. ## TL;DR From 60ce177d748434b1bdb95d5c908f25cad9cf8bb2 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:33:47 +0200 Subject: [PATCH 08/28] fix: accept a leading UTF-8 BOM in instinct files RED: BOM parsing and disk loading failed in 3 cases. GREEN: 18 targeted tests pass, including LF/CRLF and preserving interior BOM characters. parse_instinct_file line coverage is 91.4%. --- .../scripts/instinct-cli.py | 2 +- .../scripts/test_parse_instinct.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index f7f35abbb..8977f5f6c 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -525,7 +525,7 @@ def parse_instinct_file(content: str) -> list[dict]: in_frontmatter = False content_lines = [] - for line in content.split('\n'): + for line in content.removeprefix('\ufeff').split('\n'): if line.strip() == '---': if in_frontmatter: # End of frontmatter - content comes next diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index f58c58853..6dd7307f6 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -208,6 +208,23 @@ Prevents vulnerabilities. assert "Prevents vulnerabilities." in result[0]["content"] +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_parse_initial_utf8_bom(newline): + content = SAMPLE_INSTINCT_YAML.replace("\n", newline) + assert parse_instinct_file("\ufeff" + content) == parse_instinct_file(content) + + +def test_parse_preserves_bom_inside_content(): + content = SAMPLE_INSTINCT_YAML.replace("Always write", "Always \ufeffwrite") + assert "Always \ufeffwrite" in parse_instinct_file(content)[0]["content"] + + +def test_load_instinct_file_with_utf8_bom(tmp_path): + (tmp_path / "bom.yaml").write_text(SAMPLE_INSTINCT_YAML, encoding="utf-8-sig") + instincts = _load_instincts_from_dir(tmp_path, "personal", "project") + assert [instinct["id"] for instinct in instincts] == ["test-instinct"] + + def test_empty_content_no_error(): content = """\ --- From 1ae4b88e060416b4733efe1ea994a1cc5552586e Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:31 +0200 Subject: [PATCH 09/28] fix: skip non-object skill execution JSONL rows --- scripts/lib/skill-evolution/tracker.js | 5 ++++- tests/lib/skill-evolution.test.js | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/lib/skill-evolution/tracker.js b/scripts/lib/skill-evolution/tracker.js index 0ea0ba1cc..8e6d48b10 100644 --- a/scripts/lib/skill-evolution/tracker.js +++ b/scripts/lib/skill-evolution/tracker.js @@ -102,7 +102,10 @@ function readJsonl(filePath) { .filter(Boolean) .reduce((rows, line) => { try { - rows.push(JSON.parse(line)); + const record = JSON.parse(line); + if (record && typeof record === 'object' && !Array.isArray(record)) { + rows.push(record); + } } catch { // Ignore malformed rows so analytics remain best-effort. } diff --git a/tests/lib/skill-evolution.test.js b/tests/lib/skill-evolution.test.js index f2dd4e6ec..3279d0dd4 100644 --- a/tests/lib/skill-evolution.test.js +++ b/tests/lib/skill-evolution.test.js @@ -294,13 +294,30 @@ function runTests() { duration_ms: 1, user_feedback: 'accepted', recorded_at: '2026-03-15T11:45:00.000Z', - })}\n{bad-json}\n`, + })}\n{bad-json}\nnull\n42\nfalse\n"not a record"\n[]\n`, 'utf8' ); const records = tracker.readSkillExecutionRecords({ runsFilePath: malformedRunsFile }); assert.strictEqual(records.length, 1); assert.strictEqual(records[0].skill_id, 'alpha'); + const report = health.collectSkillHealth({ + repoRoot, homeDir, runsFilePath: malformedRunsFile, now, + }); + assert.strictEqual(report.skills.find(skill => skill.skill_id === 'alpha').run_count_30d, 1); + + const result = runCli([ + '--json', + '--skills-root', skillsRoot, + '--learned-root', learnedRoot, + '--imported-root', importedRoot, + '--home', homeDir, + '--runs-file', malformedRunsFile, + '--now', now, + ]); + assert.strictEqual(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.strictEqual(payload.skills.find(skill => skill.skill_id === 'alpha').run_count_30d, 1); })) passed++; else failed++; if (test('preserves zero-valued telemetry fields during normalization', () => { From e081afcbd7b82ace144782f195d67c9f17674544 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:34 +0200 Subject: [PATCH 10/28] docs: fix delivery-gate installation source --- skills/delivery-gate/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/skills/delivery-gate/SKILL.md b/skills/delivery-gate/SKILL.md index 0a98d2636..940e26160 100644 --- a/skills/delivery-gate/SKILL.md +++ b/skills/delivery-gate/SKILL.md @@ -35,8 +35,11 @@ Over many sessions of "ship and forget," the human hasn't grown. This hook enfor ## Install +From the ECC repository root: + ```bash -cp quality-gate.py ~/.claude/scripts/ +mkdir -p ~/.claude/scripts +cp skills/delivery-gate/hooks/quality-gate.py ~/.claude/scripts/ ``` Add to `~/.claude/settings.json`: From a7e5c4d030e934d576cf538d1bd2ebc69a2585f4 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:32:21 -0400 Subject: [PATCH 11/28] fix: preserve snapshot paths and reject malformed skill tracking rows --- manifests/context-packs/skill-triggers@1.json | 2 +- scripts/lib/skill-evolution/tracker.js | 5 +- scripts/lib/skill-evolution/versioning.js | 26 +++++-- tests/lib/skill-evolution-storage.test.js | 70 +++++++++++++++++++ tests/lib/skill-evolution.test.js | 2 +- 5 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 tests/lib/skill-evolution-storage.test.js diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 7b0d69770..301529ac2 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "7144a76d4b920efacf5a8d678ec6757ea34410834c82f4e226b6a32af6f8c540", + "registryDigest": "cb89b3434da17c00988095077f61208ab81c2f0611f0edbd5c90d3672bce7ac1", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/scripts/lib/skill-evolution/tracker.js b/scripts/lib/skill-evolution/tracker.js index 8e6d48b10..effa107eb 100644 --- a/scripts/lib/skill-evolution/tracker.js +++ b/scripts/lib/skill-evolution/tracker.js @@ -103,7 +103,10 @@ function readJsonl(filePath) { .reduce((rows, line) => { try { const record = JSON.parse(line); - if (record && typeof record === 'object' && !Array.isArray(record)) { + if (record && typeof record === 'object' && !Array.isArray(record) + && typeof record.skill_id === 'string' && record.skill_id.trim().length > 0 + && VALID_OUTCOMES.has(record.outcome) + && typeof record.recorded_at === 'string' && !Number.isNaN(Date.parse(record.recorded_at))) { rows.push(record); } } catch { diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 83a309d74..307fa599f 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -100,7 +100,14 @@ function listVersions(skillPath) { } const filePath = path.join(versionsDir, fileName); - const stats = fs.statSync(filePath); + let stats; + try { + stats = fs.statSync(filePath); + } catch (error) { + // A dangling link still occupies a path, but is not a readable snapshot. + if (error.code === 'ENOENT') return null; + throw error; + } if (!stats.isFile()) { return null; } @@ -146,7 +153,10 @@ function readJsonl(filePath) { .filter(Boolean) .reduce((rows, line) => { try { - rows.push(JSON.parse(line)); + const record = JSON.parse(line); + if (record && typeof record === 'object' && !Array.isArray(record)) { + rows.push(record); + } } catch { // Ignore malformed rows so the log remains append-only and resilient. } @@ -163,12 +173,18 @@ function createVersion(skillPath, options = {}) { ensureSkillVersioning(skillPath); const versions = listVersions(skillPath); - const nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1; - const snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`); + const versionsDir = getVersionsDir(skillPath); + const occupiedPaths = new Set(fs.readdirSync(versionsDir)); + let nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1; + // Listing excludes non-files, but their paths remain user-owned and occupied. + while (occupiedPaths.has(`v${nextVersion}.md`)) { + nextVersion += 1; + } + const snapshotPath = path.join(versionsDir, `v${nextVersion}.md`); const skillContent = fs.readFileSync(skillFilePath, 'utf8'); const createdAt = options.timestamp || new Date().toISOString(); - fs.writeFileSync(snapshotPath, skillContent, 'utf8'); + fs.writeFileSync(snapshotPath, skillContent, { encoding: 'utf8', flag: 'wx' }); appendEvolutionRecord(skillPath, 'amendments', { event: 'snapshot', version: nextVersion, diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js new file mode 100644 index 000000000..4b070ba06 --- /dev/null +++ b/tests/lib/skill-evolution-storage.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const versioning = require('../../scripts/lib/skill-evolution/versioning'); +const health = require('../../scripts/lib/skill-evolution/health'); + +let passed = 0; +let failed = 0; + +function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-evolution-storage-')); + const skillDir = path.join(root, 'skills', 'demo'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Original\n'); + return { root, skillDir, versionsDir: versioning.getVersionsDir(skillDir) }; +} + +function test(name, fn) { + const fixture = createFixture(); + try { + fn(fixture); + passed += 1; + console.log(` ✓ ${name}`); + } catch (error) { + failed += 1; + console.error(` ✗ ${name}: ${error.message}`); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +} + +test('snapshot creation preserves occupied directories and links plus earlier snapshots', fixture => { + const { root, skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.mkdirSync(path.join(versionsDir, 'v2.md')); + fs.writeFileSync(path.join(versionsDir, 'v2.md', 'keep.txt'), 'preserve'); + if (process.platform !== 'win32') { + fs.symlinkSync(path.join(root, 'missing'), path.join(versionsDir, 'v3.md')); + } + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Updated\n'); + const snapshot = versioning.createVersion(skillDir); + assert.equal(snapshot.version, process.platform === 'win32' ? 3 : 4); + assert.equal(fs.readFileSync(snapshot.path, 'utf8'), '# Updated\n'); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v2.md', 'keep.txt'), 'utf8'), 'preserve'); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').at(-1).version, snapshot.version); + if (process.platform !== 'win32') { + assert.ok(fs.lstatSync(path.join(versionsDir, 'v3.md')).isSymbolicLink()); + fs.symlinkSync(path.join(versionsDir, 'v1.md'), path.join(versionsDir, 'v5.md')); + assert.deepEqual(versioning.listVersions(skillDir).map(entry => entry.version), [1, 4, 5]); + assert.equal(versioning.getCurrentVersion(skillDir), 5); + } +}); + +test('health skips malformed amendment rows while retaining a valid pending proposal', fixture => { + const { root, skillDir } = fixture; + versioning.ensureSkillVersioning(skillDir); + fs.writeFileSync(versioning.getEvolutionLogPath(skillDir, 'amendments'), + 'null\n[]\n42\n{bad}\n{"event":"proposal","status":"pending"}\n'); + const report = health.collectSkillHealth({ + skillsRoot: path.join(root, 'skills'), homeDir: root, now: '2026-03-15T12:00:00.000Z', + }); + assert.equal(report.skills.find(skill => skill.skill_id === 'demo').pending_amendments, 1); +}); + +console.log(`Results: Passed: ${passed}, Failed: ${failed}`); +process.exitCode = failed > 0 ? 1 : 0; diff --git a/tests/lib/skill-evolution.test.js b/tests/lib/skill-evolution.test.js index 705c692ab..b23b65757 100644 --- a/tests/lib/skill-evolution.test.js +++ b/tests/lib/skill-evolution.test.js @@ -320,7 +320,7 @@ function runTests() { duration_ms: 1, user_feedback: 'accepted', recorded_at: '2026-03-15T11:45:00.000Z', - })}\n{bad-json}\nnull\n42\nfalse\n"not a record"\n[]\n`, + })}\n{bad-json}\nnull\n42\nfalse\n"not a record"\n[]\n{}\n{"skill_id":42}\n{"skill_id":""}\n{"skill_id":"alpha","outcome":"invalid","recorded_at":"2026-03-15T11:45:00.000Z"}\n{"skill_id":"alpha","outcome":"success","recorded_at":"bad-date"}\n`, 'utf8' ); From 61b1d49538bfdc8430fcd3c996492614f9e721ab Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:42:29 -0400 Subject: [PATCH 12/28] fix: bound snapshot allocation to safe version numbers --- scripts/lib/skill-evolution/versioning.js | 12 +++++--- tests/lib/skill-evolution-storage.test.js | 36 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 307fa599f..32bc83858 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -83,7 +83,8 @@ function parseVersionNumber(fileName) { return null; } - return Number(match[1]); + const version = Number(match[1]); + return Number.isSafeInteger(version) && version >= 1 ? version : null; } function listVersions(skillPath) { @@ -170,16 +171,19 @@ function getEvolutionLog(skillPath, logType) { function createVersion(skillPath, options = {}) { const skillFilePath = ensureSkillExists(skillPath); - ensureSkillVersioning(skillPath); const versions = listVersions(skillPath); const versionsDir = getVersionsDir(skillPath); - const occupiedPaths = new Set(fs.readdirSync(versionsDir)); + const occupiedPaths = new Set(fs.existsSync(versionsDir) ? fs.readdirSync(versionsDir) : []); let nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1; // Listing excludes non-files, but their paths remain user-owned and occupied. - while (occupiedPaths.has(`v${nextVersion}.md`)) { + while (Number.isSafeInteger(nextVersion) && occupiedPaths.has(`v${nextVersion}.md`)) { nextVersion += 1; } + if (!Number.isSafeInteger(nextVersion)) { + throw new Error('Skill snapshot version range is exhausted; existing snapshots were preserved'); + } + ensureSkillVersioning(skillPath); const snapshotPath = path.join(versionsDir, `v${nextVersion}.md`); const skillContent = fs.readFileSync(skillFilePath, 'utf8'); const createdAt = options.timestamp || new Date().toISOString(); diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 4b070ba06..8cc7a8a1b 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -66,5 +66,41 @@ test('health skips malformed amendment rows while retaining a valid pending prop assert.equal(report.skills.find(skill => skill.skill_id === 'demo').pending_amendments, 1); }); +test('snapshot allocation ignores invalid numeric names and preserves their contents', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const names = ['v0.md', 'v9007199254740992.md', `v${'9'.repeat(80)}.md`]; + for (const name of names) fs.writeFileSync(path.join(versionsDir, name), 'preserve'); + assert.deepEqual(versioning.listVersions(skillDir), []); + const snapshot = versioning.createVersion(skillDir); + assert.equal(snapshot.version, 1); + assert.equal(fs.readFileSync(snapshot.path, 'utf8'), '# Original\n'); + for (const name of names) assert.equal(fs.readFileSync(path.join(versionsDir, name), 'utf8'), 'preserve'); +}); + +test('exhausted snapshot numbering refuses creation before initializing evolution logs', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const last = path.join(versionsDir, `v${Number.MAX_SAFE_INTEGER}.md`); + fs.writeFileSync(last, 'preserve'); + assert.throws(() => versioning.createVersion(skillDir), /snapshot version range is exhausted/); + assert.deepEqual(fs.readdirSync(versionsDir), [`v${Number.MAX_SAFE_INTEGER}.md`]); + assert.equal(fs.readFileSync(last, 'utf8'), 'preserve'); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); +}); + +test('an occupied final safe snapshot path refuses creation while preserving the directory', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + fs.writeFileSync(path.join(versionsDir, `v${Number.MAX_SAFE_INTEGER - 1}.md`), 'preserve'); + const occupied = path.join(versionsDir, `v${Number.MAX_SAFE_INTEGER}.md`); + fs.mkdirSync(occupied); + fs.writeFileSync(path.join(occupied, 'keep.txt'), 'preserve'); + assert.throws(() => versioning.createVersion(skillDir), /snapshot version range is exhausted/); + assert.equal(fs.readdirSync(versionsDir).length, 2); + assert.equal(fs.readFileSync(path.join(occupied, 'keep.txt'), 'utf8'), 'preserve'); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); +}); + console.log(`Results: Passed: ${passed}, Failed: ${failed}`); process.exitCode = failed > 0 ? 1 : 0; From 15d9ffd1c98d03f3cfe808c404898da90f39fe3b Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:49:37 -0400 Subject: [PATCH 13/28] fix: validate rollback allocation before changing live skill --- scripts/lib/skill-evolution/versioning.js | 15 ++++++--- tests/lib/skill-evolution-storage.test.js | 38 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 32bc83858..a57178ac3 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -169,9 +169,7 @@ function getEvolutionLog(skillPath, logType) { return readJsonl(getEvolutionLogPath(skillPath, logType)); } -function createVersion(skillPath, options = {}) { - const skillFilePath = ensureSkillExists(skillPath); - +function allocateSnapshotVersion(skillPath) { const versions = listVersions(skillPath); const versionsDir = getVersionsDir(skillPath); const occupiedPaths = new Set(fs.existsSync(versionsDir) ? fs.readdirSync(versionsDir) : []); @@ -183,6 +181,13 @@ function createVersion(skillPath, options = {}) { if (!Number.isSafeInteger(nextVersion)) { throw new Error('Skill snapshot version range is exhausted; existing snapshots were preserved'); } + return nextVersion; +} + +function createVersion(skillPath, options = {}) { + const skillFilePath = ensureSkillExists(skillPath); + const nextVersion = allocateSnapshotVersion(skillPath); + const versionsDir = getVersionsDir(skillPath); ensureSkillVersioning(skillPath); const snapshotPath = path.join(versionsDir, `v${nextVersion}.md`); const skillContent = fs.readFileSync(skillFilePath, 'utf8'); @@ -207,12 +212,11 @@ function createVersion(skillPath, options = {}) { function rollbackTo(skillPath, targetVersion, options = {}) { const normalizedTargetVersion = Number(targetVersion); - if (!Number.isInteger(normalizedTargetVersion) || normalizedTargetVersion <= 0) { + if (!Number.isSafeInteger(normalizedTargetVersion) || normalizedTargetVersion <= 0) { throw new Error(`Invalid target version: ${targetVersion}`); } ensureSkillExists(skillPath); - ensureSkillVersioning(skillPath); const targetPath = path.join(getVersionsDir(skillPath), `v${normalizedTargetVersion}.md`); if (!fs.existsSync(targetPath)) { @@ -221,6 +225,7 @@ function rollbackTo(skillPath, targetVersion, options = {}) { const currentVersion = getCurrentVersion(skillPath); const targetContent = fs.readFileSync(targetPath, 'utf8'); + allocateSnapshotVersion(skillPath); fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8'); const createdVersion = createVersion(skillPath, { diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 8cc7a8a1b..50283f2d1 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -102,5 +102,43 @@ test('an occupied final safe snapshot path refuses creation while preserving the assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); }); +test('exhausted rollback preserves unsnapshotted content, snapshot files and evolution state', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const previous = path.join(versionsDir, 'v1.md'); + const last = path.join(versionsDir, `v${Number.MAX_SAFE_INTEGER}.md`); + fs.writeFileSync(previous, '# Previous\n'); + fs.writeFileSync(last, '# Latest snapshot\n'); + const assertPreserved = () => { + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(previous, 'utf8'), '# Previous\n'); + assert.equal(fs.readFileSync(last, 'utf8'), '# Latest snapshot\n'); + assert.equal(fs.readdirSync(versionsDir).length, 2); + }; + assert.throws(() => versioning.rollbackTo(skillDir, 1), /snapshot version range is exhausted/); + assertPreserved(); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); + versioning.ensureSkillVersioning(skillDir); + const logs = versioning.EVOLUTION_LOG_TYPES.map(type => versioning.getEvolutionLogPath(skillDir, type)); + for (const file of logs) fs.writeFileSync(file, '{"event":"preserve"}\n'); + assert.throws(() => versioning.rollbackTo(skillDir, 1), /snapshot version range is exhausted/); + assertPreserved(); + for (const file of logs) assert.equal(fs.readFileSync(file, 'utf8'), '{"event":"preserve"}\n'); +}); + +test('unsafe rollback targets preserve the live skill and excluded snapshots', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const invalid = path.join(versionsDir, 'v9007199254740992.md'); + fs.writeFileSync(invalid, '# Excluded snapshot\n'); + for (const target of [9007199254740992, '9007199254740992']) { + assert.throws(() => versioning.rollbackTo(skillDir, target), /Invalid target version/); + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(invalid, 'utf8'), '# Excluded snapshot\n'); + assert.equal(fs.readdirSync(versionsDir).length, 1); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); + } +}); + console.log(`Results: Passed: ${passed}, Failed: ${failed}`); process.exitCode = failed > 0 ? 1 : 0; From 8a404dbbdc9b9007e23cd92e4e991d17b7191b1c Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:56:29 -0400 Subject: [PATCH 14/28] fix: preserve story projections and blank provider defaults --- skills/story-lifecycle/scripts/ledger.js | 18 ++- src/llm/providers/resolver.py | 6 +- .../story-lifecycle-table-projection.test.js | 149 ++++++++++++++++++ ...serve-secret-redaction-environment.test.js | 9 ++ tests/test_resolver.py | 29 ++++ 5 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 tests/docs/story-lifecycle-table-projection.test.js diff --git a/skills/story-lifecycle/scripts/ledger.js b/skills/story-lifecycle/scripts/ledger.js index 0bbba0c71..1b240efed 100644 --- a/skills/story-lifecycle/scripts/ledger.js +++ b/skills/story-lifecycle/scripts/ledger.js @@ -288,10 +288,22 @@ function parseStory(rel, md) { function replaceTable(md, rel, header, rows) { const i = md.indexOf(DERIVED); if (i < 0) throw new LedgerError(`${rel}: missing derived-table marker`); - const start = md.indexOf('\n', i) + 1; + const newline = md.indexOf('\n', i); + const afterMarker = newline < 0 ? md.length : newline + 1; + const skipBlanks = (at) => at + (md.slice(at).match(/^(?:[ \t]*\r?\n)+/) || [''])[0].length; + const hasDerivedHeader = (at) => md.slice(at).split(/\r?\n/, 2).join('\n').replace(/[ \t]/g, '') === header.replace(/[ \t]/g, ''); + const first = skipBlanks(afterMarker); + const start = md[first] === '|' ? first : afterMarker; let end = start; - while (md[end] === '|') { const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1; } - return md.slice(0, start) + [header, ...rows].join('\n') + '\n' + md.slice(end); + while (md[end] === '|') { + while (md[end] === '|') { const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1; } + // Older projections could insert a second table before a blank-separated + // original. Only adjacent copies of this derived header belong to us. + const next = skipBlanks(end); + if (!hasDerivedHeader(next)) break; + end = next; + } + return md.slice(0, start) + (newline < 0 ? '\n' : '') + [header, ...rows].join('\n') + '\n' + md.slice(end); } // Read the ledger with pending changes overlaid; return regenerated epic/sprint files that differ. diff --git a/src/llm/providers/resolver.py b/src/llm/providers/resolver.py index d4754a288..248e8bf92 100644 --- a/src/llm/providers/resolver.py +++ b/src/llm/providers/resolver.py @@ -69,10 +69,10 @@ def _resolve_provider_config( return _strip_env_value(env_provider).lower(), None saved_config = _read_saved_llm_config() - saved_provider = saved_config.get("LLM_PROVIDER", "claude").lower() + saved_provider = saved_config.get("LLM_PROVIDER", "").lower() # A saved model belongs to its saved provider, never to an override. - saved_model = saved_config.get("LLM_MODEL") if saved_config.get("LLM_PROVIDER") else None - return saved_provider, saved_model + saved_model = saved_config.get("LLM_MODEL") if saved_provider else None + return saved_provider or "claude", saved_model def _resolve_default_model( diff --git a/tests/docs/story-lifecycle-table-projection.test.js b/tests/docs/story-lifecycle-table-projection.test.js new file mode 100644 index 000000000..13c8a09a4 --- /dev/null +++ b/tests/docs/story-lifecycle-table-projection.test.js @@ -0,0 +1,149 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const LEDGER = path.join(__dirname, '..', '..', 'skills', 'story-lifecycle', 'scripts', 'ledger.js'); +const MARKER = ''; +const EPIC_HEADER = '| ID | Title | Status |\n| --- | --- | --- |'; +const SPRINT_HEADER = '| ID | Title | Points | Status |\n| --- | --- | --- | --- |'; +const PREFIX = '# Epic: Auth flow\n\nUser introduction stays here.\n\n'; +const SUFFIX = '\n## Notes\n\nKeep this user prose byte for byte.\n'; +const ownedTemps = new Set(); + +function run(root, args) { + const result = spawnSync(process.execPath, [LEDGER, ...args, '--root', root], { + encoding: 'utf8', timeout: 10000, + }); + assert.strictEqual(result.status, 0, result.stderr || result.error?.message); + return JSON.parse(result.stdout); +} + +function read(root, rel) { + return fs.readFileSync(path.join(root, '.stories', rel), 'utf8'); +} + +function write(root, rel, text) { + fs.writeFileSync(path.join(root, '.stories', rel), text); +} + +function apply(root, changes) { + const input = path.join(root, 'changes.json'); + fs.writeFileSync(input, JSON.stringify(changes)); + return run(root, ['apply', '--input', input]); +} + +function newRepo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'story-table-projection-')); + ownedTemps.add(root); + execFileSync('git', ['init', '-q', root]); + run(root, ['init']); + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n`); + return root; +} + +function story(id, status = 'todo', sprint = 'unassigned') { + return `# Story: Sign in\n\n**ID:** ${id}\n**Epic:** auth-flow\n**Sprint:** ${sprint}\n**Status:** ${status}\n**Points:** 3\n`; +} + +function assertStable(root, rel, expected) { + assert.strictEqual(read(root, rel), expected); + assert.deepStrictEqual(run(root, ['status']).drift, []); + assert.deepStrictEqual(run(root, ['reconcile']).written, []); + assert.strictEqual(read(root, rel), expected); + assert.ok(!fs.existsSync(path.join(root, '.stories', '.txn'))); + assert.ok(!fs.existsSync(path.join(root, '.stories', '.lock'))); +} + +const tests = [ + ['an EOF marker without a newline appends its table after the marker', () => { + const root = newRepo(); + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}`); + const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + apply(root, { create: { [`${id}.md`]: story(id) } }); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n| ${id} | Sign in | todo |\n`); + }], + ['an empty EOF marker with trailing spaces remains after user prose', () => { + const root = newRepo(); + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER} `); + run(root, ['reconcile']); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER} \n${EPIC_HEADER}\n`); + }], + ...['\n', ' \t\n\n', '\r\n \t\r\n'].map(gap => [ + `blank whitespace ${JSON.stringify(gap)} before an epic table is preserved without duplicates`, () => { + const root = newRepo(); + const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${gap}${EPIC_HEADER}\n| stale-99 | Old row | done |\n${SUFFIX}`); + apply(root, { create: { [`${id}.md`]: story(id) } }); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${gap}${EPIC_HEADER}\n| ${id} | Sign in | todo |\n${SUFFIX}`); + }, + ]), + ['a sprint transition replaces a whitespace-separated table and updates total points', () => { + const root = newRepo(); + const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + const prefix = `# Sprint 1\n\n${MARKER}\n \t\n`; + const suffix = `\n## Total Points: 99\n${SUFFIX}`; + apply(root, { create: { [`${id}.md`]: story(id), + 'sprints/sprint-1.md': `${prefix}${SPRINT_HEADER}\n| stale-99 | Old row | 99 | done |\n${suffix}` } }); + apply(root, { update: { [`${id}.md`]: story(id, 'in-progress', '1') } }); + assertStable(root, 'sprints/sprint-1.md', `${prefix}${SPRINT_HEADER}\n| ${id} | Sign in | 3 | in-progress |\n${suffix.replace('99', '3')}`); + }], + ['reconciliation removes consecutive stale copies of the same derived table', () => { + const root = newRepo(); + const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + apply(root, { create: { [`${id}.md`]: story(id) } }); + const current = `${EPIC_HEADER}\n| ${id} | Sign in | todo |\n`; + const stale = '|ID|Title|Status|\n|---|---|---|\n| stale-99 | Old row | done |\n'; + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${current}\n ${'\t'}\n${stale}\n${stale}${SUFFIX}`); + assert.deepStrictEqual(run(root, ['status']).drift, ['epics/auth-flow.md']); + run(root, ['reconcile']); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${current}${SUFFIX}`); + }], + ['unrelated tables and a later table after prose stay byte-identical', () => { + const root = newRepo(); + const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + const userTables = `\n| Owner | Note |\n| --- | --- |\n| User | Keep |\n${SUFFIX}\n${EPIC_HEADER}\n| example-1 | User example | done |\n`; + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n${userTables}`); + apply(root, { create: { [`${id}.md`]: story(id) } }); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n| ${id} | Sign in | todo |\n${userTables}`); + }], + ['a stale table ending at EOF is replaced without adding another table', () => { + const root = newRepo(); + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n\n${EPIC_HEADER}\n| stale-99 | Old row | done |`); + run(root, ['reconcile']); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n\n${EPIC_HEADER}\n`); + }], + ['the initial derived table can use compact Markdown header spacing', () => { + const root = newRepo(); + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n \t\n|ID|Title|Status|\n|---|---|---|\n|stale-99|Old row|done|\n${SUFFIX}`); + run(root, ['reconcile']); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n \t\n${EPIC_HEADER}\n${SUFFIX}`); + }], + ['inserting a missing table leaves blank lines and following prose intact', () => { + const root = newRepo(); + const suffix = ` \t\n\n${SUFFIX}`; + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${suffix}`); + run(root, ['reconcile']); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n${suffix}`); + }], +]; + +let passed = 0; +let failed = 0; +console.log('\n=== Testing story-lifecycle table projection workflows ===\n'); +for (const [name, test] of tests) { + try { + test(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}\n Error: ${error.message}`); + failed++; + } finally { + for (const dir of ownedTemps) fs.rmSync(dir, { recursive: true, force: true }); + ownedTemps.clear(); + } +} +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exitCode = failed > 0 ? 1 : 0; diff --git a/tests/hooks/observe-secret-redaction-environment.test.js b/tests/hooks/observe-secret-redaction-environment.test.js index 5610bc4aa..872d15e81 100644 --- a/tests/hooks/observe-secret-redaction-environment.test.js +++ b/tests/hooks/observe-secret-redaction-environment.test.js @@ -6,6 +6,8 @@ const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); +let passed = 0; +let failed = 0; if (process.platform === 'win32') { console.log('Skipping non-Windows Python availability control.'); } else { @@ -19,7 +21,14 @@ if (process.platform === 'win32') { assert.match(result.stdout, /Passed: 0\nFailed: 1/); assert.doesNotMatch(result.stdout, /Skipping shell fixtures/); console.log('PASS missing Python fails the actual non-Windows redaction gate'); + passed += 1; + } catch (error) { + console.log(`FAIL ${error.message}`); + failed += 1; } finally { fs.rmSync(emptyPath, { recursive: true, force: true }); } } +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 24c2d80ae..25e6bfcac 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -258,6 +258,35 @@ class TestSelectedModel: assert get_provider().get_default_model() == "claude-sonnet-4-6" + @pytest.mark.parametrize("saved_provider", [None, "", " ", '""', "' '"]) + def test_missing_or_blank_saved_provider_ignores_unpaired_model( + self, tmp_path: Path, saved_provider: str | None, + ) -> None: + provider_line = "" if saved_provider is None else f"LLM_PROVIDER={saved_provider}\n" + tmp_path.joinpath(".llm.env").write_text(provider_line + "LLM_MODEL=orphan-model\n") + + provider = get_provider() + + assert provider.provider_type == ProviderType.CLAUDE + assert provider.get_default_model() == "claude-sonnet-4-6" + + @pytest.mark.parametrize("explicit", [False, True]) + def test_blank_saved_provider_preserves_provider_and_model_overrides( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, explicit: bool, + ) -> None: + tmp_path.joinpath(".llm.env").write_text('LLM_PROVIDER=""\nLLM_MODEL=orphan-model\n') + if explicit: + provider = get_provider("ollama", default_model="explicit-model") + expected_model = "explicit-model" + else: + monkeypatch.setenv("LLM_PROVIDER", "ollama") + monkeypatch.setenv("LLM_MODEL", "environment-model") + provider = get_provider() + expected_model = "environment-model" + + assert provider.provider_type == ProviderType.OLLAMA + assert provider.get_default_model() == expected_model + @pytest.mark.parametrize("explicit", [False, True]) def test_provider_override_does_not_read_invalid_saved_file( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, explicit: bool, From 0b37f482d814b98ae7a733ca37ad6b8ccafead24 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:59:09 -0400 Subject: [PATCH 15/28] chore: refresh registry identity after projection repair --- manifests/context-packs/skill-triggers@1.json | 296 +----------------- 1 file changed, 1 insertion(+), 295 deletions(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 301529ac2..c74d6ee1f 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -1,295 +1 @@ -{ - "coverage": { - "skills": 311, - "withTriggers": 33 - }, - "generatedAt": "2026-09-24T23:51:22.784Z", - "id": "skill-triggers@1", - "model": { - "effort": null, - "id": "hand-seeded", - "source": "manual-curation-pending-regeneration" - }, - "registryDigest": "cb89b3434da17c00988095077f61208ab81c2f0611f0edbd5c90d3672bce7ac1", - "schemaVersion": 1, - "triggers": { - "skill:agent-runtime-gateway-smoke-test": [ - "test local agent gateway", - "remote sandbox callback smoke test", - "agent task stuck after dispatch", - "verify gateway tunnel", - "restore web api after agent test" - ], - "skill:api-connector-builder": [ - "add api integration", - "new provider connector", - "match existing integration pattern" - ], - "skill:api-design": [ - "rest endpoint design", - "pagination api", - "status codes", - "api versioning", - "rate limiting api", - "resource naming", - "filtering api", - "api error responses", - "offset pagination", - "limit query parameter", - "pagination defaults" - ], - "skill:backend-patterns": [ - "express api", - "node backend architecture", - "nextjs api routes", - "server side patterns", - "data access layer", - "static file server", - "url path handling", - "file server" - ], - "skill:browser-qa": [ - "deployed feature test", - "visual regression screenshots", - "core web vitals check", - "axe accessibility audit", - "ship do not ship", - "staging verification" - ], - "skill:canary-watch": [ - "post deploy monitoring", - "smoke test url", - "production url check", - "console errors production", - "sse stream check", - "after deploy verification" - ], - "skill:code-tour": [ - "onboarding walkthrough", - "explain subsystem", - "architecture tour", - "pr walkthrough", - "rca tour" - ], - "skill:coding-standards": [ - "code review standards", - "naming conventions", - "readability review", - "immutability conventions", - "fix naming typo", - "export naming", - "consistent exports" - ], - "skill:content-hash-cache-pattern": [ - "cache file processing", - "content addressed cache", - "sha256 hash cache" - ], - "skill:database-migrations": [ - "zero downtime migration", - "schema change production", - "add column large table", - "backfill data", - "expand contract", - "concurrent index", - "migration rollback", - "prisma migration", - "django migration" - ], - "skill:deployment-patterns": [ - "ci cd setup", - "dockerize app", - "health checks", - "rollback strategy", - "production readiness", - "deploy pipeline", - "containerize application" - ], - "skill:design-system": [ - "design tokens", - "visual consistency audit", - "css custom properties", - "ui audit", - "design system bootstrap" - ], - "skill:django-patterns": [ - "django orm", - "drf api", - "django rest framework", - "django caching", - "django signals", - "django middleware" - ], - "skill:django-security": [ - "django authentication", - "csrf protection", - "sql injection prevention", - "xss prevention", - "django deployment security", - "role based access control", - "authorization middleware", - "permissions checks" - ], - "skill:docker-patterns": [ - "dockerfile review", - "docker compose setup", - "container security", - "multi service orchestration" - ], - "skill:error-handling": [ - "error types", - "retry logic", - "circuit breaker", - "user facing errors", - "exception handling patterns", - "typed errors", - "error boundaries", - "go error handling", - "custom error class", - "error codes", - "config validation" - ], - "skill:evm-token-decimals": [ - "token decimals", - "wei conversion", - "erc20 balance off", - "bridge token precision" - ], - "skill:frontend-a11y": [ - "aria attributes", - "screen reader support", - "focus management", - "semantic html", - "form labeling", - "keyboard navigation react", - "a11y lint errors" - ], - "skill:git-workflow": [ - "merge vs rebase", - "commit conventions", - "resolve merge conflict", - "branching strategy", - "clean up commits", - "pull request cleanup", - "git history tidy" - ], - "skill:hexagonal-architecture": [ - "ports and adapters", - "dependency injection boundaries", - "decouple domain from io" - ], - "skill:kubernetes-patterns": [ - "kubernetes manifests", - "kubectl debugging", - "pod probes", - "k8s rbac", - "autoscaling config", - "configmap secrets" - ], - "skill:orch-fix-defect": [ - "fix a bug", - "broken behavior", - "regression fix", - "reproduce bug", - "defect repair" - ], - "skill:postgres-patterns": [ - "slow postgres query", - "query optimization", - "index design", - "rls policies", - "supabase schema", - "postgres indexing", - "database performance", - "schema design postgres", - "postgres driver", - "node postgres", - "query planner" - ], - "skill:python-patterns": [ - "pythonic code", - "pep 8", - "type hints python", - "python code review", - "idiomatic python" - ], - "skill:python-testing": [ - "pytest fixtures", - "mocking python", - "parametrized tests", - "coverage python", - "tdd python" - ], - "skill:redis-patterns": [ - "cache aside pattern", - "distributed lock", - "redis rate limiting", - "cache invalidation" - ], - "skill:regex-vs-llm-structured-text": [ - "parse invoice", - "extract receipt data", - "text extraction pipeline", - "parse form fields", - "cheap document parser", - "extract table data", - "parse log lines", - "parse access logs", - "common log format", - "log line parsing" - ], - "skill:rust-patterns": [ - "rust ownership", - "borrow checker", - "rust error handling", - "traits rust", - "rust concurrency", - "idiomatic rust" - ], - "skill:search-first": [ - "find existing library", - "npm package research", - "before writing custom code", - "evaluate existing tools", - "add dependency research" - ], - "skill:security-review": [ - "security audit", - "authentication review", - "sanitize user input", - "secrets handling", - "payment security checklist", - "prevent injection attacks", - "secure api endpoints", - "authn authz review", - "vulnerability checklist", - "input validation security", - "parameterized queries", - "sql injection" - ], - "skill:security-scan": [ - "audit claude config", - "claudemd security", - "mcp server audit", - "agentshield scan", - "hook configuration audit", - "settings json security" - ], - "skill:tdd-workflow": [ - "write test first", - "failing test", - "red green refactor", - "test driven development", - "regression test first", - "write a regression test" - ], - "skill:verification-loop": [ - "pre pr checks", - "verification report", - "quality gates", - "build lint test coverage", - "before creating a pr" - ] - }, - "triggersDigest": "054898acb769893c46e5bd7037141f7be83fb775515ea1dbb52fb1c18aca19e6" -} +{"coverage":{"skills":311,"withTriggers":33},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"988dbc0f5771edc78c56a3fa2206f5b63b7b5fcb22713f08349d3c469e9f26cf","schemaVersion":1,"triggers":{"skill:agent-runtime-gateway-smoke-test":["test local agent gateway","remote sandbox callback smoke test","agent task stuck after dispatch","verify gateway tunnel","restore web api after agent test"],"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"054898acb769893c46e5bd7037141f7be83fb775515ea1dbb52fb1c18aca19e6"} From 8f34a6a2243a1544b98574670cd6e7e459c39179 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:59:40 -0400 Subject: [PATCH 16/28] chore: preserve skill manifest formatting --- manifests/context-packs/skill-triggers@1.json | 296 +++++++++++++++++- 1 file changed, 295 insertions(+), 1 deletion(-) diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index c74d6ee1f..a24291bb7 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -1 +1,295 @@ -{"coverage":{"skills":311,"withTriggers":33},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"988dbc0f5771edc78c56a3fa2206f5b63b7b5fcb22713f08349d3c469e9f26cf","schemaVersion":1,"triggers":{"skill:agent-runtime-gateway-smoke-test":["test local agent gateway","remote sandbox callback smoke test","agent task stuck after dispatch","verify gateway tunnel","restore web api after agent test"],"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"054898acb769893c46e5bd7037141f7be83fb775515ea1dbb52fb1c18aca19e6"} +{ + "coverage": { + "skills": 311, + "withTriggers": 33 + }, + "generatedAt": "2026-09-24T23:51:22.784Z", + "id": "skill-triggers@1", + "model": { + "effort": null, + "id": "hand-seeded", + "source": "manual-curation-pending-regeneration" + }, + "registryDigest": "988dbc0f5771edc78c56a3fa2206f5b63b7b5fcb22713f08349d3c469e9f26cf", + "schemaVersion": 1, + "triggers": { + "skill:agent-runtime-gateway-smoke-test": [ + "test local agent gateway", + "remote sandbox callback smoke test", + "agent task stuck after dispatch", + "verify gateway tunnel", + "restore web api after agent test" + ], + "skill:api-connector-builder": [ + "add api integration", + "new provider connector", + "match existing integration pattern" + ], + "skill:api-design": [ + "rest endpoint design", + "pagination api", + "status codes", + "api versioning", + "rate limiting api", + "resource naming", + "filtering api", + "api error responses", + "offset pagination", + "limit query parameter", + "pagination defaults" + ], + "skill:backend-patterns": [ + "express api", + "node backend architecture", + "nextjs api routes", + "server side patterns", + "data access layer", + "static file server", + "url path handling", + "file server" + ], + "skill:browser-qa": [ + "deployed feature test", + "visual regression screenshots", + "core web vitals check", + "axe accessibility audit", + "ship do not ship", + "staging verification" + ], + "skill:canary-watch": [ + "post deploy monitoring", + "smoke test url", + "production url check", + "console errors production", + "sse stream check", + "after deploy verification" + ], + "skill:code-tour": [ + "onboarding walkthrough", + "explain subsystem", + "architecture tour", + "pr walkthrough", + "rca tour" + ], + "skill:coding-standards": [ + "code review standards", + "naming conventions", + "readability review", + "immutability conventions", + "fix naming typo", + "export naming", + "consistent exports" + ], + "skill:content-hash-cache-pattern": [ + "cache file processing", + "content addressed cache", + "sha256 hash cache" + ], + "skill:database-migrations": [ + "zero downtime migration", + "schema change production", + "add column large table", + "backfill data", + "expand contract", + "concurrent index", + "migration rollback", + "prisma migration", + "django migration" + ], + "skill:deployment-patterns": [ + "ci cd setup", + "dockerize app", + "health checks", + "rollback strategy", + "production readiness", + "deploy pipeline", + "containerize application" + ], + "skill:design-system": [ + "design tokens", + "visual consistency audit", + "css custom properties", + "ui audit", + "design system bootstrap" + ], + "skill:django-patterns": [ + "django orm", + "drf api", + "django rest framework", + "django caching", + "django signals", + "django middleware" + ], + "skill:django-security": [ + "django authentication", + "csrf protection", + "sql injection prevention", + "xss prevention", + "django deployment security", + "role based access control", + "authorization middleware", + "permissions checks" + ], + "skill:docker-patterns": [ + "dockerfile review", + "docker compose setup", + "container security", + "multi service orchestration" + ], + "skill:error-handling": [ + "error types", + "retry logic", + "circuit breaker", + "user facing errors", + "exception handling patterns", + "typed errors", + "error boundaries", + "go error handling", + "custom error class", + "error codes", + "config validation" + ], + "skill:evm-token-decimals": [ + "token decimals", + "wei conversion", + "erc20 balance off", + "bridge token precision" + ], + "skill:frontend-a11y": [ + "aria attributes", + "screen reader support", + "focus management", + "semantic html", + "form labeling", + "keyboard navigation react", + "a11y lint errors" + ], + "skill:git-workflow": [ + "merge vs rebase", + "commit conventions", + "resolve merge conflict", + "branching strategy", + "clean up commits", + "pull request cleanup", + "git history tidy" + ], + "skill:hexagonal-architecture": [ + "ports and adapters", + "dependency injection boundaries", + "decouple domain from io" + ], + "skill:kubernetes-patterns": [ + "kubernetes manifests", + "kubectl debugging", + "pod probes", + "k8s rbac", + "autoscaling config", + "configmap secrets" + ], + "skill:orch-fix-defect": [ + "fix a bug", + "broken behavior", + "regression fix", + "reproduce bug", + "defect repair" + ], + "skill:postgres-patterns": [ + "slow postgres query", + "query optimization", + "index design", + "rls policies", + "supabase schema", + "postgres indexing", + "database performance", + "schema design postgres", + "postgres driver", + "node postgres", + "query planner" + ], + "skill:python-patterns": [ + "pythonic code", + "pep 8", + "type hints python", + "python code review", + "idiomatic python" + ], + "skill:python-testing": [ + "pytest fixtures", + "mocking python", + "parametrized tests", + "coverage python", + "tdd python" + ], + "skill:redis-patterns": [ + "cache aside pattern", + "distributed lock", + "redis rate limiting", + "cache invalidation" + ], + "skill:regex-vs-llm-structured-text": [ + "parse invoice", + "extract receipt data", + "text extraction pipeline", + "parse form fields", + "cheap document parser", + "extract table data", + "parse log lines", + "parse access logs", + "common log format", + "log line parsing" + ], + "skill:rust-patterns": [ + "rust ownership", + "borrow checker", + "rust error handling", + "traits rust", + "rust concurrency", + "idiomatic rust" + ], + "skill:search-first": [ + "find existing library", + "npm package research", + "before writing custom code", + "evaluate existing tools", + "add dependency research" + ], + "skill:security-review": [ + "security audit", + "authentication review", + "sanitize user input", + "secrets handling", + "payment security checklist", + "prevent injection attacks", + "secure api endpoints", + "authn authz review", + "vulnerability checklist", + "input validation security", + "parameterized queries", + "sql injection" + ], + "skill:security-scan": [ + "audit claude config", + "claudemd security", + "mcp server audit", + "agentshield scan", + "hook configuration audit", + "settings json security" + ], + "skill:tdd-workflow": [ + "write test first", + "failing test", + "red green refactor", + "test driven development", + "regression test first", + "write a regression test" + ], + "skill:verification-loop": [ + "pre pr checks", + "verification report", + "quality gates", + "build lint test coverage", + "before creating a pr" + ] + }, + "triggersDigest": "054898acb769893c46e5bd7037141f7be83fb775515ea1dbb52fb1c18aca19e6" +} From a73e04cc6df07c6bc9e38a9abe3b02640aaf4280 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:42:05 -0400 Subject: [PATCH 17/28] fix: recover failed ledger batches and preserve project state --- manifests/context-packs/skill-triggers@1.json | 2 +- scripts/hooks/ecc-statusline.js | 2 +- scripts/lib/statusline-render.js | 51 ++++++-- skills/story-lifecycle/scripts/ledger.js | 120 ++++++++++++------ tests/docs/story-lifecycle-errors.test.js | 108 ++++++++++++++++ .../story-lifecycle-table-projection.test.js | 22 ++++ .../ecc-statusline-project-plugins.test.js | 55 ++++++++ 7 files changed, 304 insertions(+), 56 deletions(-) create mode 100644 tests/docs/story-lifecycle-errors.test.js create mode 100644 tests/hooks/ecc-statusline-project-plugins.test.js diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index a24291bb7..086cf3d1c 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "988dbc0f5771edc78c56a3fa2206f5b63b7b5fcb22713f08349d3c469e9f26cf", + "registryDigest": "8f496cbd3561621f4727117c3358eddf3e350bcc545e0defd3dd529c14c6990f", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js index f57b348d2..5ccb959e8 100644 --- a/scripts/hooks/ecc-statusline.js +++ b/scripts/hooks/ecc-statusline.js @@ -147,7 +147,7 @@ function buildUsageLines(data, bridge, task) { task, }); - const plugins = render.readInstalledPlugins(); + const plugins = render.readInstalledPlugins(undefined, data.workspace?.current_dir || process.cwd()); const eccLine = render.buildEccLine({ eccVersion: getEccVersion(plugins), hooks: render.getHooksSummary(), diff --git a/scripts/lib/statusline-render.js b/scripts/lib/statusline-render.js index 74cdd11b8..6d145859d 100644 --- a/scripts/lib/statusline-render.js +++ b/scripts/lib/statusline-render.js @@ -217,30 +217,55 @@ function buildEccLine(info) { return parts.join(SEP); } +function pluginProjectRoot(workspace) { + const cwd = path.resolve(workspace || process.cwd()); + for (let candidate = cwd; ; candidate = path.dirname(candidate)) { + if (fs.existsSync(path.join(candidate, '.git'))) return candidate; + if (candidate === path.dirname(candidate)) return cwd; + } +} + +function pluginEnablement(files) { + return files.reduce((enabled, file) => { + try { + const value = JSON.parse(fs.readFileSync(file, 'utf8')).enabledPlugins; + return value && typeof value === 'object' && !Array.isArray(value) ? { ...enabled, ...value } : enabled; + } catch { return enabled; /* Missing or malformed optional display settings. */ } + }, {}); +} + /** - * Enabled plugins with versions, ecc first. - * Reads installed_plugins.json (v2) and settings.json enabledPlugins. - * @param {string} [claudeDir] - defaults to CLAUDE_CONFIG_DIR or ~/.claude + * User and applicable project/local plugin metadata for the current Git root. + * Project-local enablement overrides shared-project and selected user settings. + * This display helper does not reproduce managed or per-session host policy. + * @param {string} [claudeDir] selected profile root + * @param {string} [workspace] current working directory * @returns {Array<{name: string, version: string}>} */ -function readInstalledPlugins(claudeDir) { +function readInstalledPlugins(claudeDir, workspace) { try { const dir = claudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const installed = JSON.parse( fs.readFileSync(path.join(dir, 'plugins', 'installed_plugins.json'), 'utf8') ); - let enabled = null; - try { - const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); - if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') { - enabled = settings.enabledPlugins; - } - } catch { /* no settings \u2014 show all installed */ } + const project = pluginProjectRoot(workspace); + const normalized = value => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value); + const enabled = pluginEnablement([path.join(dir, 'settings.json'), + path.join(project, '.claude', 'settings.json'), path.join(project, '.claude', 'settings.local.json')]); + const ranks = { user: 0, project: 1, local: 2 }; + const applicable = entry => entry && typeof entry === 'object' && + ((!entry.scope || entry.scope === 'user') || + (['project', 'local'].includes(entry.scope) && typeof entry.projectPath === 'string' && + normalized(entry.projectPath) === normalized(project))); const plugins = []; for (const [id, entries] of Object.entries(installed?.plugins || {})) { - if (enabled && enabled[id] === false) continue; - const entry = Array.isArray(entries) ? entries[0] : entries; + if (enabled[id] === false) continue; + const entry = (Array.isArray(entries) ? entries : [entries]).filter(applicable).sort((a, b) => + (ranks[b.scope || 'user'] - ranks[a.scope || 'user']) || + String(b.lastUpdated || b.installedAt || '').localeCompare(String(a.lastUpdated || a.installedAt || '')) || + String(b.version || '').localeCompare(String(a.version || '')))[0]; + if (!entry) continue; plugins.push({ name: id.split('@')[0], version: entry?.version || '' }); } plugins.sort((a, b) => (a.name === 'ecc' ? -1 : b.name === 'ecc' ? 1 : a.name.localeCompare(b.name))); diff --git a/skills/story-lifecycle/scripts/ledger.js b/skills/story-lifecycle/scripts/ledger.js index 1b240efed..b29035833 100644 --- a/skills/story-lifecycle/scripts/ledger.js +++ b/skills/story-lifecycle/scripts/ledger.js @@ -261,6 +261,9 @@ function recover(dir) { } rolledBack++; } + // Restoration is complete. Cleanup failure must never repeat a completed + // rollback after another owner has changed a restored file. + fs.rmSync(journalPath); } fs.rmSync(txn, { recursive: true, force: true }); return rolledBack; @@ -293,17 +296,22 @@ function replaceTable(md, rel, header, rows) { const skipBlanks = (at) => at + (md.slice(at).match(/^(?:[ \t]*\r?\n)+/) || [''])[0].length; const hasDerivedHeader = (at) => md.slice(at).split(/\r?\n/, 2).join('\n').replace(/[ \t]/g, '') === header.replace(/[ \t]/g, ''); const first = skipBlanks(afterMarker); - const start = md[first] === '|' ? first : afterMarker; + const start = hasDerivedHeader(first) ? first : afterMarker; let end = start; - while (md[end] === '|') { - while (md[end] === '|') { const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1; } + while (hasDerivedHeader(end)) { + while (md[end] === '|') { + const nextLine = md.slice(end).split(/\r?\n/, 2)[1] || ''; + if (end !== start && /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(nextLine) && !hasDerivedHeader(end)) break; + const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1; + } // Older projections could insert a second table before a blank-separated // original. Only adjacent copies of this derived header belong to us. const next = skipBlanks(end); if (!hasDerivedHeader(next)) break; end = next; } - return md.slice(0, start) + (newline < 0 ? '\n' : '') + [header, ...rows].join('\n') + '\n' + md.slice(end); + const separator = start === end && md[start] === '|' ? '\n' : ''; + return md.slice(0, start) + (newline < 0 ? '\n' : '') + [header, ...rows].join('\n') + '\n' + separator + md.slice(end); } // Read the ledger with pending changes overlaid; return regenerated epic/sprint files that differ. @@ -377,38 +385,7 @@ function allocate(root, dir, epic, count) { return { ids }; } -// changes: { create: { rel: content }, update: { rel: content } } -function apply(root, dir, changes) { - const keys = new Set(); - const normalize = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([rel, content]) => { - const key = rel.replace(/\\/g, '/'); - if (keys.has(key)) throw new LedgerError(`duplicate path in batch: ${key}`); - keys.add(key); - return [key, content]; - })); - const create = normalize(changes.create); - const update = normalize(changes.update); - const reg = registryDir(root, dir); - for (const rel of Object.keys(create)) { - if (fs.existsSync(resolveLedgerPath(dir, rel))) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4); - const m = rel.match(STORY_FILE); - if (m && !fs.existsSync(path.join(reg, `${m[1]}-${m[2]}`))) throw new LedgerError(`${rel}: story ID was not allocated`); - if (m && parseStory(rel, create[rel]).status !== 'todo') throw new LedgerError(`${rel}: new stories start as todo`); - } - for (const rel of Object.keys(update)) { - const abs = resolveLedgerPath(dir, rel); - if (!fs.existsSync(abs)) throw new LedgerError(`cannot update missing ${rel}`); - if (!STORY_FILE.test(rel)) continue; - const from = parseStory(rel, readRegular(abs, 'utf8')).status; - const to = parseStory(rel, update[rel]).status; - if (to !== from && STATUSES.indexOf(to) !== STATUSES.indexOf(from) + 1) { - throw new LedgerError(`${rel}: status ${from} -> ${to} is not allowed; status moves forward one step at a time`); - } - } - const pending = { ...create, ...update }; - const all = { ...pending, ...project(dir, pending).derived }; - const txn = path.join(dir, '.txn'); - fs.mkdirSync(txn, { mode: 0o700 }); +function publishTransaction(dir, txn, all, create) { const entries = Object.keys(all).map((rel, i) => { const existed = fs.existsSync(resolveLedgerPath(dir, rel)); if (rel in create && existed) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4); @@ -440,6 +417,70 @@ function apply(root, dir, changes) { return { ok: true, written: entries.map((e) => e.path) }; } +// changes: { create: { rel: content }, update: { rel: content } } +function apply(root, dir, changes) { + const keys = new Set(); + const normalize = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([rel, content]) => { + const key = rel.replace(/\\/g, '/'); + if (keys.has(key)) throw new LedgerError(`duplicate path in batch: ${key}`); + keys.add(key); + return [key, content]; + })); + const create = normalize(changes.create); + const update = normalize(changes.update); + const reg = registryDir(root, dir); + for (const rel of Object.keys(create)) { + if (fs.existsSync(resolveLedgerPath(dir, rel))) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4); + const m = rel.match(STORY_FILE); + if (m && !fs.existsSync(path.join(reg, `${m[1]}-${m[2]}`))) throw new LedgerError(`${rel}: story ID was not allocated`); + if (m && parseStory(rel, create[rel]).status !== 'todo') throw new LedgerError(`${rel}: new stories start as todo`); + } + for (const rel of Object.keys(update)) { + const abs = resolveLedgerPath(dir, rel); + if (!fs.existsSync(abs)) throw new LedgerError(`cannot update missing ${rel}`); + if (!STORY_FILE.test(rel)) continue; + const from = parseStory(rel, readRegular(abs, 'utf8')).status; + const to = parseStory(rel, update[rel]).status; + if (to !== from && STATUSES.indexOf(to) !== STATUSES.indexOf(from) + 1) { + throw new LedgerError(`${rel}: status ${from} -> ${to} is not allowed; status moves forward one step at a time`); + } + } + const pending = { ...create, ...update }; + const all = { ...pending, ...project(dir, pending).derived }; + return withTransaction(dir, txn => publishTransaction(dir, txn, all, create)); +} + +function describeError(error) { return `${error.code ? `${error.code}: ` : ''}${error.message}`; } + +function withOwnerLock(dir, waitMs, operation) { + const release = acquireLock(dir, waitMs); + let result, primaryError; + try { result = operation(); } catch (error) { primaryError = error; } + try { release(); } catch (releaseError) { + if (!primaryError) throw releaseError; + throw new LedgerError(`${describeError(primaryError)}; lock release failed: ${describeError(releaseError)}`, primaryError.exitCode || 1); + } + if (primaryError) throw primaryError; + return result; +} + +function withTransaction(dir, operation) { + const txn = path.join(dir, '.txn'); + fs.mkdirSync(txn, { mode: 0o700 }); + try { + return operation(txn); + } catch (error) { + // Caller retains the live owner lock throughout restoration. A removed + // journal marks a committed batch, so recover then only cleans debris. + try { recover(dir); } catch (recoveryError) { + const state = fs.existsSync(path.join(dir, '.txn', 'journal.json')) ? 'recovery pending' : 'transaction cleanup pending'; + throw new LedgerError(`${describeError(error)}; ${state}: ${describeError(recoveryError)}; ` + + 'resolve the cause, then run recover to retry without overwriting foreign-owned files.', error.exitCode || 1); + } + throw error; + } +} + function main() { const opts = parseArgs(process.argv.slice(2)); const cmd = opts._[0]; @@ -462,17 +503,14 @@ function main() { throw new LedgerError('--wait-ms must be an integer in 0..60000'); } requireHardlinks(dir); - const release = acquireLock(dir, waitMs); - try { + return withOwnerLock(dir, waitMs, () => { const rolledBack = recover(dir); if (cmd === 'recover') return { ok: true, rolledBack }; if (cmd === 'allocate') return allocate(root, dir, opts.epic, Number(opts.count || 1)); if (cmd === 'reconcile') return apply(root, dir, {}); if (cmd === 'apply') return apply(root, dir, JSON.parse(fs.readFileSync(opts.input, 'utf8'))); throw new LedgerError(`unknown command: ${cmd}`); - } finally { - release(); - } + }); } if (require.main === module) { diff --git a/tests/docs/story-lifecycle-errors.test.js b/tests/docs/story-lifecycle-errors.test.js new file mode 100644 index 000000000..2107ed186 --- /dev/null +++ b/tests/docs/story-lifecycle-errors.test.js @@ -0,0 +1,108 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const LEDGER = path.join(__dirname, '../../skills/story-lifecycle/scripts/ledger.js'); +const temps = new Set(); +const marker = ''; +const epic = `# Epic: Auth\n\n${marker}\n| ID | Title | Status |\n| --- | --- | --- |\n`; +const story = (id, status = 'todo') => `# Story: ${id}\n\n**ID:** ${id}\n**Epic:** auth\n**Sprint:** unassigned\n**Status:** ${status}\n**Points:** 3\n`; +function run(root, args, preload) { + return spawnSync(process.execPath, [...(preload ? ['-r', preload] : []), LEDGER, ...args, '--root', root], + { encoding: 'utf8', timeout: 15000 }); +} +function ok(result) { assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); } +function input(root, changes) { const file = path.join(root, 'input.json'); fs.writeFileSync(file, JSON.stringify(changes)); return file; } +function snapshot(dir) { + return Object.fromEntries(fs.readdirSync(dir).sort().map(name => { + const file = path.join(dir, name);return [name, fs.lstatSync(file).isDirectory() ? snapshot(file) : fs.readFileSync(file).toString('base64')]; + })); +} +function fixture(update = false) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'story-ordinary-error-'));temps.add(root); + execFileSync('git', ['init', '-q', root]);ok(run(root, ['init'])); + fs.writeFileSync(path.join(root, '.stories/epics/auth.md'), epic); + const ids = ok(run(root, ['allocate', '--epic', 'auth', '--count', '2'])).ids; + if (update) ok(run(root, ['apply', '--input', input(root, { create: Object.fromEntries(ids.map(id => [`${id}.md`, story(id)])) })])); + const before = snapshot(path.join(root, '.stories')); + const changes = { [update ? 'update' : 'create']: Object.fromEntries(ids.map(id => [`${id}.md`, story(id, update ? 'in-progress' : 'todo')])) }; + return { root, ids, before, registryBefore: snapshot(path.join(root, '.git/story-lifecycle')), file: input(root, changes) }; +} +function fault(root, { _update = false, recoveryFailure = false, cleanupFailure = false, journalRemovalFailure = false, releaseFailure = false } = {}) { + const file = path.join(root, 'bounded-fault.cjs'); + fs.writeFileSync(file, `const fs=require('node:fs');const path=require('node:path'); +const dir=${JSON.stringify(path.join(root, '.stories'))};const trace=${JSON.stringify(path.join(root, 'trace.json'))}; +const state={published:0,recoveryLocks:[]};const save=()=>fs.writeFileSync(trace,JSON.stringify(state)); +const failure=(text,code)=>Object.assign(new Error(text),{code}); +for(const method of ['linkSync','renameSync']){const original=fs[method];fs[method]=function(source,target,...args){ + const publication=path.dirname(target)===dir && /^auth-[12]\\.md$/.test(path.basename(target)) && /^new-\\d+$/.test(path.basename(source)); + if(publication && state.published===1 && ${!cleanupFailure}){state.primary=true;state.firstBytes=fs.readFileSync(path.join(dir,'auth-1.md')).toString('base64');state.lock=JSON.parse(fs.readFileSync(path.join(dir,'.lock'),'utf8'));save();throw failure('PRIMARY ordinary second publication','EIO');} + if(path.basename(source).startsWith('backup-')){const holder=JSON.parse(fs.readFileSync(path.join(dir,'.lock'),'utf8'));state.recoveryLocks.push(holder.pid===process.pid);save(); + if(${recoveryFailure} && path.basename(target)==='restore.tmp' && path.basename(source)==='backup-1')throw failure('RECOVERY backup restore refused','EACCES');} + const result=original.call(fs,source,target,...args);if(publication){state.published++;save();}return result; +};} +${journalRemovalFailure ? "const rmJournal=fs.rmSync;fs.rmSync=function(target,...args){if(target===path.join(dir,'.txn','journal.json') && state.primary && state.recoveryLocks.length){throw failure('RECOVERY journal removal refused','EACCES');}return rmJournal.call(fs,target,...args);};" : ''} +${releaseFailure ? "const lstat=fs.lstatSync;fs.lstatSync=function(target,...args){if(target===path.join(dir,'.lock') && state.primary && state.recoveryLocks.length && !fs.existsSync(path.join(dir,'.txn'))){throw failure('RELEASE ownership read refused','EACCES');}return lstat.call(fs,target,...args);};" : ''} +${cleanupFailure ? "const rm=fs.rmSync;fs.rmSync=function(target,...args){if(target===path.join(dir,'.txn') && state.published===2 && fs.existsSync(target) && !fs.existsSync(path.join(dir,'.txn','journal.json'))){state.cleanup=true;save();throw failure('CLEANUP committed directory refused','EACCES');}return rm.call(fs,target,...args);};" : ''} +`); + return file; +} +function restored(f) { + assert.deepEqual(snapshot(path.join(f.root, '.stories')), f.before, 'every prior byte and new-file absence must match'); + assert.deepEqual(snapshot(path.join(f.root, '.git/story-lifecycle')), f.registryBefore); + const state = ok(run(f.root, ['status']));assert.equal(state.recoveryPending, false);assert.equal(state.projectionAvailable, undefined); + assert.deepEqual(state.drift, []); +} +const tests = [ + ['journal removal failure leaves restored bytes and a retryable journal', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { journalRemovalFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY journal removal refused/);assert.match(result.stderr, /recovery pending/i); + const current = snapshot(path.join(f.root, '.stories'));const { '.txn': transaction, ...data } = current;assert.ok(transaction['journal.json']);assert.deepEqual(data, f.before); + assert.equal(ok(run(f.root, ['status'])).recoveryPending, true);ok(run(f.root, ['recover']));restored(f); + }], + ['release failure preserves the primary error after successful rollback and permits stale-lock recovery', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { releaseFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RELEASE ownership read refused/); + const { '.lock': lock, ...data } = snapshot(path.join(f.root, '.stories'));assert.ok(lock);assert.deepEqual(data, f.before); + ok(run(f.root, ['recover']));restored(f); + }], + ['failure after the committed journal marker does not undo a completed update', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, cleanupFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /CLEANUP committed directory refused/); + for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress')); + assert.ok(!fs.existsSync(path.join(f.root, '.stories/.txn/journal.json'))); + assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock'))); + const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.equal(trace.published, 2);assert.deepEqual(trace.recoveryLocks, []); + ok(run(f.root, ['recover']));assert.deepEqual(ok(run(f.root, ['status'])).drift, []); + for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress')); + }], + ['crash70 preserves the pending journal for explicit recovery', () => { + const f = fixture();const preload = path.join(f.root, 'crash.cjs');fs.writeFileSync(preload, "process.env.STORY_LEDGER_CRASH_AFTER='1';"); + assert.equal(run(f.root, ['apply', '--input', f.file], preload).status, 70); + assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json'))); + ok(run(f.root, ['recover']));restored(f); + }], + ...[false, true].map(update => [`ordinary second ${update ? 'rename/update' : 'link/create'} failure restores the entire batch before returning`, () => { + const f = fixture(update);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/); + const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.equal(trace.published, 1);assert.equal(trace.lock.pid > 0, true); + if (update) { assert.notEqual(trace.firstBytes, f.before['auth-1.md']);assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); } + else assert.ok(trace.firstBytes); + assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); + restored(f); + }]), + ['rollback failure reports both errors, retains journal, and explicit retry restores all bytes', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, recoveryFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY backup restore refused/);assert.match(result.stderr, /recovery pending/i); + assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock'))); + const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); + assert.equal(ok(run(f.root, ['status'])).recoveryPending, true);ok(run(f.root, ['recover']));restored(f); + }], +]; +let passed = 0;let failed = 0; +for (const [name, test] of tests) { + try { test();console.log(`PASS ${name}`);passed++; } catch (error) { console.log(`FAIL ${name}: ${error.stack}`);failed++; } + finally { for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true });temps.clear(); } +} +console.log(`Passed: ${passed}\nFailed: ${failed}`);process.exitCode = failed ? 1 : 0; diff --git a/tests/docs/story-lifecycle-table-projection.test.js b/tests/docs/story-lifecycle-table-projection.test.js index 13c8a09a4..ee7adef81 100644 --- a/tests/docs/story-lifecycle-table-projection.test.js +++ b/tests/docs/story-lifecycle-table-projection.test.js @@ -57,6 +57,28 @@ function assertStable(root, rel, expected) { } const tests = [ + ...['epic', 'sprint'].flatMap(kind => ['apply', 'reconcile'].flatMap(command => ['', '\n'].map(gap => [ + `${command} preserves a handwritten ${kind} table below an empty marker with gap ${JSON.stringify(gap)}`, () => { + const root = newRepo();const rel = kind === 'epic' ? 'epics/auth-flow.md' : 'sprints/sprint-1.md'; + const prefix = kind === 'epic' ? PREFIX : '# Sprint 1\n\n'; + const handwritten = '| Owner | Note |\n| --- | --- |\n| User | Keep every byte |\n'; + const suffix = `${gap}${handwritten}${SUFFIX}${kind === 'sprint' ? '\n## Total Points: 0\n' : ''}`; + write(root, rel, `${prefix}${MARKER}\n${suffix}`); + if (command === 'apply') { + const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + apply(root, { create: { [`${id}.md`]: story(id) } }); + } else run(root, ['reconcile']); + const actual = read(root, rel);assert.ok(actual.endsWith(suffix), 'handwritten table and prose must remain byte-identical'); + assert.equal(actual.split(handwritten).length, 2);assertStable(root, rel, actual); + }, + ]))), + ['an unrelated table immediately after derived rows survives repeat transitions', () => { + const root = newRepo();const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + const user = '| Owner | Note |\n| --- | --- |\n| User | Keep |\n'; + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n${user}${SUFFIX}`); + apply(root, { create: { [`${id}.md`]: story(id) } }); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n| ${id} | Sign in | todo |\n${user}${SUFFIX}`); + }], ['an EOF marker without a newline appends its table after the marker', () => { const root = newRepo(); write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}`); diff --git a/tests/hooks/ecc-statusline-project-plugins.test.js b/tests/hooks/ecc-statusline-project-plugins.test.js new file mode 100644 index 000000000..a9bfb5d48 --- /dev/null +++ b/tests/hooks/ecc-statusline-project-plugins.test.js @@ -0,0 +1,55 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { readInstalledPlugins } = require('../../scripts/lib/statusline-render'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plugin-project-')); +const config = path.join(root, 'profile');const a = path.join(root, 'A');const b = path.join(root, 'B'); +for (const dir of [config, a, b]) fs.mkdirSync(path.join(dir, '.claude'), { recursive: true }); +fs.mkdirSync(path.join(config, 'plugins')); +const records = { 'a-only@fixture': [{ scope: 'local', projectPath: a, version: '9.9.9' }], + 'shared@fixture': [{ scope: 'project', projectPath: a, version: '1.1.0' }, { scope: 'project', projectPath: b, version: '2.2.0' }, { scope: 'user', version: '0.0.1' }], + 'global@fixture': [{ scope: 'user', version: '3.3.3' }] }; +const write = value => fs.writeFileSync(path.join(config, 'plugins/installed_plugins.json'), JSON.stringify({ version: 2, plugins: value })); +const settings = (dir, name, value) => fs.writeFileSync(path.join(dir, '.claude', name), JSON.stringify({ enabledPlugins: value })); +function cli(project) { + const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../scripts/hooks/ecc-statusline.js')], { + input: JSON.stringify({ model: { display_name: 'Fixture' }, workspace: { current_dir: project } }), encoding: 'utf8', timeout: 5000, + env: { ...process.env, CLAUDE_CONFIG_DIR: config, ECC_STATUSLINE_USAGE: '1', ECC_STATUSLINE_COMPACT: '0' }, + });assert.equal(result.status, 0, result.stderr);return result.stdout; +} +const tests = [ + ['actual B statusline excludes A-only plugins and selects B shared version independent of record order', () => { + write(records);const first = cli(b);assert.doesNotMatch(first, /a-only|1\.1\.0/);assert.match(first, /shared.*2\.2\.0/); + write({ ...records, 'shared@fixture': [...records['shared@fixture']].reverse() });assert.equal(cli(b), first); + }], + ['project A gets its applicable local and shared records', () => { write(records);const output = cli(a);assert.match(output, /a-only.*9\.9\.9/);assert.match(output, /shared.*1\.1\.0/); }], + ['project settings disable a user-enabled plugin in the real statusline', () => { + write(records);fs.writeFileSync(path.join(config, 'settings.json'), JSON.stringify({ enabledPlugins: { 'global@fixture': true } })); + settings(b, 'settings.json', { 'global@fixture': false });assert.doesNotMatch(cli(b), /global/); + }], + ['local settings override shared project settings without changing their bytes', () => { + settings(b, 'settings.local.json', { 'global@fixture': true });const file = path.join(b, '.claude/settings.json');const before = fs.readFileSync(file);assert.match(cli(b), /global/);assert.deepEqual(fs.readFileSync(file), before); + }], + ['applicable local version overrides project and user records', () => { + write({ ...records, 'shared@fixture': [...records['shared@fixture'], { scope: 'local', projectPath: b, version: '4.4.4' }] }); + assert.equal(readInstalledPlugins(config, b).find(x => x.name === 'shared').version, '4.4.4'); + }], + ['nested Git workspaces use the project inventory and local settings', () => { + require('node:child_process').execFileSync('git', ['init', '-q', b]); + const nested = path.join(b, 'src');fs.mkdirSync(nested); + write(records);settings(b, 'settings.local.json', { 'global@fixture': false }); + const output = cli(nested);assert.match(output, /shared.*2\.2\.0/);assert.doesNotMatch(output, /global|a-only/); + }], + ['a similarly named sibling path does not inherit B-scoped installations', () => { + const sibling = path.join(root, 'B-other');fs.mkdirSync(sibling);write(records); + const plugins = readInstalledPlugins(config, sibling);assert.equal(plugins.find(x => x.name === 'shared').version, '0.0.1');assert.ok(!plugins.some(x => x.name === 'a-only')); + }], +]; +let passed = 0;let failed = 0; +try { + for (const [name, test] of tests) { try { test();console.log(`PASS ${name}`);passed++; } catch (error) { console.log(`FAIL ${name}: ${error.stack}`);failed++; } } +} finally { fs.rmSync(root, { recursive: true, force: true }); } +console.log(`Passed: ${passed}\nFailed: ${failed}`);process.exitCode = failed ? 1 : 0; From 6d0b8d4dcb4d5dde9457dcf78ffd4c0c8e900163 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:57:27 -0400 Subject: [PATCH 18/28] test: bind ledger failure snapshots to file descriptors --- tests/docs/story-lifecycle-errors.test.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/docs/story-lifecycle-errors.test.js b/tests/docs/story-lifecycle-errors.test.js index 2107ed186..42c9dc46b 100644 --- a/tests/docs/story-lifecycle-errors.test.js +++ b/tests/docs/story-lifecycle-errors.test.js @@ -15,8 +15,15 @@ function run(root, args, preload) { function ok(result) { assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); } function input(root, changes) { const file = path.join(root, 'input.json'); fs.writeFileSync(file, JSON.stringify(changes)); return file; } function snapshot(dir) { - return Object.fromEntries(fs.readdirSync(dir).sort().map(name => { - const file = path.join(dir, name);return [name, fs.lstatSync(file).isDirectory() ? snapshot(file) : fs.readFileSync(file).toString('base64')]; + return Object.fromEntries(fs.readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)).map(entry => { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) return [entry.name, snapshot(file)]; + const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + assert.ok(fs.fstatSync(fd).isFile(), 'snapshot requires a regular file'); + return [entry.name, fs.readFileSync(fd, 'base64')]; + } finally { fs.closeSync(fd); } })); } function fixture(update = false) { From 4ce5ae0119217276659571e1bf0d46fb98bd2a7f Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:25:44 -0400 Subject: [PATCH 19/28] fix: preserve search scope and installed cache discovery --- .agents/skills/module-regression/SKILL.md | 2 +- .opencode/commands/instinct-status.md | 2 +- commands/auto-update.md | 2 +- commands/instinct-status.md | 2 +- commands/sessions.md | 12 ++--- commands/skill-health.md | 6 +-- commands/skill-host-compat.md | 4 +- docker/plugin-setup/codex-auth-relay.js | 9 +++- docs/ja-JP/commands/auto-update.md | 2 +- docs/ja-JP/commands/instinct-status.md | 2 +- docs/ja-JP/commands/skill-health.md | 6 +-- docs/tr/commands/instinct-status.md | 2 +- docs/tr/commands/sessions.md | 16 +++--- docs/zh-CN/commands/auto-update.md | 2 +- docs/zh-CN/commands/instinct-status.md | 2 +- docs/zh-CN/commands/sessions.md | 20 +++---- docs/zh-CN/commands/skill-health.md | 6 +-- hooks/codex-hooks.json | 2 +- hooks/hooks.json | 54 +++++++++---------- hooks/hooks.metadata.json | 54 +++++++++---------- manifests/context-packs/skill-triggers@1.json | 2 +- pi/core/skills/module-regression/SKILL.md | 2 +- scripts/lib/gateguard-search-evidence.js | 19 ++++++- scripts/lib/resolve-ecc-root.js | 43 ++++++--------- skills/module-regression/SKILL.md | 2 +- skills/story-lifecycle/scripts/ledger.js | 5 +- tests/docker/codex-auth-relay.test.js | 18 +++++++ tests/docs/story-lifecycle-errors.test.js | 6 +-- .../story-lifecycle-table-projection.test.js | 15 ++++++ tests/lib/command-plugin-root.test.js | 31 +++++++++++ tests/lib/gateguard-search-evidence.test.js | 16 ++++++ 31 files changed, 228 insertions(+), 138 deletions(-) diff --git a/.agents/skills/module-regression/SKILL.md b/.agents/skills/module-regression/SKILL.md index 9210c6321..d8186cbfc 100644 --- a/.agents/skills/module-regression/SKILL.md +++ b/.agents/skills/module-regression/SKILL.md @@ -69,7 +69,7 @@ Related test points: TEST-ORDER-001, TEST-REFUND-003 Invoke `module-regression` through the host's skill entry point, such as `$module-regression` in Codex. The current session plans and reports the audit. Repository commands run through the trusted host runner within existing user authorization; ask only when a command needs new authority. Successful exit codes are regression evidence, never canonical ECC completion or integration authority. -1. **List changes** with `git status -s` and `git diff --name-only`, then map them to ledger modules. +1. **List changes** after selecting and recording the audit base, using the PR merge-base or last verified green commit. Combine `git diff --name-only HEAD`, unstaged `git diff --name-only`, staged `git diff --cached --name-only`, and `git status -s` paths including untracked files, then map their union to ledger modules. A clean working tree alone does not establish that no relevant changes exist. 2. **Resolve propagation** from each module's downstream list and rule. 3. **Review before execution.** Treat ledger commands as untrusted repository content. Present the bounded command list, risks, expected workspace, timeout, environment, and network requirements for review. Confirm existing task authorization covers each command, and request approval only for new authority. Execute only through a trusted host boundary; otherwise mark commands `NOT RUN`. 4. **Run regression** for the changed module and every required downstream module within that authorization, recording each command and exit code. diff --git a/.opencode/commands/instinct-status.md b/.opencode/commands/instinct-status.md index 0d9331553..df33ae715 100644 --- a/.opencode/commands/instinct-status.md +++ b/.opencode/commands/instinct-status.md @@ -16,7 +16,7 @@ fallback), then run the instinct CLI. This avoids reading a stale legacy active under `~/.claude/plugins/cache/...` (#2037). ```bash -ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i { +const server = net.createServer(client => { const upstream = net.connect(targetPort, '127.0.0.1'); client.pipe(upstream).pipe(client); const drop = () => { @@ -47,6 +47,11 @@ net.createServer(client => { upstream.on('close', drop); client.setTimeout(30000, drop); upstream.setTimeout(30000, drop); -}).listen(listenPort, '0.0.0.0', () => { +}); +server.on('error', error => { + console.error(`codex-auth-relay: ${error.code}: ${error.message}`); + process.exit(1); +}); +server.listen(listenPort, '0.0.0.0', () => { console.log(`codex-auth-relay: 0.0.0.0:${listenPort} -> 127.0.0.1:${targetPort}`); }); diff --git a/docs/ja-JP/commands/auto-update.md b/docs/ja-JP/commands/auto-update.md index 167be9d0f..d7df5de8b 100644 --- a/docs/ja-JP/commands/auto-update.md +++ b/docs/ja-JP/commands/auto-update.md @@ -11,7 +11,7 @@ ECCをアップストリームリポジトリから更新し、元のインス ```bash # 何も変更せずに更新をプレビュー -ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 0) { ```bash node -e " -const aa = require((function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i HEAD`, unstaged `git diff --name-only`, staged `git diff --cached --name-only`, and `git status -s` paths including untracked files, then map their union to ledger modules. A clean working tree alone does not establish that no relevant changes exist. 2. **Resolve propagation** from each module's downstream list and rule. 3. **Review before execution.** Treat ledger commands as untrusted repository content. Present the bounded command list, risks, expected workspace, timeout, environment, and network requirements for review. Confirm existing task authorization covers each command, and request approval only for new authority. Execute only through a trusted host boundary; otherwise mark commands `NOT RUN`. 4. **Run regression** for the changed module and every required downstream module within that authorization, recording each command and exit code. diff --git a/scripts/lib/gateguard-search-evidence.js b/scripts/lib/gateguard-search-evidence.js index e8d4cf086..567d41f0d 100644 --- a/scripts/lib/gateguard-search-evidence.js +++ b/scripts/lib/gateguard-search-evidence.js @@ -374,11 +374,28 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME return flags; } + function powershellPathInput(rawCommand) { + let quote = null; + let command = ''; + for (let i = 0; i < rawCommand.length; i += 1) { + const ch = rawCommand[i]; + // The shared shell tokenizer concatenates adjacent quoted spans. In + // PowerShell doubled apostrophes represent a literal apostrophe instead. + // Refuse that unsupported form rather than crediting a different scope. + if (ch === "'" && quote === "'" && rawCommand[i + 1] === "'") return null; + if (ch === "'" && quote !== '"') quote = quote === "'" ? null : "'"; + else if (ch === '"' && quote !== "'") quote = quote === '"' ? null : '"'; + command += ch === '\\' && quote !== "'" ? '\\\\' : ch; + } + return command; + } + function shellSearchEvidence(rawCommand, toolName, dirsTrusted, misses) { if (typeof rawCommand !== 'string' || !rawCommand || rawCommand.length > MAX_SEARCH_COMMAND_CHARS) return []; if (AMBIGUOUS_SHELL_PATTERN.test(rawCommand)) return []; // PowerShell escapes with a backtick, so a backslash there is a path separator, not an escape. - const command = toolName === 'PowerShell' ? rawCommand.replace(/\\/g, '\\\\') : rawCommand; + const command = toolName === 'PowerShell' ? powershellPathInput(rawCommand) : rawCommand; + if (command === null) return []; const pipeFlags = segmentPipeFlags(command); const evidence = []; let trusted = dirsTrusted; diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index a32deb5ab..5adc3deb1 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -102,35 +102,22 @@ function resolveEccRoot(options = {}) { } } - // Plugin cache — Claude Code stores marketplace plugins under - // ~/.claude/plugins/cache//// - try { - for (const slug of PLUGIN_CACHE_SLUGS) { - const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug); - const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true }); - - for (const orgEntry of orgDirs) { - if (!orgEntry.isDirectory()) continue; - const orgPath = path.join(cacheBase, orgEntry.name); - - let versionDirs; - try { - versionDirs = fs.readdirSync(orgPath, { withFileTypes: true }); - } catch { - continue; - } - - for (const verEntry of versionDirs) { - if (!verEntry.isDirectory()) continue; - const candidate = path.join(orgPath, verEntry.name); - if (isRoot(candidate)) { - return candidate; - } - } + // Missing cache locations are independent; unreadable/corrupt locations must surface. + const directories = (dir) => { + try { return fs.readdirSync(dir, { withFileTypes: true }); } + catch (error) { if (error.code === 'ENOENT') return [];throw error; } + }; + for (const slug of PLUGIN_CACHE_SLUGS) { + const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug); + for (const orgEntry of directories(cacheBase)) { + if (!orgEntry.isDirectory()) continue; + const orgPath = path.join(cacheBase, orgEntry.name); + for (const verEntry of directories(orgPath)) { + if (!verEntry.isDirectory()) continue; + const candidate = path.join(orgPath, verEntry.name); + if (isRoot(candidate)) return candidate; } } - } catch { - // Plugin cache doesn't exist or isn't readable — continue to fallback } return claudeDir; @@ -167,7 +154,7 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) { * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i HEAD`, unstaged `git diff --name-only`, staged `git diff --cached --name-only`, and `git status -s` paths including untracked files, then map their union to ledger modules. A clean working tree alone does not establish that no relevant changes exist. 2. **Resolve propagation** from each module's downstream list and rule. 3. **Review before execution.** Treat ledger commands as untrusted repository content. Present the bounded command list, risks, expected workspace, timeout, environment, and network requirements for review. Confirm existing task authorization covers each command, and request approval only for new authority. Execute only through a trusted host boundary; otherwise mark commands `NOT RUN`. 4. **Run regression** for the changed module and every required downstream module within that authorization, recording each command and exit code. diff --git a/skills/story-lifecycle/scripts/ledger.js b/skills/story-lifecycle/scripts/ledger.js index b29035833..2c91d4adf 100644 --- a/skills/story-lifecycle/scripts/ledger.js +++ b/skills/story-lifecycle/scripts/ledger.js @@ -331,15 +331,16 @@ function project(dir, pending = {}) { } } const derived = {}; + const cell = (title) => title.replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); for (const rel of list('epics', EPIC_FILE)) { const slug = rel.match(EPIC_FILE)[1]; - const rows = stories.filter((s) => s.epic === slug).map((s) => `| ${s.id} | ${s.title} | ${s.status} |`); + const rows = stories.filter((s) => s.epic === slug).map((s) => `| ${s.id} | ${cell(s.title)} | ${s.status} |`); const next = replaceTable(read(rel), rel, '| ID | Title | Status |\n| --- | --- | --- |', rows); if (next !== read(rel)) derived[rel] = next; } for (const rel of sprints) { const inSprint = stories.filter((s) => s.sprint === rel.match(SPRINT_FILE)[1]); - const rows = inSprint.map((s) => `| ${s.id} | ${s.title} | ${s.points} | ${s.status} |`); + const rows = inSprint.map((s) => `| ${s.id} | ${cell(s.title)} | ${s.points} | ${s.status} |`); const total = inSprint.reduce((sum, s) => sum + Number(s.points), 0); const next = replaceTable(read(rel), rel, '| ID | Title | Points | Status |\n| --- | --- | --- | --- |', rows) .replace(/^## Total Points:.*$/m, `## Total Points: ${total}`); diff --git a/tests/docker/codex-auth-relay.test.js b/tests/docker/codex-auth-relay.test.js index 2ddc85c87..5a70b291f 100644 --- a/tests/docker/codex-auth-relay.test.js +++ b/tests/docker/codex-auth-relay.test.js @@ -70,3 +70,21 @@ for (const port of ['1455', '24567']) { assert.equal(child.stdout, ''); }); } + +test('occupied listener reports a bounded relay diagnostic without a startup banner', { timeout: 5000 }, async t => { + const occupied = net.createServer(); + occupied.listen(0, '0.0.0.0'); + await once(occupied, 'listening'); + t.after(() => new Promise(resolve => occupied.close(resolve))); + const port = occupied.address().port; + const child = spawn(process.execPath, [script], { env: { ...process.env, + ECC_RELAY_LISTEN: String(port), ECC_RELAY_TARGET: String(port === 1455 ? 1456 : 1455) } }); + let stderr = '';let stdout = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + child.stdout.on('data', chunk => { stdout += chunk; }); + const [code] = await once(child, 'close'); + assert.equal(code, 1); + assert.match(stderr, /^codex-auth-relay: .*EADDRINUSE/m); + assert.doesNotMatch(stderr, /Unhandled 'error' event/); + assert.equal(stdout, ''); +}); diff --git a/tests/docs/story-lifecycle-errors.test.js b/tests/docs/story-lifecycle-errors.test.js index 42c9dc46b..e1dc7e508 100644 --- a/tests/docs/story-lifecycle-errors.test.js +++ b/tests/docs/story-lifecycle-errors.test.js @@ -36,7 +36,7 @@ function fixture(update = false) { const changes = { [update ? 'update' : 'create']: Object.fromEntries(ids.map(id => [`${id}.md`, story(id, update ? 'in-progress' : 'todo')])) }; return { root, ids, before, registryBefore: snapshot(path.join(root, '.git/story-lifecycle')), file: input(root, changes) }; } -function fault(root, { _update = false, recoveryFailure = false, cleanupFailure = false, journalRemovalFailure = false, releaseFailure = false } = {}) { +function fault(root, { recoveryFailure = false, cleanupFailure = false, journalRemovalFailure = false, releaseFailure = false } = {}) { const file = path.join(root, 'bounded-fault.cjs'); fs.writeFileSync(file, `const fs=require('node:fs');const path=require('node:path'); const dir=${JSON.stringify(path.join(root, '.stories'))};const trace=${JSON.stringify(path.join(root, 'trace.json'))}; @@ -75,7 +75,7 @@ const tests = [ ok(run(f.root, ['recover']));restored(f); }], ['failure after the committed journal marker does not undo a completed update', () => { - const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, cleanupFailure: true })); + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { cleanupFailure: true })); assert.equal(result.status, 1);assert.match(result.stderr, /CLEANUP committed directory refused/); for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress')); assert.ok(!fs.existsSync(path.join(f.root, '.stories/.txn/journal.json'))); @@ -100,7 +100,7 @@ const tests = [ restored(f); }]), ['rollback failure reports both errors, retains journal, and explicit retry restores all bytes', () => { - const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, recoveryFailure: true })); + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { recoveryFailure: true })); assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY backup restore refused/);assert.match(result.stderr, /recovery pending/i); assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock'))); const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); diff --git a/tests/docs/story-lifecycle-table-projection.test.js b/tests/docs/story-lifecycle-table-projection.test.js index ee7adef81..893949a1d 100644 --- a/tests/docs/story-lifecycle-table-projection.test.js +++ b/tests/docs/story-lifecycle-table-projection.test.js @@ -57,6 +57,21 @@ function assertStable(root, rel, expected) { } const tests = [ + ...['Sign in | recover', String.raw`Sign\|in | recover`].flatMap(title => ['apply', 'reconcile'].map(command => [ + `${command} preserves title table boundaries for ${JSON.stringify(title)}`, () => { + const root = newRepo();const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + write(root, 'sprints/sprint-1.md', `# Sprint 1\n\n${MARKER}\n${SPRINT_HEADER}\n\n## Total Points: 0\n`); + const raw = story(id, 'todo', '1').replace('Sign in', title); + if (command === 'apply') apply(root, { create: { [`${id}.md`]: raw } }); + else { apply(root, { create: { [`${id}.md`]: story(id, 'todo', '1') } });write(root, `${id}.md`, raw);run(root, ['reconcile']); } + const cell = title === 'Sign in | recover' ? String.raw`Sign in \| recover` : String.raw`Sign\\\|in \| recover`; + assert.ok(read(root, 'epics/auth-flow.md').includes(`| ${id} | ${cell} | todo |\n`)); + assert.ok(read(root, 'sprints/sprint-1.md').includes(`| ${id} | ${cell} | 3 | todo |\n`)); + assert.equal(read(root, `${id}.md`), raw, 'source title bytes stay unchanged'); + assertStable(root, 'epics/auth-flow.md', read(root, 'epics/auth-flow.md')); + assertStable(root, 'sprints/sprint-1.md', read(root, 'sprints/sprint-1.md')); + }, + ])), ...['epic', 'sprint'].flatMap(kind => ['apply', 'reconcile'].flatMap(command => ['', '\n'].map(gap => [ `${command} preserves a handwritten ${kind} table below an empty marker with gap ${JSON.stringify(gap)}`, () => { const root = newRepo();const rel = kind === 'epic' ? 'epics/auth-flow.md' : 'sprints/sprint-1.md'; diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js index 0e7295dbf..86d090803 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -129,6 +129,37 @@ for (const name of ['auto-update', 'instinct-status', 'skill-health', 'skill-hos }); } } +for (const broken of [false, true]) { + test(`auto-update legacy-only cache ${broken ? 'rejects corrupt current cache before follow-up' : 'runs the actual selected updater'}`, () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-legacy-update-')); + try { + const root = path.join(temp, 'plugins/cache/everything-claude-code/vendor/1.0.0'); + fs.mkdirSync(path.join(root, 'scripts/lib'), { recursive: true }); + fs.mkdirSync(path.join(root, ECC_SKILL_SENTINEL), { recursive: true }); + fs.copyFileSync(path.join(__dirname, '../../scripts/lib/resolve-ecc-root.js'), path.join(root, 'scripts/lib/resolve-ecc-root.js')); + fs.writeFileSync(path.join(root, 'scripts/lib/utils.js'), '// fixture'); + fs.writeFileSync(path.join(root, 'scripts/auto-update.js'), "console.log('UPDATER:'+__dirname+':'+process.argv.slice(2).join(','))"); + if (broken) fs.writeFileSync(path.join(temp, 'plugins/cache/ecc'), 'occupied non-directory'); + const env = { ...process.env, HOME: temp, USERPROFILE: temp, CLAUDE_CONFIG_DIR: temp }; + delete env.CLAUDE_PLUGIN_ROOT; + const [bash] = resolveWindowsBashCandidates(['bash.exe', 'bash'], { env }); + const shell = process.platform === 'win32' ? bash : 'bash'; + const doc = fs.readFileSync(path.join(__dirname, '../../commands/auto-update.md'), 'utf8'); + const assignment = doc.split('\n').find(line => line.startsWith('ECC_ROOT=')); + const followup = broken ? 'node -e "console.log(\'FOLLOWUP_INVOKED\')"' : 'node "$ECC_ROOT/scripts/auto-update.js" --dry-run'; + const result = spawnSync(shell, ['-s'], { input: assignment + '\n' + followup + '\n', env, cwd: temp, encoding: 'utf8' }); + const { resolveEccRoot } = require('../../scripts/lib/resolve-ecc-root'); + const options = { envRoot: '', configDir: temp, probe: path.join('scripts', 'auto-update.js') }; + if (broken) { + assert.equal(result.status, 1);assert.match(result.stderr, /ENOTDIR/);assert.equal(result.stdout, ''); + assert.throws(() => resolveEccRoot(options), error => error.code === 'ENOTDIR'); + } else { + assert.equal(result.status, 0, result.stderr);assert.equal(result.stdout.trim(), `UPDATER:${fs.realpathSync(path.join(root, 'scripts'))}:--dry-run`); + assert.equal(resolveEccRoot(options), root); + } + } finally { fs.rmSync(temp, { recursive: true, force: true }); } + }); +} console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/lib/gateguard-search-evidence.test.js b/tests/lib/gateguard-search-evidence.test.js index a39e56e6f..7045359c4 100644 --- a/tests/lib/gateguard-search-evidence.test.js +++ b/tests/lib/gateguard-search-evidence.test.js @@ -233,6 +233,22 @@ test('a search confined to the target file name is a lookup, not a search', () = assert.strictEqual((findClosestMiss(lookupOfModule, testTarget, false, data) || {}).reason, 'excluded'); }); +test('PowerShell single-quoted backslash filters preserve inclusion and exclusion scope', () => { + const { findCreditingSearch, findClosestMiss } = loadHook(); + const target = String.raw`C:\repo\src\lexer\handler.py`; + const data = { cwd: String.raw`C:\repo` }; + for (const command of [String.raw`rg -g 'src\lexer\*.py' handler .`, String.raw`rg handler src\lexer`]) { + assert.ok(findCreditingSearch(filterScan([filterSearch('PowerShell', { command })]), target, false, data), command); + } + const command = String.raw`rg -g '!src\lexer\*.py' handler .`; + const scan = filterScan([filterSearch('PowerShell', { command })]); + assert.equal(findCreditingSearch(scan, target, false, data), null); + assert.equal(findClosestMiss(scan, target, false, data).reason, 'excluded'); + const ambiguous = String.raw`rg -g 'src''\lexer\*.py' handler .`; + assert.equal(findCreditingSearch(filterScan([filterSearch('PowerShell', { command: ambiguous })]), target, false, data), null, + 'unsupported PowerShell literal-apostrophe syntax must never credit a different path'); +}); + fs.rmSync(transcriptDir, { recursive: true, force: true }); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); From 6d8e69df52ef25b97bf23acde18b899e6f793289 Mon Sep 17 00:00:00 2001 From: Eneko Ruiz <196690101+eneekoruiz@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:29:15 +0200 Subject: [PATCH 20/28] fix: skip occupied snapshot paths without overflowing --- scripts/lib/skill-evolution/versioning.js | 11 +++++++++-- tests/lib/skill-evolution.test.js | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 83a309d74..d5043ae2e 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -163,8 +163,15 @@ function createVersion(skillPath, options = {}) { ensureSkillVersioning(skillPath); const versions = listVersions(skillPath); - const nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1; - const snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`); + let nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1; + let snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`); + while (Number.isSafeInteger(nextVersion) && fs.existsSync(snapshotPath)) { + nextVersion += 1; + snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`); + } + if (!Number.isSafeInteger(nextVersion)) { + throw new Error('Skill version exceeds the safe integer range'); + } const skillContent = fs.readFileSync(skillFilePath, 'utf8'); const createdAt = options.timestamp || new Date().toISOString(); diff --git a/tests/lib/skill-evolution.test.js b/tests/lib/skill-evolution.test.js index 6cd18aebe..501a05155 100644 --- a/tests/lib/skill-evolution.test.js +++ b/tests/lib/skill-evolution.test.js @@ -221,8 +221,24 @@ function runTests() { ); assert.strictEqual(versioning.getCurrentVersion(skillDir), 1); assert.ok(fs.statSync(snapshotDirectory).isDirectory()); + for (const version of [2, 3]) { + fs.mkdirSync(path.join(versioning.getVersionsDir(skillDir), `v${version}.md`)); + } + assert.strictEqual(versioning.createVersion(skillDir).version, 4); + assert.deepStrictEqual(versioning.listVersions(skillDir).map(entry => entry.version), [1, 4]); + for (const version of [2, 3]) { + assert.ok(fs.statSync(path.join(versioning.getVersionsDir(skillDir), `v${version}.md`)).isDirectory()); + } assert.strictEqual(versioning.getCurrentVersion(path.join(fixtureRoot, 'missing-skill')), 0); assert.throws(() => versioning.createVersion(snapshotDirectory), /Skill file not found/); + const oversizedSnapshot = path.join(versioning.getVersionsDir(skillDir), 'v9007199254740992.md'); + fs.writeFileSync(oversizedSnapshot, 'Preserve this snapshot'); + const overflow = spawnSync(process.execPath, [ + '-e', 'require("assert").throws(() => require(process.argv[1]).createVersion(process.argv[2]), /safe integer/)', + require.resolve('../../scripts/lib/skill-evolution/versioning'), skillDir, + ], { encoding: 'utf8', timeout: 10000 }); + assert.strictEqual(overflow.status, 0, overflow.error?.message || overflow.stderr); + assert.strictEqual(fs.readFileSync(oversizedSnapshot, 'utf8'), 'Preserve this snapshot'); } finally { cleanupTempDir(fixtureRoot); } From a668d06f30ed452cddd83d4cf86b596013579da0 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:14:44 -0400 Subject: [PATCH 21/28] fix: publish rollback target snapshot before replacing live skill --- scripts/lib/skill-evolution/versioning.js | 20 +++++--- tests/lib/skill-evolution-storage.test.js | 58 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 9adcce14b..1e1e6c7e5 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -184,13 +184,12 @@ function allocateSnapshotVersion(skillPath) { return nextVersion; } -function createVersion(skillPath, options = {}) { - const skillFilePath = ensureSkillExists(skillPath); +function publishVersion(skillPath, skillContent, options) { + ensureSkillExists(skillPath); const nextVersion = allocateSnapshotVersion(skillPath); const versionsDir = getVersionsDir(skillPath); ensureSkillVersioning(skillPath); const snapshotPath = path.join(versionsDir, `v${nextVersion}.md`); - const skillContent = fs.readFileSync(skillFilePath, 'utf8'); const createdAt = options.timestamp || new Date().toISOString(); fs.writeFileSync(snapshotPath, skillContent, { encoding: 'utf8', flag: 'wx' }); @@ -210,6 +209,12 @@ function createVersion(skillPath, options = {}) { }; } +function createVersion(skillPath, options = {}) { + const skillFilePath = ensureSkillExists(skillPath); + const skillContent = fs.readFileSync(skillFilePath, 'utf8'); + return publishVersion(skillPath, skillContent, options); +} + function rollbackTo(skillPath, targetVersion, options = {}) { const normalizedTargetVersion = Number(targetVersion); if (!Number.isSafeInteger(normalizedTargetVersion) || normalizedTargetVersion <= 0) { @@ -225,15 +230,16 @@ function rollbackTo(skillPath, targetVersion, options = {}) { const currentVersion = getCurrentVersion(skillPath); const targetContent = fs.readFileSync(targetPath, 'utf8'); - allocateSnapshotVersion(skillPath); - fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8'); - - const createdVersion = createVersion(skillPath, { + // Publish the restored-content snapshot before replacing unsnapshotted live work. + // Initialization and exclusive publication failures therefore leave SKILL.md intact. + const createdVersion = publishVersion(skillPath, targetContent, { timestamp: options.timestamp, reason: options.reason || `rollback to v${normalizedTargetVersion}`, author: options.author || null, }); + fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8'); + appendEvolutionRecord(skillPath, 'amendments', { event: 'rollback', version: createdVersion.version, diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 50283f2d1..99e028530 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -140,5 +140,63 @@ test('unsafe rollback targets preserve the live skill and excluded snapshots', f } }); +test('rollback initialization failure preserves unsnapshotted live bytes and the target', fixture => { + const { skillDir, versionsDir } = fixture; + fs.mkdirSync(versionsDir); + const target = path.join(versionsDir, 'v1.md'); + fs.writeFileSync(target, '# Saved target\n'); + fs.writeFileSync(versioning.getEvolutionDir(skillDir), 'owned blocker'); + assert.throws(() => versioning.rollbackTo(skillDir, 1), /ENOTDIR/); + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(target, 'utf8'), '# Saved target\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(fs.readFileSync(versioning.getEvolutionDir(skillDir), 'utf8'), 'owned blocker'); +}); + +test('rollback exclusive publication failure preserves live bytes and permits a later retry', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const amendmentPath = versioning.getEvolutionLogPath(skillDir, 'amendments'); + const priorLog = fs.readFileSync(amendmentPath, 'utf8'); + const writeFileSync = fs.writeFileSync; + const injectedError = Object.assign(new Error('Injected snapshot publication failure'), { code: 'EIO' }); + fs.writeFileSync = (file, ...args) => { + if (file === path.join(versionsDir, 'v2.md') && args[1]?.flag === 'wx') throw injectedError; + return writeFileSync(file, ...args); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error === injectedError); + } finally { + fs.writeFileSync = writeFileSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Original\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(fs.readFileSync(amendmentPath, 'utf8'), priorLog); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').at(-1).event, 'rollback'); +}); + +test('successful rollback snapshots restored content and retains earlier targets for another rollback', fixture => { + const { skillDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Second version\n'); + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 3); + assert.equal(fs.readFileSync(restored.path, 'utf8'), '# Original\n'); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + const second = versioning.rollbackTo(skillDir, 2); + assert.equal(second.version, 4); + assert.equal(fs.readFileSync(second.path, 'utf8'), '# Second version\n'); + assert.equal(fs.readFileSync(second.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + assert.equal(versioning.getCurrentVersion(skillDir), 4); +}); + console.log(`Results: Passed: ${passed}, Failed: ${failed}`); process.exitCode = failed > 0 ? 1 : 0; From ce54cef0e497315a2b3e0b80446f4f4d5a42bb12 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:15:16 -0400 Subject: [PATCH 22/28] test: verify rollback preservation across filesystem error codes --- tests/lib/skill-evolution-storage.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 99e028530..c37706638 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -146,7 +146,7 @@ test('rollback initialization failure preserves unsnapshotted live bytes and the const target = path.join(versionsDir, 'v1.md'); fs.writeFileSync(target, '# Saved target\n'); fs.writeFileSync(versioning.getEvolutionDir(skillDir), 'owned blocker'); - assert.throws(() => versioning.rollbackTo(skillDir, 1), /ENOTDIR/); + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => Boolean(error.code)); assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); assert.equal(fs.readFileSync(target, 'utf8'), '# Saved target\n'); assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); From 3fa6e305026684bc0d249db37e952d813e0b218d Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:45:41 -0400 Subject: [PATCH 23/28] fix: preserve rollback version state and bounded runtime workflows --- .opencode/commands/instinct-status.md | 2 +- commands/auto-update.md | 2 +- commands/instinct-status.md | 2 +- commands/sessions.md | 12 +- commands/skill-health.md | 6 +- commands/skill-host-compat.md | 4 +- docs/ja-JP/commands/auto-update.md | 2 +- docs/ja-JP/commands/instinct-status.md | 2 +- docs/ja-JP/commands/skill-health.md | 6 +- docs/tr/commands/instinct-status.md | 2 +- docs/tr/commands/sessions.md | 16 +-- docs/zh-CN/commands/auto-update.md | 2 +- docs/zh-CN/commands/instinct-status.md | 2 +- docs/zh-CN/commands/sessions.md | 20 +-- docs/zh-CN/commands/skill-health.md | 6 +- hooks/codex-hooks.json | 2 +- hooks/hooks.json | 54 +++---- hooks/hooks.metadata.json | 54 +++---- manifests/context-packs/skill-triggers@1.json | 2 +- pi/core/skills/docker-patterns/SKILL.md | 16 ++- scripts/lib/resolve-ecc-root.js | 2 +- scripts/lib/skill-evolution/versioning.js | 117 +++++++++++++-- skills/docker-patterns/SKILL.md | 16 ++- skills/gateguard/SKILL.md | 4 +- tests/lib/command-plugin-root.test.js | 26 ++++ tests/lib/skill-evolution-storage.test.js | 133 +++++++++++++++++- tests/lib/statusline-render.test.js | 2 +- 27 files changed, 384 insertions(+), 130 deletions(-) diff --git a/.opencode/commands/instinct-status.md b/.opencode/commands/instinct-status.md index df33ae715..c6e2e0b12 100644 --- a/.opencode/commands/instinct-status.md +++ b/.opencode/commands/instinct-status.md @@ -16,7 +16,7 @@ fallback), then run the instinct CLI. This avoids reading a stale legacy active under `~/.claude/plugins/cache/...` (#2037). ```bash -ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 0) { ```bash node -e " -const aa = require((function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i/dev/null || true; wait "$relay_pid" 2>/dev/null || true' EXIT + codex login +) ``` The networked service sets `NPM_CONFIG_PREFIX=/tmp/ecc-npm` and adds its `bin` @@ -442,7 +445,8 @@ on the executable `/tmp` mount while the root filesystem remains read-only. Complete the sign-in in the host browser. Each attempt mints a fresh `state`/`code`, so always follow the newest printed link. The token stays in the disposable container's `~/.codex/auth.json`; removing the named container -deletes that credential copy. Stop the relay after the login attempt. Only one container can hold host port 1455 at a time +deletes that credential copy. The login block stops the relay on success or failure. +Only one container can hold host port 1455 at a time because the OAuth redirect targets a fixed port. The host publish is restricted to loopback. Inside the container, the relay diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index 5adc3deb1..b6f6eaf25 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -154,7 +154,7 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) { * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i/dev/null || true; wait "$relay_pid" 2>/dev/null || true' EXIT + codex login +) ``` The networked service sets `NPM_CONFIG_PREFIX=/tmp/ecc-npm` and adds its `bin` @@ -442,7 +445,8 @@ on the executable `/tmp` mount while the root filesystem remains read-only. Complete the sign-in in the host browser. Each attempt mints a fresh `state`/`code`, so always follow the newest printed link. The token stays in the disposable container's `~/.codex/auth.json`; removing the named container -deletes that credential copy. Stop the relay after the login attempt. Only one container can hold host port 1455 at a time +deletes that credential copy. The login block stops the relay on success or failure. +Only one container can hold host port 1455 at a time because the OAuth redirect targets a fixed port. The host publish is restricted to loopback. Inside the container, the relay diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 5db374d63..208174601 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -400,8 +400,8 @@ An Edit, Write, NotebookEdit, or MultiEdit entry is allowed, with an characters, never credit. - **Shell scope:** a shell segment only credits a target equal to or inside one of its path operands (resolved against the tool `cwd`; a glob operand - must match the target or a directory above it, and a git pathspec's `*` - also matches `/`), or inside the `cwd` when it has none: + must match the target or a directory above it, and a default git pathspec's + `*` also matches `/`), or inside the `cwd` when it has none: `rg payment docs` or `grep -rn payment /usr/share/doc` never credit `src/payment.py`. An input redirection before any operand (`rg payment < file`) or a `<` inside a word reads stdin and never counts. diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js index 86d090803..818eee73c 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -160,6 +160,32 @@ for (const broken of [false, true]) { } finally { fs.rmSync(temp, { recursive: true, force: true }); } }); } +for (const level of ['slug', 'org']) { + test(`actual command skips non-directory cache children at ${level} level`, () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stray-cache-')); + try { + const root = path.join(temp, 'plugins/cache/everything-claude-code/vendor/1.0.0'); + fs.mkdirSync(path.join(root, 'scripts/lib'), { recursive: true }); + fs.mkdirSync(path.join(root, ECC_SKILL_SENTINEL), { recursive: true }); + fs.copyFileSync(path.join(__dirname, '../../scripts/lib/resolve-ecc-root.js'), path.join(root, 'scripts/lib/resolve-ecc-root.js')); + fs.writeFileSync(path.join(root, 'scripts/lib/utils.js'), '// fixture'); + fs.writeFileSync(path.join(root, 'scripts/auto-update.js'), "console.log('UPDATER:'+__dirname)"); + const cache = path.join(temp, 'plugins/cache/ecc'); + const strayDir = level === 'slug' ? cache : path.join(cache, 'vendor'); + fs.mkdirSync(strayDir, { recursive: true }); + fs.writeFileSync(path.join(strayDir, 'stray.txt'), 'preserve'); + const env = { ...process.env, HOME: temp, USERPROFILE: temp, CLAUDE_CONFIG_DIR: temp }; + delete env.CLAUDE_PLUGIN_ROOT; + const [bash] = resolveWindowsBashCandidates(['bash.exe', 'bash'], { env }); + const shell = process.platform === 'win32' ? bash : 'bash'; + const assignment = fs.readFileSync(path.join(__dirname, '../../commands/auto-update.md'), 'utf8').split('\n').find(line => line.startsWith('ECC_ROOT=')); + const result = spawnSync(shell, ['-s'], { input: assignment + '\nnode "$ECC_ROOT/scripts/auto-update.js"\n', env, cwd: temp, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), `UPDATER:${fs.realpathSync(path.join(root, 'scripts'))}`); + assert.equal(fs.readFileSync(path.join(strayDir, 'stray.txt'), 'utf8'), 'preserve'); + } finally { fs.rmSync(temp, { recursive: true, force: true }); } + }); +} console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index c37706638..6da8933b1 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -162,7 +162,7 @@ test('rollback exclusive publication failure preserves live bytes and permits a const writeFileSync = fs.writeFileSync; const injectedError = Object.assign(new Error('Injected snapshot publication failure'), { code: 'EIO' }); fs.writeFileSync = (file, ...args) => { - if (file === path.join(versionsDir, 'v2.md') && args[1]?.flag === 'wx') throw injectedError; + if (typeof file === 'number' && args[0] === '# Original\n') throw injectedError; return writeFileSync(file, ...args); }; try { @@ -181,6 +181,137 @@ test('rollback exclusive publication failure preserves live bytes and permits a assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').at(-1).event, 'rollback'); }); +test('failed live rollback write preserves version and applied history, then retries without a duplicate', fixture => { + const { root, skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const logPath = versioning.getEvolutionLogPath(skillDir, 'amendments'); + const priorLog = fs.readFileSync(logPath, 'utf8'); + const writeFileSync = fs.writeFileSync; + const injectedError = Object.assign(new Error('Injected live write failure'), { code: 'EACCES' }); + fs.writeFileSync = (file, ...args) => { + if (file === path.join(skillDir, 'SKILL.md')) throw injectedError; + return writeFileSync(file, ...args); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error === injectedError); + } finally { + fs.writeFileSync = writeFileSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(versioning.getCurrentVersion(skillDir), 1); + assert.equal(fs.readFileSync(logPath, 'utf8'), priorLog); + const report = health.collectSkillHealth({ skillsRoot: path.join(root, 'skills'), homeDir: root }); + assert.equal(report.skills.find(skill => skill.skill_id === 'demo').current_version, 'v1'); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 1); +}); + +test('failed rollback preserves a foreign snapshot replacement and both error details', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + const live = path.join(skillDir, 'SKILL.md'); + fs.writeFileSync(live, '# Unsnapshotted work\n'); + const target = path.join(versionsDir, 'v2.md'); + const writeFileSync = fs.writeFileSync; + const primary = Object.assign(new Error('Injected live refusal'), { code: 'EACCES' }); + fs.writeFileSync = (file, ...args) => { + if (file === live) { + fs.renameSync(target, path.join(versionsDir, 'owned-attempt.saved')); + writeFileSync(target, '# Foreign snapshot\n'); + throw primary; + } + return writeFileSync(file, ...args); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => + error.primaryError === primary && /changed ownership/.test(error.recoveryError.message) && /recovery is pending/.test(error.message)); + } finally { + fs.writeFileSync = writeFileSync; + } + assert.equal(fs.readFileSync(live, 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.readFileSync(target, 'utf8'), '# Foreign snapshot\n'); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 0); +}); + +test('descriptor metadata and final-link failures never expose an empty or incomplete version', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const logPath = versioning.getEvolutionLogPath(skillDir, 'amendments'); + const priorLog = fs.readFileSync(logPath, 'utf8'); + for (const operation of ['fstatSync', 'linkSync']) { + const original = fs[operation]; + const injectedError = Object.assign(new Error(`Injected ${operation} failure`), { code: 'EIO' }); + fs[operation] = () => { throw injectedError; }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error === injectedError || (error.primaryError === injectedError && /recovery is pending/.test(error.message))); + } finally { + fs[operation] = original; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.existsSync(path.join(versionsDir, 'v2.md')), false); + assert.deepEqual(versioning.listVersions(skillDir).map(row => row.version), [1]); + assert.equal(versioning.getCurrentVersion(skillDir), 1); + assert.equal(fs.readFileSync(logPath, 'utf8'), priorLog); + } + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); +}); + +test('exclusive snapshot publication preserves a path occupied after allocation', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const target = path.join(versionsDir, 'v2.md'); + const linkSync = fs.linkSync; + fs.linkSync = (source, destination) => { + if (destination === target) fs.writeFileSync(target, '# Foreign snapshot\n', { flag: 'wx' }); + return linkSync(source, destination); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error.code === 'EEXIST'); + } finally { + fs.linkSync = linkSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.readFileSync(target, 'utf8'), '# Foreign snapshot\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md', 'v2.md']); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 0); +}); + +test('snapshot staging cleanup preserves foreign entries and permits a clean later retry', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + const live = path.join(skillDir, 'SKILL.md'); + fs.writeFileSync(live, '# Unsnapshotted work\n'); + const linkSync = fs.linkSync; + let foreignPath; + fs.linkSync = (source, destination) => { + foreignPath = path.join(path.dirname(source), 'foreign-owned.txt'); + fs.writeFileSync(foreignPath, 'foreign work'); + return linkSync(source, destination); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => /recovery is pending/.test(error.message)); + } finally { + fs.linkSync = linkSync; + } + assert.equal(fs.readFileSync(foreignPath, 'utf8'), 'foreign work'); + assert.equal(fs.readFileSync(live, 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.existsSync(path.join(versionsDir, 'v2.md')), false); + assert.equal(versioning.getCurrentVersion(skillDir), 1); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 0); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(live, 'utf8')); + assert.equal(fs.readFileSync(foreignPath, 'utf8'), 'foreign work'); +}); + test('successful rollback snapshots restored content and retains earlier targets for another rollback', fixture => { const { skillDir } = fixture; versioning.createVersion(skillDir); diff --git a/tests/lib/statusline-render.test.js b/tests/lib/statusline-render.test.js index a46fd96f4..b85843f07 100644 --- a/tests/lib/statusline-render.test.js +++ b/tests/lib/statusline-render.test.js @@ -280,7 +280,7 @@ function runTests() { assert.strictEqual(summary.disabledCount, 2); }); - console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}\n`); return { passed, failed }; } From 1ec5b328c63fa8d5ffb11241ca33e2943120c1b1 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:05:00 -0400 Subject: [PATCH 24/28] fix: normalize root overrides and report unverified snapshot staging --- .opencode/commands/instinct-status.md | 2 +- commands/auto-update.md | 2 +- commands/instinct-status.md | 2 +- commands/skill-health.md | 6 +-- commands/skill-host-compat.md | 4 +- docs/ja-JP/commands/auto-update.md | 2 +- docs/ja-JP/commands/instinct-status.md | 2 +- docs/ja-JP/commands/skill-health.md | 6 +-- docs/tr/commands/instinct-status.md | 2 +- docs/zh-CN/commands/auto-update.md | 2 +- docs/zh-CN/commands/instinct-status.md | 2 +- docs/zh-CN/commands/skill-health.md | 6 +-- scripts/lib/skill-evolution/versioning.js | 18 +++++++-- tests/lib/command-plugin-root.test.js | 2 +- tests/lib/skill-evolution-storage.test.js | 48 ++++++++++++++++++++--- 15 files changed, 78 insertions(+), 28 deletions(-) diff --git a/.opencode/commands/instinct-status.md b/.opencode/commands/instinct-status.md index c6e2e0b12..cd0dffa55 100644 --- a/.opencode/commands/instinct-status.md +++ b/.opencode/commands/instinct-status.md @@ -16,7 +16,7 @@ fallback), then run the instinct CLI. This avoids reading a stale legacy active under `~/.claude/plugins/cache/...` (#2037). ```bash -ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i versioning.createVersion(skillDir), /snapshot version range is exhausted/); - assert.deepEqual(fs.readdirSync(versionsDir), [`v${Number.MAX_SAFE_INTEGER}.md`]); + assert.deepEqual(fs.readdirSync(versionsDir).sort(), [`v${Number.MAX_SAFE_INTEGER}.md`]); assert.equal(fs.readFileSync(last, 'utf8'), 'preserve'); assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); }); @@ -149,7 +149,7 @@ test('rollback initialization failure preserves unsnapshotted live bytes and the assert.throws(() => versioning.rollbackTo(skillDir, 1), error => Boolean(error.code)); assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); assert.equal(fs.readFileSync(target, 'utf8'), '# Saved target\n'); - assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.deepEqual(fs.readdirSync(versionsDir).sort(), ['v1.md']); assert.equal(fs.readFileSync(versioning.getEvolutionDir(skillDir), 'utf8'), 'owned blocker'); }); @@ -172,7 +172,7 @@ test('rollback exclusive publication failure preserves live bytes and permits a } assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Original\n'); - assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.deepEqual(fs.readdirSync(versionsDir).sort(), ['v1.md']); assert.equal(fs.readFileSync(amendmentPath, 'utf8'), priorLog); const restored = versioning.rollbackTo(skillDir, 1); assert.equal(restored.version, 2); @@ -199,7 +199,7 @@ test('failed live rollback write preserves version and applied history, then ret fs.writeFileSync = writeFileSync; } assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); - assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.deepEqual(fs.readdirSync(versionsDir).sort(), ['v1.md']); assert.equal(versioning.getCurrentVersion(skillDir), 1); assert.equal(fs.readFileSync(logPath, 'utf8'), priorLog); const report = health.collectSkillHealth({ skillsRoot: path.join(root, 'skills'), homeDir: root }); @@ -280,7 +280,7 @@ test('exclusive snapshot publication preserves a path occupied after allocation' } assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); assert.equal(fs.readFileSync(target, 'utf8'), '# Foreign snapshot\n'); - assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md', 'v2.md']); + assert.deepEqual(fs.readdirSync(versionsDir).sort(), ['v1.md', 'v2.md']); assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 0); }); @@ -312,6 +312,44 @@ test('snapshot staging cleanup preserves foreign entries and permits a clean lat assert.equal(fs.readFileSync(foreignPath, 'utf8'), 'foreign work'); }); +for (const foreign of [false, true]) { +test(`unverified staging directory identity preserves ${foreign ? 'foreign contents' : 'empty directory'} with a pending diagnostic`, fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const logPath = versioning.getEvolutionLogPath(skillDir, 'amendments'); + const priorLog = fs.readFileSync(logPath, 'utf8'); + const lstatSync = fs.lstatSync; + let stagingDir; + const primary = Object.assign(new Error('Injected staging identity failure'), { code: 'EIO' }); + fs.lstatSync = (file, ...args) => { + if (path.basename(file).startsWith('.snapshot-')) { + stagingDir = file; + if (foreign) fs.writeFileSync(path.join(file, 'foreign-owned.txt'), 'preserve'); + throw primary; + } + return lstatSync(file, ...args); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => + error.primaryError === primary && /identity could not be verified/.test(error.message) && /recovery is pending/.test(error.message)); + } finally { + fs.lstatSync = lstatSync; + } + if (foreign) assert.equal(fs.readFileSync(path.join(stagingDir, 'foreign-owned.txt'), 'utf8'), 'preserve'); + else assert.deepEqual(fs.readdirSync(stagingDir), []); + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.existsSync(path.join(versionsDir, 'v2.md')), false); + assert.equal(versioning.getCurrentVersion(skillDir), 1); + assert.equal(fs.readFileSync(logPath, 'utf8'), priorLog); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + if (foreign) assert.equal(fs.readFileSync(path.join(stagingDir, 'foreign-owned.txt'), 'utf8'), 'preserve'); + else assert.deepEqual(fs.readdirSync(stagingDir), []); +}); + +} + test('successful rollback snapshots restored content and retains earlier targets for another rollback', fixture => { const { skillDir } = fixture; versioning.createVersion(skillDir); From bded8387ebe8cce539b198d3fce7bfbdd1a16579 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:34:15 -0400 Subject: [PATCH 25/28] fix: preflight and document snapshot storage capability --- commands/skill-health.md | 4 ++ docs/ja-JP/commands/skill-health.md | 4 ++ docs/zh-CN/commands/skill-health.md | 4 ++ scripts/lib/skill-evolution/versioning.js | 49 ++++++++++++++++++++++- tests/lib/skill-evolution-storage.test.js | 28 +++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/commands/skill-health.md b/commands/skill-health.md index 67c27c795..266a0ce6a 100644 --- a/commands/skill-health.md +++ b/commands/skill-health.md @@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json - **Failure Patterns** — Clustered failure reasons with horizontal bar chart - **Pending Amendments** — Amendment proposals awaiting review - **Version History** — Timeline of version snapshots per skill + +## Snapshot storage + +Snapshot creation and rollback require writable storage with hardlink support. The versioning API checks that capability before creating or changing version history and refuses unsupported storage without a copy fallback. Use a hardlink-capable location for new snapshots. Reading existing health and version history does not require creating hard links. diff --git a/docs/ja-JP/commands/skill-health.md b/docs/ja-JP/commands/skill-health.md index 876ac5247..566b6a1dd 100644 --- a/docs/ja-JP/commands/skill-health.md +++ b/docs/ja-JP/commands/skill-health.md @@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json - **失敗パターン** — 水平バーチャート付きのクラスタ化された失敗理由 - **保留中の修正案** — レビュー待ちの修正提案 - **バージョン履歴** — スキルごとのバージョンスナップショットのタイムライン + +## スナップショットの保存先 + +スナップショットの作成とロールバックには、ハードリンクを作成できる書き込み可能な保存先が必要です。バージョン管理 API は履歴を作成または変更する前にこの機能を確認し、未対応の保存先ではコピーへのフォールバックを行わず拒否します。新しいスナップショットにはハードリンク対応の保存先を使用してください。既存のヘルス情報やバージョン履歴の読み取りでは、ハードリンクの作成は不要です。 diff --git a/docs/zh-CN/commands/skill-health.md b/docs/zh-CN/commands/skill-health.md index 6471108aa..c7b14696a 100644 --- a/docs/zh-CN/commands/skill-health.md +++ b/docs/zh-CN/commands/skill-health.md @@ -52,3 +52,7 @@ node "$ECC_ROOT/scripts/skills-health.js" --dashboard --json * **故障模式** — 聚类故障原因并显示水平条形图 * **待处理修订** — 等待审查的修订提案 * **版本历史** — 每个技能的版本快照时间线 + +## 快照存储 + +创建快照和回滚需要支持硬链接的可写存储。版本管理 API 会在创建或修改版本历史前检查该能力;不支持时会拒绝操作,不会回退到复制文件。新快照应存放在支持硬链接的位置。读取现有健康信息和版本历史不需要创建硬链接。 diff --git a/scripts/lib/skill-evolution/versioning.js b/scripts/lib/skill-evolution/versioning.js index 05205c12d..349b49b1b 100644 --- a/scripts/lib/skill-evolution/versioning.js +++ b/scripts/lib/skill-evolution/versioning.js @@ -231,8 +231,8 @@ function removeOwnedStaging(staging, contentIdentity) { fs.rmdirSync(staging.path); } -function prepareStaging(versionsDir) { - const stagingDir = fs.mkdtempSync(path.join(versionsDir, '.snapshot-')); +function prepareStaging(versionsDir, prefix = '.snapshot-') { + const stagingDir = fs.mkdtempSync(path.join(versionsDir, prefix)); try { return { path: stagingDir, contentPath: path.join(stagingDir, 'content.md'), identity: fs.lstatSync(stagingDir) }; } catch (primaryError) { @@ -244,10 +244,54 @@ function prepareStaging(versionsDir) { } } +function verifySnapshotStorage(skillPath) { + const versionsDir = getVersionsDir(skillPath); + const base = fs.existsSync(versionsDir) ? versionsDir : normalizeSkillDir(skillPath); + const staging = prepareStaging(base, '.snapshot-storage-'); + const linkPath = path.join(staging.path, 'hardlink-check.md'); + let identity; + let linked = false; + let linking = false; + try { + const descriptor = fs.openSync(staging.contentPath, 'wx'); + try { + identity = fs.fstatSync(descriptor); + fs.writeFileSync(descriptor, 'ECC snapshot storage check\n', 'utf8'); + } finally { + fs.closeSync(descriptor); + } + linking = true; + fs.linkSync(staging.contentPath, linkPath); + linked = true; + linking = false; + removeOwnedSnapshot({ path: linkPath, identity }); + linked = false; + removeOwnedStaging(staging, identity); + } catch (primaryError) { + try { + if (linked) removeOwnedSnapshot({ path: linkPath, identity }); + removeOwnedStaging(staging, identity); + } catch (recoveryError) { + const error = new Error(`${primaryError.message}; storage-check cleanup failed: ${recoveryError.message}; recovery is pending`, { cause: primaryError }); + error.primaryError = primaryError; + error.recoveryError = recoveryError; + throw error; + } + if (linking && ['ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EPERM', 'EXDEV', 'EINVAL'].includes(primaryError.code)) { + const error = new Error(`Skill versioning requires hardlink-capable storage for snapshot creation and rollback; capability check failed before history initialization (${primaryError.code})`, { cause: primaryError }); + error.code = primaryError.code; + error.primaryError = primaryError; + throw error; + } + throw primaryError; + } +} + function publishVersion(skillPath, skillContent, options) { ensureSkillExists(skillPath); const nextVersion = allocateSnapshotVersion(skillPath); const versionsDir = getVersionsDir(skillPath); + verifySnapshotStorage(skillPath); ensureSkillVersioning(skillPath); const staging = prepareStaging(versionsDir); const stagedPath = staging.contentPath; @@ -294,6 +338,7 @@ function recordSnapshot(skillPath, snapshot, options) { return { version: snapshot.version, path: snapshot.path, created_at: snapshot.created_at }; } +/** Snapshot creation requires hardlink-capable storage, checked before history initialization. */ function createVersion(skillPath, options = {}) { const skillFilePath = ensureSkillExists(skillPath); const skillContent = fs.readFileSync(skillFilePath, 'utf8'); diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 6b5bc09d9..9706ca2c2 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -350,6 +350,34 @@ test(`unverified staging directory identity preserves ${foreign ? 'foreign conte } +for (const rollback of [false, true]) { + test(`${rollback ? 'rollback' : 'snapshot'} rejects unsupported hardlink storage before initializing history`, fixture => { + const { skillDir, versionsDir } = fixture; + if (rollback) { + fs.mkdirSync(versionsDir); + fs.writeFileSync(path.join(versionsDir, 'v1.md'), '# Saved target\n'); + } + const linkSync = fs.linkSync; + const primary = Object.assign(new Error('Injected unsupported hard links'), { code: 'EOPNOTSUPP' }); + fs.linkSync = () => { throw primary; }; + try { + assert.throws(() => rollback ? versioning.rollbackTo(skillDir, 1) : versioning.createVersion(skillDir), error => + /requires hardlink-capable storage/.test(error.message) && error.primaryError === primary); + } finally { + fs.linkSync = linkSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Original\n'); + assert.equal(fs.existsSync(versioning.getEvolutionDir(skillDir)), false); + if (rollback) { + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(fs.readFileSync(path.join(versionsDir, 'v1.md'), 'utf8'), '# Saved target\n'); + } else assert.equal(fs.existsSync(versionsDir), false); + assert.equal(fs.readdirSync(skillDir).filter(name => name.startsWith('.snapshot-')).length, 0); + const snapshot = versioning.createVersion(skillDir); + assert.equal(snapshot.version, rollback ? 2 : 1); + }); +} + test('successful rollback snapshots restored content and retains earlier targets for another rollback', fixture => { const { skillDir } = fixture; versioning.createVersion(skillDir); From 33372e99703ee4182de72584538ce5bd655f86ad Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:45:43 -0400 Subject: [PATCH 26/28] test: make presence expiry controls deterministic --- tests/scripts/plan-canvas.test.js | 39 ++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 1ee9036a2..c214ee150 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -1267,20 +1267,33 @@ async function main(suite = createTestRunner()) { const bound = await staleCanvas.listen(0); const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: staleArtifact } })); - await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } }); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing'); - await new Promise(resolve => setTimeout(resolve, 60)); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'waiting'); + // Keep real HTTP transitions, but advance their activity clock explicitly: + // loaded runners can exceed the expiry between two HTTP round trips. + const originalNow = Date.now; + let now = originalNow(); + Date.now = () => now; + try { + await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } }); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing'); + now += 19; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'typing'); + now += 1; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'waiting'); - // An abandoned agent decays to queued so the human is never told a - // stalled session is still being worked on. - await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); - await request(bound.port, 'POST', `/api/session/${opened.key}/feedback`, { - body: { items: [{ kind: 'chat', text: 'still there?' }] } - }); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking'); - await new Promise(resolve => setTimeout(resolve, 60)); - assert.strictEqual(staleCanvas.presenceFor(opened.key), 'queued'); + // An abandoned agent decays to queued so the human is never told a + // stalled session is still being worked on. + await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); + await request(bound.port, 'POST', `/api/session/${opened.key}/feedback`, { + body: { items: [{ kind: 'chat', text: 'still there?' }] } + }); + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking'); + now += 39; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'thinking'); + now += 1; + assert.strictEqual(staleCanvas.presenceFor(opened.key), 'queued'); + } finally { + Date.now = originalNow; + } await closeStaleCanvas(); }); From 0908bd31482ccadb949f5d81825318b7090e1235 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:59:14 -0400 Subject: [PATCH 27/28] test: distinguish snapshot probe and publication faults --- tests/lib/skill-evolution-storage.test.js | 34 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index 9706ca2c2..430c0da7c 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -245,13 +245,31 @@ test('descriptor metadata and final-link failures never expose an empty or incom const priorLog = fs.readFileSync(logPath, 'utf8'); for (const operation of ['fstatSync', 'linkSync']) { const original = fs[operation]; + const openSync = fs.openSync; + let publicationDescriptor; + let reachedPublication = false; + fs.openSync = (file, ...args) => { + const descriptor = openSync(file, ...args); + const directory = path.basename(path.dirname(file)); + if (directory.startsWith('.snapshot-') && !directory.startsWith('.snapshot-storage-')) publicationDescriptor = descriptor; + return descriptor; + }; const injectedError = Object.assign(new Error(`Injected ${operation} failure`), { code: 'EIO' }); - fs[operation] = () => { throw injectedError; }; + fs[operation] = (...args) => { + if ((operation === 'fstatSync' && args[0] === publicationDescriptor) + || (operation === 'linkSync' && args[1] === path.join(versionsDir, 'v2.md'))) { + reachedPublication = true; + throw injectedError; + } + return original(...args); + }; try { assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error === injectedError || (error.primaryError === injectedError && /recovery is pending/.test(error.message))); } finally { fs[operation] = original; + fs.openSync = openSync; } + assert.equal(reachedPublication, true, `${operation} must fail publication after the storage probe succeeds`); assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); assert.equal(fs.existsSync(path.join(versionsDir, 'v2.md')), false); assert.deepEqual(versioning.listVersions(skillDir).map(row => row.version), [1]); @@ -292,8 +310,10 @@ test('snapshot staging cleanup preserves foreign entries and permits a clean lat const linkSync = fs.linkSync; let foreignPath; fs.linkSync = (source, destination) => { - foreignPath = path.join(path.dirname(source), 'foreign-owned.txt'); - fs.writeFileSync(foreignPath, 'foreign work'); + if (destination === path.join(versionsDir, 'v2.md')) { + foreignPath = path.join(path.dirname(source), 'foreign-owned.txt'); + fs.writeFileSync(foreignPath, 'foreign work'); + } return linkSync(source, destination); }; try { @@ -301,6 +321,7 @@ test('snapshot staging cleanup preserves foreign entries and permits a clean lat } finally { fs.linkSync = linkSync; } + assert.ok(foreignPath, 'foreign entry must be injected into publication staging'); assert.equal(fs.readFileSync(foreignPath, 'utf8'), 'foreign work'); assert.equal(fs.readFileSync(live, 'utf8'), '# Unsnapshotted work\n'); assert.equal(fs.existsSync(path.join(versionsDir, 'v2.md')), false); @@ -312,8 +333,9 @@ test('snapshot staging cleanup preserves foreign entries and permits a clean lat assert.equal(fs.readFileSync(foreignPath, 'utf8'), 'foreign work'); }); +for (const phase of ['storage', 'publication']) { for (const foreign of [false, true]) { -test(`unverified staging directory identity preserves ${foreign ? 'foreign contents' : 'empty directory'} with a pending diagnostic`, fixture => { +test(`unverified ${phase} staging directory identity preserves ${foreign ? 'foreign contents' : 'empty directory'} with a pending diagnostic`, fixture => { const { skillDir, versionsDir } = fixture; versioning.createVersion(skillDir); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); @@ -323,7 +345,8 @@ test(`unverified staging directory identity preserves ${foreign ? 'foreign conte let stagingDir; const primary = Object.assign(new Error('Injected staging identity failure'), { code: 'EIO' }); fs.lstatSync = (file, ...args) => { - if (path.basename(file).startsWith('.snapshot-')) { + const name = path.basename(file); + if (phase === 'storage' ? name.startsWith('.snapshot-storage-') : name.startsWith('.snapshot-') && !name.startsWith('.snapshot-storage-')) { stagingDir = file; if (foreign) fs.writeFileSync(path.join(file, 'foreign-owned.txt'), 'preserve'); throw primary; @@ -348,6 +371,7 @@ test(`unverified staging directory identity preserves ${foreign ? 'foreign conte else assert.deepEqual(fs.readdirSync(stagingDir), []); }); +} } for (const rollback of [false, true]) { From ee77b3a9302c9a372d0fe3dc6b53fd1718cba797 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:45:04 -0400 Subject: [PATCH 28/28] test: allow bounded CLI startup on loaded runners --- tests/lib/eval-harness/cli.test.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/lib/eval-harness/cli.test.js b/tests/lib/eval-harness/cli.test.js index e552da166..fbfd7bf4a 100644 --- a/tests/lib/eval-harness/cli.test.js +++ b/tests/lib/eval-harness/cli.test.js @@ -8,7 +8,9 @@ const vm = require('vm'); const { test, tempDir, cleanup, finish } = require('./helpers'); const harness = require('../../../scripts/lib/eval-harness'); const cli = path.resolve(__dirname, '../../../scripts/eval-harness.js'); -const run = args => spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', timeout: 3000 }); +// Allow bounded process startup under loaded native runners; still fail hangs. +const CLI_TIMEOUT_MS = 15000; +const run = args => spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', timeout: CLI_TIMEOUT_MS }); // Exercise spawn failures without starting an example, mutating the real // process object, or depending on a host-specific missing executable. @@ -76,13 +78,28 @@ test('dangling receipt value flags are usage errors before reading missing input for (const flag of ['--artifact', '--gate', '--out']) { for (const tail of [[flag], [flag, '--artifact']]) { const result = run([...command, ...tail]); - assert.strictEqual(result.status, 2, `${tail}: ${result.stderr}`); + assert.strictEqual(result.status, 2, `${tail}: ${result.error?.code || result.signal || result.stderr}`); assert.match(result.stderr, /needs a value/); } } } }); +test('usage validation survives a bounded slow child startup', () => { + const dir = tempDir('cli-slow-start'); + try { + const preload = path.join(dir, 'startup-delay.cjs'); + fs.writeFileSync(preload, 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3500);\n'); + const result = spawnSync(process.execPath, ['--require', preload, cli, + 'receipt', 'verify', '/absent-receipt', '/absent-capsule', '--out'], { + encoding: 'utf8', timeout: CLI_TIMEOUT_MS, + }); + assert.strictEqual(result.status, 2, result.error?.code || result.signal || result.stderr); + assert.strictEqual(result.stdout, ''); + assert.match(result.stderr, /needs a value/); + } finally { cleanup(dir); } +}); + test('invalid output flag does not cause producer projection writes', () => { const dir = tempDir('cli-build'); try {