diff --git a/.changeset/zesty-pumas-hum.md b/.changeset/zesty-pumas-hum.md new file mode 100644 index 000000000..7eff390ae --- /dev/null +++ b/.changeset/zesty-pumas-hum.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3341 +--- +**Installer no longer crashes when a source file disappears mid-copy.** `copyWithPathReplacement` used to throw an unhandled ENOENT if a listed workflow/command file was deleted between its directory listing and the actual read — a rare filesystem race that could abort an entire install. It now skips the vanished file and continues installing everything else. (#3333) diff --git a/bin/install.js b/bin/install.js index dd3e583e9..a392c169b 100755 --- a/bin/install.js +++ b/bin/install.js @@ -7878,6 +7878,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const srcPath = path.join(srcDir, entry.name); const destPath = path.join(destDir, entry.name); + // #3333: srcPath was enumerated by readdirSync above, but a filesystem is not + // transactional — the file it named can vanish between listing and this read + // (a concurrent process, or another test in this suite writing/cleaning up a + // fixture inside this same real directory). Treat "gone by the time we get + // here" as benign and skip it, never a fatal crash of the whole install. + if (!entry.isDirectory() && !fs.existsSync(srcPath)) { + continue; + } + if (entry.isDirectory()) { copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot); } else if (entry.name.endsWith('.md')) { diff --git a/tests/fix-2587-cursor-hook-workspace-roots.test.cjs b/tests/cursor-hook-workspace-roots.test.cjs similarity index 100% rename from tests/fix-2587-cursor-hook-workspace-roots.test.cjs rename to tests/cursor-hook-workspace-roots.test.cjs diff --git a/tests/fix-3045-cursor-subagent-isolation.test.cjs b/tests/cursor-subagent-isolation.test.cjs similarity index 100% rename from tests/fix-3045-cursor-subagent-isolation.test.cjs rename to tests/cursor-subagent-isolation.test.cjs diff --git a/tests/effort-surface-axis.test.cjs b/tests/effort-surface-axis.test.cjs index 4a24f2260..72e498323 100644 --- a/tests/effort-surface-axis.test.cjs +++ b/tests/effort-surface-axis.test.cjs @@ -1,7 +1,11 @@ -// allow-test-rule: source-text-is-the-product (see #2481) +// allow-test-rule: source-text-is-the-product (see #2481, #2615) // The final describe block asserts on gsd-core/workflows/review.md's text. A // workflow .md IS what the runtime loads — its literal command lines are the // deployed contract, and there is no runtime seam that executes review.md here. +// The #2615 matrix-parity block below is the same kind of contract assertion: +// docs/reference/host-integration-capability-matrix.md IS the cited source of +// truth for every descriptor axis (ADR-1239), so asserting a shipped axis +// value appears there and matches is a contract assertion, not a source grep. // Every other block in this file is behavioral (CLI + module surface). /** @@ -37,6 +41,31 @@ const { _HOST_INTEGRATION_VOCAB, validateRuntimeBody, } = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'capability-validator.cjs')); +const registry = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); + +// #2615: the host-integration capability matrix, normalized so CRLF checkouts +// (Windows autocrlf) don't break the row regexes below. +const MATRIX = path.join(REPO_ROOT, 'docs', 'reference', 'host-integration-capability-matrix.md'); +const MATRIX_TEXT = fs.readFileSync(MATRIX, 'utf-8').replace(/\r\n/g, '\n'); + +/** Extract a `## ` section body, stopping at the next top-level host heading. */ +function matrixSection(host) { + const start = MATRIX_TEXT.indexOf(`\n## ${host}\n`); + if (start === -1) return null; + const rest = MATRIX_TEXT.slice(start + 1); + const end = rest.indexOf('\n## '); + return end === -1 ? rest : rest.slice(0, end); +} + +/** Read the value cell of a `| | | …` row. */ +function matrixAxisValue(body, axis) { + const row = body.split(/\r?\n/).find((l) => l.startsWith(`| ${axis} |`)); + return row ? row.split('|')[2].trim() : null; +} + +const MATRIX_RUNTIMES = Object.keys(registry.runtimes).filter( + (id) => registry.runtimes[id]?.runtime?.hostIntegration, +); /** * A real shipped descriptor with one hostIntegration axis stripped. @@ -427,3 +456,47 @@ describe('#2481 review workflow resolves effort per reviewer', () => { assert.deepStrictEqual(argvEffort, ['claude', 'codex', 'opencode']); }); }); + +describe('#2615: the matrix documents the effortSurface axis', () => { + test('the axes legend defines effortSurface and its vocabulary', () => { + const legendRow = MATRIX_TEXT.split(/\r?\n/).find((l) => l.startsWith('| `effortSurface` |')); + assert.ok(legendRow, 'the axes legend must define effortSurface (#2615)'); + for (const member of ['`argv`', '`none`', '`undocumented`']) { + assert.ok(legendRow.includes(member), + `the legend must document the ${member} vocabulary member (#2615)`); + } + }); + + test('there is at least one runtime to check', () => { + // Guards the loops below against silently asserting nothing. + assert.ok(MATRIX_RUNTIMES.length >= 18, `expected the full runtime corpus, got ${MATRIX_RUNTIMES.length}`); + }); + + for (const id of MATRIX_RUNTIMES) { + describe(`runtime: ${id}`, () => { + test('has a matrix section', () => { + assert.ok(matrixSection(id), `${id}: every installed runtime needs a matrix section (ADR-1239)`); + }); + + test('documents effortSurface, and the value matches the descriptor', () => { + const body = matrixSection(id); + assert.ok(body, `${id}: missing matrix section`); + + const documented = matrixAxisValue(body, 'effortSurface'); + assert.ok(documented, `${id}: the matrix must carry an effortSurface row (#2615)`); + + const declared = registry.runtimes[id].runtime.hostIntegration.effortSurface; + if (declared === undefined) { + // kimi-code declares no value: its mechanism (`/effort`) is interactive-only + // and neither `argv` nor `none` describes it. The matrix must say so rather + // than invent a value. + assert.match(documented, /not declared/i, + `${id}: an absent descriptor value must be documented as absent, not guessed (#2615)`); + } else { + assert.equal(documented, declared, + `${id}: the matrix effortSurface value must match the shipped descriptor`); + } + }); + }); + } +}); diff --git a/tests/fix-2284-hermes-agent-delegate-task-projection.test.cjs b/tests/fix-2284-hermes-agent-delegate-task-projection.test.cjs deleted file mode 100644 index 82b59dce5..000000000 --- a/tests/fix-2284-hermes-agent-delegate-task-projection.test.cjs +++ /dev/null @@ -1,895 +0,0 @@ -// allow-test-rule: source-text-is-the-product — see #2284 -// Reads installed .md workflow files whose deployed text IS the contract the -// Hermes host reads at runtime — testing text content tests the deployed -// contract, exactly like the sibling hermes-skills-migration test. - -/** - * #2284 — Hermes named-dispatch → delegate_task projection. - * - * The Hermes installer previously only brand-swapped "Claude Code" → - * "Hermes Agent" in shipped `gsd-core/workflows/*.md`, leaving a false - * "The Agent tool IS available" assertion and literal `Agent(...)` call - * syntax installed verbatim — Hermes exposes `delegate_task`, not `Agent`. - * - * Covers: - * 1. Direct converter contract — projectNamedDispatchToStructuralDelegate / - * convertClaudeToHermesMarkdown against representative fixture prose, - * across ALL THREE real corpus call-argument shapes: multi-line - * one-key-per-line, single-line object-literal (`Agent({...})` — - * import.md/ingest-docs.md), and single-line compact - * (`Agent(subagent_type="x", model="y", prompt="...")` — - * code-review-fix.md/ship.md/etc). - * 2. Real disposable-HOME `--hermes --global` e2e install — no literal - * `Agent(` survives, `delegate_task` is present, commands→skill path - * (convertClaudeCommandToClaudeSkill) still works unregressed; spot- - * checks import.md, ingest-docs.md, and code-review-fix.md specifically - * (the object-literal and single-line-compact sites). - * 3. Fail-closed role resolution — a referenced gsd-* role prompt missing - * from the shipped agents/ directory aborts install with an explicit - * error, in EVERY call-argument shape, both at the converter level and - * through the real install path (deterministic fs.readdirSync - * injection per the repo's cross-platform IO-failure-injection - * convention — never chmod/permission tricks). - * 4. The post-projection guard (belt-and-suspenders) — fails loud on any - * residual subagent_type / leaked model= / unprojected Agent( the - * projection above did not anticipate, rather than silently shipping it. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); - -const { - convertClaudeToHermesMarkdown, - projectNamedDispatchToStructuralDelegate, - _hostIntegrationDispatch, - _resolveAvailableGsdRoles, - HERMES_DISPATCH_TOOL_CONFIG, - maskStringLiterals, - findDispatchCallSpans, - _assertProjectionComplete, - applyClaudeCodeBrandSwap, - convertClaudeToWindsurfMarkdown, - install, - uninstall, -} = require('../bin/install.js'); - -const { cleanup } = require('./helpers.cjs'); -const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); - -const HERMES_DISPATCH = _hostIntegrationDispatch('hermes'); - -// Representative fixture prose mirroring the real shape found in -// gsd-core/workflows/plan-phase.md — the "Agent tool IS available" contract -// assertion followed by a literal, multi-arg Agent(...) dispatch call whose -// subagent_type resolves to a real shipped role. -const FIXTURE_ASSERTION_AND_CALL = [ - 'The Agent tool IS available in a top-level Hermes Agent session. Always spawn', - 'gsd-phase-researcher, gsd-planner, and gsd-plan-checker as separate Agent() calls.', - '', - '```', - 'Agent(', - ' prompt=filled_research_hook_fragment,', - ' subagent_type="gsd-planner",', - ' model="{researcher_model}",', - ' description="Research Phase {phase}"', - ')', - '```', - '', - '> **ORCHESTRATOR RULE — ALL RUNTIMES**: After calling Agent() above, stop working on this task immediately.', - 'Wait for the subagent to return its result. Only resume when the subagent result is available.', -].join('\n'); - -function hermesToolConfig(overrides = {}) { - return Object.assign({}, HERMES_DISPATCH_TOOL_CONFIG, { - availableRoles: _resolveAvailableGsdRoles(), - runtime: 'hermes', - }, overrides); -} - -// ─── 1. Direct converter contract ──────────────────────────────────────────── - -describe('#2284 convertClaudeToHermesMarkdown / projectNamedDispatchToStructuralDelegate — converter contract', () => { - test('capabilities/hermes/capability.json dispatch facts are unchanged (docs-sourced, not touched by this fix)', () => { - // Locks in the maintainer-confirmed constraint: this fix reads the - // existing sourced facts, it never edits them. - assert.strictEqual(HERMES_DISPATCH.namedDispatch, false); - assert.strictEqual(HERMES_DISPATCH.background, true); - assert.strictEqual(HERMES_DISPATCH.backgroundDispatch, false); - assert.strictEqual(HERMES_DISPATCH.subagentToolkit, 'read-only'); - assert.strictEqual(HERMES_DISPATCH.maxDepth, 1); - assert.strictEqual(HERMES_DISPATCH.nested, true); - }); - - test('no literal Agent( call syntax survives the projection', () => { - const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); - assert.ok(!/\bAgent\(/.test(out), `literal Agent( survived:\n${out}`); - }); - - test('emits a delegate_task-shaped dispatch call with the resolved role reference', () => { - const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); - assert.ok(/delegate_task\(/.test(out), 'delegate_task( call syntax present'); - assert.ok(/gsd_role="gsd-planner"/.test(out), 'gsd_role carries the resolved role identifier'); - assert.ok(/gsd_role_prompt=/.test(out), 'gsd_role_prompt carries the loaded-content instruction'); - assert.ok(/role="leaf"/.test(out), 'structural role pinned to Hermes\'s non-orchestrating leaf value'); - }); - - test('drops per-call model forwarding (host-model inheritance is explicit)', () => { - const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); - assert.ok(!/model="\{researcher_model\}"/.test(out), 'per-call model="{researcher_model}" line stripped'); - assert.ok(!/\bmodel=/.test(out), 'no model= parameter forwarded anywhere in the projected call'); - }); - - test('the "Agent tool IS available" assertion becomes an accurate delegate_task statement', () => { - const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); - assert.ok(!/Agent tool IS available/.test(out), 'false Claude-shaped assertion removed'); - assert.ok(/delegate_task/.test(out), 'assertion references the real Hermes dispatch primitive'); - assert.ok(/no concept of a named subagent identity/i.test(out), 'assertion states the roleless-lookup contract (namedDispatch: false)'); - }); - - test('async halt/resume wording is preserved (no busy-poll)', () => { - const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); - assert.ok(/stop working on this task immediately/.test(out), 'halt-after-dispatch instruction preserved'); - assert.ok(/Wait for the subagent to return its result/.test(out), 'resume-on-completion instruction preserved'); - }); - - test('fail-closed wording is present for the role-resolution step', () => { - const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); - assert.ok(/FAIL CLOSED/.test(out), 'explicit FAIL CLOSED instruction present'); - assert.ok(/never execute the role inline/i.test(out), 'explicit prohibition on silent inline execution'); - }); - - test('run_in_background= maps onto Hermes\'s native background= (dispatch.background: true)', () => { - const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n run_in_background=true,\n description="d"\n)'; - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(/\bbackground=true\b/.test(out), 'background=true present'); - assert.ok(!/run_in_background=/.test(out), 'Claude-native run_in_background= param name gone'); - }); - - test('genuinely branches on dispatch.background: false — strips (never renames) the background flag', () => { - const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n run_in_background=true,\n description="d"\n)'; - const out = projectNamedDispatchToStructuralDelegate( - fixture, - Object.assign({}, HERMES_DISPATCH, { background: false }), - hermesToolConfig(), - ); - assert.ok(!/run_in_background=/.test(out), 'unsupported flag not left in Claude form'); - assert.ok(!/\bbackground=true\b/.test(out), 'flag not forwarded when dispatch.background is false'); - }); - - test('genuinely branches on dispatch.namedDispatch: true — passes named dispatch through unprojected', () => { - const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n description="d"\n)'; - const out = projectNamedDispatchToStructuralDelegate( - fixture, - Object.assign({}, HERMES_DISPATCH, { namedDispatch: true }), - hermesToolConfig(), - ); - // No role-prompt-embedding machinery should be injected when the target - // primitive can resolve named agents itself. - assert.ok(!/gsd_role_prompt=/.test(out), 'no prompt-content-embedding injected when namedDispatch is true'); - assert.ok(!/FAIL CLOSED/.test(out), 'no fail-closed role-resolution injected when namedDispatch is true'); - assert.ok(/delegate_task\(/.test(out), 'call syntax still renamed to the target tool name'); - }); - - test('genuinely branches on subagentToolkit/maxDepth — omits the depth/toolkit caveat when the target can orchestrate', () => { - const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n description="d"\n)'; - const restrictedOut = projectNamedDispatchToStructuralDelegate( - fixture, HERMES_DISPATCH, hermesToolConfig(), - ); - assert.ok(/nested delegation is unavailable/.test(restrictedOut), 'read-only/depth-1 caveat present for the real sourced facts'); - - const orchestrateCapableOut = projectNamedDispatchToStructuralDelegate( - fixture, - Object.assign({}, HERMES_DISPATCH, { subagentToolkit: 'full', maxDepth: -1 }), - hermesToolConfig(), - ); - assert.ok(!/nested delegation is unavailable/.test(orchestrateCapableOut), 'caveat omitted when the target genuinely supports nested delegation'); - }); - - test('preserves body content and prose the projection does not target', () => { - const fixture = 'Some unrelated prose.\n\nAgent(\n prompt=x,\n subagent_type="gsd-verifier",\n description="d"\n)\n\nMore unrelated prose.'; - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(out.includes('Some unrelated prose.')); - assert.ok(out.includes('More unrelated prose.')); - }); -}); - -// ─── 1b. All three real corpus call-argument shapes ───────────────────────── - -describe('#2284 all three real corpus Agent(...) call-argument shapes', () => { - // (a) multi-line, one key= per line — plan-phase.md/execute-phase.md/etc. - const MULTI_LINE = 'Agent(\n prompt=x,\n subagent_type="gsd-planner",\n model="{researcher_model}",\n description="d"\n)'; - // (b) single-line object-literal (colon syntax) — import.md/ingest-docs.md. - const OBJECT_LITERAL = 'Agent({\n subagent_type: "gsd-plan-checker",\n prompt: "Validate the plan."\n})'; - // (c) single-line compact — code-review-fix.md/code-review.md/ship.md/etc. - const SINGLE_LINE_COMPACT = 'Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt="Fix the findings.")'; - - const forms = [ - ['multi-line one-key-per-line', MULTI_LINE, 'gsd-planner'], - ['single-line object-literal', OBJECT_LITERAL, 'gsd-plan-checker'], - ['single-line compact', SINGLE_LINE_COMPACT, 'gsd-code-fixer'], - ]; - - for (const [label, fixture, role] of forms) { - test(`${label}: projects to delegate_task with gsd_role_prompt + role="leaf"`, () => { - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(/delegate_task\(/.test(out), `${label}: delegate_task( present`); - assert.ok(out.includes(`gsd_role="${role}"`), `${label}: gsd_role carries "${role}"`); - assert.ok(/gsd_role_prompt=/.test(out), `${label}: gsd_role_prompt injected`); - assert.ok(/role="leaf"/.test(out), `${label}: structural role="leaf" injected`); - assert.ok(/FAIL CLOSED/.test(out), `${label}: fail-closed wording present`); - }); - - test(`${label}: no residual subagent_type (either = or : syntax)`, () => { - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(!/\bsubagent_type\s*[=:]/.test(out), `${label}: subagent_type token gone:\n${out}`); - }); - - test(`${label}: no leaked model= (host-model inheritance, never forwarded)`, () => { - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - const mask = maskStringLiterals(out); - assert.ok(!/\bmodel\s*[=:]/.test(mask), `${label}: no model= or model: token survives:\n${out}`); - }); - - test(`${label}: a bogus role triggers the fail-closed throw`, () => { - const bogusFixture = fixture.replace(role, 'gsd-totally-fake-role-2284'); - assert.throws( - () => convertClaudeToHermesMarkdown(bogusFixture, { runtime: 'hermes' }), - /gsd-totally-fake-role-2284/, - `${label}: expected an explicit fail-closed error naming the bogus role`, - ); - }); - } - - test('object-literal wrapper braces are stripped (Hermes delegate_task is a flat kwarg call)', () => { - const out = convertClaudeToHermesMarkdown(OBJECT_LITERAL, { runtime: 'hermes' }); - assert.ok(!/delegate_task\(\s*\{/.test(out), 'no leftover "{" immediately after delegate_task('); - assert.ok(!/\}\s*\)\s*$/.test(out.trim()), 'no leftover "}" immediately before the closing )'); - }); - - test('object-literal form: non-role/model keys (e.g. prompt:) are left in their original colon style', () => { - const out = convertClaudeToHermesMarkdown(OBJECT_LITERAL, { runtime: 'hermes' }); - assert.ok(out.includes('prompt: "Validate the plan."'), 'untouched arg keys keep their original syntax'); - }); - - test('single-line-compact: a real corpus fixture identical to code-review-fix.md:201 shape (multi-line prompt body opened on the compact head)', () => { - // code-review-fix.md's real shape: `Agent(subagent_type="x", model="y", prompt="` opens a - // MULTI-LINE prompt body (no escaping) that closes many lines later with `")`. - const fixture = [ - 'Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt="', - '', - '${REVIEW_PATH}', - '', - '', - 'Read REVIEW.md findings, apply fixes.', - '${AGENT_SKILLS_FIXER}")', - ].join('\n'); - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives a multi-line-body compact-head call'); - assert.ok(/delegate_task\(/.test(out)); - assert.ok(out.includes('gsd_role="gsd-code-fixer"')); - assert.ok(!/\bmodel\s*[=:]/.test(maskStringLiterals(out)), 'model stripped even though the prompt body spans many lines'); - assert.ok(out.includes(''), 'multi-line prompt BODY content is preserved verbatim'); - assert.ok(out.includes('${REVIEW_PATH}'), 'interpolation placeholders inside the prompt body are untouched'); - }); - - test('disconnected prose mention (not part of any real Agent(...) call, e.g. map-codebase.md-style) is still renamed and validated', () => { - const fixture = 'Use Agent tool with `subagent_type="gsd-codebase-mapper"` for parallel execution.'; - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(out.includes('gsd_role="gsd-codebase-mapper"'), 'prose mention renamed to gsd_role='); - assert.ok(!/\bsubagent_type\s*[=:]/.test(out)); - }); - - test('a documentation TEMPLATE placeholder role (curly-brace interpolation, e.g. universal-anti-patterns.md\'s subagent_type: "gsd-{agent}") is renamed but NOT fail-closed validated', () => { - const fixture = 'ALWAYS use `subagent_type: "gsd-{agent}"` (e.g., `gsd-phase-researcher`, `gsd-executor`).'; - assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(out.includes('gsd_role: "gsd-{agent}"'), 'template placeholder renamed, value preserved verbatim'); - }); - - test('run_in_background: true (colon-prose form, e.g. execute-phase.md) maps onto background: true, same as the = form', () => { - const fixture = 'Dispatch each `Agent()` call one at a time with `run_in_background: true`.'; - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(/background:\s*true/.test(out), 'colon-prose form mapped to the native background param'); - assert.ok(!/run_in_background/.test(out), 'Claude-native run_in_background token gone'); - }); - - test('a call site preceded by explanatory comments describing the now-removed model= conditional strips both the arg AND the dead comments (Finding 5)', () => { - const fixture = [ - 'Agent(', - ' subagent_type="gsd-executor",', - ' description="Execute plan",', - ' # Only include model= when executor_model is an explicit model name.', - ' # When executor_model is "inherit", omit this parameter entirely so', - ' # Claude Code inherits the orchestrator model automatically.', - ' model="{executor_model}", # omit this line when executor_model == "inherit"', - ' isolation="worktree",', - ' prompt="Execute the plan."', - ')', - ].join('\n'); - const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); - assert.ok(!/model="\{executor_model\}"/.test(out), 'model= argument line removed'); - assert.ok(!/Only include model=/.test(out), 'dead explanatory comment (line 1) removed'); - assert.ok(!/omit this parameter entirely/.test(out), 'dead explanatory comment (line 2) removed'); - assert.ok(!/inherits the orchestrator model/.test(out), 'dead explanatory comment (line 3) removed'); - assert.ok(out.includes('isolation="worktree"'), 'unrelated surrounding arguments preserved'); - }); -}); - -// ─── 1c. Post-projection guard (belt-and-suspenders, #2284 requirement 3) ─── - -describe('#2284 post-projection guard — fails loud on any unanticipated residual form', () => { - const toolConfig = hermesToolConfig(); - - test('throws when a residual subagent_type token survives (any syntax)', () => { - assert.throws( - () => _assertProjectionComplete('delegate_task(subagent_type="gsd-planner")', toolConfig), - /residual subagent_type/i, - ); - assert.throws( - () => _assertProjectionComplete('delegate_task(subagent_type: "gsd-planner")', toolConfig), - /residual subagent_type/i, - ); - }); - - test('throws when literal Agent( call syntax survives', () => { - // Isolated from the subagent_type check above (which fires first and - // would otherwise mask this assertion) — a bare Agent() mention with no - // remaining subagent_type token. - assert.throws( - () => _assertProjectionComplete('Please call Agent() to dispatch.', toolConfig), - /literal Agent\(/i, - ); - }); - - test('throws when a model= argument leaks inside a delegate_task(...) call', () => { - assert.throws( - () => _assertProjectionComplete('delegate_task(gsd_role="gsd-planner", model="{m}")', toolConfig), - /leaked model=/i, - ); - }); - - test('does NOT throw on a clean, fully-projected document', () => { - const clean = 'delegate_task(gsd_role="gsd-planner", gsd_role_prompt=, role="leaf", prompt="x")'; - assert.doesNotThrow(() => _assertProjectionComplete(clean, toolConfig)); - }); - - test('does NOT flag Agent( or subagent_type mentioned INSIDE a quoted string (not real call syntax)', () => { - // e.g. settings.md: `description: "Chain stages via Agent() subagents"`. - const proseInsideString = 'delegate_task(description="Chain stages via Agent() subagents, not subagent_type=x")'; - assert.doesNotThrow(() => _assertProjectionComplete(proseInsideString, toolConfig)); - }); - - test('findDispatchCallSpans correctly balances parens across a quoted prompt body containing its own parens', () => { - // Mirrors discuss-phase-assumptions.md's real shape: parenthetical prose - // ("(e.g., ...)") embedded inside a triple-quoted prompt body. - const fixture = 'Agent(subagent_type="gsd-verifier", prompt="""\nAnalyze (e.g., "Technical Approach") the codebase.\n(3-5 areas, calibrated by tier)\n""")'; - const spans = findDispatchCallSpans(fixture, 'Agent'); - assert.strictEqual(spans.length, 1, 'exactly one call span found despite embedded parens'); - assert.strictEqual(spans[0].end, fixture.length, 'span correctly extends to the TRUE closing paren, not a premature one inside the string'); - }); -}); - -// ─── 2. Real disposable-HOME e2e install ───────────────────────────────────── - -describe('#2284 real disposable-HOME --hermes --global install', () => { - let tmpHome; - let savedHome; - let savedUserProfile; - let savedHermesHome; - - beforeEach(() => { - tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2284-hermes-home-')); - savedHome = process.env.HOME; - savedUserProfile = process.env.USERPROFILE; - savedHermesHome = process.env.HERMES_HOME; - process.env.HOME = tmpHome; - process.env.USERPROFILE = tmpHome; - process.env.HERMES_HOME = path.join(tmpHome, '.hermes'); - }); - - afterEach(() => { - try { - uninstall(true, 'hermes'); - } catch (_e) { - // best-effort — some fail-closed tests intentionally leave a partial install - } - if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile; - if (savedHermesHome === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = savedHermesHome; - cleanup(tmpHome); - }); - - test('installed plan-phase.md has no literal Agent( and does contain delegate_task', () => { - const result = install(true, 'hermes'); - assert.strictEqual(result.runtime, 'hermes'); - - const planPhasePath = path.join(result.configDir, 'gsd-core', 'workflows', 'plan-phase.md'); - assert.ok(fs.existsSync(planPhasePath), `expected installed workflow at ${planPhasePath}`); - const content = fs.readFileSync(planPhasePath, 'utf8'); - - assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( call syntax in installed plan-phase.md'); - assert.ok(/delegate_task\(/.test(content), 'delegate_task( present in installed plan-phase.md'); - assert.ok(!/Agent tool IS available/.test(content), 'false assertion not installed verbatim'); - }); - - test('spot-check a second workflow (execute-phase.md) — same guarantees hold', () => { - const result = install(true, 'hermes'); - const executePhasePath = path.join(result.configDir, 'gsd-core', 'workflows', 'execute-phase.md'); - assert.ok(fs.existsSync(executePhasePath)); - const content = fs.readFileSync(executePhasePath, 'utf8'); - - assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( call syntax in installed execute-phase.md'); - assert.ok(/delegate_task\(/.test(content), 'delegate_task( present in installed execute-phase.md'); - }); - - test('spot-check import.md (object-literal Agent({...}) form) in the installed tree', () => { - const result = install(true, 'hermes'); - const importPath = path.join(result.configDir, 'gsd-core', 'workflows', 'import.md'); - assert.ok(fs.existsSync(importPath)); - const content = fs.readFileSync(importPath, 'utf8'); - assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed import.md'); - assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed import.md'); - assert.ok(content.includes('gsd_role="gsd-plan-checker"'), 'gsd_role carries the resolved role'); - assert.ok(/gsd_role_prompt=/.test(content), 'role-prompt-resolution injected'); - }); - - test('spot-check ingest-docs.md (object-literal Agent({...}) form, two call sites) in the installed tree', () => { - const result = install(true, 'hermes'); - const ingestPath = path.join(result.configDir, 'gsd-core', 'workflows', 'ingest-docs.md'); - assert.ok(fs.existsSync(ingestPath)); - const content = fs.readFileSync(ingestPath, 'utf8'); - assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed ingest-docs.md'); - assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed ingest-docs.md'); - assert.ok(content.includes('gsd_role="gsd-doc-synthesizer"'), 'first call site role resolved'); - assert.ok(content.includes('gsd_role="gsd-roadmapper"'), 'second call site role resolved'); - }); - - test('spot-check code-review-fix.md (single-line-compact Agent(subagent_type=..., model=..., prompt="multi-line body) form) in the installed tree', () => { - const result = install(true, 'hermes'); - const crfPath = path.join(result.configDir, 'gsd-core', 'workflows', 'code-review-fix.md'); - assert.ok(fs.existsSync(crfPath)); - const content = fs.readFileSync(crfPath, 'utf8'); - assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed code-review-fix.md'); - assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed code-review-fix.md'); - const mask = maskStringLiterals(content); - assert.ok(!/\bmodel\s*[=:]/.test(mask), 'no leaked model= inside any real call in installed code-review-fix.md'); - assert.ok(content.includes('gsd_role="gsd-code-fixer"'), 'gsd-code-fixer role resolved'); - assert.ok(content.includes('gsd_role="gsd-code-reviewer"'), 'gsd-code-reviewer role resolved (2nd/3rd call sites)'); - }); - - test('EVERY installed workflow file is free of literal Agent( call syntax', () => { - const result = install(true, 'hermes'); - const workflowsDir = path.join(result.configDir, 'gsd-core', 'workflows'); - assert.ok(fs.existsSync(workflowsDir)); - const files = fs.readdirSync(workflowsDir).filter((f) => f.endsWith('.md')); - assert.ok(files.length > 10, 'sanity: a real corpus of workflow files was installed'); - for (const f of files) { - const content = fs.readFileSync(path.join(workflowsDir, f), 'utf8'); - assert.ok(!/\bAgent\(/.test(content), `${f} still contains literal Agent( call syntax`); - } - }); - - test('commands/gsd/*.md → Hermes-skill path (convertClaudeCommandToClaudeSkill) still works, unregressed', () => { - const result = install(true, 'hermes'); - const categoryDir = path.join(result.configDir, 'skills', 'gsd'); - assert.ok(fs.existsSync(categoryDir), 'skills/gsd category dir installed'); - - const helpSkillPath = nestedSkillPath(categoryDir, 'gsd-', 'help'); - assert.ok(fs.existsSync(helpSkillPath), `expected nested skill at ${helpSkillPath}`); - const skillContent = fs.readFileSync(helpSkillPath, 'utf8'); - assert.ok(/^---/.test(skillContent), 'skill file has YAML frontmatter'); - assert.ok(/name:\s*gsd-help/.test(skillContent), 'skill frontmatter name is the canonical gsd-help'); - }); -}); - -// ─── 3. Fail-closed role resolution ────────────────────────────────────────── - -describe('#2284 fail-closed role resolution', () => { - test('converter throws when a literal gsd_role reference has no matching shipped role', () => { - const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-totally-fake-role-2284",\n description="d"\n)'; - assert.throws( - () => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }), - /gsd-totally-fake-role-2284/, - 'expected an explicit error naming the unresolvable role', - ); - }); - - test('converter throws (never silently installs) when the agents/ directory cannot be resolved at all', () => { - const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-planner",\n description="d"\n)'; - assert.throws( - () => projectNamedDispatchToStructuralDelegate(fixture, HERMES_DISPATCH, hermesToolConfig({ availableRoles: null })), - /could not resolve/i, - ); - }); - - test('a literal reference to a role that DOES exist never throws', () => { - const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-verifier",\n description="d"\n)'; - assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); - }); - - test('dynamic (non-literal) role references are not statically checked and never throw', () => { - // Mirrors the real plan-phase.md shape: subagent_type=research_hook.ref.agent - // is resolved at runtime by the host, not a literal string install.js can verify. - const fixture = 'Agent(\n prompt=x,\n subagent_type=research_hook.ref.agent,\n description="d"\n)'; - assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); - }); - - describe('real install path — deterministic fs.readdirSync injection (never chmod/permission tricks)', () => { - let tmpHome; - let savedHome; - let savedUserProfile; - let savedHermesHome; - let origReaddirSync; - let injectedAgentsDir; - - beforeEach(() => { - tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2284-hermes-failclosed-')); - savedHome = process.env.HOME; - savedUserProfile = process.env.USERPROFILE; - savedHermesHome = process.env.HERMES_HOME; - process.env.HOME = tmpHome; - process.env.USERPROFILE = tmpHome; - process.env.HERMES_HOME = path.join(tmpHome, '.hermes'); - injectedAgentsDir = path.resolve(__dirname, '..', 'agents'); - origReaddirSync = fs.readdirSync; - }); - - afterEach(() => { - fs.readdirSync = origReaddirSync; - try { - uninstall(true, 'hermes'); - } catch (_e) { - // best-effort — the install intentionally failed partway through - } - if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; - if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile; - if (savedHermesHome === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = savedHermesHome; - cleanup(tmpHome); - }); - - test('a real --hermes --global install aborts with an explicit error when the shipped agents/ dir is unreadable', () => { - fs.readdirSync = function (p, opts) { - if (typeof p === 'string' && path.resolve(p) === injectedAgentsDir) { - throw new Error('#2284 injected fs.readdirSync failure — simulated unreadable agents/ dir'); - } - return origReaddirSync.call(fs, p, opts); - }; - - assert.throws( - () => install(true, 'hermes'), - /could not resolve|refusing to install/i, - 'a real hermes install must fail closed, never silently install workflows with unverifiable role references', - ); - }); - }); -}); - -// ─── 5. Corpus-wide invariant (round-2 CRITICAL regression guard) ─────────── -// -// #2284 round-2: `findDispatchCallSpans` originally relied on WHOLE-DOCUMENT -// cumulative quote parity (`maskStringLiterals` run once over the entire -// file). A markdown workflow mixes prose, ```bash fences full of their own -// double-quoted strings, and shell quoting — there is no single document-wide -// quote grammar. In the real corpus, a `"`-heavy bash block upstream of -// gsd-core/workflows/code-review.md's real -// `Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", ...)` -// call (~line 488) desynced that cumulative state, making the span detector -// blind to the call. It shipped completely unnormalized except for the -// catch-all's `subagent_type=`→`gsd_role=` rename: a Frankenstein -// `Agent(gsd_role="gsd-code-reviewer", model="{REVIEWER_MODEL}", ...)` — head -// still literal `Agent(`, `model=` leaked, no `gsd_role_prompt`/`role="leaf"` -// injected. A per-file/spot-check test suite did not exercise this file's -// exact shape and missed it; THIS is the real regression protection — -// hash-only goldens cannot catch a semantic defect like this. -describe('#2284 corpus-wide invariant — every shipped workflow/reference/template .md', () => { - function walkMarkdown(dir) { - if (!fs.existsSync(dir)) return []; - let out = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) out = out.concat(walkMarkdown(full)); - else if (entry.name.endsWith('.md')) out.push(full); - } - return out; - } - - const CORPUS_ROOT = path.join(__dirname, '..', 'gsd-core'); - const CORPUS_FILES = ['workflows', 'references', 'templates', 'contexts'] - .flatMap((sub) => walkMarkdown(path.join(CORPUS_ROOT, sub))); - - test('sanity: a real, substantial corpus was found to scan', () => { - assert.ok(CORPUS_FILES.length > 100, `expected >100 shipped .md files, found ${CORPUS_FILES.length}`); - }); - - test('every shipped .md file projects with ZERO residual Agent(, ZERO residual subagent_type, and ZERO leaked model= inside any delegate_task(...) call', () => { - const failures = []; - let totalDelegateTaskCalls = 0; - for (const file of CORPUS_FILES) { - const rel = path.relative(CORPUS_ROOT, file); - const content = fs.readFileSync(file, 'utf8'); - let out; - try { - out = convertClaudeToHermesMarkdown(content, { runtime: 'hermes' }); - } catch (e) { - failures.push(`${rel}: converter threw unexpectedly: ${e.message}`); - continue; - } - if (/\bAgent\(/.test(out)) failures.push(`${rel}: residual literal Agent( survives`); - if (/\bsubagent_type\s*[=:]/.test(out)) failures.push(`${rel}: residual subagent_type survives`); - for (const span of findDispatchCallSpans(out, 'delegate_task')) { - const rawSpanText = out.slice(span.start, span.end); - if (/\bmodel\s*[=:]/.test(rawSpanText)) failures.push(`${rel}: leaked model= inside a delegate_task(...) call`); - } - totalDelegateTaskCalls += (out.match(/delegate_task\(/g) || []).length; - } - assert.deepStrictEqual(failures, [], `corpus-wide invariant violations:\n${failures.join('\n')}`); - assert.ok(totalDelegateTaskCalls > 50, `sanity: expected a substantial number of real delegate_task( calls emitted, got ${totalDelegateTaskCalls}`); - }); - - test('code-review.md specifically: the real Agent(subagent_type="gsd-code-reviewer", model=..., prompt=...) call (~line 488) projects cleanly despite an upstream `"`-heavy bash fence', () => { - const file = path.join(CORPUS_ROOT, 'workflows', 'code-review.md'); - const content = fs.readFileSync(file, 'utf8'); - const out = convertClaudeToHermesMarkdown(content, { runtime: 'hermes' }); - - assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives in code-review.md'); - assert.ok(!/\bsubagent_type\s*[=:]/.test(out), 'no residual subagent_type in code-review.md'); - assert.ok(out.includes('gsd_role="gsd-code-reviewer"'), 'the real call\'s role is resolved, not just the catch-all rename'); - - const callStart = out.indexOf('delegate_task(gsd_role="gsd-code-reviewer"'); - assert.ok(callStart !== -1, 'the real call head IS delegate_task( — not a bare catch-all-renamed Agent( survivor'); - const callSpans = findDispatchCallSpans(out, 'delegate_task'); - const realCallSpan = callSpans.find((s) => s.start === callStart); - assert.ok(realCallSpan, 'the real call is detected as a complete, well-formed delegate_task(...) span'); - const rawCall = out.slice(realCallSpan.start, realCallSpan.end); - assert.ok(/gsd_role_prompt=/.test(rawCall), 'gsd_role_prompt injected into the real call'); - assert.ok(/role="leaf"/.test(rawCall), 'role="leaf" injected into the real call'); - assert.ok(!/\bmodel\s*[=:]/.test(rawCall), 'no model= leaked inside the real call'); - }); -}); - -// ─── 6. Bash-fence quote-imbalance regression (round-2 root cause) ────────── - -describe('#2284 bash-fence quote-imbalance before a real call (round-2 root cause)', () => { - // Minimal repro of code-review.md's real shape: a ```bash fence containing - // an ODD/unbalanced count of literal double-quotes (ordinary, realistic - // shell prose — `echo "..."` plus a nested escaped quote), followed by a - // real Agent(...) call further down in the SAME document. Under the - // round-1 whole-document cumulative-quote-parity bug, the fence's - // unbalanced quoting flipped the parser's "am I inside a string" state by - // the time it reached the real call, making the call invisible to - // `findDispatchCallSpans` entirely. - const FIXTURE = [ - '```bash', - 'echo "Warning: skipping structural findings embed (${SIZE} bytes). Re-run if needed."', - 'if [ -n "$X" ]; then echo "note: check the \\"quoted\\" value"; fi', - '```', - '', - 'Spawn the reviewer:', - '', - '```', - 'Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt="', - '', - '${FILES_TO_READ}', - '', - 'Review and report.', - '")', - '```', - ].join('\n'); - - test('findDispatchCallSpans finds the real call despite the upstream quote-heavy bash fence', () => { - const spans = findDispatchCallSpans(FIXTURE, 'Agent'); - assert.strictEqual(spans.length, 1, 'exactly one Agent(...) call span found'); - assert.ok(FIXTURE.slice(spans[0].start, spans[0].end).startsWith('Agent(subagent_type="gsd-code-reviewer"')); - }); - - test('the fixture projects fully and correctly (delegate_task head, role injected, no model leak, no residual Agent()', () => { - const out = convertClaudeToHermesMarkdown(FIXTURE, { runtime: 'hermes' }); - assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives'); - assert.ok(!/\bsubagent_type\s*[=:]/.test(out), 'no residual subagent_type'); - assert.ok(out.includes('delegate_task(gsd_role="gsd-code-reviewer"'), 'real call head IS delegate_task(, role resolved inline at the head — not a bare catch-all rename'); - assert.ok(/gsd_role_prompt=/.test(out), 'role-prompt-resolution injected'); - assert.ok(/role="leaf"/.test(out), 'structural role injected'); - const mask = maskStringLiterals(out); - assert.ok(!/\bmodel\s*[=:]/.test(mask), 'no model= leaked'); - }); - - test('the independent post-projection guard catches the deliberately-broken (un-normalized) output this exact fixture used to produce', () => { - // The round-1/round-2 Frankenstein output: catch-all renamed - // subagent_type=→gsd_role= but the head stayed literal Agent( and - // model= leaked through, because the call was never detected as a span. - const frankenstein = 'Agent(gsd_role="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt="Review and report.")'; - const toolConfig = hermesToolConfig(); - assert.throws( - () => _assertProjectionComplete(frankenstein, toolConfig), - /literal Agent\(/i, - 'the independent guard must fail loud on the exact Frankenstein shape the bug produced', - ); - }); -}); - -// ─── 7. plan-review-convergence.md dispatch-adjacent terminology (LOW finding a) ── -// -// The projection renamed `Agent(`→`delegate_task(` but originally left -// adjacent bare-word "Agent" references in the SAME sentence/paragraph -// un-normalized (gsd-core/workflows/plan-review-convergence.md ~lines 108, -// 347, 355), producing self-contradictory installed Hermes text (e.g. -// "...delegate_task(...)... the convergence orchestrator runs at depth 0 -// with Agent available..."). Fixed via two narrowly-scoped exact-phrase -// replacements (NOT a broad bare-word `Agent` rename, which would corrupt -// legitimate `Agent`-adjacent prose elsewhere — role names, "Agent Brief", -// agent-file references). -describe('#2284 plan-review-convergence.md dispatch-adjacent terminology consistency (LOW finding a)', () => { - const FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-review-convergence.md'); - const CONTENT = fs.readFileSync(FILE, 'utf8'); - const OUT = convertClaudeToHermesMarkdown(CONTENT, { runtime: 'hermes' }); - - test('sanity: the source file still contains the two flagged dispatch-adjacent phrases (regression canary for this test itself)', () => { - assert.ok(/orchestrator runs at depth 0 with Agent available/.test(CONTENT), 'source phrase 1 present'); - assert.ok(/\(bug #936: depth-1 Agent has no Agent tool\)/.test(CONTENT), 'source phrase 2 present'); - }); - - test('no literal Agent( survives and no residual bare "Agent available"/"Agent has no Agent tool" contradiction', () => { - assert.ok(!/\bAgent\(/.test(OUT), 'no literal Agent( call syntax survives'); - assert.ok(!/\bAgent available\b/.test(OUT), 'no bare "Agent available" left adjacent to a renamed delegate_task( mention'); - assert.ok(!/depth-1 Agent has no/.test(OUT), 'no bare "depth-1 Agent" left adjacent to the renamed delegate_task('); - }); - - test('both flagged paragraphs (source ~lines 108, 347) consistently say "delegate_task available"', () => { - const matches = OUT.match(/orchestrator runs at depth 0 with delegate_task available/g) || []; - assert.strictEqual(matches.length, 2, 'both paragraphs (initial planning + replan) normalized consistently'); - }); - - test('the success_criteria bullet (source ~line 355) reads consistently: "depth-1 delegate_task has no nested delegate_task"', () => { - assert.ok(OUT.includes('(bug #936: depth-1 delegate_task has no nested delegate_task)')); - }); - - test('unrelated bare "Agent" mentions NOT adjacent to a renamed dispatch call are left untouched (no broad rename)', () => { - // "Review via Agent → Skill(...)" (success_criteria) has no Agent(...) - // call in the same bullet — the projection never touched it, so it must - // not be renamed either. - assert.ok(OUT.includes('Review via Agent → Skill("gsd-review")'), 'unrelated bare "Agent" prose left intact — no broad bare-word rename'); - // "Hermes Agent" is the runtime's own brand name (from brandingRewrites), - // never the dispatch primitive — must never be touched by this fix. - assert.ok(OUT.includes('the one level of nesting that works on Hermes Agent'), 'runtime brand name "Hermes Agent" untouched by the dispatch-terminology fix'); - }); -}); - -// ─── 8. Branding protected-region — tables (finding b) ── -// -// The shared "Claude Code" → host-brand-name swap (applied by EVERY runtime -// that brands workflow content: cursor/windsurf/trae/cline/codebuddy -// hardcoded, qwen/hermes descriptor-driven) rewrote "Claude Code" even -// inside `` comparison tables -// (gsd-core/workflows/{plan-phase,execute-phase}.md), where "Claude Code" is -// a COMPARED-RUNTIME LABEL, not a host self-reference — mislabeling the -// comparison. Cross-cutting: reproduces on every branding runtime, not just -// Hermes. Fixed via `applyClaudeCodeBrandSwap`, a protected-region -// extract/restore wrapper used by every runtime's brand-swap call site. -describe('#2284(b) branding protected-region — comparison tables', () => { - test('applyClaudeCodeBrandSwap leaves content byte-identical, but still swaps self-references outside it', () => { - const fixture = [ - 'This tool runs on Claude Code and other hosts.', - '', - '', - '- **Claude Code:** Uses `Agent(...)` — blocks until complete', - '- **Other runtimes:** sequential inline execution', - '', - '', - 'Claude Code users should also read CONTRIBUTING.md.', - ].join('\n'); - - const out = applyClaudeCodeBrandSwap(fixture, 'Windsurf'); - const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; - assert.ok(block.includes('**Claude Code:**'), 'compared-runtime label inside the block is untouched'); - assert.ok(!block.includes('Windsurf'), 'the block never gains the installing runtime\'s own brand name'); - assert.ok(out.includes('This tool runs on Windsurf and other hosts.'), 'genuine self-reference BEFORE the block is branded'); - assert.ok(out.includes('Windsurf users should also read CONTRIBUTING.md.'), 'genuine self-reference AFTER the block is branded'); - }); - - test('a no-op brand name (falsy) returns content unchanged (fail-closed default, matches the qwen/hermes "guarded" no-op pattern)', () => { - const fixture = 'Claude Code does the thing.'; - assert.strictEqual(applyClaudeCodeBrandSwap(fixture, undefined), fixture); - assert.strictEqual(applyClaudeCodeBrandSwap(fixture, null), fixture); - assert.strictEqual(applyClaudeCodeBrandSwap(fixture, ''), fixture); - }); - - test('multiple blocks in the same document are each protected independently', () => { - const fixture = [ - 'Claude Code: A', - 'Claude Code self-reference.', - 'Claude Code: B', - ].join('\n'); - const out = applyClaudeCodeBrandSwap(fixture, 'Trae'); - assert.ok(out.includes('Claude Code: A')); - assert.ok(out.includes('Claude Code: B')); - assert.ok(out.includes('Trae self-reference.')); - }); - - test('collision-robust: arbitrary sentinel-like content in surrounding prose (NUL byte, token) round-trips untouched while genuine self-references are still swapped — the split-and-rejoin rewrite has no sentinel/placeholder to collide with', () => { - const fixture = [ - 'Claude Code embeds a literal NUL byte here: [] and a placeholder-shaped token in its prose.', - '', - '', - '- **Claude Code:** reference implementation', - '', - '', - 'Claude Code again, after the block.', - ].join('\n'); - - const out = applyClaudeCodeBrandSwap(fixture, 'Trae'); - - // Genuine self-references outside the block ARE swapped. - assert.ok(out.startsWith('Trae embeds'), 'leading self-reference swapped'); - assert.ok(out.includes('Trae again, after the block.'), 'trailing self-reference swapped'); - - // The NUL byte survives verbatim, exactly once, with no corruption. - assert.ok(out.includes('[]'), 'NUL byte preserved verbatim'); - assert.strictEqual(out.split('').length - 1, 1, 'NUL byte appears exactly once — not duplicated or leaked'); - - // The placeholder-shaped token survives verbatim, exactly once — proving - // there is no internal sentinel this content could collide with. - assert.ok(out.includes(''), 'placeholder-shaped token preserved verbatim'); - assert.strictEqual((out.match(//g) || []).length, 1, 'placeholder-shaped token appears exactly once — not duplicated or leaked'); - - // The protected block is untouched, including its interior "Claude Code" label. - const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; - assert.ok(block.includes('**Claude Code:**'), 'block interior "Claude Code" left verbatim'); - assert.ok(!block.includes('Trae'), 'block never gains the installing runtime\'s own brand name'); - }); - - test('inside-AND-outside: a fixture with "Claude Code" both inside a block and in surrounding prose swaps only the outside occurrence', () => { - const fixture = [ - 'Claude Code is the host running this installer.', - '', - '- **Claude Code:** compared-runtime label, must stay verbatim', - '', - 'This is still Claude Code speaking.', - ].join('\n'); - - const out = applyClaudeCodeBrandSwap(fixture, 'Cursor'); - - assert.ok(out.includes('Cursor is the host running this installer.'), 'outside occurrence before the block is swapped'); - assert.ok(out.includes('This is still Cursor speaking.'), 'outside occurrence after the block is swapped'); - - const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; - assert.ok(block.includes('**Claude Code:**'), 'inside occurrence is preserved verbatim'); - assert.ok(!block.includes('Cursor'), 'inside occurrence is never swapped'); - }); - - describe('real corpus: gsd-core/workflows/execute-phase.md table', () => { - const FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); - const CONTENT = fs.readFileSync(FILE, 'utf8'); - - test('sanity: the source file has a block containing "Claude Code:" as a compared-runtime label', () => { - assert.ok(//.test(CONTENT)); - const block = CONTENT.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; - assert.ok(/\*\*Claude Code:\*\*/.test(block)); - }); - - test('Windsurf: the compared-runtime label "Claude Code:" is NOT swapped to "Windsurf:", but genuine self-references elsewhere ARE', () => { - const out = convertClaudeToWindsurfMarkdown(CONTENT); - const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; - assert.ok(/\*\*Claude Code:\*\*/.test(block), 'comparison-table label preserved for Windsurf'); - assert.ok(!/\*\*Windsurf:\*\*/.test(block), 'comparison table never mislabeled with the installing runtime\'s own name'); - const outsideBlock = out.replace(/[\s\S]*?<\/runtime_compatibility>/g, ''); - assert.ok(/\bWindsurf\b/.test(outsideBlock), 'genuine self-references outside the block ARE branded to Windsurf'); - assert.ok(!/\bClaude Code\b/.test(outsideBlock), 'no residual "Claude Code" self-reference survives outside the block'); - }); - - test('Hermes: the compared-runtime label "Claude Code:" is NOT swapped to "Hermes Agent:", but genuine self-references elsewhere ARE', () => { - const out = convertClaudeToHermesMarkdown(CONTENT, { runtime: 'hermes' }); - const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; - assert.ok(/\*\*Claude Code:\*\*/.test(block), 'comparison-table label preserved for Hermes'); - assert.ok(!/\*\*Hermes Agent:\*\*/.test(block), 'comparison table never mislabeled with Hermes\'s own brand name'); - const outsideBlock = out.replace(/[\s\S]*?<\/runtime_compatibility>/g, ''); - assert.ok(/\bHermes Agent\b/.test(outsideBlock), 'genuine self-references outside the block ARE branded to Hermes Agent'); - }); - }); -}); diff --git a/tests/fix-2598-opencode-background-dispatch.test.cjs b/tests/fix-2598-opencode-background-dispatch.test.cjs deleted file mode 100644 index 652470bb4..000000000 --- a/tests/fix-2598-opencode-background-dispatch.test.cjs +++ /dev/null @@ -1,91 +0,0 @@ -/** - * #2598 — the OpenCode descriptor declared background/concurrent subagent - * dispatch that OpenCode does not actually provide by default. - * - * `capabilities/opencode/capability.json` carried - * `runtime.hostIntegration.dispatch.background: true` and - * `dispatch.backgroundDispatch: true`. OpenCode's native subagent dispatch - * (Task tool / `@`-mention / `subtask`) is synchronous: the `background` - * parameter is hidden from the model behind the opt-in - * `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag, which defaults to false - * (`enabledByExperimental(...)` over a `bool()` that defaults false), and the - * session loop still `tasks.pop()`s one subtask at a time (upstream #14195, - * #29638 — the latter still open). - * - * `negotiateHostCapabilities` and every `degradationFor`/`shouldFlattenDispatch` - * consumer TRUSTS these per-field values, so declaring a capability the host - * lacks overstates it — the opposite of the fail-closed posture the negotiation - * exists to enforce. - * - * History note: these fields were flipped to `true` by #2087 citing a reading of - * OpenCode v1.17 as "background subagents enabled by default in all modes". - * That reading does not hold against current upstream `dev`, where the flag is - * opt-in. This test pins the corrected values so a future descriptor edit cannot - * silently re-assert an unsupported capability. - */ - -// allow-test-rule: source-text-is-the-product #2598 — the descriptor JSON and the -// host-integration matrix ARE the negotiated contract; asserting their values is behavioral. - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const DESCRIPTOR = path.join(ROOT, 'capabilities', 'opencode', 'capability.json'); -const MATRIX = path.join(ROOT, 'docs', 'reference', 'host-integration-capability-matrix.md'); - -function opencodeDispatch() { - const parsed = JSON.parse(fs.readFileSync(DESCRIPTOR, 'utf8')); - return parsed.runtime.hostIntegration.dispatch; -} - -describe('#2598: OpenCode does not declare background/concurrent subagent dispatch', () => { - test('descriptor declares background: false', () => { - assert.equal( - opencodeDispatch().background, - false, - 'OpenCode subagent dispatch is synchronous unless an experimental opt-in flag is set', - ); - }); - - test('descriptor declares backgroundDispatch: false', () => { - assert.equal( - opencodeDispatch().backgroundDispatch, - false, - 'concurrent dispatch requires an opt-in flag, so it must not be declared as available', - ); - }); - - test('the capabilities that ARE real are left intact', () => { - // Narrow the blast radius: this fix must not quietly downgrade neighbouring - // sub-fields that were never in question. - const d = opencodeDispatch(); - assert.equal(d.namedDispatch, true, 'named subagent dispatch is genuinely supported'); - assert.equal(d.subagentToolkit, 'full', 'the general subagent has full tool access'); - assert.equal(d.isolation, 'orchestrator-worktree', - 'isolation is orchestrator-managed via `opencode run --dir`, unaffected by #2598'); - }); - - test('the host-integration matrix agrees with the descriptor', () => { - // ADR-1239 designates the matrix the deployment source-of-truth; a - // descriptor/matrix disagreement is how this defect survived in the first - // place (the matrix said true, the ADR binding table said false). - const matrix = fs.readFileSync(MATRIX, 'utf8'); - const section = matrix.slice(matrix.indexOf('## opencode')); - const end = section.indexOf('\n## '); - const opencodeSection = end === -1 ? section : section.slice(0, end); - - for (const field of ['dispatch.background', 'dispatch.backgroundDispatch']) { - const row = opencodeSection.split('\n').find((l) => l.startsWith(`| ${field} |`)); - assert.ok(row, `matrix must document ${field} for opencode`); - const value = row.split('|')[2].trim(); - assert.equal(value, 'false', `matrix ${field} must match the descriptor`); - } - }); -}); diff --git a/tests/fix-2603-kimi-code-host-matrix.test.cjs b/tests/fix-2603-kimi-code-host-matrix.test.cjs deleted file mode 100644 index 962d1369e..000000000 --- a/tests/fix-2603-kimi-code-host-matrix.test.cjs +++ /dev/null @@ -1,224 +0,0 @@ -/** - * #2603 — `docs/reference/host-integration-capability-matrix.md` documented 18 of the - * 19 installed runtimes but had no `## kimi-code` section, so kimi-code's - * `runtime.hostIntegration` axes shipped with no cited source and no evidence quote. - * - * Sourcing each axis independently (the issue's explicit requirement — "Do not copy - * `kimi`'s section", they are distinct products) showed three axis values had been - * inherited from the Python `kimi` descriptor rather than sourced for Kimi Code CLI: - * - * - `embeddingMode: imperative` → `declarative`. Kimi Code plugins are a - * `kimi.plugin.json` manifest plus markdown Skills; "Plugins are configuration and - * markdown only" with no in-process programmatic API (docs/en/customization/plugins.md). - * Same shape as codex, which is `declarative`. - * - `dispatch.nested: false` → `true`. The `coder` built-in "can dispatch its own - * nested sub-agents when a task decomposes naturally" (docs/en/customization/agents.md). - * The Python `kimi` CLI genuinely prohibits nesting; Kimi Code does not. - * - `dispatch.maxDepth: 1` → `'undocumented'`. Nesting is documented but no depth - * bound is published, so the fail-closed sentinel applies rather than a guessed 1. - * - * `dispatch.namedDispatch` deliberately stays `false`: GSD's kimi-code artifact layout - * installs Agent Skills only (no `agents` kind), so no named GSD subagent is registered - * with the host and `resolveDispatchType` maps every role onto coder/explore/plan. - * Flipping it without also shipping agent files would reintroduce the dispatch failure - * recorded in docs/migration/kimi-to-kimi-code.md. - * - * This is the same defect class as #2598 (a descriptor axis asserting something the - * host docs contradict), and takes the same countermeasure: pin the corrected values - * AND require the matrix to agree with the descriptor, because a descriptor/matrix - * disagreement is how the gap survived. - */ - -// allow-test-rule: source-text-is-the-product #2603 — the descriptor JSON and the -// host-integration matrix ARE the negotiated contract; asserting their values is behavioral. - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const DESCRIPTOR = path.join(ROOT, 'capabilities', 'kimi-code', 'capability.json'); -const MATRIX = path.join(ROOT, 'docs', 'reference', 'host-integration-capability-matrix.md'); - -const { - profileOf, - negotiateHostCapabilities, -} = require(path.join(ROOT, 'gsd-core/bin/lib/host-integration.cjs')); - -function kimiCodeAxes() { - return JSON.parse(fs.readFileSync(DESCRIPTOR, 'utf8')).runtime.hostIntegration; -} - -/** Extract the `## ` section body, stopping at the next top-level host heading. */ -function matrixSection(host) { - const matrix = fs.readFileSync(MATRIX, 'utf8'); - const start = matrix.indexOf(`\n## ${host}\n`); - if (start === -1) return null; - const rest = matrix.slice(start + 1); - const end = rest.indexOf('\n## '); - return end === -1 ? rest : rest.slice(0, end); -} - -/** Read the `| | | …` cell out of a matrix section. */ -function matrixValue(section, axis) { - const row = section.split('\n').find((l) => l.startsWith(`| ${axis} |`)); - return row ? row.split('|')[2].trim() : null; -} - -describe('#2603: the host-integration matrix documents kimi-code', () => { - test('a `## kimi-code` section exists', () => { - assert.ok( - matrixSection('kimi-code'), - 'the matrix is the deployment source-of-truth for every installed runtime; kimi-code must have a section', - ); - }); - - test('every hostIntegration axis kimi-code declares is documented in the matrix', () => { - const section = matrixSection('kimi-code'); - const axes = kimiCodeAxes(); - - const scalarAxes = Object.keys(axes).filter((k) => k !== 'dispatch'); - for (const axis of scalarAxes) { - assert.ok( - matrixValue(section, axis), - `matrix must document the "${axis}" axis for kimi-code`, - ); - } - - // `builtInSubagents` is a GSD-side list, not a negotiated axis — the matrix - // documents it in prose, not as its own row. - const dispatchAxes = Object.keys(axes.dispatch).filter((k) => k !== 'builtInSubagents'); - for (const axis of dispatchAxes) { - assert.ok( - matrixValue(section, `dispatch.${axis}`), - `matrix must document the "dispatch.${axis}" sub-axis for kimi-code`, - ); - } - }); - - test('the matrix values agree with the shipped descriptor', () => { - const section = matrixSection('kimi-code'); - const axes = kimiCodeAxes(); - - for (const axis of Object.keys(axes).filter((k) => k !== 'dispatch')) { - assert.equal( - matrixValue(section, axis), - String(axes[axis]), - `matrix "${axis}" must match the descriptor`, - ); - } - for (const axis of Object.keys(axes.dispatch).filter((k) => k !== 'builtInSubagents')) { - assert.equal( - matrixValue(section, `dispatch.${axis}`), - String(axes.dispatch[axis]), - `matrix "dispatch.${axis}" must match the descriptor`, - ); - } - }); - - test('the kimi-code section is sourced independently of the kimi section', () => { - // The two are distinct products (Python kimi-cli vs TypeScript Kimi Code CLI); - // the issue's central requirement is that kimi's section was NOT copied. The - // check is scoped to the axis ROWS — the section's prose intro deliberately - // names kimi's Python API to draw the contrast, which is the opposite of a copy. - const rows = matrixSection('kimi-code') - .split('\n') - .filter((l) => l.startsWith('| ') && !l.startsWith('| Axis |') && !l.startsWith('|---')); - - assert.ok(rows.length >= 11, 'expected a row per hostIntegration axis'); - for (const row of rows) { - assert.ok( - !row.includes('kimi_cli'), - `kimi-code axis row must not cite the Python kimi-cli: ${row.slice(0, 60)}`, - ); - assert.ok( - !row.includes('moonshotai.github.io/kimi-cli'), - `kimi-code axis row must not cite kimi-cli docs: ${row.slice(0, 60)}`, - ); - } - assert.ok( - rows.some((r) => r.includes('kimi-code/blob/main/docs')), - 'kimi-code axes must cite the Kimi Code CLI docs', - ); - }); -}); - -describe('#2603: axis values inherited from the Python kimi descriptor are corrected', () => { - test('embeddingMode is declarative — plugins expose no in-process API', () => { - assert.equal(kimiCodeAxes().embeddingMode, 'declarative'); - }); - - test('kimi-code therefore classifies as the declarative-cli profile', () => { - assert.equal(profileOf(kimiCodeAxes()), 'declarative-cli'); - }); - - test('dispatch.nested is true — the coder built-in dispatches nested sub-agents', () => { - assert.equal(kimiCodeAxes().dispatch.nested, true); - }); - - test('dispatch.maxDepth is the undocumented sentinel, not a guessed integer', () => { - assert.equal(kimiCodeAxes().dispatch.maxDepth, 'undocumented'); - }); - - test('namedDispatch stays false — GSD installs no agent files for this host', () => { - // Guard against a well-meaning "the docs say custom agents exist" edit: flipping - // this makes resolveDispatchType return `gsd-planner` unchanged, which kimi-code - // cannot dispatch (docs/migration/kimi-to-kimi-code.md). - assert.equal(kimiCodeAxes().dispatch.namedDispatch, false); - }); - - test('the undocumented maxDepth sentinel is reported as a sentinel, not as malformed', () => { - // Surfaced by this change: maxDepth was the ONE dispatch sub-axis with no - // sentinel-specific warning, so the documented fail-closed value was reported - // as "missing or not a number" — indistinguishable from a genuinely broken - // descriptor. kimi-code would have been the sixth runtime to hit that path. - const { warnings } = negotiateHostCapabilities(kimiCodeAxes()); - - assert.ok( - warnings.some((w) => w.includes('dispatch.maxDepth is undocumented')), - `expected a maxDepth sentinel warning, got: ${JSON.stringify(warnings)}`, - ); - assert.ok( - !warnings.some((w) => w.includes('maxDepth is missing or not a number')), - 'the documented sentinel must not be reported as a malformed value', - ); - }); - - test('a genuinely malformed maxDepth is still reported as malformed', () => { - // Boundary: the sentinel carve-out must not swallow the real error case. - const axes = kimiCodeAxes(); - const malformed = { ...axes, dispatch: { ...axes.dispatch, maxDepth: 'not-a-number' } }; - const { warnings } = negotiateHostCapabilities(malformed); - - assert.ok( - warnings.some((w) => w.includes('maxDepth is missing or not a number')), - `expected the malformed-value warning, got: ${JSON.stringify(warnings)}`, - ); - }); - - test('both maxDepth paths still degrade the effective value closed to 0', () => { - const axes = kimiCodeAxes(); - assert.equal(negotiateHostCapabilities(axes).effective.dispatch.maxDepth, 0); - const malformed = { ...axes, dispatch: { ...axes.dispatch, maxDepth: 'not-a-number' } }; - assert.equal(negotiateHostCapabilities(malformed).effective.dispatch.maxDepth, 0); - }); - - test('the axes that were already correct are left intact', () => { - const axes = kimiCodeAxes(); - assert.equal(axes.commandSurface, 'slash-file'); - assert.equal(axes.modelMode, 'passive'); - assert.equal(axes.hookBus, 'host'); - assert.equal(axes.stateIO, 'filesystem'); - assert.equal(axes.transport, 'mcp'); - assert.equal(axes.runtime, 'node'); - assert.equal(axes.dispatch.background, true); - assert.equal(axes.dispatch.backgroundDispatch, true); - assert.equal(axes.dispatch.subagentToolkit, 'built-in-only'); - assert.equal(axes.dispatch.isolation, 'orchestrator-worktree'); - }); -}); diff --git a/tests/fix-2615-effortsurface-matrix-parity.test.cjs b/tests/fix-2615-effortsurface-matrix-parity.test.cjs deleted file mode 100644 index 91f1db443..000000000 --- a/tests/fix-2615-effortsurface-matrix-parity.test.cjs +++ /dev/null @@ -1,99 +0,0 @@ -// allow-test-rule: source-text-is-the-product #2615 — the host-integration matrix -// IS the cited source of truth for every descriptor axis (ADR-1239); asserting that a -// shipped axis value appears there, and matches, is a contract assertion. - -/** - * Regression test for #2615 — `effortSurface` was a shipped `hostIntegration` axis - * with NO presence in the matrix that is supposed to be its cited source of truth. - * - * #2481 added the axis and wrote docs-sourced values into 18 descriptors - * (`claude`/`codex`/`opencode` -> `argv`, 15 others -> `undocumented`) but never - * touched `docs/reference/host-integration-capability-matrix.md`: the axes legend - * omitted it and not one per-runtime table carried a row. `src/host-integration.cts` - * states "every value is documented or explicitly 'undocumented'" — for this axis - * that was false for every runtime. - * - * This test is deliberately GENERIC rather than a hardcoded list: it derives the - * runtimes from the registry, so a runtime added later fails here until its matrix - * row exists. That is the ratchet the original gap needed — #2481 added an axis and - * nothing caught the missing documentation. - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const MATRIX = path.join(ROOT, 'docs', 'reference', 'host-integration-capability-matrix.md'); -const registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs')); - -// Normalize CRLF so the row regexes hold on a Windows autocrlf checkout. -const MATRIX_TEXT = fs.readFileSync(MATRIX, 'utf-8').replace(/\r\n/g, '\n'); - -/** Extract a `## ` section body, stopping at the next top-level host heading. */ -function section(host) { - const start = MATRIX_TEXT.indexOf(`\n## ${host}\n`); - if (start === -1) return null; - const rest = MATRIX_TEXT.slice(start + 1); - const end = rest.indexOf('\n## '); - return end === -1 ? rest : rest.slice(0, end); -} - -/** Read the value cell of a `| | | …` row. */ -function axisValue(body, axis) { - const row = body.split(/\r?\n/).find((l) => l.startsWith(`| ${axis} |`)); - return row ? row.split('|')[2].trim() : null; -} - -const RUNTIMES = Object.keys(registry.runtimes).filter( - (id) => registry.runtimes[id]?.runtime?.hostIntegration, -); - -describe('#2615: the matrix documents the effortSurface axis', () => { - test('the axes legend defines effortSurface and its vocabulary', () => { - const legendRow = MATRIX_TEXT.split(/\r?\n/).find((l) => l.startsWith('| `effortSurface` |')); - assert.ok(legendRow, 'the axes legend must define effortSurface (#2615)'); - for (const member of ['`argv`', '`none`', '`undocumented`']) { - assert.ok(legendRow.includes(member), - `the legend must document the ${member} vocabulary member (#2615)`); - } - }); - - test('there is at least one runtime to check', () => { - // Guards the loops below against silently asserting nothing. - assert.ok(RUNTIMES.length >= 18, `expected the full runtime corpus, got ${RUNTIMES.length}`); - }); - - for (const id of RUNTIMES) { - describe(`runtime: ${id}`, () => { - test('has a matrix section', () => { - assert.ok(section(id), `${id}: every installed runtime needs a matrix section (ADR-1239)`); - }); - - test('documents effortSurface, and the value matches the descriptor', () => { - const body = section(id); - assert.ok(body, `${id}: missing matrix section`); - - const documented = axisValue(body, 'effortSurface'); - assert.ok(documented, `${id}: the matrix must carry an effortSurface row (#2615)`); - - const declared = registry.runtimes[id].runtime.hostIntegration.effortSurface; - if (declared === undefined) { - // kimi-code declares no value: its mechanism (`/effort`) is interactive-only - // and neither `argv` nor `none` describes it. The matrix must say so rather - // than invent a value. - assert.match(documented, /not declared/i, - `${id}: an absent descriptor value must be documented as absent, not guessed (#2615)`); - } else { - assert.equal(documented, declared, - `${id}: the matrix effortSurface value must match the shipped descriptor`); - } - }); - }); - } -}); diff --git a/tests/fix-2658-trae-runtime-detection-and-instruction-path.test.cjs b/tests/fix-2658-trae-runtime-detection-and-instruction-path.test.cjs deleted file mode 100644 index ba2692490..000000000 --- a/tests/fix-2658-trae-runtime-detection-and-instruction-path.test.cjs +++ /dev/null @@ -1,265 +0,0 @@ -'use strict'; - -/** - * Regression tests for #2658 — Trae runtime not detected in workflow - * runtime-detection blocks (falls back to claude), and the install-time - * `CLAUDE.md` path rewrite mutilates the claude fallback into a malformed - * path instead of resolving to the Trae rules file. - * - * Defects collided (see - * .gsd/bug/fix-2658-trae-runtime-not-detected-falls-back-to-/10-diagnosis.md): - * - * 1. `gsd-core/workflows/new-project.md` AND `gsd-core/workflows/ingest-docs.md` - * (found during this remediation — same pattern, same gap, not just - * new-project.md as originally reported) never recognized trae (path - * `/.trae/` or env `TRAE_CONFIG_DIR`) in their runtime-detection blocks — - * fell through to `RUNTIME=claude`. - * 2. The `trae.js` entry in `bin/install.js`'s `RUNTIME_CONTENT_DISPATCH` - * replaced bare `CLAUDE.md` first, leaving a stale `.claude/` prefix: - * `.claude/CLAUDE.md` -> `.claude/.trae/rules/`. - * 3. `convertClaudeToTraeMarkdown` (mirrored in `bin/install.js` and - * `src/runtime-artifact-conversion.cts`) had the same class of bug but a - * DIFFERENT wrong output (`.trae/.trae/rules/`), because its generic - * `.claude/` -> `.trae/` rewrite ran after the bare `CLAUDE.md` rewrite - * and re-mutated the leftover prefix. - * 4. `capabilities/trae/capability.json` didn't declare - * `hostBehaviors.projectInstructionFile`, so even a correctly-detected - * trae runtime resolved to the generic `AGENTS.md` default via - * `getProjectInstructionFile`. - * 5. Found by the end-to-end install test below, one level deeper than the - * static trace: `copyWithPathReplacement` (bin/install.js) runs a - * GENERIC `~/.claude/` / `$HOME/.claude/` / `./.claude/` -> runtime-dir - * rewrite on every .md file BEFORE calling `convertClaudeToTraeMarkdown`, - * substituting a `pathPrefix` the converter is never given (it differs - * per install: relative for a project-local install, an arbitrary - * absolute path for a local install rooted elsewhere, `~/.trae/` for a - * global one). The converter's `.claude/CLAUDE.md`-specific patterns - * (defect 3's fix) never fire on that already-rewritten text, and the - * bare fallback still doubles the prefix — a first attempt at fixing - * this handled only the `./.trae/CLAUDE.md` shape and missed the - * `~/.claude/` / `$HOME/.claude/` forms `gsd-core/workflows/profile-user.md` - * actually uses, caught by row 12 (the real spawned install) below on a - * second run. Fixed with a prefix-preserving pattern (capture whatever - * precedes a `.trae/` tail, keep it, fix only the filename suffix) - * instead of assuming one fixed shape. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const fc = require('fast-check'); - -process.env['GSD_TEST_MODE'] = '1'; - -const { getProjectInstructionFile } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); -const { convertClaudeToTraeMarkdown } = require('../bin/install.js'); -const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); - -const { runMinimalInstall, walk } = require('./helpers/install-shared.cjs'); -const { cleanup } = require('./helpers.cjs'); - -const REPO_ROOT = path.join(__dirname, '..'); -const MALFORMED_SINGLE = '.claude/.trae/rules'; -const MALFORMED_DOUBLE = '.trae/.trae/rules'; -const EXPECTED_PATH = '.trae/rules/rules.md'; - -describe('#2658 acceptance criterion 2: getProjectInstructionFile resolves trae to a concrete file', () => { - test('trae maps to .trae/rules/rules.md (not the generic AGENTS.md default)', () => { - assert.strictEqual(getProjectInstructionFile('trae'), EXPECTED_PATH); - }); - - test('capability descriptor declares the same path getProjectInstructionFile returns', () => { - const cap = JSON.parse( - fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'trae', 'capability.json'), 'utf8'), - ); - assert.strictEqual(cap.runtime.hostBehaviors.projectInstructionFile, EXPECTED_PATH); - assert.strictEqual(getProjectInstructionFile('trae'), cap.runtime.hostBehaviors.projectInstructionFile); - }); - - test('the declared path is a concrete file, not a bare directory (acceptance criterion 2)', () => { - assert.ok(!EXPECTED_PATH.endsWith('/'), 'must not be directory-terminated'); - assert.ok(/\.md$/.test(EXPECTED_PATH), 'must name a concrete markdown file'); - }); -}); - -describe('#2658: convertClaudeToTraeMarkdown never mutilates the CLAUDE.md path (bin/install.js)', () => { - const cases = [ - ['bare CLAUDE.md', 'See CLAUDE.md for details.'], - ['./CLAUDE.md', 'Read ./CLAUDE.md before starting.'], - ['backtick-wrapped `CLAUDE.md`', 'The file `CLAUDE.md` is authoritative.'], - ['the exact reported-bug input: .claude/CLAUDE.md', 'Fallback path is .claude/CLAUDE.md by default.'], - ['backtick-wrapped .claude/CLAUDE.md', 'Fallback: `.claude/CLAUDE.md`.'], - ['./.claude/CLAUDE.md', 'From root: ./.claude/CLAUDE.md'], - ]; - for (const [label, input] of cases) { - test(`${label} -> ${EXPECTED_PATH}, no malformed output`, () => { - const out = convertClaudeToTraeMarkdown(input); - assert.ok(!out.includes(MALFORMED_SINGLE), `output must not contain "${MALFORMED_SINGLE}": ${out}`); - assert.ok(!out.includes(MALFORMED_DOUBLE), `output must not contain "${MALFORMED_DOUBLE}": ${out}`); - assert.ok(out.includes(EXPECTED_PATH), `output must contain "${EXPECTED_PATH}": ${out}`); - }); - } - - test('fast-check property: any surrounding text around .claude/CLAUDE.md never yields a malformed path', () => { - fc.assert( - fc.property( - fc.string({ maxLength: 40 }), - fc.string({ maxLength: 40 }), - (prefix, suffix) => { - const content = `${prefix}.claude/CLAUDE.md${suffix}`; - const out = convertClaudeToTraeMarkdown(content); - assert.ok(!out.includes(MALFORMED_SINGLE)); - assert.ok(!out.includes(MALFORMED_DOUBLE)); - }, - ), - { numRuns: 200 }, - ); - }); -}); - -describe('#2658 defect 5: post-generic-rewrite ".trae/"-prefixed forms preserve their prefix instead of doubling it', () => { - // These simulate the text `copyWithPathReplacement`'s generic `~/.claude/` / - // `$HOME/.claude/` / `./.claude/` -> runtime-dir pass hands to - // convertClaudeToTraeMarkdown — the converter never sees the original - // `.claude/`-prefixed source in this pipeline, only these already-rewritten - // shapes. A fixed-shape patch that only handled the local relative form - // left the local-install-absolute-path and global tilde forms broken. - const cases = [ - ['local relative (post "./.claude/" -> "./.trae/" rewrite)', './.trae/CLAUDE.md', './.trae/rules/rules.md'], - [ - 'local install absolute path (post "./.claude/" -> "/.trae/" rewrite)', - '/private/var/folders/xx/gsd-trae-local-abc123/.trae/CLAUDE.md', - '/private/var/folders/xx/gsd-trae-local-abc123/.trae/rules/rules.md', - ], - ['global tilde (post "~/.claude/" -> "~/.trae/" rewrite)', '~/.trae/CLAUDE.md', '~/.trae/rules/rules.md'], - ['backtick-wrapped local relative', '`./.trae/CLAUDE.md`', '`./.trae/rules/rules.md`'], - ]; - for (const [label, input, expected] of cases) { - test(`${label} -> prefix preserved, no malformed path`, () => { - const out = convertClaudeToTraeMarkdown(input); - assert.ok(!out.includes(MALFORMED_SINGLE), `output must not contain "${MALFORMED_SINGLE}": ${out}`); - assert.ok(!out.includes(MALFORMED_DOUBLE), `output must not contain "${MALFORMED_DOUBLE}": ${out}`); - assert.strictEqual(out, expected); - }); - } - - test('fast-check property: any arbitrary path ending in .trae/ never yields a doubled prefix', () => { - fc.assert( - fc.property( - fc.string({ maxLength: 30 }).filter((s) => !s.includes('`') && !/\s/.test(s)), - (prefix) => { - const content = `${prefix}.trae/CLAUDE.md`; - const out = convertClaudeToTraeMarkdown(content); - assert.ok(!out.includes(MALFORMED_SINGLE)); - assert.ok(!out.includes(MALFORMED_DOUBLE)); - }, - ), - { numRuns: 200 }, - ); - }); -}); - -describe('#2658 output parity: bin/install.js vs runtime-artifact-conversion.cjs convertClaudeToTraeMarkdown (#2094 mirror)', () => { - // Parity must hold for the pre-existing reported-bug input AND for every - // arbitrary-prefix ".trae/"-tail shape the prefix-preserving regex - // (bin/install.js:2747-2748, mirrored byte-for-byte at - // src/runtime-artifact-conversion.cts:1357-1358) was added to handle. A - // change to only one copy of that regex would otherwise pass every other - // test in this file — none of the defect-5 cases above call the mirror — - // while silently diverging from the other copy. - const parityCases = [ - ['the reported-bug input (bare .claude/ prefix)', 'Fallback path is .claude/CLAUDE.md by default.'], - ['local relative prefix (post "./.claude/" -> "./.trae/" rewrite)', './.trae/CLAUDE.md'], - [ - 'nested project-path absolute prefix (post "./.claude/" -> "/.trae/" rewrite)', - '/private/var/folders/xx/gsd-trae-local-abc123/.trae/CLAUDE.md', - ], - ['global tilde prefix (post "~/.claude/" -> "~/.trae/" rewrite)', '~/.trae/CLAUDE.md'], - ['$HOME-variable prefix (post "$HOME/.claude/" -> "$HOME/.trae/" rewrite)', '$HOME/.trae/CLAUDE.md'], - ['backtick-wrapped local relative prefix', '`./.trae/CLAUDE.md`'], - ]; - - for (const [label, input] of parityCases) { - test(`identical output for ${label}`, () => { - assert.strictEqual( - convertClaudeToTraeMarkdown(input), - runtimeArtifactConversion.convertClaudeToTraeMarkdown(input), - ); - }); - } - - test('fast-check property: any arbitrary ".trae/"-tail path produces identical output in both implementations', () => { - fc.assert( - fc.property( - fc.string({ maxLength: 30 }).filter((s) => !s.includes('`') && !/\s/.test(s)), - (prefix) => { - const content = `${prefix}.trae/CLAUDE.md`; - assert.strictEqual( - convertClaudeToTraeMarkdown(content), - runtimeArtifactConversion.convertClaudeToTraeMarkdown(content), - ); - }, - ), - { numRuns: 200 }, - ); - }); -}); - -describe('#2658: end-to-end --trae install never emits the malformed path (acceptance criterion 1)', () => { - test('local install: no emitted .md/.js/.cjs file contains the malformed strings; the rules file is concrete', () => { - const { configDir, root } = runMinimalInstall({ runtime: 'trae', scope: 'local' }); - try { - const files = walk(configDir) - .filter((f) => /\.(md|js|cjs)$/.test(f)) - // gsd-core/CHANGELOG.md is excluded by exact relative path (not a blanket - // .md skip — the emitted agent/command/workflow markdown this gate exists - // to guard stays fully scanned). CHANGELOG.md legitimately QUOTES the - // malformed `.claude/.trae/rules` / `.trae/.trae/rules` strings while - // documenting the #2658 fix itself (#3006) — that historical-value - // citation is not a regression of the installer's actual output. Verified - // empirically: excluding only this one file drops the hit count to zero - // across all 620 other emitted files. - .filter((f) => f.split(path.sep).join('/').indexOf('gsd-core/CHANGELOG.md') === -1); - assert.ok(files.length > 0, 'expected at least one emitted .md/.js/.cjs file'); - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - assert.ok(!content.includes(MALFORMED_SINGLE), `${file} must not contain "${MALFORMED_SINGLE}"`); - assert.ok(!content.includes(MALFORMED_DOUBLE), `${file} must not contain "${MALFORMED_DOUBLE}"`); - } - } finally { - cleanup(root); - } - }); -}); - -describe('#2658 acceptance criterion 3: new-project.md / ingest-docs.md detect trae before falling back to claude', () => { - const workflowsDir = path.join(REPO_ROOT, 'gsd-core', 'workflows'); - - test('new-project.md recognizes /.trae/ path and TRAE_CONFIG_DIR before the claude fallback', () => { - const content = fs.readFileSync(path.join(workflowsDir, 'new-project.md'), 'utf8'); - const pathBlock = content.match(/Derive `RUNTIME`[\s\S]*?Otherwise → `RUNTIME=claude`/); - assert.ok(pathBlock, 'runtime-detection path block must exist'); - assert.ok( - /Path contains `\/\.trae\/` → `RUNTIME=trae`/.test(pathBlock[0]), - 'path-based detection must recognize /.trae/ before the claude fallback', - ); - const envBlock = content.match(/if \[ -n "\$CODEX_HOME" \][\s\S]*?else RUNTIME="claude"; fi/); - assert.ok(envBlock, 'env-var fallback block must exist'); - assert.ok( - /TRAE_CONFIG_DIR/.test(envBlock[0]), - 'env-var fallback must recognize TRAE_CONFIG_DIR before the claude fallback', - ); - }); - - test('ingest-docs.md carries the same trae detection (found during this remediation, not just new-project.md)', () => { - const content = fs.readFileSync(path.join(workflowsDir, 'ingest-docs.md'), 'utf8'); - const block = content.match(/\*\*Detect runtime\*\*[\s\S]*?else → `RUNTIME=claude`/); - assert.ok(block, 'runtime-detection block must exist'); - assert.ok( - /`\/\.trae\/` → `RUNTIME=trae`/.test(block[0]), - 'ingest-docs.md must also recognize /.trae/ before the claude fallback', - ); - assert.ok(/TRAE_CONFIG_DIR/.test(content), 'env-var fallback mention must include TRAE_CONFIG_DIR'); - }); -}); diff --git a/tests/fix-3045-dispatch-isolation-resolver.test.cjs b/tests/fix-3045-dispatch-isolation-resolver.test.cjs deleted file mode 100644 index ca93c2517..000000000 --- a/tests/fix-3045-dispatch-isolation-resolver.test.cjs +++ /dev/null @@ -1,317 +0,0 @@ -'use strict'; - -/** - * #3045 follow-up (two-review convergence: "the guard is fail-open in the - * default install") — CORE REDESIGN coverage for the sentinel WRITE side. - * - * Seam: `gsd-tools.cjs query dispatch-isolation` (routeDispatchIsolation) is - * now the SOLE, unconditional write path — it persists the resolved - * isolation decision (mode + harnessFlag + phase/plan identifiers) as a side - * effect of resolving it, so the workflow cannot learn ISOLATION without also - * recording it. `record-dispatch-isolation` (routeRecordDispatchIsolation) - * remains as an explicit fallback/testable primitive and shares the exact - * same atomic-write implementation. - * - * Every test here drives the REAL gsd-tools.cjs CLI (via runGsdTools) and - * asserts on the sentinel file it actually wrote, parsed as JSON — no - * fixture-text/source-string assertions. - */ - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const os = require('node:os'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { gitOrThrow } = require('./helpers/git-fixture.cjs'); -const { SENTINEL_RELATIVE_PATH, readSentinel } = require('../hooks/lib/isolation-sentinel.js'); -const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); - -function sentinelFile(dir) { - return path.join(dir, SENTINEL_RELATIVE_PATH); -} - -function readSentinelRaw(dir) { - return JSON.parse(fs.readFileSync(sentinelFile(dir), 'utf-8')); -} - -describe('#3045 CORE REDESIGN — dispatch-isolation records as an unconditional side effect', () => { - test('a plain --raw query with no explicit isolation-record verb still writes the sentinel', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet'); - const result = runGsdTools( - ['query', 'dispatch-isolation', '--raw', '--phase', '7'], - dir, - { GSD_RUNTIME: 'claude', HOME: dir }, - ); - assert.equal(result.success, true, result.error); - assert.equal(result.output.trim(), 'harness-worktree'); - - const sentinel = readSentinelRaw(dir); - assert.equal(sentinel.isolation, 'harness-worktree'); - assert.equal(sentinel.harness_flag, 'isolation="worktree"'); - assert.equal(sentinel.phase, '7'); - assert.equal(sentinel.plan, null); - assert.equal(typeof sentinel.written_at, 'number'); - } finally { - cleanup(dir); - } - }); - - test('--json output and the recorded sentinel agree on isolation + harnessFlag', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools( - ['query', 'dispatch-isolation', '--json', '--phase', '3', '--plan', 'plan-b'], - dir, - { GSD_RUNTIME: 'claude', HOME: dir }, - ); - assert.equal(result.success, true, result.error); - const parsed = JSON.parse(result.output); - const sentinel = readSentinelRaw(dir); - assert.equal(sentinel.isolation, parsed.isolation); - assert.equal(sentinel.harness_flag, parsed.harnessFlag); - assert.equal(sentinel.phase, '3'); - assert.equal(sentinel.plan, 'plan-b'); - } finally { - cleanup(dir); - } - }); - - test('--force-isolation none overrides a naturally-resolved harness-worktree host and clears harnessFlag', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools( - ['query', 'dispatch-isolation', '--raw', '--phase', '4', '--force-isolation', 'none'], - dir, - { GSD_RUNTIME: 'claude', HOME: dir }, - ); - assert.equal(result.success, true, result.error); - // routeDispatchIsolation's own stdout still reflects the FORCED value. - assert.equal(result.output.trim(), 'none'); - - const sentinel = readSentinelRaw(dir); - assert.equal(sentinel.isolation, 'none'); - assert.equal(sentinel.harness_flag, null); - } finally { - cleanup(dir); - } - }); - - test('an invalid --force-isolation value is ignored, not applied', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools( - ['query', 'dispatch-isolation', '--raw', '--force-isolation', 'bogus-mode'], - dir, - { GSD_RUNTIME: 'claude', HOME: dir }, - ); - assert.equal(result.success, true, result.error); - assert.equal(result.output.trim(), 'harness-worktree'); - assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree'); - } finally { - cleanup(dir); - } - }); - - test('#3045 BLOCKER 1 — a later, plan-scoped call overwrites an earlier phase-only sentinel atomically', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - // Phase-level resolve (as the "Resolve ISOLATION" step performs it). - runGsdTools(['query', 'dispatch-isolation', '--raw', '--phase', '9'], dir, { GSD_RUNTIME: 'claude', HOME: dir }); - assert.equal(readSentinelRaw(dir).plan, null); - - // Per-plan gate degrades THIS plan to sequential (submodule intersection). - const r = runGsdTools( - ['query', 'dispatch-isolation', '--raw', '--phase', '9', '--plan', 'plan-sub', '--force-isolation', 'none'], - dir, - { GSD_RUNTIME: 'claude', HOME: dir }, - ); - assert.equal(r.success, true, r.error); - - const sentinel = readSentinelRaw(dir); - assert.equal(sentinel.isolation, 'none', 'the plan-scoped degrade must win over the stale phase-level record'); - assert.equal(sentinel.plan, 'plan-sub'); - assert.equal(sentinel.phase, '9'); - } finally { - cleanup(dir); - } - }); - - test('the sentinel round-trips through the real reader (hooks/lib/isolation-sentinel.js)', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - runGsdTools( - ['query', 'dispatch-isolation', '--raw', '--phase', '2', '--plan', 'p1'], - dir, - { GSD_RUNTIME: 'claude', HOME: dir }, - ); - const read = readSentinel(dir); - assert.equal(read.present, true); - assert.equal(read.stale, false); - assert.equal(read.malformed, false); - assert.equal(read.isolation, 'harness-worktree'); - assert.equal(read.harnessFlag, 'isolation="worktree"'); - assert.equal(read.phase, '2'); - assert.equal(read.plan, 'p1'); - } finally { - cleanup(dir); - } - }); -}); - -describe('#3045 MAJOR — --harness-flag can now accept a bare CLI-flag value (Cursor real registry value + generalized parsing)', () => { - test('record-dispatch-isolation --harness-flag=--worktree persists the REAL cursor registry value verbatim', () => { - const cursorFlag = runtimes.cursor.runtime.harnessIsolationFlag; - assert.equal(cursorFlag, '--worktree', 'precondition: registry shape assumed by this test'); - - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools( - ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', `--harness-flag=${cursorFlag}`, '--phase', '1'], - dir, - { HOME: dir }, - ); - assert.equal(result.success, true, result.error); - const sentinel = readSentinelRaw(dir); - assert.equal(sentinel.harness_flag, cursorFlag); - } finally { - cleanup(dir); - } - }); - - test('record-dispatch-isolation --harness-flag= persists ANY bare-CLI-flag-shaped value verbatim (parser is not Cursor-specific)', () => { - // A prior draft of this test asserted `runtimes.windsurf.runtime.harnessIsolationFlag - // === '--worktree'`, assuming Windsurf's registry entry mirrors Cursor's. - // It does not: Windsurf's `hostIntegration.dispatch.isolation` is 'none' - // and it declares NO `harnessIsolationFlag` at all — per ADR-1239 - // (docs/adr/1239-gsd-embeddable-orchestration-engine.md:247,250), - // `pi`/`zcode`/`windsurf` "genuinely cannot benefit and correctly stay - // none" because they lack named/concurrent subagent dispatch, so there is - // no per-dispatch isolation flag for Windsurf to record. That was a wrong - // test expectation (a fabricated registry precondition), not a production - // defect — corrected here to prove the `--harness-flag=` parser - // generalizes to any bare-CLI-flag-shaped value, not merely Cursor's - // specific '--worktree' string (which the sub-test above already pins). - assert.equal( - runtimes.windsurf.runtime.harnessIsolationFlag, - undefined, - 'precondition: windsurf declares no harnessIsolationFlag (isolation: "none", ADR-1239)', - ); - - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools( - ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag=--isolated', '--phase', '1'], - dir, - { HOME: dir }, - ); - assert.equal(result.success, true, result.error); - assert.equal(readSentinelRaw(dir).harness_flag, '--isolated'); - } finally { - cleanup(dir); - } - }); - - test('the legacy space-separated form still rejects a value that looks like another flag (unchanged, regression pin)', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools( - ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag', '--worktree', '--phase', '1'], - dir, - { HOME: dir }, - ); - assert.equal(result.success, true, result.error); - assert.equal(readSentinelRaw(dir).harness_flag, null, 'space form must not swallow a value shaped like a flag'); - } finally { - cleanup(dir); - } - }); - - test('record-dispatch-isolation still errors with usage text when --isolation is missing', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools(['query', 'record-dispatch-isolation'], dir, { HOME: dir }); - assert.equal(result.success, false); - assert.match(result.error, /Usage: record-dispatch-isolation/); - } finally { - cleanup(dir); - } - }); - - test('record-dispatch-isolation accepts --plan and records it', () => { - const dir = createTempProject('gsd-3045-resolver-'); - try { - const result = runGsdTools( - ['query', 'record-dispatch-isolation', '--isolation', 'none', '--phase', '5', '--plan', 'plan-x'], - dir, - { HOME: dir }, - ); - assert.equal(result.success, true, result.error); - const sentinel = readSentinelRaw(dir); - assert.equal(sentinel.isolation, 'none'); - assert.equal(sentinel.phase, '5'); - assert.equal(sentinel.plan, 'plan-x'); - } finally { - cleanup(dir); - } - }); -}); - -describe('#3045 MINOR — writer/reader sentinel path derivation now agrees for a linked worktree without its own .planning/', () => { - function git(args, cwd) { - gitOrThrow(args, { cwd }); - } - - test('a sentinel written from a linked worktree (via --cwd) is found by readSentinel() called with that SAME worktree path', () => { - const mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-main-')); - const wtParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-wtparent-')); - try { - git(['init'], mainRepo); - git(['config', 'user.email', 'test@test.com'], mainRepo); - git(['config', 'user.name', 'Test'], mainRepo); - git(['config', 'commit.gpgsign', 'false'], mainRepo); - fs.writeFileSync(path.join(mainRepo, 'README.md'), 'placeholder\n'); - git(['add', '-A'], mainRepo); - git(['commit', '-m', 'initial commit'], mainRepo); - - // .planning/ is created AFTER the commit — uncommitted/untracked, the - // documented shape where a linked worktree does NOT get its own copy - // (git worktree only checks out tracked files). - fs.mkdirSync(path.join(mainRepo, '.planning')); - fs.writeFileSync(path.join(mainRepo, '.planning', 'config.json'), JSON.stringify({})); - - const linked = path.join(wtParent, 'linked'); - git(['worktree', 'add', linked, '-b', 'gsd-3045-minor-branch'], mainRepo); - assert.equal(fs.existsSync(path.join(linked, '.planning')), false, 'precondition: linked worktree has no own .planning/'); - - // Write FROM the linked worktree path — mirrors an orchestrator - // running in a linked worktree calling `dispatch-isolation`. - const result = runGsdTools( - ['query', 'dispatch-isolation', '--raw', '--cwd', linked, '--phase', '1'], - mainRepo, - { GSD_RUNTIME: 'claude', HOME: mainRepo }, - ); - assert.equal(result.success, true, result.error); - - // The writer resolved up to the MAIN worktree (findProjectRoot(resolveMainWorktreeCwd(...))) — - // the sentinel must NOT exist at the linked worktree's own (nonexistent) .gsd/. - assert.equal(fs.existsSync(sentinelFile(linked)), false, 'writer must not have written under the linked worktree itself'); - assert.equal(fs.existsSync(sentinelFile(mainRepo)), true, 'writer must have resolved up to the main worktree'); - - // The READER, given the raw linked-worktree cwd (exactly what a guard - // hook receives as data.cwd / workspace_roots[i]), must derive the SAME - // root the writer did and find the sentinel — this is the MINOR fix. - const read = readSentinel(linked); - assert.equal(read.present, true, 'reader must resolve the linked worktree up to the main worktree, same as the writer'); - assert.equal(read.stale, false); - assert.equal(read.isolation, 'harness-worktree'); - } finally { - cleanup(mainRepo); - cleanup(wtParent); - } - }); -}); diff --git a/tests/gsd-agent-isolation-guard.test.cjs b/tests/gsd-agent-isolation-guard.test.cjs index c7a0a82c2..5eceb335b 100644 --- a/tests/gsd-agent-isolation-guard.test.cjs +++ b/tests/gsd-agent-isolation-guard.test.cjs @@ -47,12 +47,14 @@ const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const os = require('node:os'); const fc = require('./helpers/fast-check-setup.cjs'); const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); -const { toLegacyResult } = require('./helpers/git-fixture.cjs'); +const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); -const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js'); +const { createTempDir, createTempProject, runGsdTools, cleanup } = require('./helpers.cjs'); +const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS, readSentinel } = require('../hooks/lib/isolation-sentinel.js'); +const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js'); @@ -93,6 +95,20 @@ function runHook(payload, cwd, extraEnv = {}) { return toLegacyResult(r); } +/** + * #3045 follow-up (folded from tests/fix-3045-dispatch-isolation-resolver.test.cjs, + * #3333 wave 1): sentinel-file path/read helpers for the WRITE-side coverage + * below, which drives the real `gsd-tools.cjs query dispatch-isolation` CLI + * (routeDispatchIsolation) rather than the guard hook. + */ +function sentinelFile(dir) { + return path.join(dir, SENTINEL_RELATIVE_PATH); +} + +function readSentinelRaw(dir) { + return JSON.parse(fs.readFileSync(sentinelFile(dir), 'utf-8')); +} + function agentPayload(overrides = {}) { return { hook_event_name: 'PreToolUse', @@ -324,77 +340,59 @@ describe('gsd-agent-isolation-guard.js: #3045 BLOCKER regression — sentinel is cleanup(useWorktreesFalseProject); }); - test('sentinel says isolation=none -> ALLOW even though registry resolves harness-worktree (the BLOCKER)', () => { + test('sentinel says isolation=none -> ALLOW even though registry resolves harness-worktree (the BLOCKER)', (t) => { writeSentinel(harnessProject, { isolation: 'none' }); - try { - const r = runHook(agentPayload(), harnessProject); // no isolation param on the dispatch - assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); - assert.equal(r.stdout, ''); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook(agentPayload(), harnessProject); // no isolation param on the dispatch + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stdout, ''); }); - test('sentinel says isolation=orchestrator-worktree -> ALLOW', () => { + test('sentinel says isolation=orchestrator-worktree -> ALLOW', (t) => { writeSentinel(harnessProject, { isolation: 'orchestrator-worktree' }); - try { - const r = runHook(agentPayload(), harnessProject); - assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); - assert.equal(r.stdout, ''); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stdout, ''); }); - test('sentinel says isolation=harness-worktree + dispatch missing the flag -> DENY', () => { + test('sentinel says isolation=harness-worktree + dispatch missing the flag -> DENY', (t) => { writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' }); - try { - const r = runHook(agentPayload(), harnessProject); - assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); - assert.equal(JSON.parse(r.stdout).decision, 'block'); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); }); - test('sentinel says isolation=harness-worktree + dispatch carries the flag -> ALLOW', () => { + test('sentinel says isolation=harness-worktree + dispatch carries the flag -> ALLOW', (t) => { writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' }); - try { - const r = runHook( - agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }), - harnessProject - ); - assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }), + harnessProject + ); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); }); - test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', () => { + test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', (t) => { // The stale sentinel LIES (says none) — proving the fallback re-derives // from the registry instead of trusting it is exactly the point. writeSentinel(harnessProject, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) }); - try { - const r = runHook(agentPayload(), harnessProject); - assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); - assert.equal(JSON.parse(r.stdout).decision, 'block'); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); }); - test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', () => { + test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', (t) => { const sentinelPath = path.join(harnessProject, SENTINEL_RELATIVE_PATH); fs.mkdirSync(path.dirname(sentinelPath), { recursive: true }); fs.writeFileSync(sentinelPath, '{ this is not valid json'); - try { - const r = runHook(agentPayload(), harnessProject); - assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); - assert.equal(JSON.parse(r.stdout).decision, 'block'); - assert.equal(r.stderr.length > 0, true, 'must not crash — a clean block reason, not a stack trace'); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + assert.equal(r.stderr.length > 0, true, 'must not crash — a clean block reason, not a stack trace'); }); test('no sentinel + workflow.use_worktrees=false -> ALLOW (project-level opt-out, case (a) from the BLOCKER)', () => { @@ -474,25 +472,22 @@ describe('gsd-agent-isolation-guard.js: #3045 BLOCKER 2 — default-install fail cleanup(unconfiguredHarnessProject); }); - test('part A: fresh sentinel confirms harness-worktree but carries NO harness_flag, and the runtime is not confidently resolvable -> DENY (was ALLOW pre-fix)', () => { + test('part A: fresh sentinel confirms harness-worktree but carries NO harness_flag, and the runtime is not confidently resolvable -> DENY (was ALLOW pre-fix)', (t) => { // This is the exact BLOCKER 2 regression: previously this branch fell // through to the "not confident -> none" degrade and ALLOWED the // dispatch to run unisolated, on the DEFAULT-INSTALL path (no `runtime` // key in config.json — gsd-core/templates/config.json's shipped shape — // and no ~/.gsd/defaults.json runtime either). writeSentinel(unconfiguredHarnessProject, { isolation: 'harness-worktree', harnessFlag: null }); - try { - const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: unconfiguredHarnessProject }); - assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — must DENY, not silently allow`); - const out = JSON.parse(r.stdout); - assert.equal(out.decision, 'block'); - assert.match(out.reason, /cannot verify|harness_flag/i); - } finally { - cleanup(path.join(unconfiguredHarnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(unconfiguredHarnessProject, '.gsd'))); + const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: unconfiguredHarnessProject }); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — must DENY, not silently allow`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block'); + assert.match(out.reason, /cannot verify|harness_flag/i); }); - test('part B: ~/.gsd/defaults.json runtime (installer-persisted, #2395) is now a confident signal — makes the default install enforce', () => { + test('part B: ~/.gsd/defaults.json runtime (installer-persisted, #2395) is now a confident signal — makes the default install enforce', (t) => { // No sentinel at all here — pure conservative-fallback path. Before this // fix, an unconfigured project (no config.json runtime key, the COMMON // scaffold shape) always fell back to 'none'/allow regardless of what the @@ -501,32 +496,26 @@ describe('gsd-agent-isolation-guard.js: #3045 BLOCKER 2 — default-install fail // already writes for every non-Claude install) is read as confidently as // GSD_RUNTIME or config.json's own key. const home = mkProject('gsd-aig-b2-home-'); - try { - fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); - fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'claude' })); + t.after(() => cleanup(home)); + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'claude' })); - const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: home }); - assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — defaults.json runtime must be enforced`); - assert.equal(JSON.parse(r.stdout).decision, 'block'); - } finally { - cleanup(home); - } + const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: home }); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — defaults.json runtime must be enforced`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); }); - test('part B (negative control): a project WITH its own config.json runtime key still wins over defaults.json', () => { + test('part B (negative control): a project WITH its own config.json runtime key still wins over defaults.json', (t) => { const home = mkProject('gsd-aig-b2-home2-'); - try { - fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); - // defaults.json says a runtime with NO harness-worktree capability; - // config.json's own `runtime: claude` must take precedence. - fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'windsurf' })); + t.after(() => cleanup(home)); + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + // defaults.json says a runtime with NO harness-worktree capability; + // config.json's own `runtime: claude` must take precedence. + fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'windsurf' })); - const r = runHook(agentPayload(), harnessProject, { HOME: home }); - assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); - assert.equal(JSON.parse(r.stdout).decision, 'block'); - } finally { - cleanup(home); - } + const r = runHook(agentPayload(), harnessProject, { HOME: home }); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); }); }); @@ -542,47 +531,38 @@ describe('gsd-agent-isolation-guard.js: #3045 SECURITY F2 — sentinel bound to cleanup(harnessProject); }); - test('a fresh "none" sentinel for a DIFFERENT phase than this dispatch is not applied — falls through to conservative fallback and DENIES', () => { + test('a fresh "none" sentinel for a DIFFERENT phase than this dispatch is not applied — falls through to conservative fallback and DENIES', (t) => { // Sentinel legitimately recorded 'none' for phase 1 (e.g. a submodule // degrade). This dispatch's own description names phase 2 — the guard // must not reuse phase 1's stale-but-fresh "none" to authorize it. writeSentinel(harnessProject, { isolation: 'none', phase: '1', plan: 'plan-a' }); - try { - const r = runHook( - agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }), - harnessProject, - ); - assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — mismatched sentinel must not silently allow`); - assert.equal(JSON.parse(r.stdout).decision, 'block'); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }), + harnessProject, + ); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — mismatched sentinel must not silently allow`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); }); - test('a fresh sentinel for the SAME phase/plan as this dispatch is applied normally (positive control)', () => { + test('a fresh sentinel for the SAME phase/plan as this dispatch is applied normally (positive control)', (t) => { writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' }); - try { - const r = runHook( - agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }), - harnessProject, - ); - assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }), + harnessProject, + ); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); }); - test('a dispatch whose description does not match the expected shape does not itself trigger a mismatch (best-effort extraction)', () => { + test('a dispatch whose description does not match the expected shape does not itself trigger a mismatch (best-effort extraction)', (t) => { writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' }); - try { - const r = runHook( - agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'some other free-form text' } }), - harnessProject, - ); - assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'some other free-form text' } }), + harnessProject, + ); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); }); }); @@ -613,39 +593,284 @@ describe('gsd-agent-isolation-guard.js: #3045 MAJOR — clock seam boundary cove return { now: () => nowMs }; } - test('sentinel exactly at SENTINEL_STALE_MS - 1 is still FRESH (trusted)', () => { + test('sentinel exactly at SENTINEL_STALE_MS - 1 is still FRESH (trusted)', (t) => { const writtenAt = 1_000_000; writeSentinel(harnessProject, { isolation: 'none', writtenAt }); - try { - const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) }); - assert.equal(state.isolation, 'none', 'still within the trust window — must use the sentinel, not the registry fallback'); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) }); + assert.equal(state.isolation, 'none', 'still within the trust window — must use the sentinel, not the registry fallback'); }); - test('sentinel exactly AT SENTINEL_STALE_MS is STALE (age > threshold is the only fresh condition)', () => { + test('sentinel exactly AT SENTINEL_STALE_MS is STALE (age > threshold is the only fresh condition)', (t) => { const writtenAt = 1_000_000; writeSentinel(harnessProject, { isolation: 'none', writtenAt }); - try { - const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) }); - // Registry fallback for this project resolves harness-worktree (claude, - // no workflow.use_worktrees:false) — proves the sentinel's 'none' was - // NOT trusted at exactly the boundary. - assert.equal(state.isolation, 'harness-worktree'); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) }); + // Registry fallback for this project resolves harness-worktree (claude, + // no workflow.use_worktrees:false) — proves the sentinel's 'none' was + // NOT trusted at exactly the boundary. + assert.equal(state.isolation, 'harness-worktree'); }); - test('sentinel at SENTINEL_STALE_MS + 1 is STALE', () => { + test('sentinel at SENTINEL_STALE_MS + 1 is STALE', (t) => { const writtenAt = 1_000_000; writeSentinel(harnessProject, { isolation: 'none', writtenAt }); - try { - const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) }); - assert.equal(state.isolation, 'harness-worktree'); - } finally { - cleanup(path.join(harnessProject, '.gsd')); - } + t.after(() => cleanup(path.join(harnessProject, '.gsd'))); + const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) }); + assert.equal(state.isolation, 'harness-worktree'); + }); +}); + +// Folded from tests/fix-3045-dispatch-isolation-resolver.test.cjs (#3333 wave +// 1, test-only consolidation — no behavior change). These describe blocks +// cover the sentinel WRITE side: `gsd-tools.cjs query dispatch-isolation` +// (routeDispatchIsolation) persists the resolved isolation decision as an +// unconditional side effect of resolving it, and `record-dispatch-isolation` +// (routeRecordDispatchIsolation) is the explicit fallback/testable primitive +// sharing the same atomic-write implementation. Every test here drives the +// REAL gsd-tools.cjs CLI (via runGsdTools) and asserts on the sentinel file +// it actually wrote, parsed as JSON. +describe('#3045 CORE REDESIGN — dispatch-isolation records as an unconditional side effect', () => { + test('a plain --raw query with no explicit isolation-record verb still writes the sentinel', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet'); + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '7'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(result.output.trim(), 'harness-worktree'); + + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'harness-worktree'); + assert.equal(sentinel.harness_flag, 'isolation="worktree"'); + assert.equal(sentinel.phase, '7'); + assert.equal(sentinel.plan, null); + assert.equal(typeof sentinel.written_at, 'number'); + }); + + test('--json output and the recorded sentinel agree on isolation + harnessFlag', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools( + ['query', 'dispatch-isolation', '--json', '--phase', '3', '--plan', 'plan-b'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, parsed.isolation); + assert.equal(sentinel.harness_flag, parsed.harnessFlag); + assert.equal(sentinel.phase, '3'); + assert.equal(sentinel.plan, 'plan-b'); + }); + + test('--force-isolation none overrides a naturally-resolved harness-worktree host and clears harnessFlag', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '4', '--force-isolation', 'none'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + // routeDispatchIsolation's own stdout still reflects the FORCED value. + assert.equal(result.output.trim(), 'none'); + + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'none'); + assert.equal(sentinel.harness_flag, null); + }); + + test('an invalid --force-isolation value is ignored, not applied', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--force-isolation', 'bogus-mode'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(result.output.trim(), 'harness-worktree'); + assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree'); + }); + + test('#3045 BLOCKER 1 — a later, plan-scoped call overwrites an earlier phase-only sentinel atomically', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + // Phase-level resolve (as the "Resolve ISOLATION" step performs it). + runGsdTools(['query', 'dispatch-isolation', '--raw', '--phase', '9'], dir, { GSD_RUNTIME: 'claude', HOME: dir }); + assert.equal(readSentinelRaw(dir).plan, null); + + // Per-plan gate degrades THIS plan to sequential (submodule intersection). + const r = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '9', '--plan', 'plan-sub', '--force-isolation', 'none'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(r.success, true, r.error); + + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'none', 'the plan-scoped degrade must win over the stale phase-level record'); + assert.equal(sentinel.plan, 'plan-sub'); + assert.equal(sentinel.phase, '9'); + }); + + test('the sentinel round-trips through the real reader (hooks/lib/isolation-sentinel.js)', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '2', '--plan', 'p1'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + const read = readSentinel(dir); + assert.equal(read.present, true); + assert.equal(read.stale, false); + assert.equal(read.malformed, false); + assert.equal(read.isolation, 'harness-worktree'); + assert.equal(read.harnessFlag, 'isolation="worktree"'); + assert.equal(read.phase, '2'); + assert.equal(read.plan, 'p1'); + }); +}); + +describe('#3045 MAJOR — --harness-flag can now accept a bare CLI-flag value (Cursor real registry value + generalized parsing)', () => { + test('record-dispatch-isolation --harness-flag=--worktree persists the REAL cursor registry value verbatim', (t) => { + const cursorFlag = runtimes.cursor.runtime.harnessIsolationFlag; + assert.equal(cursorFlag, '--worktree', 'precondition: registry shape assumed by this test'); + + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', `--harness-flag=${cursorFlag}`, '--phase', '1'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.harness_flag, cursorFlag); + }); + + test('record-dispatch-isolation --harness-flag= persists ANY bare-CLI-flag-shaped value verbatim (parser is not Cursor-specific)', (t) => { + // A prior draft of this test asserted `runtimes.windsurf.runtime.harnessIsolationFlag + // === '--worktree'`, assuming Windsurf's registry entry mirrors Cursor's. + // It does not: Windsurf's `hostIntegration.dispatch.isolation` is 'none' + // and it declares NO `harnessIsolationFlag` at all — per ADR-1239 + // (docs/adr/1239-gsd-embeddable-orchestration-engine.md:247,250), + // `pi`/`zcode`/`windsurf` "genuinely cannot benefit and correctly stay + // none" because they lack named/concurrent subagent dispatch, so there is + // no per-dispatch isolation flag for Windsurf to record. That was a wrong + // test expectation (a fabricated registry precondition), not a production + // defect — corrected here to prove the `--harness-flag=` parser + // generalizes to any bare-CLI-flag-shaped value, not merely Cursor's + // specific '--worktree' string (which the sub-test above already pins). + assert.equal( + runtimes.windsurf.runtime.harnessIsolationFlag, + undefined, + 'precondition: windsurf declares no harnessIsolationFlag (isolation: "none", ADR-1239)', + ); + + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag=--isolated', '--phase', '1'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(readSentinelRaw(dir).harness_flag, '--isolated'); + }); + + test('the legacy space-separated form still rejects a value that looks like another flag (unchanged, regression pin)', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag', '--worktree', '--phase', '1'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(readSentinelRaw(dir).harness_flag, null, 'space form must not swallow a value shaped like a flag'); + }); + + test('record-dispatch-isolation still errors with usage text when --isolation is missing', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools(['query', 'record-dispatch-isolation'], dir, { HOME: dir }); + assert.equal(result.success, false); + assert.match(result.error, /Usage: record-dispatch-isolation/); + }); + + test('record-dispatch-isolation accepts --plan and records it', (t) => { + const dir = createTempProject('gsd-3045-resolver-'); + t.after(() => cleanup(dir)); + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'none', '--phase', '5', '--plan', 'plan-x'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'none'); + assert.equal(sentinel.phase, '5'); + assert.equal(sentinel.plan, 'plan-x'); + }); +}); + +describe('#3045 MINOR — writer/reader sentinel path derivation now agrees for a linked worktree without its own .planning/', () => { + function git(args, cwd) { + gitOrThrow(args, { cwd }); + } + + test('a sentinel written from a linked worktree (via --cwd) is found by readSentinel() called with that SAME worktree path', (t) => { + const mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-main-')); + const wtParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-wtparent-')); + t.after(() => { + cleanup(mainRepo); + cleanup(wtParent); + }); + git(['init'], mainRepo); + git(['config', 'user.email', 'test@test.com'], mainRepo); + git(['config', 'user.name', 'Test'], mainRepo); + git(['config', 'commit.gpgsign', 'false'], mainRepo); + fs.writeFileSync(path.join(mainRepo, 'README.md'), 'placeholder\n'); + git(['add', '-A'], mainRepo); + git(['commit', '-m', 'initial commit'], mainRepo); + + // .planning/ is created AFTER the commit — uncommitted/untracked, the + // documented shape where a linked worktree does NOT get its own copy + // (git worktree only checks out tracked files). + fs.mkdirSync(path.join(mainRepo, '.planning')); + fs.writeFileSync(path.join(mainRepo, '.planning', 'config.json'), JSON.stringify({})); + + const linked = path.join(wtParent, 'linked'); + git(['worktree', 'add', linked, '-b', 'gsd-3045-minor-branch'], mainRepo); + assert.equal(fs.existsSync(path.join(linked, '.planning')), false, 'precondition: linked worktree has no own .planning/'); + + // Write FROM the linked worktree path — mirrors an orchestrator + // running in a linked worktree calling `dispatch-isolation`. + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--cwd', linked, '--phase', '1'], + mainRepo, + { GSD_RUNTIME: 'claude', HOME: mainRepo }, + ); + assert.equal(result.success, true, result.error); + + // The writer resolved up to the MAIN worktree (findProjectRoot(resolveMainWorktreeCwd(...))) — + // the sentinel must NOT exist at the linked worktree's own (nonexistent) .gsd/. + assert.equal(fs.existsSync(sentinelFile(linked)), false, 'writer must not have written under the linked worktree itself'); + assert.equal(fs.existsSync(sentinelFile(mainRepo)), true, 'writer must have resolved up to the main worktree'); + + // The READER, given the raw linked-worktree cwd (exactly what a guard + // hook receives as data.cwd / workspace_roots[i]), must derive the SAME + // root the writer did and find the sentinel — this is the MINOR fix. + const read = readSentinel(linked); + assert.equal(read.present, true, 'reader must resolve the linked worktree up to the main worktree, same as the writer'); + assert.equal(read.stale, false); + assert.equal(read.isolation, 'harness-worktree'); }); }); diff --git a/tests/hermes-skills-migration.test.cjs b/tests/hermes-skills-migration.test.cjs index a276f41e3..ba44ab499 100644 --- a/tests/hermes-skills-migration.test.cjs +++ b/tests/hermes-skills-migration.test.cjs @@ -21,10 +21,23 @@ const fs = require('fs'); const { convertClaudeCommandToClaudeSkill, + convertClaudeToHermesMarkdown, + projectNamedDispatchToStructuralDelegate, + _hostIntegrationDispatch, + _resolveAvailableGsdRoles, + HERMES_DISPATCH_TOOL_CONFIG, + maskStringLiterals, + findDispatchCallSpans, + _assertProjectionComplete, + applyClaudeCodeBrandSwap, + convertClaudeToWindsurfMarkdown, + install, + uninstall, } = require('../bin/install.js'); const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); const { parseFrontmatter, cleanup } = require('./helpers.cjs'); +const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); const pkg = require('../package.json'); const { @@ -35,6 +48,37 @@ const { const manifest = loadSkillsManifest(); const resolvedProfileFull = resolveProfile({ modes: [], manifest }); +// ─── #2284 shared fixtures/helpers (delegate_task dispatch projection) ────── +const HERMES_DISPATCH = _hostIntegrationDispatch('hermes'); + +// Representative fixture prose mirroring the real shape found in +// gsd-core/workflows/plan-phase.md — the "Agent tool IS available" contract +// assertion followed by a literal, multi-arg Agent(...) dispatch call whose +// subagent_type resolves to a real shipped role. +const FIXTURE_ASSERTION_AND_CALL = [ + 'The Agent tool IS available in a top-level Hermes Agent session. Always spawn', + 'gsd-phase-researcher, gsd-planner, and gsd-plan-checker as separate Agent() calls.', + '', + '```', + 'Agent(', + ' prompt=filled_research_hook_fragment,', + ' subagent_type="gsd-planner",', + ' model="{researcher_model}",', + ' description="Research Phase {phase}"', + ')', + '```', + '', + '> **ORCHESTRATOR RULE — ALL RUNTIMES**: After calling Agent() above, stop working on this task immediately.', + 'Wait for the subagent to return its result. Only resume when the subagent result is available.', +].join('\n'); + +function hermesToolConfig(overrides = {}) { + return Object.assign({}, HERMES_DISPATCH_TOOL_CONFIG, { + availableRoles: _resolveAvailableGsdRoles(), + runtime: 'hermes', + }, overrides); +} + // ─── convertClaudeCommandToClaudeSkill (used by Hermes via copyCommandsAsClaudeSkills) ── describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => { @@ -395,3 +439,835 @@ describe('#1383 regression: gsd-tools version lookup without a runtime-root pack assert.strictEqual(v, '', 'garbled VERSION is rejected, so the caller omits the field'); }); }); + +// ─── #2284: Hermes named-dispatch → delegate_task projection ──────────────── +// Folded from tests/fix-2284-hermes-agent-delegate-task-projection.test.cjs +// (test-hygiene consolidation, issue #3333). The Hermes installer previously +// only brand-swapped "Claude Code" → "Hermes Agent" in shipped +// `gsd-core/workflows/*.md`, leaving a false "The Agent tool IS available" +// assertion and literal `Agent(...)` call syntax installed verbatim — Hermes +// exposes `delegate_task`, not `Agent`. +// +// Covers: +// 1. Direct converter contract — projectNamedDispatchToStructuralDelegate / +// convertClaudeToHermesMarkdown against representative fixture prose, +// across ALL THREE real corpus call-argument shapes: multi-line +// one-key-per-line, single-line object-literal (`Agent({...})` — +// import.md/ingest-docs.md), and single-line compact +// (`Agent(subagent_type="x", model="y", prompt="...")` — +// code-review-fix.md/ship.md/etc). +// 2. Real disposable-HOME `--hermes --global` e2e install — no literal +// `Agent(` survives, `delegate_task` is present, commands→skill path +// (convertClaudeCommandToClaudeSkill) still works unregressed; spot- +// checks import.md, ingest-docs.md, and code-review-fix.md specifically +// (the object-literal and single-line-compact sites). +// 3. Fail-closed role resolution — a referenced gsd-* role prompt missing +// from the shipped agents/ directory aborts install with an explicit +// error, in EVERY call-argument shape, both at the converter level and +// through the real install path (deterministic fs.readdirSync +// injection per the repo's cross-platform IO-failure-injection +// convention — never chmod/permission tricks). +// 4. The post-projection guard (belt-and-suspenders) — fails loud on any +// residual subagent_type / leaked model= / unprojected Agent( the +// projection above did not anticipate, rather than silently shipping it. + +// ─── 1. Direct converter contract ──────────────────────────────────────────── + +describe('#2284 convertClaudeToHermesMarkdown / projectNamedDispatchToStructuralDelegate — converter contract', () => { + test('capabilities/hermes/capability.json dispatch facts are unchanged (docs-sourced, not touched by this fix)', () => { + // Locks in the maintainer-confirmed constraint: this fix reads the + // existing sourced facts, it never edits them. + assert.strictEqual(HERMES_DISPATCH.namedDispatch, false); + assert.strictEqual(HERMES_DISPATCH.background, true); + assert.strictEqual(HERMES_DISPATCH.backgroundDispatch, false); + assert.strictEqual(HERMES_DISPATCH.subagentToolkit, 'read-only'); + assert.strictEqual(HERMES_DISPATCH.maxDepth, 1); + assert.strictEqual(HERMES_DISPATCH.nested, true); + }); + + test('no literal Agent( call syntax survives the projection', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(!/\bAgent\(/.test(out), `literal Agent( survived:\n${out}`); + }); + + test('emits a delegate_task-shaped dispatch call with the resolved role reference', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(/delegate_task\(/.test(out), 'delegate_task( call syntax present'); + assert.ok(/gsd_role="gsd-planner"/.test(out), 'gsd_role carries the resolved role identifier'); + assert.ok(/gsd_role_prompt=/.test(out), 'gsd_role_prompt carries the loaded-content instruction'); + assert.ok(/role="leaf"/.test(out), 'structural role pinned to Hermes\'s non-orchestrating leaf value'); + }); + + test('drops per-call model forwarding (host-model inheritance is explicit)', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(!/model="\{researcher_model\}"/.test(out), 'per-call model="{researcher_model}" line stripped'); + assert.ok(!/\bmodel=/.test(out), 'no model= parameter forwarded anywhere in the projected call'); + }); + + test('the "Agent tool IS available" assertion becomes an accurate delegate_task statement', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(!/Agent tool IS available/.test(out), 'false Claude-shaped assertion removed'); + assert.ok(/delegate_task/.test(out), 'assertion references the real Hermes dispatch primitive'); + assert.ok(/no concept of a named subagent identity/i.test(out), 'assertion states the roleless-lookup contract (namedDispatch: false)'); + }); + + test('async halt/resume wording is preserved (no busy-poll)', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(/stop working on this task immediately/.test(out), 'halt-after-dispatch instruction preserved'); + assert.ok(/Wait for the subagent to return its result/.test(out), 'resume-on-completion instruction preserved'); + }); + + test('fail-closed wording is present for the role-resolution step', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE_ASSERTION_AND_CALL, { runtime: 'hermes' }); + assert.ok(/FAIL CLOSED/.test(out), 'explicit FAIL CLOSED instruction present'); + assert.ok(/never execute the role inline/i.test(out), 'explicit prohibition on silent inline execution'); + }); + + test('run_in_background= maps onto Hermes\'s native background= (dispatch.background: true)', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n run_in_background=true,\n description="d"\n)'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(/\bbackground=true\b/.test(out), 'background=true present'); + assert.ok(!/run_in_background=/.test(out), 'Claude-native run_in_background= param name gone'); + }); + + test('genuinely branches on dispatch.background: false — strips (never renames) the background flag', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n run_in_background=true,\n description="d"\n)'; + const out = projectNamedDispatchToStructuralDelegate( + fixture, + Object.assign({}, HERMES_DISPATCH, { background: false }), + hermesToolConfig(), + ); + assert.ok(!/run_in_background=/.test(out), 'unsupported flag not left in Claude form'); + assert.ok(!/\bbackground=true\b/.test(out), 'flag not forwarded when dispatch.background is false'); + }); + + test('genuinely branches on dispatch.namedDispatch: true — passes named dispatch through unprojected', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n description="d"\n)'; + const out = projectNamedDispatchToStructuralDelegate( + fixture, + Object.assign({}, HERMES_DISPATCH, { namedDispatch: true }), + hermesToolConfig(), + ); + // No role-prompt-embedding machinery should be injected when the target + // primitive can resolve named agents itself. + assert.ok(!/gsd_role_prompt=/.test(out), 'no prompt-content-embedding injected when namedDispatch is true'); + assert.ok(!/FAIL CLOSED/.test(out), 'no fail-closed role-resolution injected when namedDispatch is true'); + assert.ok(/delegate_task\(/.test(out), 'call syntax still renamed to the target tool name'); + }); + + test('genuinely branches on subagentToolkit/maxDepth — omits the depth/toolkit caveat when the target can orchestrate', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-executor",\n description="d"\n)'; + const restrictedOut = projectNamedDispatchToStructuralDelegate( + fixture, HERMES_DISPATCH, hermesToolConfig(), + ); + assert.ok(/nested delegation is unavailable/.test(restrictedOut), 'read-only/depth-1 caveat present for the real sourced facts'); + + const orchestrateCapableOut = projectNamedDispatchToStructuralDelegate( + fixture, + Object.assign({}, HERMES_DISPATCH, { subagentToolkit: 'full', maxDepth: -1 }), + hermesToolConfig(), + ); + assert.ok(!/nested delegation is unavailable/.test(orchestrateCapableOut), 'caveat omitted when the target genuinely supports nested delegation'); + }); + + test('preserves body content and prose the projection does not target', () => { + const fixture = 'Some unrelated prose.\n\nAgent(\n prompt=x,\n subagent_type="gsd-verifier",\n description="d"\n)\n\nMore unrelated prose.'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(out.includes('Some unrelated prose.')); + assert.ok(out.includes('More unrelated prose.')); + }); +}); + +// ─── 1b. All three real corpus call-argument shapes ───────────────────────── + +describe('#2284 all three real corpus Agent(...) call-argument shapes', () => { + // (a) multi-line, one key= per line — plan-phase.md/execute-phase.md/etc. + const MULTI_LINE = 'Agent(\n prompt=x,\n subagent_type="gsd-planner",\n model="{researcher_model}",\n description="d"\n)'; + // (b) single-line object-literal (colon syntax) — import.md/ingest-docs.md. + const OBJECT_LITERAL = 'Agent({\n subagent_type: "gsd-plan-checker",\n prompt: "Validate the plan."\n})'; + // (c) single-line compact — code-review-fix.md/code-review.md/ship.md/etc. + const SINGLE_LINE_COMPACT = 'Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt="Fix the findings.")'; + + const forms = [ + ['multi-line one-key-per-line', MULTI_LINE, 'gsd-planner'], + ['single-line object-literal', OBJECT_LITERAL, 'gsd-plan-checker'], + ['single-line compact', SINGLE_LINE_COMPACT, 'gsd-code-fixer'], + ]; + + for (const [label, fixture, role] of forms) { + test(`${label}: projects to delegate_task with gsd_role_prompt + role="leaf"`, () => { + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(/delegate_task\(/.test(out), `${label}: delegate_task( present`); + assert.ok(out.includes(`gsd_role="${role}"`), `${label}: gsd_role carries "${role}"`); + assert.ok(/gsd_role_prompt=/.test(out), `${label}: gsd_role_prompt injected`); + assert.ok(/role="leaf"/.test(out), `${label}: structural role="leaf" injected`); + assert.ok(/FAIL CLOSED/.test(out), `${label}: fail-closed wording present`); + }); + + test(`${label}: no residual subagent_type (either = or : syntax)`, () => { + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out), `${label}: subagent_type token gone:\n${out}`); + }); + + test(`${label}: no leaked model= (host-model inheritance, never forwarded)`, () => { + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + const mask = maskStringLiterals(out); + assert.ok(!/\bmodel\s*[=:]/.test(mask), `${label}: no model= or model: token survives:\n${out}`); + }); + + test(`${label}: a bogus role triggers the fail-closed throw`, () => { + const bogusFixture = fixture.replace(role, 'gsd-totally-fake-role-2284'); + assert.throws( + () => convertClaudeToHermesMarkdown(bogusFixture, { runtime: 'hermes' }), + /gsd-totally-fake-role-2284/, + `${label}: expected an explicit fail-closed error naming the bogus role`, + ); + }); + } + + test('object-literal wrapper braces are stripped (Hermes delegate_task is a flat kwarg call)', () => { + const out = convertClaudeToHermesMarkdown(OBJECT_LITERAL, { runtime: 'hermes' }); + assert.ok(!/delegate_task\(\s*\{/.test(out), 'no leftover "{" immediately after delegate_task('); + assert.ok(!/\}\s*\)\s*$/.test(out.trim()), 'no leftover "}" immediately before the closing )'); + }); + + test('object-literal form: non-role/model keys (e.g. prompt:) are left in their original colon style', () => { + const out = convertClaudeToHermesMarkdown(OBJECT_LITERAL, { runtime: 'hermes' }); + assert.ok(out.includes('prompt: "Validate the plan."'), 'untouched arg keys keep their original syntax'); + }); + + test('single-line-compact: a real corpus fixture identical to code-review-fix.md:201 shape (multi-line prompt body opened on the compact head)', () => { + // code-review-fix.md's real shape: `Agent(subagent_type="x", model="y", prompt="` opens a + // MULTI-LINE prompt body (no escaping) that closes many lines later with `")`. + const fixture = [ + 'Agent(subagent_type="gsd-code-fixer", model="{FIXER_MODEL}", prompt="', + '', + '${REVIEW_PATH}', + '', + '', + 'Read REVIEW.md findings, apply fixes.', + '${AGENT_SKILLS_FIXER}")', + ].join('\n'); + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives a multi-line-body compact-head call'); + assert.ok(/delegate_task\(/.test(out)); + assert.ok(out.includes('gsd_role="gsd-code-fixer"')); + assert.ok(!/\bmodel\s*[=:]/.test(maskStringLiterals(out)), 'model stripped even though the prompt body spans many lines'); + assert.ok(out.includes(''), 'multi-line prompt BODY content is preserved verbatim'); + assert.ok(out.includes('${REVIEW_PATH}'), 'interpolation placeholders inside the prompt body are untouched'); + }); + + test('disconnected prose mention (not part of any real Agent(...) call, e.g. map-codebase.md-style) is still renamed and validated', () => { + const fixture = 'Use Agent tool with `subagent_type="gsd-codebase-mapper"` for parallel execution.'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(out.includes('gsd_role="gsd-codebase-mapper"'), 'prose mention renamed to gsd_role='); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out)); + }); + + test('a documentation TEMPLATE placeholder role (curly-brace interpolation, e.g. universal-anti-patterns.md\'s subagent_type: "gsd-{agent}") is renamed but NOT fail-closed validated', () => { + const fixture = 'ALWAYS use `subagent_type: "gsd-{agent}"` (e.g., `gsd-phase-researcher`, `gsd-executor`).'; + assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(out.includes('gsd_role: "gsd-{agent}"'), 'template placeholder renamed, value preserved verbatim'); + }); + + test('run_in_background: true (colon-prose form, e.g. execute-phase.md) maps onto background: true, same as the = form', () => { + const fixture = 'Dispatch each `Agent()` call one at a time with `run_in_background: true`.'; + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(/background:\s*true/.test(out), 'colon-prose form mapped to the native background param'); + assert.ok(!/run_in_background/.test(out), 'Claude-native run_in_background token gone'); + }); + + test('a call site preceded by explanatory comments describing the now-removed model= conditional strips both the arg AND the dead comments (Finding 5)', () => { + const fixture = [ + 'Agent(', + ' subagent_type="gsd-executor",', + ' description="Execute plan",', + ' # Only include model= when executor_model is an explicit model name.', + ' # When executor_model is "inherit", omit this parameter entirely so', + ' # Claude Code inherits the orchestrator model automatically.', + ' model="{executor_model}", # omit this line when executor_model == "inherit"', + ' isolation="worktree",', + ' prompt="Execute the plan."', + ')', + ].join('\n'); + const out = convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }); + assert.ok(!/model="\{executor_model\}"/.test(out), 'model= argument line removed'); + assert.ok(!/Only include model=/.test(out), 'dead explanatory comment (line 1) removed'); + assert.ok(!/omit this parameter entirely/.test(out), 'dead explanatory comment (line 2) removed'); + assert.ok(!/inherits the orchestrator model/.test(out), 'dead explanatory comment (line 3) removed'); + assert.ok(out.includes('isolation="worktree"'), 'unrelated surrounding arguments preserved'); + }); +}); + +// ─── 1c. Post-projection guard (belt-and-suspenders, #2284 requirement 3) ─── + +describe('#2284 post-projection guard — fails loud on any unanticipated residual form', () => { + const toolConfig = hermesToolConfig(); + + test('throws when a residual subagent_type token survives (any syntax)', () => { + assert.throws( + () => _assertProjectionComplete('delegate_task(subagent_type="gsd-planner")', toolConfig), + /residual subagent_type/i, + ); + assert.throws( + () => _assertProjectionComplete('delegate_task(subagent_type: "gsd-planner")', toolConfig), + /residual subagent_type/i, + ); + }); + + test('throws when literal Agent( call syntax survives', () => { + // Isolated from the subagent_type check above (which fires first and + // would otherwise mask this assertion) — a bare Agent() mention with no + // remaining subagent_type token. + assert.throws( + () => _assertProjectionComplete('Please call Agent() to dispatch.', toolConfig), + /literal Agent\(/i, + ); + }); + + test('throws when a model= argument leaks inside a delegate_task(...) call', () => { + assert.throws( + () => _assertProjectionComplete('delegate_task(gsd_role="gsd-planner", model="{m}")', toolConfig), + /leaked model=/i, + ); + }); + + test('does NOT throw on a clean, fully-projected document', () => { + const clean = 'delegate_task(gsd_role="gsd-planner", gsd_role_prompt=, role="leaf", prompt="x")'; + assert.doesNotThrow(() => _assertProjectionComplete(clean, toolConfig)); + }); + + test('does NOT flag Agent( or subagent_type mentioned INSIDE a quoted string (not real call syntax)', () => { + // e.g. settings.md: `description: "Chain stages via Agent() subagents"`. + const proseInsideString = 'delegate_task(description="Chain stages via Agent() subagents, not subagent_type=x")'; + assert.doesNotThrow(() => _assertProjectionComplete(proseInsideString, toolConfig)); + }); + + test('findDispatchCallSpans correctly balances parens across a quoted prompt body containing its own parens', () => { + // Mirrors discuss-phase-assumptions.md's real shape: parenthetical prose + // ("(e.g., ...)") embedded inside a triple-quoted prompt body. + const fixture = 'Agent(subagent_type="gsd-verifier", prompt="""\nAnalyze (e.g., "Technical Approach") the codebase.\n(3-5 areas, calibrated by tier)\n""")'; + const spans = findDispatchCallSpans(fixture, 'Agent'); + assert.strictEqual(spans.length, 1, 'exactly one call span found despite embedded parens'); + assert.strictEqual(spans[0].end, fixture.length, 'span correctly extends to the TRUE closing paren, not a premature one inside the string'); + }); +}); + +// ─── 2. Real disposable-HOME e2e install ───────────────────────────────────── + +describe('#2284 real disposable-HOME --hermes --global install', () => { + let tmpHome; + let savedHome; + let savedUserProfile; + let savedHermesHome; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2284-hermes-home-')); + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + savedHermesHome = process.env.HERMES_HOME; + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + process.env.HERMES_HOME = path.join(tmpHome, '.hermes'); + }); + + afterEach(() => { + try { + uninstall(true, 'hermes'); + } catch (_e) { + // best-effort — some fail-closed tests intentionally leave a partial install + } + if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile; + if (savedHermesHome === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = savedHermesHome; + cleanup(tmpHome); + }); + + test('installed plan-phase.md has no literal Agent( and does contain delegate_task', () => { + const result = install(true, 'hermes'); + assert.strictEqual(result.runtime, 'hermes'); + + const planPhasePath = path.join(result.configDir, 'gsd-core', 'workflows', 'plan-phase.md'); + assert.ok(fs.existsSync(planPhasePath), `expected installed workflow at ${planPhasePath}`); + const content = fs.readFileSync(planPhasePath, 'utf8'); + + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( call syntax in installed plan-phase.md'); + assert.ok(/delegate_task\(/.test(content), 'delegate_task( present in installed plan-phase.md'); + assert.ok(!/Agent tool IS available/.test(content), 'false assertion not installed verbatim'); + }); + + test('spot-check a second workflow (execute-phase.md) — same guarantees hold', () => { + const result = install(true, 'hermes'); + const executePhasePath = path.join(result.configDir, 'gsd-core', 'workflows', 'execute-phase.md'); + assert.ok(fs.existsSync(executePhasePath)); + const content = fs.readFileSync(executePhasePath, 'utf8'); + + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( call syntax in installed execute-phase.md'); + assert.ok(/delegate_task\(/.test(content), 'delegate_task( present in installed execute-phase.md'); + }); + + test('spot-check import.md (object-literal Agent({...}) form) in the installed tree', () => { + const result = install(true, 'hermes'); + const importPath = path.join(result.configDir, 'gsd-core', 'workflows', 'import.md'); + assert.ok(fs.existsSync(importPath)); + const content = fs.readFileSync(importPath, 'utf8'); + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed import.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed import.md'); + assert.ok(content.includes('gsd_role="gsd-plan-checker"'), 'gsd_role carries the resolved role'); + assert.ok(/gsd_role_prompt=/.test(content), 'role-prompt-resolution injected'); + }); + + test('spot-check ingest-docs.md (object-literal Agent({...}) form, two call sites) in the installed tree', () => { + const result = install(true, 'hermes'); + const ingestPath = path.join(result.configDir, 'gsd-core', 'workflows', 'ingest-docs.md'); + assert.ok(fs.existsSync(ingestPath)); + const content = fs.readFileSync(ingestPath, 'utf8'); + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed ingest-docs.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed ingest-docs.md'); + assert.ok(content.includes('gsd_role="gsd-doc-synthesizer"'), 'first call site role resolved'); + assert.ok(content.includes('gsd_role="gsd-roadmapper"'), 'second call site role resolved'); + }); + + test('spot-check code-review-fix.md (single-line-compact Agent(subagent_type=..., model=..., prompt="multi-line body) form) in the installed tree', () => { + const result = install(true, 'hermes'); + const crfPath = path.join(result.configDir, 'gsd-core', 'workflows', 'code-review-fix.md'); + assert.ok(fs.existsSync(crfPath)); + const content = fs.readFileSync(crfPath, 'utf8'); + assert.ok(!/\bAgent\(/.test(content), 'no literal Agent( in installed code-review-fix.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(content), 'no residual subagent_type in installed code-review-fix.md'); + const mask = maskStringLiterals(content); + assert.ok(!/\bmodel\s*[=:]/.test(mask), 'no leaked model= inside any real call in installed code-review-fix.md'); + assert.ok(content.includes('gsd_role="gsd-code-fixer"'), 'gsd-code-fixer role resolved'); + assert.ok(content.includes('gsd_role="gsd-code-reviewer"'), 'gsd-code-reviewer role resolved (2nd/3rd call sites)'); + }); + + test('EVERY installed workflow file is free of literal Agent( call syntax', () => { + const result = install(true, 'hermes'); + const workflowsDir = path.join(result.configDir, 'gsd-core', 'workflows'); + assert.ok(fs.existsSync(workflowsDir)); + const files = fs.readdirSync(workflowsDir).filter((f) => f.endsWith('.md')); + assert.ok(files.length > 10, 'sanity: a real corpus of workflow files was installed'); + for (const f of files) { + const content = fs.readFileSync(path.join(workflowsDir, f), 'utf8'); + assert.ok(!/\bAgent\(/.test(content), `${f} still contains literal Agent( call syntax`); + } + }); + + test('commands/gsd/*.md → Hermes-skill path (convertClaudeCommandToClaudeSkill) still works, unregressed', () => { + const result = install(true, 'hermes'); + const categoryDir = path.join(result.configDir, 'skills', 'gsd'); + assert.ok(fs.existsSync(categoryDir), 'skills/gsd category dir installed'); + + const helpSkillPath = nestedSkillPath(categoryDir, 'gsd-', 'help'); + assert.ok(fs.existsSync(helpSkillPath), `expected nested skill at ${helpSkillPath}`); + const skillContent = fs.readFileSync(helpSkillPath, 'utf8'); + assert.ok(/^---/.test(skillContent), 'skill file has YAML frontmatter'); + assert.ok(/name:\s*gsd-help/.test(skillContent), 'skill frontmatter name is the canonical gsd-help'); + }); +}); + +// ─── 3. Fail-closed role resolution ────────────────────────────────────────── + +describe('#2284 fail-closed role resolution', () => { + test('converter throws when a literal gsd_role reference has no matching shipped role', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-totally-fake-role-2284",\n description="d"\n)'; + assert.throws( + () => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' }), + /gsd-totally-fake-role-2284/, + 'expected an explicit error naming the unresolvable role', + ); + }); + + test('converter throws (never silently installs) when the agents/ directory cannot be resolved at all', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-planner",\n description="d"\n)'; + assert.throws( + () => projectNamedDispatchToStructuralDelegate(fixture, HERMES_DISPATCH, hermesToolConfig({ availableRoles: null })), + /could not resolve/i, + ); + }); + + test('a literal reference to a role that DOES exist never throws', () => { + const fixture = 'Agent(\n prompt=x,\n subagent_type="gsd-verifier",\n description="d"\n)'; + assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); + }); + + test('dynamic (non-literal) role references are not statically checked and never throw', () => { + // Mirrors the real plan-phase.md shape: subagent_type=research_hook.ref.agent + // is resolved at runtime by the host, not a literal string install.js can verify. + const fixture = 'Agent(\n prompt=x,\n subagent_type=research_hook.ref.agent,\n description="d"\n)'; + assert.doesNotThrow(() => convertClaudeToHermesMarkdown(fixture, { runtime: 'hermes' })); + }); + + describe('real install path — deterministic fs.readdirSync injection (never chmod/permission tricks)', () => { + let tmpHome; + let savedHome; + let savedUserProfile; + let savedHermesHome; + let origReaddirSync; + let injectedAgentsDir; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2284-hermes-failclosed-')); + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + savedHermesHome = process.env.HERMES_HOME; + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + process.env.HERMES_HOME = path.join(tmpHome, '.hermes'); + injectedAgentsDir = path.resolve(__dirname, '..', 'agents'); + origReaddirSync = fs.readdirSync; + }); + + afterEach(() => { + fs.readdirSync = origReaddirSync; + try { + uninstall(true, 'hermes'); + } catch (_e) { + // best-effort — the install intentionally failed partway through + } + if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile; + if (savedHermesHome === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = savedHermesHome; + cleanup(tmpHome); + }); + + test('a real --hermes --global install aborts with an explicit error when the shipped agents/ dir is unreadable', () => { + fs.readdirSync = function (p, opts) { + if (typeof p === 'string' && path.resolve(p) === injectedAgentsDir) { + throw new Error('#2284 injected fs.readdirSync failure — simulated unreadable agents/ dir'); + } + return origReaddirSync.call(fs, p, opts); + }; + + assert.throws( + () => install(true, 'hermes'), + /could not resolve|refusing to install/i, + 'a real hermes install must fail closed, never silently install workflows with unverifiable role references', + ); + }); + }); +}); + +// ─── 5. Corpus-wide invariant (round-2 CRITICAL regression guard) ─────────── +// +// #2284 round-2: `findDispatchCallSpans` originally relied on WHOLE-DOCUMENT +// cumulative quote parity (`maskStringLiterals` run once over the entire +// file). A markdown workflow mixes prose, ```bash fences full of their own +// double-quoted strings, and shell quoting — there is no single document-wide +// quote grammar. In the real corpus, a `"`-heavy bash block upstream of +// gsd-core/workflows/code-review.md's real +// `Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", ...)` +// call (~line 488) desynced that cumulative state, making the span detector +// blind to the call. It shipped completely unnormalized except for the +// catch-all's `subagent_type=`→`gsd_role=` rename: a Frankenstein +// `Agent(gsd_role="gsd-code-reviewer", model="{REVIEWER_MODEL}", ...)` — head +// still literal `Agent(`, `model=` leaked, no `gsd_role_prompt`/`role="leaf"` +// injected. A per-file/spot-check test suite did not exercise this file's +// exact shape and missed it; THIS is the real regression protection — +// hash-only goldens cannot catch a semantic defect like this. +describe('#2284 corpus-wide invariant — every shipped workflow/reference/template .md', () => { + function walkMarkdown(dir) { + if (!fs.existsSync(dir)) return []; + let out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out = out.concat(walkMarkdown(full)); + else if (entry.name.endsWith('.md')) out.push(full); + } + return out; + } + + const CORPUS_ROOT = path.join(__dirname, '..', 'gsd-core'); + const CORPUS_FILES = ['workflows', 'references', 'templates', 'contexts'] + .flatMap((sub) => walkMarkdown(path.join(CORPUS_ROOT, sub))); + + test('sanity: a real, substantial corpus was found to scan', () => { + assert.ok(CORPUS_FILES.length > 100, `expected >100 shipped .md files, found ${CORPUS_FILES.length}`); + }); + + test('every shipped .md file projects with ZERO residual Agent(, ZERO residual subagent_type, and ZERO leaked model= inside any delegate_task(...) call', () => { + const failures = []; + let totalDelegateTaskCalls = 0; + for (const file of CORPUS_FILES) { + const rel = path.relative(CORPUS_ROOT, file); + const content = fs.readFileSync(file, 'utf8'); + let out; + try { + out = convertClaudeToHermesMarkdown(content, { runtime: 'hermes' }); + } catch (e) { + failures.push(`${rel}: converter threw unexpectedly: ${e.message}`); + continue; + } + if (/\bAgent\(/.test(out)) failures.push(`${rel}: residual literal Agent( survives`); + if (/\bsubagent_type\s*[=:]/.test(out)) failures.push(`${rel}: residual subagent_type survives`); + for (const span of findDispatchCallSpans(out, 'delegate_task')) { + const rawSpanText = out.slice(span.start, span.end); + if (/\bmodel\s*[=:]/.test(rawSpanText)) failures.push(`${rel}: leaked model= inside a delegate_task(...) call`); + } + totalDelegateTaskCalls += (out.match(/delegate_task\(/g) || []).length; + } + assert.deepStrictEqual(failures, [], `corpus-wide invariant violations:\n${failures.join('\n')}`); + assert.ok(totalDelegateTaskCalls > 50, `sanity: expected a substantial number of real delegate_task( calls emitted, got ${totalDelegateTaskCalls}`); + }); + + test('code-review.md specifically: the real Agent(subagent_type="gsd-code-reviewer", model=..., prompt=...) call (~line 488) projects cleanly despite an upstream `"`-heavy bash fence', () => { + const file = path.join(CORPUS_ROOT, 'workflows', 'code-review.md'); + const content = fs.readFileSync(file, 'utf8'); + const out = convertClaudeToHermesMarkdown(content, { runtime: 'hermes' }); + + assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives in code-review.md'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out), 'no residual subagent_type in code-review.md'); + assert.ok(out.includes('gsd_role="gsd-code-reviewer"'), 'the real call\'s role is resolved, not just the catch-all rename'); + + const callStart = out.indexOf('delegate_task(gsd_role="gsd-code-reviewer"'); + assert.ok(callStart !== -1, 'the real call head IS delegate_task( — not a bare catch-all-renamed Agent( survivor'); + const callSpans = findDispatchCallSpans(out, 'delegate_task'); + const realCallSpan = callSpans.find((s) => s.start === callStart); + assert.ok(realCallSpan, 'the real call is detected as a complete, well-formed delegate_task(...) span'); + const rawCall = out.slice(realCallSpan.start, realCallSpan.end); + assert.ok(/gsd_role_prompt=/.test(rawCall), 'gsd_role_prompt injected into the real call'); + assert.ok(/role="leaf"/.test(rawCall), 'role="leaf" injected into the real call'); + assert.ok(!/\bmodel\s*[=:]/.test(rawCall), 'no model= leaked inside the real call'); + }); +}); + +// ─── 6. Bash-fence quote-imbalance regression (round-2 root cause) ────────── + +describe('#2284 bash-fence quote-imbalance before a real call (round-2 root cause)', () => { + // Minimal repro of code-review.md's real shape: a ```bash fence containing + // an ODD/unbalanced count of literal double-quotes (ordinary, realistic + // shell prose — `echo "..."` plus a nested escaped quote), followed by a + // real Agent(...) call further down in the SAME document. Under the + // round-1 whole-document cumulative-quote-parity bug, the fence's + // unbalanced quoting flipped the parser's "am I inside a string" state by + // the time it reached the real call, making the call invisible to + // `findDispatchCallSpans` entirely. + const FIXTURE = [ + '```bash', + 'echo "Warning: skipping structural findings embed (${SIZE} bytes). Re-run if needed."', + 'if [ -n "$X" ]; then echo "note: check the \\"quoted\\" value"; fi', + '```', + '', + 'Spawn the reviewer:', + '', + '```', + 'Agent(subagent_type="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt="', + '', + '${FILES_TO_READ}', + '', + 'Review and report.', + '")', + '```', + ].join('\n'); + + test('findDispatchCallSpans finds the real call despite the upstream quote-heavy bash fence', () => { + const spans = findDispatchCallSpans(FIXTURE, 'Agent'); + assert.strictEqual(spans.length, 1, 'exactly one Agent(...) call span found'); + assert.ok(FIXTURE.slice(spans[0].start, spans[0].end).startsWith('Agent(subagent_type="gsd-code-reviewer"')); + }); + + test('the fixture projects fully and correctly (delegate_task head, role injected, no model leak, no residual Agent()', () => { + const out = convertClaudeToHermesMarkdown(FIXTURE, { runtime: 'hermes' }); + assert.ok(!/\bAgent\(/.test(out), 'no literal Agent( survives'); + assert.ok(!/\bsubagent_type\s*[=:]/.test(out), 'no residual subagent_type'); + assert.ok(out.includes('delegate_task(gsd_role="gsd-code-reviewer"'), 'real call head IS delegate_task(, role resolved inline at the head — not a bare catch-all rename'); + assert.ok(/gsd_role_prompt=/.test(out), 'role-prompt-resolution injected'); + assert.ok(/role="leaf"/.test(out), 'structural role injected'); + const mask = maskStringLiterals(out); + assert.ok(!/\bmodel\s*[=:]/.test(mask), 'no model= leaked'); + }); + + test('the independent post-projection guard catches the deliberately-broken (un-normalized) output this exact fixture used to produce', () => { + // The round-1/round-2 Frankenstein output: catch-all renamed + // subagent_type=→gsd_role= but the head stayed literal Agent( and + // model= leaked through, because the call was never detected as a span. + const frankenstein = 'Agent(gsd_role="gsd-code-reviewer", model="{REVIEWER_MODEL}", prompt="Review and report.")'; + const toolConfig = hermesToolConfig(); + assert.throws( + () => _assertProjectionComplete(frankenstein, toolConfig), + /literal Agent\(/i, + 'the independent guard must fail loud on the exact Frankenstein shape the bug produced', + ); + }); +}); + +// ─── 7. plan-review-convergence.md dispatch-adjacent terminology (LOW finding a) ── +// +// The projection renamed `Agent(`→`delegate_task(` but originally left +// adjacent bare-word "Agent" references in the SAME sentence/paragraph +// un-normalized (gsd-core/workflows/plan-review-convergence.md ~lines 108, +// 347, 355), producing self-contradictory installed Hermes text (e.g. +// "...delegate_task(...)... the convergence orchestrator runs at depth 0 +// with Agent available..."). Fixed via two narrowly-scoped exact-phrase +// replacements (NOT a broad bare-word `Agent` rename, which would corrupt +// legitimate `Agent`-adjacent prose elsewhere — role names, "Agent Brief", +// agent-file references). +describe('#2284 plan-review-convergence.md dispatch-adjacent terminology consistency (LOW finding a)', () => { + const FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-review-convergence.md'); + const CONTENT = fs.readFileSync(FILE, 'utf8'); + const OUT = convertClaudeToHermesMarkdown(CONTENT, { runtime: 'hermes' }); + + test('sanity: the source file still contains the two flagged dispatch-adjacent phrases (regression canary for this test itself)', () => { + assert.ok(/orchestrator runs at depth 0 with Agent available/.test(CONTENT), 'source phrase 1 present'); + assert.ok(/\(bug #936: depth-1 Agent has no Agent tool\)/.test(CONTENT), 'source phrase 2 present'); + }); + + test('no literal Agent( survives and no residual bare "Agent available"/"Agent has no Agent tool" contradiction', () => { + assert.ok(!/\bAgent\(/.test(OUT), 'no literal Agent( call syntax survives'); + assert.ok(!/\bAgent available\b/.test(OUT), 'no bare "Agent available" left adjacent to a renamed delegate_task( mention'); + assert.ok(!/depth-1 Agent has no/.test(OUT), 'no bare "depth-1 Agent" left adjacent to the renamed delegate_task('); + }); + + test('both flagged paragraphs (source ~lines 108, 347) consistently say "delegate_task available"', () => { + const matches = OUT.match(/orchestrator runs at depth 0 with delegate_task available/g) || []; + assert.strictEqual(matches.length, 2, 'both paragraphs (initial planning + replan) normalized consistently'); + }); + + test('the success_criteria bullet (source ~line 355) reads consistently: "depth-1 delegate_task has no nested delegate_task"', () => { + assert.ok(OUT.includes('(bug #936: depth-1 delegate_task has no nested delegate_task)')); + }); + + test('unrelated bare "Agent" mentions NOT adjacent to a renamed dispatch call are left untouched (no broad rename)', () => { + // "Review via Agent → Skill(...)" (success_criteria) has no Agent(...) + // call in the same bullet — the projection never touched it, so it must + // not be renamed either. + assert.ok(OUT.includes('Review via Agent → Skill("gsd-review")'), 'unrelated bare "Agent" prose left intact — no broad bare-word rename'); + // "Hermes Agent" is the runtime's own brand name (from brandingRewrites), + // never the dispatch primitive — must never be touched by this fix. + assert.ok(OUT.includes('the one level of nesting that works on Hermes Agent'), 'runtime brand name "Hermes Agent" untouched by the dispatch-terminology fix'); + }); +}); + +// ─── 8. Branding protected-region — tables (finding b) ── +// +// The shared "Claude Code" → host-brand-name swap (applied by EVERY runtime +// that brands workflow content: cursor/windsurf/trae/cline/codebuddy +// hardcoded, qwen/hermes descriptor-driven) rewrote "Claude Code" even +// inside `` comparison tables +// (gsd-core/workflows/{plan-phase,execute-phase}.md), where "Claude Code" is +// a COMPARED-RUNTIME LABEL, not a host self-reference — mislabeling the +// comparison. Cross-cutting: reproduces on every branding runtime, not just +// Hermes. Fixed via `applyClaudeCodeBrandSwap`, a protected-region +// extract/restore wrapper used by every runtime's brand-swap call site. +describe('#2284(b) branding protected-region — comparison tables', () => { + test('applyClaudeCodeBrandSwap leaves content byte-identical, but still swaps self-references outside it', () => { + const fixture = [ + 'This tool runs on Claude Code and other hosts.', + '', + '', + '- **Claude Code:** Uses `Agent(...)` — blocks until complete', + '- **Other runtimes:** sequential inline execution', + '', + '', + 'Claude Code users should also read CONTRIBUTING.md.', + ].join('\n'); + + const out = applyClaudeCodeBrandSwap(fixture, 'Windsurf'); + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(block.includes('**Claude Code:**'), 'compared-runtime label inside the block is untouched'); + assert.ok(!block.includes('Windsurf'), 'the block never gains the installing runtime\'s own brand name'); + assert.ok(out.includes('This tool runs on Windsurf and other hosts.'), 'genuine self-reference BEFORE the block is branded'); + assert.ok(out.includes('Windsurf users should also read CONTRIBUTING.md.'), 'genuine self-reference AFTER the block is branded'); + }); + + test('a no-op brand name (falsy) returns content unchanged (fail-closed default, matches the qwen/hermes "guarded" no-op pattern)', () => { + const fixture = 'Claude Code does the thing.'; + assert.strictEqual(applyClaudeCodeBrandSwap(fixture, undefined), fixture); + assert.strictEqual(applyClaudeCodeBrandSwap(fixture, null), fixture); + assert.strictEqual(applyClaudeCodeBrandSwap(fixture, ''), fixture); + }); + + test('multiple blocks in the same document are each protected independently', () => { + const fixture = [ + 'Claude Code: A', + 'Claude Code self-reference.', + 'Claude Code: B', + ].join('\n'); + const out = applyClaudeCodeBrandSwap(fixture, 'Trae'); + assert.ok(out.includes('Claude Code: A')); + assert.ok(out.includes('Claude Code: B')); + assert.ok(out.includes('Trae self-reference.')); + }); + + test('collision-robust: arbitrary sentinel-like content in surrounding prose (NUL byte, token) round-trips untouched while genuine self-references are still swapped — the split-and-rejoin rewrite has no sentinel/placeholder to collide with', () => { + const fixture = [ + 'Claude Code embeds a literal NUL byte here: [\x00] and a placeholder-shaped token in its prose.', + '', + '', + '- **Claude Code:** reference implementation', + '', + '', + 'Claude Code again, after the block.', + ].join('\n'); + + const out = applyClaudeCodeBrandSwap(fixture, 'Trae'); + + // Genuine self-references outside the block ARE swapped. + assert.ok(out.startsWith('Trae embeds'), 'leading self-reference swapped'); + assert.ok(out.includes('Trae again, after the block.'), 'trailing self-reference swapped'); + + // The NUL byte survives verbatim, exactly once, with no corruption. + assert.ok(out.includes('[\x00]'), 'NUL byte preserved verbatim'); + assert.strictEqual(out.split('\x00').length - 1, 1, 'NUL byte appears exactly once — not duplicated or leaked'); + + // The placeholder-shaped token survives verbatim, exactly once — proving + // there is no internal sentinel this content could collide with. + assert.ok(out.includes(''), 'placeholder-shaped token preserved verbatim'); + assert.strictEqual((out.match(//g) || []).length, 1, 'placeholder-shaped token appears exactly once — not duplicated or leaked'); + + // The protected block is untouched, including its interior "Claude Code" label. + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(block.includes('**Claude Code:**'), 'block interior "Claude Code" left verbatim'); + assert.ok(!block.includes('Trae'), 'block never gains the installing runtime\'s own brand name'); + }); + + test('inside-AND-outside: a fixture with "Claude Code" both inside a block and in surrounding prose swaps only the outside occurrence', () => { + const fixture = [ + 'Claude Code is the host running this installer.', + '', + '- **Claude Code:** compared-runtime label, must stay verbatim', + '', + 'This is still Claude Code speaking.', + ].join('\n'); + + const out = applyClaudeCodeBrandSwap(fixture, 'Cursor'); + + assert.ok(out.includes('Cursor is the host running this installer.'), 'outside occurrence before the block is swapped'); + assert.ok(out.includes('This is still Cursor speaking.'), 'outside occurrence after the block is swapped'); + + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(block.includes('**Claude Code:**'), 'inside occurrence is preserved verbatim'); + assert.ok(!block.includes('Cursor'), 'inside occurrence is never swapped'); + }); + + describe('real corpus: gsd-core/workflows/execute-phase.md table', () => { + const FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); + const CONTENT = fs.readFileSync(FILE, 'utf8'); + + test('sanity: the source file has a block containing "Claude Code:" as a compared-runtime label', () => { + assert.ok(//.test(CONTENT)); + const block = CONTENT.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(/\*\*Claude Code:\*\*/.test(block)); + }); + + test('Windsurf: the compared-runtime label "Claude Code:" is NOT swapped to "Windsurf:", but genuine self-references elsewhere ARE', () => { + const out = convertClaudeToWindsurfMarkdown(CONTENT); + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(/\*\*Claude Code:\*\*/.test(block), 'comparison-table label preserved for Windsurf'); + assert.ok(!/\*\*Windsurf:\*\*/.test(block), 'comparison table never mislabeled with the installing runtime\'s own name'); + const outsideBlock = out.replace(/[\s\S]*?<\/runtime_compatibility>/g, ''); + assert.ok(/\bWindsurf\b/.test(outsideBlock), 'genuine self-references outside the block ARE branded to Windsurf'); + assert.ok(!/\bClaude Code\b/.test(outsideBlock), 'no residual "Claude Code" self-reference survives outside the block'); + }); + + test('Hermes: the compared-runtime label "Claude Code:" is NOT swapped to "Hermes Agent:", but genuine self-references elsewhere ARE', () => { + const out = convertClaudeToHermesMarkdown(CONTENT, { runtime: 'hermes' }); + const block = out.match(/[\s\S]*?<\/runtime_compatibility>/)[0]; + assert.ok(/\*\*Claude Code:\*\*/.test(block), 'comparison-table label preserved for Hermes'); + assert.ok(!/\*\*Hermes Agent:\*\*/.test(block), 'comparison table never mislabeled with Hermes\'s own brand name'); + const outsideBlock = out.replace(/[\s\S]*?<\/runtime_compatibility>/g, ''); + assert.ok(/\bHermes Agent\b/.test(outsideBlock), 'genuine self-references outside the block ARE branded to Hermes Agent'); + }); + }); +}); diff --git a/tests/fix-2717-cursor-windsurf-codex-commonjs-marker.test.cjs b/tests/hooks-commonjs-marker.test.cjs similarity index 100% rename from tests/fix-2717-cursor-windsurf-codex-commonjs-marker.test.cjs rename to tests/hooks-commonjs-marker.test.cjs diff --git a/tests/host-integration-descriptors.test.cjs b/tests/host-integration-descriptors.test.cjs index 2c1138744..81dad30f6 100644 --- a/tests/host-integration-descriptors.test.cjs +++ b/tests/host-integration-descriptors.test.cjs @@ -10,8 +10,11 @@ * sentinels fail-closed in negotiation (safe documented default, never propagate). */ +process.env.GSD_TEST_MODE = '1'; + const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); const { @@ -26,6 +29,38 @@ const { validateCapability, } = require(path.join(__dirname, '../gsd-core/bin/lib/capability-validator.cjs')); +// Folded from tests/fix-2598-opencode-background-dispatch.test.cjs and +// tests/fix-2603-kimi-code-host-matrix.test.cjs (#3333, test-hygiene backlog). +const ROOT = path.join(__dirname, '..'); +const OPENCODE_DESCRIPTOR = path.join(ROOT, 'capabilities', 'opencode', 'capability.json'); +const KIMI_CODE_DESCRIPTOR = path.join(ROOT, 'capabilities', 'kimi-code', 'capability.json'); +const HOST_INTEGRATION_MATRIX = path.join(ROOT, 'docs', 'reference', 'host-integration-capability-matrix.md'); + +function opencodeDispatch() { + const parsed = JSON.parse(fs.readFileSync(OPENCODE_DESCRIPTOR, 'utf8')); + return parsed.runtime.hostIntegration.dispatch; +} + +function kimiCodeAxes() { + return JSON.parse(fs.readFileSync(KIMI_CODE_DESCRIPTOR, 'utf8')).runtime.hostIntegration; +} + +/** Extract the `## ` section body, stopping at the next top-level host heading. */ +function matrixSection(host) { + const matrix = fs.readFileSync(HOST_INTEGRATION_MATRIX, 'utf8'); + const start = matrix.indexOf(`\n## ${host}\n`); + if (start === -1) return null; + const rest = matrix.slice(start + 1); + const end = rest.indexOf('\n## '); + return end === -1 ? rest : rest.slice(0, end); +} + +/** Read the `| | | …` cell out of a matrix section. */ +function matrixValue(section, axis) { + const row = section.split('\n').find((l) => l.startsWith(`| ${axis} |`)); + return row ? row.split('|')[2].trim() : null; +} + // All 8 scalar hostIntegration axis keys const SCALAR_AXES = ['embeddingMode', 'commandSurface', 'modelMode', 'hookBus', 'stateIO', 'transport', 'runtime']; // All 6 dispatch sub-keys (includes backgroundDispatch added in feat/1679-dispatch-flatten) @@ -452,3 +487,205 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => { }); }); }); + +// allow-test-rule: source-text-is-the-product #2598 — the descriptor JSON and the +// host-integration matrix ARE the negotiated contract; asserting their values is behavioral. +describe('#2598: OpenCode does not declare background/concurrent subagent dispatch', () => { + test('descriptor declares background: false', () => { + assert.equal( + opencodeDispatch().background, + false, + 'OpenCode subagent dispatch is synchronous unless an experimental opt-in flag is set', + ); + }); + + test('descriptor declares backgroundDispatch: false', () => { + assert.equal( + opencodeDispatch().backgroundDispatch, + false, + 'concurrent dispatch requires an opt-in flag, so it must not be declared as available', + ); + }); + + test('the capabilities that ARE real are left intact', () => { + // Narrow the blast radius: this fix must not quietly downgrade neighbouring + // sub-fields that were never in question. + const d = opencodeDispatch(); + assert.equal(d.namedDispatch, true, 'named subagent dispatch is genuinely supported'); + assert.equal(d.subagentToolkit, 'full', 'the general subagent has full tool access'); + assert.equal(d.isolation, 'orchestrator-worktree', + 'isolation is orchestrator-managed via `opencode run --dir`, unaffected by #2598'); + }); + + test('the host-integration matrix agrees with the descriptor', () => { + // ADR-1239 designates the matrix the deployment source-of-truth; a + // descriptor/matrix disagreement is how this defect survived in the first + // place (the matrix said true, the ADR binding table said false). + const matrix = fs.readFileSync(HOST_INTEGRATION_MATRIX, 'utf8'); + const section = matrix.slice(matrix.indexOf('## opencode')); + const end = section.indexOf('\n## '); + const opencodeSection = end === -1 ? section : section.slice(0, end); + + for (const field of ['dispatch.background', 'dispatch.backgroundDispatch']) { + const row = opencodeSection.split('\n').find((l) => l.startsWith(`| ${field} |`)); + assert.ok(row, `matrix must document ${field} for opencode`); + const value = row.split('|')[2].trim(); + assert.equal(value, 'false', `matrix ${field} must match the descriptor`); + } + }); +}); + +// allow-test-rule: source-text-is-the-product #2603 — the descriptor JSON and the +// host-integration matrix ARE the negotiated contract; asserting their values is behavioral. +describe('#2603: the host-integration matrix documents kimi-code', () => { + test('a `## kimi-code` section exists', () => { + assert.ok( + matrixSection('kimi-code'), + 'the matrix is the deployment source-of-truth for every installed runtime; kimi-code must have a section', + ); + }); + + test('every hostIntegration axis kimi-code declares is documented in the matrix', () => { + const section = matrixSection('kimi-code'); + const axes = kimiCodeAxes(); + + const scalarAxes = Object.keys(axes).filter((k) => k !== 'dispatch'); + for (const axis of scalarAxes) { + assert.ok( + matrixValue(section, axis), + `matrix must document the "${axis}" axis for kimi-code`, + ); + } + + // `builtInSubagents` is a GSD-side list, not a negotiated axis — the matrix + // documents it in prose, not as its own row. + const dispatchAxes = Object.keys(axes.dispatch).filter((k) => k !== 'builtInSubagents'); + for (const axis of dispatchAxes) { + assert.ok( + matrixValue(section, `dispatch.${axis}`), + `matrix must document the "dispatch.${axis}" sub-axis for kimi-code`, + ); + } + }); + + test('the matrix values agree with the shipped descriptor', () => { + const section = matrixSection('kimi-code'); + const axes = kimiCodeAxes(); + + for (const axis of Object.keys(axes).filter((k) => k !== 'dispatch')) { + assert.equal( + matrixValue(section, axis), + String(axes[axis]), + `matrix "${axis}" must match the descriptor`, + ); + } + for (const axis of Object.keys(axes.dispatch).filter((k) => k !== 'builtInSubagents')) { + assert.equal( + matrixValue(section, `dispatch.${axis}`), + String(axes.dispatch[axis]), + `matrix "dispatch.${axis}" must match the descriptor`, + ); + } + }); + + test('the kimi-code section is sourced independently of the kimi section', () => { + // The two are distinct products (Python kimi-cli vs TypeScript Kimi Code CLI); + // the issue's central requirement is that kimi's section was NOT copied. The + // check is scoped to the axis ROWS — the section's prose intro deliberately + // names kimi's Python API to draw the contrast, which is the opposite of a copy. + const rows = matrixSection('kimi-code') + .split('\n') + .filter((l) => l.startsWith('| ') && !l.startsWith('| Axis |') && !l.startsWith('|---')); + + assert.ok(rows.length >= 11, 'expected a row per hostIntegration axis'); + for (const row of rows) { + assert.ok( + !row.includes('kimi_cli'), + `kimi-code axis row must not cite the Python kimi-cli: ${row.slice(0, 60)}`, + ); + assert.ok( + !row.includes('moonshotai.github.io/kimi-cli'), + `kimi-code axis row must not cite kimi-cli docs: ${row.slice(0, 60)}`, + ); + } + assert.ok( + rows.some((r) => r.includes('kimi-code/blob/main/docs')), + 'kimi-code axes must cite the Kimi Code CLI docs', + ); + }); +}); + +describe('#2603: axis values inherited from the Python kimi descriptor are corrected', () => { + test('embeddingMode is declarative — plugins expose no in-process API', () => { + assert.equal(kimiCodeAxes().embeddingMode, 'declarative'); + }); + + test('kimi-code therefore classifies as the declarative-cli profile', () => { + assert.equal(profileOf(kimiCodeAxes()), 'declarative-cli'); + }); + + test('dispatch.nested is true — the coder built-in dispatches nested sub-agents', () => { + assert.equal(kimiCodeAxes().dispatch.nested, true); + }); + + test('dispatch.maxDepth is the undocumented sentinel, not a guessed integer', () => { + assert.equal(kimiCodeAxes().dispatch.maxDepth, 'undocumented'); + }); + + test('namedDispatch stays false — GSD installs no agent files for this host', () => { + // Guard against a well-meaning "the docs say custom agents exist" edit: flipping + // this makes resolveDispatchType return `gsd-planner` unchanged, which kimi-code + // cannot dispatch (docs/migration/kimi-to-kimi-code.md). + assert.equal(kimiCodeAxes().dispatch.namedDispatch, false); + }); + + test('the undocumented maxDepth sentinel is reported as a sentinel, not as malformed', () => { + // Surfaced by this change: maxDepth was the ONE dispatch sub-axis with no + // sentinel-specific warning, so the documented fail-closed value was reported + // as "missing or not a number" — indistinguishable from a genuinely broken + // descriptor. kimi-code would have been the sixth runtime to hit that path. + const { warnings } = negotiateHostCapabilities(kimiCodeAxes()); + + assert.ok( + warnings.some((w) => w.includes('dispatch.maxDepth is undocumented')), + `expected a maxDepth sentinel warning, got: ${JSON.stringify(warnings)}`, + ); + assert.ok( + !warnings.some((w) => w.includes('maxDepth is missing or not a number')), + 'the documented sentinel must not be reported as a malformed value', + ); + }); + + test('a genuinely malformed maxDepth is still reported as malformed', () => { + // Boundary: the sentinel carve-out must not swallow the real error case. + const axes = kimiCodeAxes(); + const malformed = { ...axes, dispatch: { ...axes.dispatch, maxDepth: 'not-a-number' } }; + const { warnings } = negotiateHostCapabilities(malformed); + + assert.ok( + warnings.some((w) => w.includes('maxDepth is missing or not a number')), + `expected the malformed-value warning, got: ${JSON.stringify(warnings)}`, + ); + }); + + test('both maxDepth paths still degrade the effective value closed to 0', () => { + const axes = kimiCodeAxes(); + assert.equal(negotiateHostCapabilities(axes).effective.dispatch.maxDepth, 0); + const malformed = { ...axes, dispatch: { ...axes.dispatch, maxDepth: 'not-a-number' } }; + assert.equal(negotiateHostCapabilities(malformed).effective.dispatch.maxDepth, 0); + }); + + test('the axes that were already correct are left intact', () => { + const axes = kimiCodeAxes(); + assert.equal(axes.commandSurface, 'slash-file'); + assert.equal(axes.modelMode, 'passive'); + assert.equal(axes.hookBus, 'host'); + assert.equal(axes.stateIO, 'filesystem'); + assert.equal(axes.transport, 'mcp'); + assert.equal(axes.runtime, 'node'); + assert.equal(axes.dispatch.background, true); + assert.equal(axes.dispatch.backgroundDispatch, true); + assert.equal(axes.dispatch.subagentToolkit, 'built-in-only'); + assert.equal(axes.dispatch.isolation, 'orchestrator-worktree'); + }); +}); diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs index 338680779..e4ab815be 100644 --- a/tests/install-regressions.test.cjs +++ b/tests/install-regressions.test.cjs @@ -38,7 +38,7 @@ try { else process.env.GSD_TEST_MODE = savedTestMode; } -const { install, mergeClaudePermissions, GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS, rewriteLegacyManagedNodeHookCommands, resolveNodeRunner } = installExports || {}; +const { install, mergeClaudePermissions, GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_LEGACY_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS, rewriteLegacyManagedNodeHookCommands, resolveNodeRunner, copyWithPathReplacement } = installExports || {}; const { installRuntimeArtifacts, @@ -1357,3 +1357,72 @@ describe('#1924: preserveUserArtifacts helper exists in install.js', () => { }); }); } + +// ─── #3333 — copyWithPathReplacement TOCTOU: source vanishes mid-copy ──────── +// +// copyWithPathReplacement enumerates srcDir via readdirSync, then later reads +// (or copies) each listed entry. A filesystem is not transactional: the file +// readdirSync named can be deleted by a concurrent process before the loop +// reaches it, throwing an unhandled ENOENT and crashing the whole install. +// This is not hypothetical — tests/planning-prompt-drift.test.cjs writes a +// throwaway fixture directly into the real, shared gsd-core/workflows/ and +// deletes it in t.after(); a concurrently-running install path that lists +// that directory can observe the fixture in its readdirSync snapshot but hit +// ENOENT reading it once the writer's cleanup fires. Confirmed crash from a +// real remote gsd-test run: +// Error: ENOENT: no such file or directory, open +// '/work/gsd-core/workflows/zzz-e5-drift-fixture.md' +// at Object.readFileSync (node:fs:441:20) +// at copyWithPathReplacement (/work/bin/install.js:7888:24) + +describe('#3333 regression: copyWithPathReplacement tolerates a source file vanishing mid-copy (TOCTOU)', () => { + test('a .md file deleted between readdirSync and read is skipped, siblings still copied, no crash', (t) => { + assert.strictEqual(typeof copyWithPathReplacement, 'function', + 'copyWithPathReplacement must be exported from bin/install.js'); + + const srcDir = createTempDir('gsd-3333-src-'); + const destRoot = createTempDir('gsd-3333-dest-'); + t.after(() => { + cleanup(srcDir); + cleanup(destRoot); + }); + + fs.writeFileSync(path.join(srcDir, 'alpha.md'), '# alpha\n'); + fs.writeFileSync(path.join(srcDir, 'vanish.md'), '# vanish\n'); + fs.writeFileSync(path.join(srcDir, 'beta.md'), '# beta\n'); + + const vanishPath = path.join(srcDir, 'vanish.md'); + + // Deterministically inject the race at its true origin point: readdirSync + // (called inside copyWithPathReplacement) returns the snapshot that still + // includes 'vanish.md', but the file is removed from disk immediately + // after that snapshot is taken and before the entry loop reaches it — the + // same shape as a concurrent test suite's t.after() cleanup firing mid-copy. + const origReaddirSync = fs.readdirSync; + fs.readdirSync = function (dir, opts) { + const result = origReaddirSync.call(fs, dir, opts); + if (dir === srcDir) { + try { fs.unlinkSync(vanishPath); } catch { /* already gone */ } + } + return result; + }; + t.after(() => { fs.readdirSync = origReaddirSync; }); + + const destDir = path.join(destRoot, 'out'); + + assert.doesNotThrow(() => { + copyWithPathReplacement(srcDir, destDir, '~/.claude/', 'claude', false, false, destRoot); + }, 'copyWithPathReplacement must not throw when a listed source file vanishes before it is read (#3333)'); + + assert.ok(fs.existsSync(path.join(destDir, 'alpha.md')), + 'alpha.md (unaffected sibling before the vanished entry) must still be copied'); + assert.strictEqual(fs.readFileSync(path.join(destDir, 'alpha.md'), 'utf8'), '# alpha\n'); + + assert.ok(fs.existsSync(path.join(destDir, 'beta.md')), + 'beta.md (unaffected sibling after the vanished entry) must still be copied'); + assert.strictEqual(fs.readFileSync(path.join(destDir, 'beta.md'), 'utf8'), '# beta\n'); + + assert.ok(!fs.existsSync(path.join(destDir, 'vanish.md')), + 'vanish.md destination must not exist — the vanished source must be skipped, not partially written'); + }); +}); diff --git a/tests/fix-2657-untrack-compiled-artifacts.test.cjs b/tests/lint-compiled-artifact-sync.test.cjs similarity index 100% rename from tests/fix-2657-untrack-compiled-artifacts.test.cjs rename to tests/lint-compiled-artifact-sync.test.cjs diff --git a/tests/fix-3023-shared-hooks-dir-resolution.test.cjs b/tests/shared-hooks-dir-resolution.test.cjs similarity index 100% rename from tests/fix-3023-shared-hooks-dir-resolution.test.cjs rename to tests/shared-hooks-dir-resolution.test.cjs diff --git a/tests/trae-imperative-reference.test.cjs b/tests/trae-imperative-reference.test.cjs index bf452993d..fe5e5a149 100644 --- a/tests/trae-imperative-reference.test.cjs +++ b/tests/trae-imperative-reference.test.cjs @@ -23,10 +23,13 @@ * tests/trae-upgrades.test.cjs. */ -const { test } = require('node:test'); +const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const fc = require('fast-check'); + +process.env['GSD_TEST_MODE'] = '1'; const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); const { @@ -36,6 +39,12 @@ const { PROFILE_BASELINES, UNDOCUMENTED, } = require('../gsd-core/bin/lib/host-integration.cjs'); +const { getProjectInstructionFile } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); +const { convertClaudeToTraeMarkdown } = require('../bin/install.js'); +const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); + +const { runMinimalInstall, walk } = require('./helpers/install-shared.cjs'); +const { cleanup } = require('./helpers.cjs'); const TRAE_CAP = JSON.parse( fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'trae', 'capability.json'), 'utf8'), @@ -155,3 +164,255 @@ test('no `runtime === "trae"` string-equality branch remains in the descriptor-m `AC2: no hardcoded runtime==='trae' branch may remain in ${path.relative(repoRoot, file)}; found: ${offenders.join(', ')}`); } }); + +// -- folded from tests/fix-2658-trae-runtime-detection-and-instruction-path.test.cjs (#3333) -- + +/** + * Regression tests for #2658 — Trae runtime not detected in workflow + * runtime-detection blocks (falls back to claude), and the install-time + * `CLAUDE.md` path rewrite mutilates the claude fallback into a malformed + * path instead of resolving to the Trae rules file. + * + * Defects collided (see + * .gsd/bug/fix-2658-trae-runtime-not-detected-falls-back-to-/10-diagnosis.md): + * + * 1. `gsd-core/workflows/new-project.md` AND `gsd-core/workflows/ingest-docs.md` + * (found during this remediation — same pattern, same gap, not just + * new-project.md as originally reported) never recognized trae (path + * `/.trae/` or env `TRAE_CONFIG_DIR`) in their runtime-detection blocks — + * fell through to `RUNTIME=claude`. + * 2. The `trae.js` entry in `bin/install.js`'s `RUNTIME_CONTENT_DISPATCH` + * replaced bare `CLAUDE.md` first, leaving a stale `.claude/` prefix: + * `.claude/CLAUDE.md` -> `.claude/.trae/rules/`. + * 3. `convertClaudeToTraeMarkdown` (mirrored in `bin/install.js` and + * `src/runtime-artifact-conversion.cts`) had the same class of bug but a + * DIFFERENT wrong output (`.trae/.trae/rules/`), because its generic + * `.claude/` -> `.trae/` rewrite ran after the bare `CLAUDE.md` rewrite + * and re-mutated the leftover prefix. + * 4. `capabilities/trae/capability.json` didn't declare + * `hostBehaviors.projectInstructionFile`, so even a correctly-detected + * trae runtime resolved to the generic `AGENTS.md` default via + * `getProjectInstructionFile`. + * 5. Found by the end-to-end install test below, one level deeper than the + * static trace: `copyWithPathReplacement` (bin/install.js) runs a + * GENERIC `~/.claude/` / `$HOME/.claude/` / `./.claude/` -> runtime-dir + * rewrite on every .md file BEFORE calling `convertClaudeToTraeMarkdown`, + * substituting a `pathPrefix` the converter is never given (it differs + * per install: relative for a project-local install, an arbitrary + * absolute path for a local install rooted elsewhere, `~/.trae/` for a + * global one). The converter's `.claude/CLAUDE.md`-specific patterns + * (defect 3's fix) never fire on that already-rewritten text, and the + * bare fallback still doubles the prefix — a first attempt at fixing + * this handled only the `./.trae/CLAUDE.md` shape and missed the + * `~/.claude/` / `$HOME/.claude/` forms `gsd-core/workflows/profile-user.md` + * actually uses, caught by row 12 (the real spawned install) below on a + * second run. Fixed with a prefix-preserving pattern (capture whatever + * precedes a `.trae/` tail, keep it, fix only the filename suffix) + * instead of assuming one fixed shape. + */ +describe('#2658: trae runtime detection and instruction path (folded from fix-2658-trae-runtime-detection-and-instruction-path.test.cjs)', () => { + const REPO_ROOT = path.join(__dirname, '..'); + const MALFORMED_SINGLE = '.claude/.trae/rules'; + const MALFORMED_DOUBLE = '.trae/.trae/rules'; + const EXPECTED_PATH = '.trae/rules/rules.md'; + + describe('#2658 acceptance criterion 2: getProjectInstructionFile resolves trae to a concrete file', () => { + test('trae maps to .trae/rules/rules.md (not the generic AGENTS.md default)', () => { + assert.strictEqual(getProjectInstructionFile('trae'), EXPECTED_PATH); + }); + + test('capability descriptor declares the same path getProjectInstructionFile returns', () => { + const cap = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'trae', 'capability.json'), 'utf8'), + ); + assert.strictEqual(cap.runtime.hostBehaviors.projectInstructionFile, EXPECTED_PATH); + assert.strictEqual(getProjectInstructionFile('trae'), cap.runtime.hostBehaviors.projectInstructionFile); + }); + + test('the declared path is a concrete file, not a bare directory (acceptance criterion 2)', () => { + assert.ok(!EXPECTED_PATH.endsWith('/'), 'must not be directory-terminated'); + assert.ok(/\.md$/.test(EXPECTED_PATH), 'must name a concrete markdown file'); + }); + }); + + describe('#2658: convertClaudeToTraeMarkdown never mutilates the CLAUDE.md path (bin/install.js)', () => { + const cases = [ + ['bare CLAUDE.md', 'See CLAUDE.md for details.'], + ['./CLAUDE.md', 'Read ./CLAUDE.md before starting.'], + ['backtick-wrapped `CLAUDE.md`', 'The file `CLAUDE.md` is authoritative.'], + ['the exact reported-bug input: .claude/CLAUDE.md', 'Fallback path is .claude/CLAUDE.md by default.'], + ['backtick-wrapped .claude/CLAUDE.md', 'Fallback: `.claude/CLAUDE.md`.'], + ['./.claude/CLAUDE.md', 'From root: ./.claude/CLAUDE.md'], + ]; + for (const [label, input] of cases) { + test(`${label} -> ${EXPECTED_PATH}, no malformed output`, () => { + const out = convertClaudeToTraeMarkdown(input); + assert.ok(!out.includes(MALFORMED_SINGLE), `output must not contain "${MALFORMED_SINGLE}": ${out}`); + assert.ok(!out.includes(MALFORMED_DOUBLE), `output must not contain "${MALFORMED_DOUBLE}": ${out}`); + assert.ok(out.includes(EXPECTED_PATH), `output must contain "${EXPECTED_PATH}": ${out}`); + }); + } + + test('fast-check property: any surrounding text around .claude/CLAUDE.md never yields a malformed path', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 40 }), + fc.string({ maxLength: 40 }), + (prefix, suffix) => { + const content = `${prefix}.claude/CLAUDE.md${suffix}`; + const out = convertClaudeToTraeMarkdown(content); + assert.ok(!out.includes(MALFORMED_SINGLE)); + assert.ok(!out.includes(MALFORMED_DOUBLE)); + }, + ), + { numRuns: 200 }, + ); + }); + }); + + describe('#2658 defect 5: post-generic-rewrite ".trae/"-prefixed forms preserve their prefix instead of doubling it', () => { + // These simulate the text `copyWithPathReplacement`'s generic `~/.claude/` / + // `$HOME/.claude/` / `./.claude/` -> runtime-dir pass hands to + // convertClaudeToTraeMarkdown — the converter never sees the original + // `.claude/`-prefixed source in this pipeline, only these already-rewritten + // shapes. A fixed-shape patch that only handled the local relative form + // left the local-install-absolute-path and global tilde forms broken. + const cases = [ + ['local relative (post "./.claude/" -> "./.trae/" rewrite)', './.trae/CLAUDE.md', './.trae/rules/rules.md'], + [ + 'local install absolute path (post "./.claude/" -> "/.trae/" rewrite)', + '/private/var/folders/xx/gsd-trae-local-abc123/.trae/CLAUDE.md', + '/private/var/folders/xx/gsd-trae-local-abc123/.trae/rules/rules.md', + ], + ['global tilde (post "~/.claude/" -> "~/.trae/" rewrite)', '~/.trae/CLAUDE.md', '~/.trae/rules/rules.md'], + ['backtick-wrapped local relative', '`./.trae/CLAUDE.md`', '`./.trae/rules/rules.md`'], + ]; + for (const [label, input, expected] of cases) { + test(`${label} -> prefix preserved, no malformed path`, () => { + const out = convertClaudeToTraeMarkdown(input); + assert.ok(!out.includes(MALFORMED_SINGLE), `output must not contain "${MALFORMED_SINGLE}": ${out}`); + assert.ok(!out.includes(MALFORMED_DOUBLE), `output must not contain "${MALFORMED_DOUBLE}": ${out}`); + assert.strictEqual(out, expected); + }); + } + + test('fast-check property: any arbitrary path ending in .trae/ never yields a doubled prefix', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 30 }).filter((s) => !s.includes('`') && !/\s/.test(s)), + (prefix) => { + const content = `${prefix}.trae/CLAUDE.md`; + const out = convertClaudeToTraeMarkdown(content); + assert.ok(!out.includes(MALFORMED_SINGLE)); + assert.ok(!out.includes(MALFORMED_DOUBLE)); + }, + ), + { numRuns: 200 }, + ); + }); + }); + + describe('#2658 output parity: bin/install.js vs runtime-artifact-conversion.cjs convertClaudeToTraeMarkdown (#2094 mirror)', () => { + // Parity must hold for the pre-existing reported-bug input AND for every + // arbitrary-prefix ".trae/"-tail shape the prefix-preserving regex + // (bin/install.js:2747-2748, mirrored byte-for-byte at + // src/runtime-artifact-conversion.cts:1357-1358) was added to handle. A + // change to only one copy of that regex would otherwise pass every other + // test in this file — none of the defect-5 cases above call the mirror — + // while silently diverging from the other copy. + const parityCases = [ + ['the reported-bug input (bare .claude/ prefix)', 'Fallback path is .claude/CLAUDE.md by default.'], + ['local relative prefix (post "./.claude/" -> "./.trae/" rewrite)', './.trae/CLAUDE.md'], + [ + 'nested project-path absolute prefix (post "./.claude/" -> "/.trae/" rewrite)', + '/private/var/folders/xx/gsd-trae-local-abc123/.trae/CLAUDE.md', + ], + ['global tilde prefix (post "~/.claude/" -> "~/.trae/" rewrite)', '~/.trae/CLAUDE.md'], + ['$HOME-variable prefix (post "$HOME/.claude/" -> "$HOME/.trae/" rewrite)', '$HOME/.trae/CLAUDE.md'], + ['backtick-wrapped local relative prefix', '`./.trae/CLAUDE.md`'], + ]; + + for (const [label, input] of parityCases) { + test(`identical output for ${label}`, () => { + assert.strictEqual( + convertClaudeToTraeMarkdown(input), + runtimeArtifactConversion.convertClaudeToTraeMarkdown(input), + ); + }); + } + + test('fast-check property: any arbitrary ".trae/"-tail path produces identical output in both implementations', () => { + fc.assert( + fc.property( + fc.string({ maxLength: 30 }).filter((s) => !s.includes('`') && !/\s/.test(s)), + (prefix) => { + const content = `${prefix}.trae/CLAUDE.md`; + assert.strictEqual( + convertClaudeToTraeMarkdown(content), + runtimeArtifactConversion.convertClaudeToTraeMarkdown(content), + ); + }, + ), + { numRuns: 200 }, + ); + }); + }); + + describe('#2658: end-to-end --trae install never emits the malformed path (acceptance criterion 1)', () => { + test('local install: no emitted .md/.js/.cjs file contains the malformed strings; the rules file is concrete', () => { + const { configDir, root } = runMinimalInstall({ runtime: 'trae', scope: 'local' }); + try { + const files = walk(configDir) + .filter((f) => /\.(md|js|cjs)$/.test(f)) + // gsd-core/CHANGELOG.md is excluded by exact relative path (not a blanket + // .md skip — the emitted agent/command/workflow markdown this gate exists + // to guard stays fully scanned). CHANGELOG.md legitimately QUOTES the + // malformed `.claude/.trae/rules` / `.trae/.trae/rules` strings while + // documenting the #2658 fix itself (#3006) — that historical-value + // citation is not a regression of the installer's actual output. Verified + // empirically: excluding only this one file drops the hit count to zero + // across all 620 other emitted files. + .filter((f) => f.split(path.sep).join('/').indexOf('gsd-core/CHANGELOG.md') === -1); + assert.ok(files.length > 0, 'expected at least one emitted .md/.js/.cjs file'); + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + assert.ok(!content.includes(MALFORMED_SINGLE), `${file} must not contain "${MALFORMED_SINGLE}"`); + assert.ok(!content.includes(MALFORMED_DOUBLE), `${file} must not contain "${MALFORMED_DOUBLE}"`); + } + } finally { + cleanup(root); + } + }); + }); + + describe('#2658 acceptance criterion 3: new-project.md / ingest-docs.md detect trae before falling back to claude', () => { + const workflowsDir = path.join(REPO_ROOT, 'gsd-core', 'workflows'); + + test('new-project.md recognizes /.trae/ path and TRAE_CONFIG_DIR before the claude fallback', () => { + const content = fs.readFileSync(path.join(workflowsDir, 'new-project.md'), 'utf8'); + const pathBlock = content.match(/Derive `RUNTIME`[\s\S]*?Otherwise → `RUNTIME=claude`/); + assert.ok(pathBlock, 'runtime-detection path block must exist'); + assert.ok( + /Path contains `\/\.trae\/` → `RUNTIME=trae`/.test(pathBlock[0]), + 'path-based detection must recognize /.trae/ before the claude fallback', + ); + const envBlock = content.match(/if \[ -n "\$CODEX_HOME" \][\s\S]*?else RUNTIME="claude"; fi/); + assert.ok(envBlock, 'env-var fallback block must exist'); + assert.ok( + /TRAE_CONFIG_DIR/.test(envBlock[0]), + 'env-var fallback must recognize TRAE_CONFIG_DIR before the claude fallback', + ); + }); + + test('ingest-docs.md carries the same trae detection (found during this remediation, not just new-project.md)', () => { + const content = fs.readFileSync(path.join(workflowsDir, 'ingest-docs.md'), 'utf8'); + const block = content.match(/\*\*Detect runtime\*\*[\s\S]*?else → `RUNTIME=claude`/); + assert.ok(block, 'runtime-detection block must exist'); + assert.ok( + /`\/\.trae\/` → `RUNTIME=trae`/.test(block[0]), + 'ingest-docs.md must also recognize /.trae/ before the claude fallback', + ); + assert.ok(/TRAE_CONFIG_DIR/.test(content), 'env-var fallback mention must include TRAE_CONFIG_DIR'); + }); + }); +});