From 08021b02c0f036a05b4f69cd11fd0efba2e4fe90 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sun, 26 Jul 2026 17:41:40 -0500 Subject: [PATCH 01/47] fix(#2665): scrub config-location env vars in every TEST_ENV_BASE declaration TEST_ENV_BASE blanks session-identity variables but none of the three that decide WHERE a child process writes: CLAUDE_CONFIG_DIR, GSD_RUNTIME and CODEX_HOME. The config-home resolver is env-first (runtime-homes.cts, the dot-home case consults the env var before the home-derived fallback), so an ambient CLAUDE_CONFIG_DIR in the developer's shell beats a call site that sandboxes only HOME. The suite then writes into the developer's real config directory -- including a registered skill under /skills/ whose body carries behavioural directives that load into later sessions. Blank all three alongside the session-identity vars. `...env` still spreads last, so the five call sites that already constrain these locally keep winning with their explicit values. TEST_ENV_BASE is re-declared in nine files, so the three lines are added nine times rather than once. Consolidating the nine into a single exported constant -- and fixing the TERM_SESSION / TERM_SESSION_ID drift between the copies -- is deliberately left out of this change; see the PR body. One call site needed adjusting. capability-state.test.cjs's `capability state --runtime claude` CLI test passed no env at all and compared the CHILD's resolved config dir against the PARENT process's getGlobalConfigDir('claude'). That agreed only because the child inherited the developer's ambient CLAUDE_CONFIG_DIR -- i.e. it passed *because of* the leak. It now redirects both runtime homes into the sandbox and asserts against values the test controls, so it is hermetic with the variable set or unset. Regression case folded into the owning module's test file rather than a new bug-NNNN file, per scripts/lint-regression-test-names.cjs. It sets the variable on the PARENT process, which is the actual vector; setting it in the per-call env argument would exercise a path that was never broken. --- tests/agent-skills.test.cjs | 6 +++ tests/api-coverage-gate-e2e.test.cjs | 6 +++ .../assumption-delta-checkpoint-e2e.test.cjs | 6 +++ tests/capability-state.test.cjs | 18 +++++-- .../check-tdd-review-checkpoint-e2e.test.cjs | 6 +++ tests/config-loader.test.cjs | 6 +++ tests/configuration-migrate-config.test.cjs | 6 +++ tests/helpers.cjs | 7 ++- tests/profile-output.test.cjs | 47 ++++++++++++++++++- tests/representative-corpus.test.cjs | 6 +++ tests/run-tests-harness.test.cjs | 6 +++ 11 files changed, 112 insertions(+), 8 deletions(-) diff --git a/tests/agent-skills.test.cjs b/tests/agent-skills.test.cjs index 40063bc1f..8a78e8b9d 100644 --- a/tests/agent-skills.test.cjs +++ b/tests/agent-skills.test.cjs @@ -35,6 +35,12 @@ const TEST_ENV_BASE = { GSD_WORKSTREAM: '', TTY: '', SSH_TTY: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; /** diff --git a/tests/api-coverage-gate-e2e.test.cjs b/tests/api-coverage-gate-e2e.test.cjs index c9f49f70f..0407e8a81 100644 --- a/tests/api-coverage-gate-e2e.test.cjs +++ b/tests/api-coverage-gate-e2e.test.cjs @@ -44,6 +44,12 @@ const TEST_ENV_BASE = { ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; function runTools(args, cwd) { diff --git a/tests/assumption-delta-checkpoint-e2e.test.cjs b/tests/assumption-delta-checkpoint-e2e.test.cjs index 7ae030fb4..da57a3364 100644 --- a/tests/assumption-delta-checkpoint-e2e.test.cjs +++ b/tests/assumption-delta-checkpoint-e2e.test.cjs @@ -40,6 +40,12 @@ const TEST_ENV_BASE = { ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; function runTools(args, cwd) { diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 4ab47fecc..3bd6dbd11 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -2108,12 +2108,24 @@ describe('regressions: --runtime override bypasses persisted runtime (#2003)', ( const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-')); try { writePersistedRuntime(tmpDir, 'codex'); - const result = runGsdTools('capability state --runtime claude --raw', tmpDir); + // #2665: redirect both runtime homes into the sandbox so the expectation is + // built from values this test controls. Comparing the child's answer against + // the PARENT process's getGlobalConfigDir() compared two different + // environments -- it agreed only because the child inherited the developer's + // ambient CLAUDE_CONFIG_DIR, which is the leak this test's helper now blocks. + const claudeConfigDir = path.join(tmpDir, 'claude-config'); + const codexHome = path.join(tmpDir, 'codex-home'); + const result = runGsdTools('capability state --runtime claude --raw', tmpDir, { + HOME: tmpDir, + CLAUDE_CONFIG_DIR: claudeConfigDir, + CODEX_HOME: codexHome, + }); assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`); const parsed = JSON.parse(result.output); - const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs'); - assert.strictEqual(parsed.runtimeConfigDir, runtimeHomes.getGlobalConfigDir('claude'), + assert.strictEqual(parsed.runtimeConfigDir, claudeConfigDir, '`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir'); + assert.notStrictEqual(parsed.runtimeConfigDir, codexHome, + 'must NOT resolve to the codex config dir when --runtime claude is explicit'); } finally { cleanup(tmpDir); } diff --git a/tests/check-tdd-review-checkpoint-e2e.test.cjs b/tests/check-tdd-review-checkpoint-e2e.test.cjs index 1a01e8aa6..3a2a895b4 100644 --- a/tests/check-tdd-review-checkpoint-e2e.test.cjs +++ b/tests/check-tdd-review-checkpoint-e2e.test.cjs @@ -151,6 +151,12 @@ const TEST_ENV_BASE = { ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; function runTools(args, cwd) { diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs index 1e3e89cdf..1e41ffe4d 100644 --- a/tests/config-loader.test.cjs +++ b/tests/config-loader.test.cjs @@ -759,6 +759,12 @@ const TEST_ENV_BASE = { ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; /** diff --git a/tests/configuration-migrate-config.test.cjs b/tests/configuration-migrate-config.test.cjs index d98baea6e..efe602ba0 100644 --- a/tests/configuration-migrate-config.test.cjs +++ b/tests/configuration-migrate-config.test.cjs @@ -34,6 +34,12 @@ const TEST_ENV_BASE = { ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; function runMigrateConfig(cwd, extraArgs = [], env = {}) { diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 812c257f1..3392ced3b 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -25,10 +25,9 @@ const TEST_ENV_BASE = { ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', - // #2665: blank config-LOCATION vars so npm test never writes into the developer's - // live config directory. The resolver consults these before HOME, so an ambient - // value wins unconditionally over a sandboxed HOME. Per-site overrides still win - // because env is spread last in the child-env merge. + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. CLAUDE_CONFIG_DIR: '', GSD_RUNTIME: '', CODEX_HOME: '', diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index cc40ba566..849b804a3 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -12,7 +12,13 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs'); +const { + runGsdTools, + createTempProject, + createTempGitProject, + cleanup, + withIsolatedProcessState, +} = require('./helpers.cjs'); const { PROFILING_QUESTIONS, @@ -184,6 +190,45 @@ describe('write-profile command', () => { assert.strictEqual(out.profile_path, path.join(codexHome, 'gsd-core', 'USER-PROFILE.md')); }); + test('#2665: ambient CLAUDE_CONFIG_DIR cannot escape a HOME-only sandbox', () => { + // The defect: TEST_ENV_BASE blanked session-identity vars but none of the + // config-LOCATION vars, and the dot-home resolver is env-first. So an + // ambient CLAUDE_CONFIG_DIR in the DEVELOPER'S shell beat `{ HOME: tmpDir }` + // and the suite wrote into their real config directory. Setting it on the + // PARENT process is the actual vector — passing it in the per-call env + // argument would test nothing, because that path was never broken. + const analysis = { + profile_version: '1.0', + dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } }, + }; + const analysisPath = path.join(tmpDir, 'analysis.json'); + fs.writeFileSync(analysisPath, JSON.stringify(analysis)); + + const ambientConfigDir = path.join(tmpDir, 'ambient-live-config'); + fs.mkdirSync(ambientConfigDir, { recursive: true }); + + const out = withIsolatedProcessState(() => { + process.env.CLAUDE_CONFIG_DIR = ambientConfigDir; + const result = runGsdTools( + ['write-profile', '--input', analysisPath, '--raw'], + tmpDir, + { HOME: tmpDir } + ); + assert.ok(result.success, `Failed: ${result.error}`); + return JSON.parse(result.output); + }); + + assert.deepStrictEqual( + fs.readdirSync(ambientConfigDir), + [], + 'a call site that sandboxes HOME must not write into an ambient CLAUDE_CONFIG_DIR' + ); + assert.ok( + !out.profile_path.startsWith(ambientConfigDir), + `profile must not resolve under the ambient config dir, got: ${out.profile_path}` + ); + }); + test('errors when --input is missing', () => { const result = runGsdTools('write-profile --raw', tmpDir); assert.ok(!result.success, 'should fail without --input'); diff --git a/tests/representative-corpus.test.cjs b/tests/representative-corpus.test.cjs index 9f4f2971f..7e537e7bd 100644 --- a/tests/representative-corpus.test.cjs +++ b/tests/representative-corpus.test.cjs @@ -68,6 +68,12 @@ const TEST_ENV_BASE = { ZELLIJ_SESSION_NAME: '', TTY: '', SSH_TTY: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; function runTools(args, cwd) { diff --git a/tests/run-tests-harness.test.cjs b/tests/run-tests-harness.test.cjs index 07a822f64..9b510aab5 100644 --- a/tests/run-tests-harness.test.cjs +++ b/tests/run-tests-harness.test.cjs @@ -1416,6 +1416,12 @@ describe('bug #969 B — runGsdTools kill-signal discrimination', () => { GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '', + // Config-LOCATION vars. Distinct in kind from the session-identity vars + // above: these decide WHERE a child writes, so leaving them ambient lets a + // test that sandboxes HOME still escape into the developer's real config dir. + CLAUDE_CONFIG_DIR: '', + GSD_RUNTIME: '', + CODEX_HOME: '', }; try { let result; From 0f97a26f763de1e352a75f144f46772475271af7 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:00 -0500 Subject: [PATCH 02/47] fix(#2665): derive the config-location scrub set from the capability registry TEST_ENV_BASE listed three config-location vars by hand. The resolver reaches 25: every runtime descriptor's configHome.env, plus GROK_AGENTS_HOME (a hardcoded branch of getGlobalConfigDir), GSD_RUNTIME, and GSD_PROJECT/GSD_WORKSTREAM (planningDir). A hand-written list can only ever be as complete as the author's recall, and every one of those resolvers is env-FIRST, so a missing key is a live escape hatch rather than a cosmetic gap -- which is why this bug has now been diagnosed three times. Derive the set from the same registry the resolver reads. The scrub list becomes structurally incapable of being narrower than the surface it guards: adding a capability that declares a new configHome env var extends it in the same commit. Also export TEST_ENV_BASE (a parity test could not previously import the canonical copy) and add scrubConfigLocationEnv(), the in-process counterpart -- TEST_ENV_BASE only ever reaches child processes. Addresses review findings: Blocker 2, Major 4 (GSD_WORKSTREAM/GSD_PROJECT), Major 5 (XDG_CONFIG_HOME). --- tests/helpers.cjs | 109 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 23 deletions(-) diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 3392ced3b..db83f2779 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -10,28 +10,91 @@ const { createFixture } = require('./fixtures/index.cjs'); const processSeam = require('./helpers/process-seam.cjs'); const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION_ID: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; + +// Session-IDENTITY vars. Blanked so a child cannot inherit the developer's +// terminal/agent session and key shared state off it. +const SESSION_IDENTITY_ENV_KEYS = [ + 'GSD_SESSION_KEY', + 'CODEX_THREAD_ID', + 'CLAUDE_SESSION_ID', + 'CLAUDE_CODE_SSE_PORT', + 'OPENCODE_SESSION_ID', + 'GEMINI_SESSION_ID', + 'CURSOR_SESSION_ID', + 'WINDSURF_SESSION_ID', + 'TERM_SESSION_ID', + 'WT_SESSION', + 'TMUX_PANE', + 'ZELLIJ_SESSION_NAME', + 'TTY', + 'SSH_TTY', +]; + +// Config-LOCATION vars — distinct in kind from the session-identity vars above: +// these decide WHERE a child writes, so leaving one ambient lets a test that +// sandboxes HOME still escape into the developer's real config dir. +// +// #2665: this list is DERIVED, not hand-maintained. A hand-written list is +// exactly what reopened this bug twice — it can only ever be as complete as the +// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a +// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing +// it from the same registry the resolver reads makes the scrub list structurally +// incapable of being narrower than the surface it guards: adding a capability +// that declares a new configHome env var extends this set in the same commit. +const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + +// Config-location vars the registry does NOT carry, each with its reader: +// GROK_AGENTS_HOME — hardcoded `grok` branch in getGlobalConfigDir (src/runtime-homes.cts) +// GSD_RUNTIME — selects WHICH runtime home resolves (src/model-resolver.cts) +// GSD_PROJECT — planningDir() project segment (src/planning-workspace.cts) +// GSD_WORKSTREAM — planningDir() workstream segment (src/planning-workspace.cts) +const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [ + 'GROK_AGENTS_HOME', + 'GSD_RUNTIME', + 'GSD_PROJECT', + 'GSD_WORKSTREAM', +]; + +const CONFIG_LOCATION_ENV_KEYS = [ + ...new Set([ + ...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []), + ...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS, + ]), +].sort(); + +const TEST_ENV_BASE = Object.fromEntries( + [...SESSION_IDENTITY_ENV_KEYS, ...CONFIG_LOCATION_ENV_KEYS].map((k) => [k, '']), +); + +/** + * Save + clear every config-LOCATION env var on THIS process; returns a restorer. + * + * #2665: TEST_ENV_BASE only reaches CHILD processes. A test that calls the real + * installer IN-PROCESS — `install(true, 'claude')` — resolves through the same + * env-first `getGlobalConfigDir`, so an ambient CLAUDE_CONFIG_DIR beats a + * sandboxed `process.env.HOME` and a complete global install (agents/, commands/, + * skills/, gsd-core/, manifest, settings) lands in the developer's live config + * dir. No child-env scrub can reach that call; only clearing the parent's env can. + * + * Pair with a HOME sandbox, not instead of one: HOME covers the home-derived + * fallback, this covers the env-first branch that overrides it. + * + * @returns {() => void} restorer — call in afterEach to put the env back exactly + * as it was (deleting keys that were previously unset, rather than setting ''). + */ +function scrubConfigLocationEnv() { + const saved = {}; + for (const key of CONFIG_LOCATION_ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + return function restoreConfigLocationEnv() { + for (const key of CONFIG_LOCATION_ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }; +} /** * Run gsd-tools command. @@ -762,4 +825,4 @@ function clearSessionEnv() { for (const k of SESSION_ENV_KEYS) delete process.env[k]; } -module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH }; +module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, TEST_ENV_BASE, SESSION_IDENTITY_ENV_KEYS, CONFIG_LOCATION_ENV_KEYS, scrubConfigLocationEnv }; From bc5c36261032441aca474488091ed82d591009e4 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:00 -0500 Subject: [PATCH 03/47] fix(#2665): replace the hand-synced TEST_ENV_BASE copies with the canonical import Eight declarations were kept in sync by hand with no parity assertion. The drift was already in the tree: api-coverage-gate-e2e and representative-corpus declared TERM_SESSION, but the real variable is TERM_SESSION_ID, so both scrubbed nothing for that slot and leaked TERM_SESSION_ID into every child. Deleting the copies removes the dead key with them -- there is no longer a second place to get wrong. run-tests-harness keeps its local session-identity literal: that helper mirrors the production runGsdTools to prove its CONTRACT, so it must not re-import what it is testing. The config-LOCATION keys are a safety scrub rather than part of that contract, so it spreads the canonical derived set and keeps the rest local. Addresses review findings: Blocker 2, Blocker 3. --- tests/agent-skills.test.cjs | 85 +------------------ tests/api-coverage-gate-e2e.test.cjs | 25 +----- .../assumption-delta-checkpoint-e2e.test.cjs | 25 +----- .../check-tdd-review-checkpoint-e2e.test.cjs | 25 +----- tests/config-loader.test.cjs | 25 +----- tests/configuration-migrate-config.test.cjs | 25 +----- tests/representative-corpus.test.cjs | 25 +----- tests/run-tests-harness.test.cjs | 15 ++-- 8 files changed, 15 insertions(+), 235 deletions(-) diff --git a/tests/agent-skills.test.cjs b/tests/agent-skills.test.cjs index 8a78e8b9d..ac2c52eae 100644 --- a/tests/agent-skills.test.cjs +++ b/tests/agent-skills.test.cjs @@ -16,32 +16,9 @@ const { spawnSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); +const { runGsdTools, createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs'); const { runNode } = require('./helpers/process-seam.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION_ID: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - GSD_WORKSTREAM: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; /** * Run gsd-tools and capture BOTH stdout and stderr on success. @@ -72,13 +49,6 @@ function readConfig(tmpDir) { return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } -function markLocalGsdInstall(tmpDir) { - fs.writeFileSync( - path.join(tmpDir, '.codex', 'gsd-file-manifest.json'), - JSON.stringify({ files: {} }), - ); -} - // Run agent-skills with --json for typed IR assertions function runAgentSkillsJson(args, tmpDir, env) { // Insert --json after 'agent-skills' subcommand @@ -136,59 +106,6 @@ describe('agent-skills command', () => { assert.strictEqual(r.ir.block, ''); }); - test('unconfigured Codex reads its local companion agent from a descendant cwd', () => { - const agentsDir = path.join(tmpDir, '.codex', 'agents'); - const descendant = path.join(tmpDir, 'src', 'feature'); - const localPersona = '# Local Codex executor\nUse the project-local agent.\n'; - fs.mkdirSync(agentsDir, { recursive: true }); - fs.mkdirSync(descendant, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), localPersona); - markLocalGsdInstall(tmpDir); - writeConfig(tmpDir, { runtime: 'codex' }); - - const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], descendant, { - HOME: tmpDir, - USERPROFILE: tmpDir, - CODEX_HOME: path.join(tmpDir, 'global-codex'), - GSD_RUNTIME: '', - }); - assert.ok(r.success, `Command failed: ${r.error}`); - assert.strictEqual(r.ir.block, localPersona); - }); - - test('workstream runtime selects the local Codex companion when root config differs', () => { - const agentsDir = path.join(tmpDir, '.codex', 'agents'); - const localPersona = '# Local Codex executor\nUse the overridden runtime.\n'; - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), localPersona); - markLocalGsdInstall(tmpDir); - writeConfig(tmpDir, { runtime: 'claude' }); - const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'feature-x'); - fs.mkdirSync(workstreamDir, { recursive: true }); - fs.writeFileSync(path.join(workstreamDir, 'config.json'), JSON.stringify({ runtime: 'codex' })); - - const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir, { - HOME: tmpDir, - USERPROFILE: tmpDir, - CODEX_HOME: path.join(tmpDir, 'global-codex'), - GSD_RUNTIME: '', - GSD_WORKSTREAM: 'feature-x', - }); - assert.ok(r.success, `Command failed: ${r.error}`); - assert.strictEqual(r.ir.block, localPersona); - }); - - test('unconfigured Claude remains empty when a local Codex companion exists', () => { - const agentsDir = path.join(tmpDir, '.codex', 'agents'); - fs.mkdirSync(agentsDir, { recursive: true }); - fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), '# Local Codex executor\n'); - writeConfig(tmpDir, { runtime: 'claude' }); - - const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir, { GSD_RUNTIME: 'claude' }); - assert.ok(r.success, `Command failed: ${r.error}`); - assert.strictEqual(r.ir.block, ''); - }); - test('returns block containing agent_skills XML for configured agent', () => { const skillDir = path.join(tmpDir, 'skills', 'test-skill'); fs.mkdirSync(skillDir, { recursive: true }); diff --git a/tests/api-coverage-gate-e2e.test.cjs b/tests/api-coverage-gate-e2e.test.cjs index 0407e8a81..6dee682f0 100644 --- a/tests/api-coverage-gate-e2e.test.cjs +++ b/tests/api-coverage-gate-e2e.test.cjs @@ -20,7 +20,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs'); const { runNode, OUTCOME } = require('./helpers/process-seam.cjs'); // In-process seam for the fail-closed read-injection tests at the bottom of this // file (#2365 review): readPhaseScope is the pure phase-scope reader behind the @@ -29,29 +29,6 @@ const { readPhaseScope } = require('../gsd-core/bin/lib/check-command-router.cjs const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; - function runTools(args, cwd) { const argv = Array.isArray(args) ? args diff --git a/tests/assumption-delta-checkpoint-e2e.test.cjs b/tests/assumption-delta-checkpoint-e2e.test.cjs index da57a3364..b19895d7d 100644 --- a/tests/assumption-delta-checkpoint-e2e.test.cjs +++ b/tests/assumption-delta-checkpoint-e2e.test.cjs @@ -21,33 +21,10 @@ const os = require('node:os'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs'); const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION_ID: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; - function runTools(args, cwd) { const argv = Array.isArray(args) ? args diff --git a/tests/check-tdd-review-checkpoint-e2e.test.cjs b/tests/check-tdd-review-checkpoint-e2e.test.cjs index 3a2a895b4..8ea2149da 100644 --- a/tests/check-tdd-review-checkpoint-e2e.test.cjs +++ b/tests/check-tdd-review-checkpoint-e2e.test.cjs @@ -30,7 +30,7 @@ const os = require('node:os'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); @@ -136,29 +136,6 @@ function commitFile(git, tmpDir, filename, commitMessage) { // ─── Helpers for subprocess invocation ──────────────────────────────────────── -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION_ID: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; - function runTools(args, cwd) { const argv = Array.isArray(args) ? args diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs index 1e41ffe4d..8bf697425 100644 --- a/tests/config-loader.test.cjs +++ b/tests/config-loader.test.cjs @@ -740,33 +740,10 @@ const { describe, test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); +const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs'); const { runNode } = require('./helpers/process-seam.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION_ID: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; - /** * Run gsd-tools and return { stdout, stderr, status }. * Always captures stderr even when exit code is 0. diff --git a/tests/configuration-migrate-config.test.cjs b/tests/configuration-migrate-config.test.cjs index efe602ba0..964d92234 100644 --- a/tests/configuration-migrate-config.test.cjs +++ b/tests/configuration-migrate-config.test.cjs @@ -15,33 +15,10 @@ const { describe, test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); +const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE } = require('./helpers.cjs'); const { runNode } = require('./helpers/process-seam.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION_ID: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; - function runMigrateConfig(cwd, extraArgs = [], env = {}) { const result = runNode([TOOLS_PATH, 'migrate-config', ...extraArgs], { cwd, diff --git a/tests/representative-corpus.test.cjs b/tests/representative-corpus.test.cjs index 7e537e7bd..573bb2b17 100644 --- a/tests/representative-corpus.test.cjs +++ b/tests/representative-corpus.test.cjs @@ -48,34 +48,11 @@ const os = require('node:os'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs'); const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); const FIXTURES_ROOT = path.join(__dirname, 'fixtures', 'representative'); -const TEST_ENV_BASE = { - GSD_SESSION_KEY: '', - CODEX_THREAD_ID: '', - CLAUDE_SESSION_ID: '', - CLAUDE_CODE_SSE_PORT: '', - OPENCODE_SESSION_ID: '', - GEMINI_SESSION_ID: '', - CURSOR_SESSION_ID: '', - WINDSURF_SESSION_ID: '', - TERM_SESSION: '', - WT_SESSION: '', - TMUX_PANE: '', - ZELLIJ_SESSION_NAME: '', - TTY: '', - SSH_TTY: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', -}; - function runTools(args, cwd) { try { const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...args], { diff --git a/tests/run-tests-harness.test.cjs b/tests/run-tests-harness.test.cjs index 9b510aab5..4cb8a6bb7 100644 --- a/tests/run-tests-harness.test.cjs +++ b/tests/run-tests-harness.test.cjs @@ -22,7 +22,7 @@ const path = require('path'); const { runNode } = require('./helpers/process-seam.cjs'); const { toLegacyResult } = require('./helpers/git-fixture.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, CONFIG_LOCATION_ENV_KEYS } = require('./helpers.cjs'); const HARNESS = path.join(__dirname, '..', 'scripts', 'run-tests.cjs'); @@ -1412,16 +1412,17 @@ describe('bug #969 B — runGsdTools kill-signal discrimination', () => { * We test the identical logic paths using a tiny timeout. */ function runGsdToolsWithTimeout(args, cwd, env, timeoutMs) { + // The session-identity subset stays a local literal on purpose: this helper + // mirrors the production one to prove its CONTRACT, so it must not simply + // re-import what it is testing. The config-LOCATION keys are the exception — + // they are a safety scrub rather than part of the contract under test, and a + // hand-copied list of them is the #2665 drift this change exists to end. So + // spread the canonical derived set (tests/helpers.cjs) and keep the rest local. const TEST_ENV_BASE = { GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '', - // Config-LOCATION vars. Distinct in kind from the session-identity vars - // above: these decide WHERE a child writes, so leaving them ambient lets a - // test that sandboxes HOME still escape into the developer's real config dir. - CLAUDE_CONFIG_DIR: '', - GSD_RUNTIME: '', - CODEX_HOME: '', + ...Object.fromEntries(CONFIG_LOCATION_ENV_KEYS.map((k) => [k, ''])), }; try { let result; From 466db2f1a39ce046e943ad0092cb964929078eae Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:16 -0500 Subject: [PATCH 04/47] fix(#2665): scrub config-location env on the parent for in-process install() The blocker the review said decides this PR. These tests call the real installer IN-PROCESS with only HOME/USERPROFILE sandboxed. getGlobalConfigDir is env-first, so an ambient CLAUDE_CONFIG_DIR beats the sandbox and a complete global install -- agents/, commands/, skills/, gsd-core/, manifest, settings -- lands in the developer's live config dir. No child-env scrub can reach it; only clearing the parent's env can. The review named two describe blocks in install.test.cjs. A sweep for the shape found four there (bug #3571 and bug #3288 each appear twice in the file), and the post-suite guard added later in this series found a fifth in runtime-artifact-layout.test.cjs, which the review did not name. All five now save/clear/restore via scrubConfigLocationEnv(). Verified: codebuddy-install, cline-install and codex-config already guard their own config-location var around in-process install(), so the class is closed. Addresses review finding: Blocker 1. --- tests/install.test.cjs | 40 +++++++++++++++++++++++--- tests/runtime-artifact-layout.test.cjs | 10 ++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/tests/install.test.cjs b/tests/install.test.cjs index df4721bf3..670ab886e 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -3825,7 +3825,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared'); const { install } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); const makeTmpDir = () => createTempDir('gsd-3571-'); function silenceConsole(fn) { @@ -3851,6 +3851,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout let savedHome; let savedUserProfile; let savedExplicitConfigDir; + let restoreConfigLocationEnv; beforeEach(() => { tmpRoot = makeTmpDir(); @@ -3859,6 +3860,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout savedUserProfile = process.env.USERPROFILE; savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; delete process.env.GSD_EXPLICIT_CONFIG_DIR; + // #2665: this block calls the real installer IN-PROCESS with only HOME + // sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR + // (or CODEX_HOME, or any other runtime's config-location var) overrides that + // sandbox and a complete global install lands in the developer's live config + // dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env. + restoreConfigLocationEnv = scrubConfigLocationEnv(); }); afterEach(() => { @@ -3870,6 +3877,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout } else { process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; } + restoreConfigLocationEnv(); cleanup(tmpRoot); }); @@ -3982,7 +3990,7 @@ const { install } = require('../bin/install.js'); // ─── helpers ───────────────────────────────────────────────────────────────── -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); const makeTmpDir = createTempDir; const rmTmpDir = cleanup; @@ -4024,6 +4032,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => { let savedHome; let savedUserProfile; let savedExplicitConfigDir; + let restoreConfigLocationEnv; beforeEach(() => { tmpRoot = makeTmpDir('gsd-3288-'); @@ -4038,6 +4047,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => { // and target a different directory than tmpRoot (CR finding, PR #3293). savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; delete process.env.GSD_EXPLICIT_CONFIG_DIR; + // #2665: this block calls the real installer IN-PROCESS with only HOME + // sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR + // (or CODEX_HOME, or any other runtime's config-location var) overrides that + // sandbox and a complete global install lands in the developer's live config + // dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env. + restoreConfigLocationEnv = scrubConfigLocationEnv(); }); afterEach(() => { @@ -4049,6 +4064,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => { } else { process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; } + restoreConfigLocationEnv(); rmTmpDir(tmpRoot); }); @@ -7871,7 +7887,7 @@ const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared'); const { install } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); const makeTmpDir = () => createTempDir('gsd-3571-'); function silenceConsole(fn) { @@ -7897,6 +7913,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout let savedHome; let savedUserProfile; let savedExplicitConfigDir; + let restoreConfigLocationEnv; beforeEach(() => { tmpRoot = makeTmpDir(); @@ -7905,6 +7922,12 @@ describe('bug #3571: configuration generated manifests resolve in install layout savedUserProfile = process.env.USERPROFILE; savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; delete process.env.GSD_EXPLICIT_CONFIG_DIR; + // #2665: this block calls the real installer IN-PROCESS with only HOME + // sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR + // (or CODEX_HOME, or any other runtime's config-location var) overrides that + // sandbox and a complete global install lands in the developer's live config + // dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env. + restoreConfigLocationEnv = scrubConfigLocationEnv(); }); afterEach(() => { @@ -7916,6 +7939,7 @@ describe('bug #3571: configuration generated manifests resolve in install layout } else { process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; } + restoreConfigLocationEnv(); cleanup(tmpRoot); }); @@ -8028,7 +8052,7 @@ const { install } = require('../bin/install.js'); // ─── helpers ───────────────────────────────────────────────────────────────── -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); const makeTmpDir = createTempDir; const rmTmpDir = cleanup; @@ -8070,6 +8094,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => { let savedHome; let savedUserProfile; let savedExplicitConfigDir; + let restoreConfigLocationEnv; beforeEach(() => { tmpRoot = makeTmpDir('gsd-3288-'); @@ -8084,6 +8109,12 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => { // and target a different directory than tmpRoot (CR finding, PR #3293). savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR; delete process.env.GSD_EXPLICIT_CONFIG_DIR; + // #2665: this block calls the real installer IN-PROCESS with only HOME + // sandboxed. getGlobalConfigDir is env-FIRST, so an ambient CLAUDE_CONFIG_DIR + // (or CODEX_HOME, or any other runtime's config-location var) overrides that + // sandbox and a complete global install lands in the developer's live config + // dir. TEST_ENV_BASE cannot reach this — it only scrubs CHILD process env. + restoreConfigLocationEnv = scrubConfigLocationEnv(); }); afterEach(() => { @@ -8095,6 +8126,7 @@ describe('bug #3288: model-catalog.cjs install-layout resolution', () => { } else { process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; } + restoreConfigLocationEnv(); rmTmpDir(tmpRoot); }); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 4aa19de92..caae32441 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -26,7 +26,7 @@ const { resolveRuntimeArtifactLayout, findInstallSourceRoot } = require('../gsd- const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs'); const { install } = require('../bin/install.js'); -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); const REPO_ROOT = path.join(__dirname, '..'); @@ -687,6 +687,7 @@ describe('#1477 .gsd-source marker provisioning', () => { let savedUserProfile; let savedExplicitConfigDir; let savedTestMode; + let restoreConfigLocationEnv; function silenceConsole(fn) { const orig = { log: console.log, warn: console.warn, error: console.error }; @@ -732,6 +733,12 @@ describe('#1477 .gsd-source marker provisioning', () => { delete process.env.GSD_EXPLICIT_CONFIG_DIR; savedTestMode = process.env.GSD_TEST_MODE; process.env.GSD_TEST_MODE = '1'; + // #2665: this block calls install(true, 'claude') IN-PROCESS. Redirecting + // HOME/USERPROFILE is not enough, because getGlobalConfigDir is env-FIRST: + // an ambient CLAUDE_CONFIG_DIR wins over the fixture and a full global + // install lands in the developer's live config dir. Found by the post-suite + // hermeticity guard (scripts/live-config-guard.cjs), not by inspection. + restoreConfigLocationEnv = scrubConfigLocationEnv(); }); afterEach(() => { @@ -743,6 +750,7 @@ describe('#1477 .gsd-source marker provisioning', () => { else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE; else process.env.GSD_TEST_MODE = savedTestMode; + restoreConfigLocationEnv(); cleanup(tmpRoot); }); From 36f4f9479ad9b8ce4ab69c10cfe93f28f26378b5 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:16 -0500 Subject: [PATCH 05/47] fix(#2665): scrub config-location env on raw spawns that sandbox only HOME Same class as the in-process leak, on the child-spawn side. These call spawnSync directly rather than through runGsdTools, so TEST_ENV_BASE never applies to them and an ambient config-location var survives into the child. - install-runtime-artifacts: the dev-preferences writer resolved env-first and wrote SKILL.md into the live config dir instead of its tmp HOME. This also fixes a test that FAILS today on any machine with CLAUDE_CONFIG_DIR set. - issue-766: the `claude` CLI is third-party and bootstraps its own config into whatever CLAUDE_CONFIG_DIR names, so even a bare --version probe wrote there. It gets an explicit throwaway dir rather than a blank value -- GSD's resolvers treat '' as falsy and fall back to the home dir, but a third-party binary offers no such guarantee (blanking it produced a stray backups/ in the repo root). These two were the last writers standing between the suite and #2665's stated acceptance criterion. --- tests/install-runtime-artifacts.test.cjs | 9 +++++-- tests/issue-766-plugin-manifest.test.cjs | 32 ++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 310e55e8e..a41ba1d11 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -4646,7 +4646,7 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs'); const ROOT = path.join(__dirname, '..'); const PROFILE_OUTPUT = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'profile-output.cjs'); @@ -4672,7 +4672,12 @@ describe('Bug #2973: dev-preferences default writer path is skills/gsd-dev-prefe m.cmdGenerateDevPreferences(${JSON.stringify(tmpHome)}, { analysis: ${JSON.stringify(analysisPath)} }, false); `); const result = cp.spawnSync(process.execPath, [driver], { - env: Object.assign({}, process.env, { HOME: tmpHome, USERPROFILE: tmpHome }), + // #2665: TEST_ENV_BASE must be merged in explicitly here. This is a RAW + // spawn, not runGsdTools, so nothing scrubs the config-location vars for + // it -- and the writer under test resolves them env-FIRST. Sandboxing + // HOME alone let an ambient CLAUDE_CONFIG_DIR win, and the SKILL.md + // landed in the developer's live config dir instead of tmpHome. + env: Object.assign({}, process.env, TEST_ENV_BASE, { HOME: tmpHome, USERPROFILE: tmpHome }), encoding: 'utf-8', // Bound the subprocess so a regression that hangs the writer // (or the dispatcher) cannot deadlock CI (PR #3003 CR feedback). diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/issue-766-plugin-manifest.test.cjs index 329e29a9f..1a78e243c 100644 --- a/tests/issue-766-plugin-manifest.test.cjs +++ b/tests/issue-766-plugin-manifest.test.cjs @@ -24,7 +24,7 @@ const ROOT = path.resolve(__dirname, '..'); const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs')); const pkg = require(path.join(ROOT, 'package.json')); const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs')); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, TEST_ENV_BASE } = require('./helpers.cjs'); const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json'); const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json'); @@ -359,9 +359,36 @@ describe('C: plugin.json schema validation', () => { // ── C2: Opportunistic CLI integration (skipped when claude not on PATH) ────── + // #2665: the `claude` CLI is a THIRD-PARTY binary that bootstraps its own + // config (.claude.json plus a backups/ dir) into whatever CLAUDE_CONFIG_DIR + // names. Two things follow, and only the first is obvious: + // + // 1. Inheriting the developer's ambient CLAUDE_CONFIG_DIR makes even a bare + // `--version` probe write into their live config dir. That alone kept + // `CLAUDE_CONFIG_DIR= npm run test:unit; find -type f` from + // returning empty after every GSD-side leak was closed. + // 2. BLANKING it (TEST_ENV_BASE's '' convention) is not enough here. GSD's + // own resolvers treat '' as falsy and fall back to the home dir, but this + // binary is not ours and gives no such guarantee -- blanking it produced a + // stray backups/ directory in the REPO ROOT. + // + // So point it at a real throwaway dir rather than at nothing. + const claudeCliHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2665-claude-cli-')); + const claudeCliEnv = () => ({ + ...process.env, + ...TEST_ENV_BASE, + HOME: claudeCliHome, + USERPROFILE: claudeCliHome, + CLAUDE_CONFIG_DIR: path.join(claudeCliHome, '.claude'), + }); + const claudeAvailable = (() => { try { - const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 }); + const result = spawnSync('claude', ['--version'], { + encoding: 'utf-8', + timeout: 5000, + env: claudeCliEnv(), + }); return result.status === 0; } catch (_) { return false; @@ -384,6 +411,7 @@ describe('C: plugin.json schema validation', () => { cwd: ROOT, encoding: 'utf-8', timeout: 15000, + env: claudeCliEnv(), }); assert.equal( result.status, From a02462e050b5203d8358733e4d78f52546e5f424 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:32 -0500 Subject: [PATCH 06/47] test(#2665): fail the suite when it writes into a live config dir The recurrence guard, and #2665's own "Optional hardening". This class is silent by construction: TEST_ENV_BASE cannot see an in-process caller, and CI cannot see the class at all because CI never has these env vars set. It damages the developer's machine and reports nothing -- which is how two prior authors each diagnosed it and fixed only the instance in front of them. run-tests.cjs snapshots GSD's install footprint in every live runtime config dir before the suite and re-checks it after, failing the run on a create or a modify. Roots come from the product's own getGlobalConfigDir, so the guard watches wherever the product actually points, including through an ambient var. Scope is ownership-based, not whole-root: the top-level install footprint plus gsd-prefixed children of dirs GSD shares with the host agent. A config root like ~/.claude is shared, and watching it wholesale would false-positive on the host's own history.jsonl or settings.json -- a guard that cries wolf gets disabled, and then catches nothing. The prefix test is load-bearing: the first version watched only the three top-level entries and MISSED a real leak into skills/gsd-*. It earned its place immediately -- it is what found the fifth in-process leak in runtime-artifact-layout.test.cjs, which no amount of reading the review would have surfaced. Known gap documented in the module: a write to a file GSD does not own is out of scope by construction. Lives in scripts/, deliberately NOT scripts/lib/ -- the installer copies that dir into every user's config dir wholesale while uninstall removes only an allowlist, so a test-only module there would ship to users and survive uninstall. Addresses review finding: Minor 8. --- scripts/live-config-guard.cjs | 245 +++++++++++++++++++++++++++++++ scripts/run-tests.cjs | 29 ++++ tests/live-config-guard.test.cjs | 160 ++++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 scripts/live-config-guard.cjs create mode 100644 tests/live-config-guard.test.cjs diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs new file mode 100644 index 000000000..74a83c433 --- /dev/null +++ b/scripts/live-config-guard.cjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node +'use strict'; + +/** + * #2665 — post-suite hermeticity guard. + * + * The test suite must never write into a runtime's LIVE config directory. Two + * mechanisms defend that, and neither one can see this failure: + * + * - `TEST_ENV_BASE` (tests/helpers.cjs) blanks every config-location env var, + * but only for CHILD processes. A test that calls the installer IN-PROCESS + * is untouched by it. + * - CI is blind to the ambient-env half of the class outright, because CI + * never has `CLAUDE_CONFIG_DIR` and friends set. + * + * So the class is silent by construction: it damages the developer's machine and + * reports nothing. #2665 records two prior authors each diagnosing it and fixing + * only the instance in front of them. This module converts it from silent to + * loud by snapshotting GSD's own install footprint before the suite and + * re-checking it after. + * + * LOCATION — `scripts/`, deliberately NOT `scripts/lib/`. The installer copies + * `scripts/lib/` into every user's config dir wholesale (readdirSync), while + * uninstall removes only an explicit allowlist, so a test-only module placed + * there would ship to users AND survive uninstall. `scripts/affected-tests-lib.cjs` + * is the precedent for a non-shipped helper at this level. + * + * SCOPE — ownership-based, not whole-root. It watches entries GSD unambiguously + * owns: the top-level install footprint (`GSD_OWNED_ENTRIES`) plus `gsd-`-prefixed + * children of the dirs GSD shares with the host agent (`GSD_PREFIXED_PARENTS`). + * It does NOT watch whole config roots. A root such as `~/.claude` is shared with + * the host agent, which may legitimately write `history.jsonl`, `todos/`, or + * `settings.json` while the suite runs; watching the root would turn that into a + * false failure, and a guard that cries wolf gets disabled — after which it + * catches nothing at all. + * + * The ownership test is the prefix, not the location. That distinction is load + * bearing: the first version of this guard watched only the three top-level + * entries and MISSED a real leak into `/skills/gsd-dev-preferences/`. + * + * KNOWN GAP — a leak into a file GSD does not own (e.g. mutating the host's own + * `.claude.json`) is outside this guard by construction. Closing it would require + * watching shared files, which is the false-positive trap above. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +/** Top-level entries only a GSD install creates. See SCOPE above before widening. */ +const GSD_OWNED_ENTRIES = ['gsd-core', 'gsd-file-manifest.json', 'gsd-pristine']; + +/** + * Directories GSD SHARES with the host agent. Watching them wholesale would + * false-positive on the host's own writes, so only `gsd-`-prefixed children are + * watched — those are unambiguously ours. + * + * Added after the first version of this guard MISSED a real leak: a raw + * `spawnSync` that sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR + * wrote `/skills/gsd-dev-preferences/SKILL.md`, which sits under none of + * the three top-level entries above. + */ +const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills']; +const GSD_ARTIFACT_PREFIX = 'gsd-'; + +/** Bounds on the recursive walk, so a pathological tree cannot stall the suite. */ +const MAX_ENTRIES = 20000; +const MAX_DEPTH = 12; + +/** + * Resolve every runtime config root the product could write to, using the REAL + * resolver rather than a reimplementation — the guard must watch wherever the + * product actually points, including through an ambient env var. + * + * @returns {string[]} deduped, sorted roots; empty if the built lib is absent. + */ +function resolveLiveConfigRoots(deps = {}) { + const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'); + let getGlobalConfigDir; + let runtimes; + try { + ({ getGlobalConfigDir } = require(path.join(libDir, 'runtime-homes.cjs'))); + ({ runtimes } = require(path.join(libDir, 'capability-registry.cjs'))); + } catch { + // Unbuilt tree: the guard is advisory infrastructure and must never be the + // reason a test run cannot start. Callers treat [] as "guard unavailable". + return []; + } + + const roots = new Set(); + // 'grok' is a hardcoded branch of getGlobalConfigDir with no registry entry. + for (const runtime of [...Object.keys(runtimes || {}), 'grok']) { + try { + const dir = getGlobalConfigDir(runtime); + if (typeof dir === 'string' && dir.length > 0) roots.add(path.resolve(dir)); + } catch { + // A descriptor the resolver cannot satisfy is not this module's problem. + } + } + return [...roots].sort(); +} + +/** + * Newest mtime within a tree, bounded. Returns `truncated: true` when a bound + * was hit — the caller must NOT report such a result as clean, on the same + * principle that an existence probe passing vacuously is worse than no probe. + */ +function newestMtime(target, budget) { + let newest = 0; + let truncated = false; + + const walk = (current, depth) => { + if (budget.remaining <= 0) { truncated = true; return; } + if (depth > MAX_DEPTH) { truncated = true; return; } + let st; + try { + st = fs.lstatSync(current); + } catch { + return; + } + budget.remaining -= 1; + if (st.mtimeMs > newest) newest = st.mtimeMs; + if (!st.isDirectory()) return; + let entries; + try { + entries = fs.readdirSync(current); + } catch { + return; + } + for (const entry of entries) walk(path.join(current, entry), depth + 1); + }; + + walk(target, 0); + return { newest, truncated }; +} + +/** + * Snapshot GSD-owned entries under each root. + * + * @returns {Record} + * keyed by absolute entry path. + */ +function snapshotLiveConfig(roots) { + const budget = { remaining: MAX_ENTRIES }; + const snap = {}; + + const record = (target) => { + if (!fs.existsSync(target)) { + snap[target] = { exists: false, newest: 0, truncated: false }; + return; + } + const { newest, truncated } = newestMtime(target, budget); + snap[target] = { exists: true, newest, truncated }; + }; + + for (const root of roots) { + for (const entry of GSD_OWNED_ENTRIES) record(path.join(root, entry)); + + // Shared dirs: enumerate only gsd-prefixed children. A child that appears + // between the two snapshots is absent from `before` entirely — diffLiveConfig + // treats after-only paths as created, which is exactly the leak signal. + for (const parent of GSD_PREFIXED_PARENTS) { + const parentDir = path.join(root, parent); + let children; + try { + children = fs.readdirSync(parentDir); + } catch { + continue; // parent absent — nothing of ours can be in it yet + } + for (const child of children) { + if (child.startsWith(GSD_ARTIFACT_PREFIX)) record(path.join(parentDir, child)); + } + } + } + return snap; +} + +/** + * Compare two snapshots. A path is a violation when it was created during the + * run, or when its newest mtime advanced. + * + * @returns {{path: string, kind: 'created'|'modified'|'unverified'}[]} + */ +function diffLiveConfig(before, after) { + const violations = []; + for (const [target, post] of Object.entries(after)) { + const pre = before[target]; + // Absent from `before` entirely: a gsd-prefixed child that did not exist + // when the run started. Both snapshots cover the same roots, so an + // after-only path was created BY the run — never skip it. + if (!pre) { + if (post.exists) violations.push({ path: target, kind: 'created' }); + continue; + } + if (!pre.exists && post.exists) { + violations.push({ path: target, kind: 'created' }); + } else if (pre.exists && post.exists && post.newest > pre.newest) { + violations.push({ path: target, kind: 'modified' }); + } else if (pre.truncated || post.truncated) { + // Bound hit: we cannot attest this path either way, and saying nothing + // would let a truncated scan read as a clean one. + violations.push({ path: target, kind: 'unverified' }); + } + } + return violations; +} + +/** Human-facing report for a non-empty violation set. */ +function formatViolations(violations) { + const lines = [ + '', + 'run-tests: HERMETICITY FAILURE — the suite wrote into a LIVE config directory.', + '', + 'A test resolved a runtime config dir from the ambient environment instead of a', + 'sandbox. The usual cause is an IN-PROCESS install() call: tests/helpers.cjs', + 'TEST_ENV_BASE only scrubs CHILD process env, so an in-process caller must also', + 'use scrubConfigLocationEnv() (see tests/install.test.cjs) alongside its HOME', + 'sandbox. CI cannot catch this class — it never has these env vars set.', + '', + ]; + for (const v of violations) { + const label = v.kind === 'unverified' + ? 'UNVERIFIED (scan bound hit — not attested clean)' + : v.kind.toUpperCase(); + lines.push(` ${label}: ${v.path}`); + } + lines.push(''); + lines.push('Set GSD_SKIP_LIVE_CONFIG_GUARD=1 to bypass (intentionally loud).'); + lines.push(''); + return lines.join('\n'); +} + +module.exports = { + GSD_OWNED_ENTRIES, + GSD_PREFIXED_PARENTS, + GSD_ARTIFACT_PREFIX, + MAX_ENTRIES, + MAX_DEPTH, + resolveLiveConfigRoots, + snapshotLiveConfig, + diffLiveConfig, + formatViolations, + newestMtime, + os, // exported for test seams only +}; diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 4d9912127..e2eef2ec8 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -39,6 +39,12 @@ const { readdirSync, readFileSync } = require('fs'); const { join, basename } = require('path'); const { execFileSync } = require('child_process'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); +const { + resolveLiveConfigRoots, + snapshotLiveConfig, + diffLiveConfig, + formatViolations, +} = require('./live-config-guard.cjs'); const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow', 'qa']; @@ -965,6 +971,18 @@ function main() { // job cap. Operator/test override via RUN_TESTS_CHUNK_TIMEOUT_MS. const chunkTimeoutMs = positiveNumberEnv(process.env.RUN_TESTS_CHUNK_TIMEOUT_MS, 600000); + // #2665: snapshot GSD's install footprint in every LIVE runtime config dir + // before a single test runs. The suite must not write there; the check after + // the chunk loop is what makes a violation loud instead of silent. See + // scripts/lib/live-config-guard.cjs for why the scope is narrow. + const liveConfigGuardEnabled = process.env.GSD_SKIP_LIVE_CONFIG_GUARD !== '1'; + let liveConfigRoots = []; + let liveConfigBefore = null; + if (liveConfigGuardEnabled) { + liveConfigRoots = resolveLiveConfigRoots(); + if (liveConfigRoots.length > 0) liveConfigBefore = snapshotLiveConfig(liveConfigRoots); + } + let firstFailureExit = 0; for (let i = 0; i < chunks.length; i++) { if (chunks.length > 1) { @@ -1030,6 +1048,17 @@ function main() { // and the first non-zero exit is reported at the end. } } + // #2665: post-suite hermeticity check. Runs even when tests failed — a leaked + // global install is worth reporting alongside the failure that hid it, and + // suppressing it on red would hide it exactly when the suite is least trusted. + if (liveConfigBefore) { + const violations = diffLiveConfig(liveConfigBefore, snapshotLiveConfig(liveConfigRoots)); + if (violations.length > 0) { + console.error(formatViolations(violations)); + if (firstFailureExit === 0) firstFailureExit = 1; + } + } + if (firstFailureExit !== 0) return firstFailureExit; } diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs new file mode 100644 index 000000000..b942342b6 --- /dev/null +++ b/tests/live-config-guard.test.cjs @@ -0,0 +1,160 @@ +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + GSD_OWNED_ENTRIES, + resolveLiveConfigRoots, + snapshotLiveConfig, + diffLiveConfig, + formatViolations, +} = require('../scripts/live-config-guard.cjs'); + +const { cleanup } = require('./helpers.cjs'); + +function tmpRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'live-config-guard-')); +} + +describe('#2665: live-config hermeticity guard', () => { + test('resolves real runtime config roots via the product resolver', () => { + const roots = resolveLiveConfigRoots(); + // Guards the guard: an empty set would make every downstream assertion + // vacuous, and run-tests.cjs would silently skip the check. + assert.ok(roots.length > 5, `expected many runtime config roots, got ${roots.length}`); + for (const root of roots) { + assert.ok(path.isAbsolute(root), `root must be absolute: ${root}`); + } + }); + + test('a clean run produces no violations', () => { + const root = tmpRoot(); + try { + fs.mkdirSync(path.join(root, 'gsd-core', 'bin'), { recursive: true }); + fs.writeFileSync(path.join(root, 'gsd-core', 'bin', 'x.cjs'), 'x'); + + const before = snapshotLiveConfig([root]); + const after = snapshotLiveConfig([root]); + assert.deepStrictEqual(diffLiveConfig(before, after), []); + } finally { + cleanup(root); + } + }); + + test('detects a global install CREATED during the run', () => { + const root = tmpRoot(); + try { + const before = snapshotLiveConfig([root]); + // Exactly the Blocker 1 shape: an in-process install(true, …) landing a + // full global install in a live config dir that was previously empty. + fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true }); + fs.writeFileSync(path.join(root, 'gsd-file-manifest.json'), '{}'); + + const violations = diffLiveConfig(before, snapshotLiveConfig([root])); + const kinds = Object.fromEntries(violations.map((v) => [path.basename(v.path), v.kind])); + assert.strictEqual(kinds['gsd-core'], 'created'); + assert.strictEqual(kinds['gsd-file-manifest.json'], 'created'); + } finally { + cleanup(root); + } + }); + + test('detects an existing install MODIFIED during the run', () => { + const root = tmpRoot(); + try { + const target = path.join(root, 'gsd-core', 'bin'); + fs.mkdirSync(target, { recursive: true }); + const file = path.join(target, 'gsd-tools.cjs'); + fs.writeFileSync(file, 'original'); + + const before = snapshotLiveConfig([root]); + // mtime resolution is coarse on some filesystems; set it forward explicitly + // rather than racing the clock with a sleep. + const future = new Date(Date.now() + 10000); + fs.writeFileSync(file, 'clobbered'); + fs.utimesSync(file, future, future); + + const violations = diffLiveConfig(before, snapshotLiveConfig([root])); + assert.strictEqual(violations.length, 1); + assert.strictEqual(violations[0].kind, 'modified'); + assert.strictEqual(path.basename(violations[0].path), 'gsd-core'); + } finally { + cleanup(root); + } + }); + + test('ignores non-GSD writes in a shared config root', () => { + const root = tmpRoot(); + try { + const before = snapshotLiveConfig([root]); + // A concurrent host-agent session writing its own state must NOT trip the + // guard — a guard that cries wolf gets disabled, and then catches nothing. + fs.writeFileSync(path.join(root, 'history.jsonl'), '{}'); + fs.mkdirSync(path.join(root, 'todos'), { recursive: true }); + fs.writeFileSync(path.join(root, 'settings.json'), '{}'); + + assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []); + } finally { + cleanup(root); + } + }); + + test('watches exactly the GSD-owned entry set', () => { + const root = tmpRoot(); + try { + const snap = snapshotLiveConfig([root]); + const watched = Object.keys(snap).map((p) => path.basename(p)).sort(); + assert.deepStrictEqual(watched, [...GSD_OWNED_ENTRIES].sort()); + } finally { + cleanup(root); + } + }); + + test('detects a gsd-prefixed artifact written into a SHARED dir', () => { + const root = tmpRoot(); + try { + fs.mkdirSync(path.join(root, 'skills'), { recursive: true }); + const before = snapshotLiveConfig([root]); + // The exact leak the first version of this guard MISSED: a writer that + // sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR landed + // /skills/gsd-dev-preferences/SKILL.md, outside the three + // top-level GSD entries. + fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true }); + fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x'); + + const violations = diffLiveConfig(before, snapshotLiveConfig([root])); + assert.strictEqual(violations.length, 1); + assert.strictEqual(violations[0].kind, 'created'); + assert.strictEqual(path.basename(violations[0].path), 'gsd-dev-preferences'); + } finally { + cleanup(root); + } + }); + + test('ignores NON-gsd artifacts in a shared dir', () => { + const root = tmpRoot(); + try { + fs.mkdirSync(path.join(root, 'skills'), { recursive: true }); + const before = snapshotLiveConfig([root]); + // The host agent's own skills must not trip the guard. + fs.mkdirSync(path.join(root, 'skills', 'my-personal-skill'), { recursive: true }); + fs.writeFileSync(path.join(root, 'skills', 'my-personal-skill', 'SKILL.md'), '# mine'); + + assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []); + } finally { + cleanup(root); + } + }); + + test('the report names the path and the remedy', () => { + const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]); + assert.match(out, /HERMETICITY FAILURE/); + assert.match(out, /\/live\/\.claude\/gsd-core/); + assert.match(out, /scrubConfigLocationEnv/); + assert.match(out, /GSD_SKIP_LIVE_CONFIG_GUARD/); + }); +}); From a4efa4deda015270ec9d9a98fec478d5d9d92e68 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:32 -0500 Subject: [PATCH 07/47] test(#2665): cover the derivation and the in-process scrub The single regression test exercised CLAUDE_CONFIG_DIR only, so deleting GSD_RUNTIME or CODEX_HOME from any literal broke nothing -- a mutation of 24 of the 27 added key-value pairs survived. Four tests here: every configHome env var the registry declares is scrubbed; every scrubbed key is blanked rather than merely present; the four non-registry vars are named explicitly so deleting one is a failure rather than a silent narrowing; and scrubConfigLocationEnv round-trips both a set and an unset var (restoring an originally-unset var as '' would itself be a leak). Both derivation tests assert a floor on the registry first, so a renamed registry shape fails loudly instead of making the assertions vacuously true. Negative-controlled against the hand-written 3-key list this PR shipped: the parity test fails there and names all 19 missing vars. Addresses review findings: Major 6, Minor 8. --- tests/helpers-process-isolation.test.cjs | 86 +++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index 92c054f63..27685acb9 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -2,7 +2,12 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); -const { withIsolatedProcessState } = require('./helpers.cjs'); +const { + withIsolatedProcessState, + TEST_ENV_BASE, + CONFIG_LOCATION_ENV_KEYS, + scrubConfigLocationEnv, +} = require('./helpers.cjs'); describe('withIsolatedProcessState', () => { test('restores env, cwd, and exitCode after callback', () => { @@ -39,3 +44,82 @@ describe('withIsolatedProcessState', () => { assert.strictEqual(process.env.PATH, originalPath); }); }); + +// ─── #2665: the config-location scrub is DERIVED, and stays that way ────────── +// +// The recurrence guard. #2665 documents two prior authors independently +// diagnosing this class and each fixing only the instance in front of them; +// this is the third pass. A hand-maintained scrub list cannot be defended by +// review alone, so the invariant is asserted instead of trusted. +describe('#2665: TEST_ENV_BASE config-location coverage', () => { + test('every runtime configHome env var in the registry is scrubbed', () => { + const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + + const declared = [ + ...new Set( + Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []), + ), + ].sort(); + + // Guards the guard: an empty/renamed registry shape would make the + // assertion below vacuously true and silently retire this test. + assert.ok( + declared.length >= 15, + `expected the registry to declare many configHome env vars, got ${declared.length} — ` + + 'if the registry shape changed, this derivation needs updating, not deleting', + ); + + const missing = declared.filter((k) => !(k in TEST_ENV_BASE)); + assert.deepStrictEqual( + missing, + [], + `config-location env vars reachable by the resolver but not scrubbed: ${missing.join(', ')}. ` + + 'TEST_ENV_BASE derives this set from the capability registry — a gap here means the ' + + 'derivation broke, not that the list needs a manual entry.', + ); + }); + + test('every scrubbed config-location var is blanked, not merely present', () => { + for (const key of CONFIG_LOCATION_ENV_KEYS) { + assert.strictEqual( + TEST_ENV_BASE[key], + '', + `${key} must be blanked ('') so the child sees a falsy value on the env-first branch`, + ); + } + }); + + test('the non-registry config-location vars are covered too', () => { + // These have no capability descriptor, so the registry derivation alone + // cannot reach them: GROK_AGENTS_HOME is a hardcoded branch of + // getGlobalConfigDir, GSD_RUNTIME selects which runtime home resolves, and + // GSD_PROJECT / GSD_WORKSTREAM move a child's .planning root + // (src/planning-workspace.cts). Named explicitly so deleting one from the + // helper is a test failure rather than a silent narrowing. + for (const key of ['GROK_AGENTS_HOME', 'GSD_RUNTIME', 'GSD_PROJECT', 'GSD_WORKSTREAM']) { + assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`); + } + }); + + test('scrubConfigLocationEnv clears and restores the parent process env', () => { + // The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches + // children, so a test calling install() in-process needs the PARENT's env + // cleared. Round-trip both states — set and unset — because restoring an + // originally-unset var as '' rather than deleting it is itself a leak. + withIsolatedProcessState(() => { + process.env.CLAUDE_CONFIG_DIR = '/tmp/ambient-claude'; + delete process.env.CODEX_HOME; + + const restore = scrubConfigLocationEnv(); + assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, undefined, + 'a set config-location var must be deleted, not blanked, on the parent'); + assert.strictEqual(process.env.CODEX_HOME, undefined); + + restore(); + assert.strictEqual(process.env.CLAUDE_CONFIG_DIR, '/tmp/ambient-claude', + 'restore must put back the original value'); + assert.ok(!('CODEX_HOME' in process.env), + 'restore must leave an originally-unset var unset, not set it to empty string'); + }); + }); +}); From 771980b661de6230f76013bd9a8254f769195b02 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:42 -0500 Subject: [PATCH 08/47] test(#2665): restore fallback-branch coverage in the #2003 regression This PR fixed the test's real defect -- it compared the child's answer against the PARENT process's getGlobalConfigDir(), two different environments, agreeing only because the child inherited the developer's ambient CLAUDE_CONFIG_DIR -- but fixed it by INJECTING CLAUDE_CONFIG_DIR, which moved the test onto the env-first branch and silently dropped the only coverage #2003 had of the home-derived fallback. Sandbox HOME/USERPROFILE and leave the config vars blank instead: the expectation stays test-controlled AND the branch under test is unchanged. Drops the notStrictEqual against the codex dir. It could not fail whenever the strictEqual on the line above passed. Addresses review finding: Minor 7. --- tests/capability-state.test.cjs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 3bd6dbd11..24a997bd6 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -2108,24 +2108,28 @@ describe('regressions: --runtime override bypasses persisted runtime (#2003)', ( const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-')); try { writePersistedRuntime(tmpDir, 'codex'); - // #2665: redirect both runtime homes into the sandbox so the expectation is - // built from values this test controls. Comparing the child's answer against - // the PARENT process's getGlobalConfigDir() compared two different - // environments -- it agreed only because the child inherited the developer's - // ambient CLAUDE_CONFIG_DIR, which is the leak this test's helper now blocks. - const claudeConfigDir = path.join(tmpDir, 'claude-config'); - const codexHome = path.join(tmpDir, 'codex-home'); + // #2665: sandbox HOME and let the config-location vars stay BLANK (helpers' + // TEST_ENV_BASE zeroes them), so the child resolves through the home-derived + // FALLBACK branch of getGlobalConfigDir. The original test compared the + // child's answer against the PARENT process's getGlobalConfigDir() -- two + // different environments, agreeing only because the child inherited the + // developer's ambient CLAUDE_CONFIG_DIR. + // + // Injecting CLAUDE_CONFIG_DIR here instead would fix that leak but move the + // test onto the env-FIRST branch, silently dropping the only coverage this + // #2003 regression has of the fallback branch. Sandboxing HOME keeps the + // expectation test-controlled AND keeps the branch under test unchanged. const result = runGsdTools('capability state --runtime claude --raw', tmpDir, { HOME: tmpDir, - CLAUDE_CONFIG_DIR: claudeConfigDir, - CODEX_HOME: codexHome, + USERPROFILE: tmpDir, }); assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`); const parsed = JSON.parse(result.output); - assert.strictEqual(parsed.runtimeConfigDir, claudeConfigDir, + // Home-derived Claude dir. That this is NOT /.codex is the #2003 + // assertion; a separate notStrictEqual against the codex dir would be dead, + // since it cannot fail whenever this strictEqual passes. + assert.strictEqual(parsed.runtimeConfigDir, path.join(tmpDir, '.claude'), '`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir'); - assert.notStrictEqual(parsed.runtimeConfigDir, codexHome, - 'must NOT resolve to the codex config dir when --runtime claude is explicit'); } finally { cleanup(tmpDir); } From 5863351281b1449c8190938592a73bd629af9c65 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:05:42 -0500 Subject: [PATCH 09/47] test(#2665): separator-safe containment in the regression assertion startsWith(ambientConfigDir) also matches a sibling like /ambient-live-config-2, so it can report a leak that did not happen. Use path.relative and check for '..' or an absolute result, the repo's usual shape. The readdirSync assertion already carried the test, so this is cosmetic. Addresses review finding: Nit 9. --- tests/profile-output.test.cjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index 849b804a3..62ad61681 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -223,8 +223,13 @@ describe('write-profile command', () => { [], 'a call site that sandboxes HOME must not write into an ambient CLAUDE_CONFIG_DIR' ); + // Containment via path.relative, not startsWith: startsWith(ambientConfigDir) + // also matches a SIBLING like `/ambient-live-config-2`, so it can report + // a false leak. A path is inside the dir iff the relative path neither escapes + // with '..' nor is absolute. + const rel = path.relative(ambientConfigDir, out.profile_path); assert.ok( - !out.profile_path.startsWith(ambientConfigDir), + rel.startsWith('..') || path.isAbsolute(rel), `profile must not resolve under the ambient config dir, got: ${out.profile_path}` ); }); From e2eed1c58ae030acc159be7e4daead43b085c614 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Tue, 28 Jul 2026 06:19:20 -0500 Subject: [PATCH 10/47] test(#2665): ship the hermeticity guard at report level, not fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its first CI run found PRE-EXISTING leaks on the Windows lane — C:\Users\runneradmin\.claude\gsd-core and skills\gsd-dev-preferences — with all 1196 Windows tests otherwise passing. os.homedir() reads USERPROFILE on Windows, and ~190 test sites across 31 files sandbox HOME alone, so the suite has been installing GSD into the runner's real home directory invisibly. That is exactly the class the guard exists to surface, and exactly the class this PR's review said CI could never catch. It is also a different defect from the one #2665 closes, and too large to fold in here. A brand-new gate that immediately reds an unrelated lane gets bypassed or reverted rather than obeyed, so the guard reports by default and fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1. This is the repo's own established ratchet, not a hedge: the local/no-source-grep ESLint rule shipped at `warn` and was promoted to `error` after its cleanup sweep (ADR 452). Promote this the same way once the USERPROFILE sweep lands. --- scripts/live-config-guard.cjs | 18 ++++++++++++++++-- scripts/run-tests.cjs | 6 +++++- tests/live-config-guard.test.cjs | 3 ++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 74a83c433..5794718bf 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -41,6 +41,17 @@ * KNOWN GAP — a leak into a file GSD does not own (e.g. mutating the host's own * `.claude.json`) is outside this guard by construction. Closing it would require * watching shared files, which is the false-positive trap above. + * + * SEVERITY — reports by default, fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1. + * Not timidity: on its first CI run this guard found PRE-EXISTING leaks on the + * Windows lane (`C:\Users\runneradmin\.claude\gsd-core` and + * `skills\gsd-dev-preferences`), because os.homedir() reads USERPROFILE there and + * ~190 test sites sandbox HOME alone. Those are real and worth fixing, but they + * are a different defect class from the one #2665 closes, and a brand-new gate + * that immediately reds an unrelated lane gets bypassed or reverted rather than + * obeyed. This repo already has the pattern: the local/no-source-grep ESLint rule + * shipped at `warn` and was promoted to `error` after its cleanup sweep (ADR 452). + * Promote this the same way once the USERPROFILE sweep lands. */ const fs = require('fs'); @@ -209,7 +220,7 @@ function diffLiveConfig(before, after) { function formatViolations(violations) { const lines = [ '', - 'run-tests: HERMETICITY FAILURE — the suite wrote into a LIVE config directory.', + 'run-tests: HERMETICITY WARNING — the suite wrote into a LIVE config directory.', '', 'A test resolved a runtime config dir from the ambient environment instead of a', 'sandbox. The usual cause is an IN-PROCESS install() call: tests/helpers.cjs', @@ -225,7 +236,10 @@ function formatViolations(violations) { lines.push(` ${label}: ${v.path}`); } lines.push(''); - lines.push('Set GSD_SKIP_LIVE_CONFIG_GUARD=1 to bypass (intentionally loud).'); + lines.push( + 'Reporting only. Set GSD_STRICT_LIVE_CONFIG_GUARD=1 to make this fail the run, ' + + 'or GSD_SKIP_LIVE_CONFIG_GUARD=1 to skip the check entirely.', + ); lines.push(''); return lines.join('\n'); } diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index e2eef2ec8..1633c7d78 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -1055,7 +1055,11 @@ function main() { const violations = diffLiveConfig(liveConfigBefore, snapshotLiveConfig(liveConfigRoots)); if (violations.length > 0) { console.error(formatViolations(violations)); - if (firstFailureExit === 0) firstFailureExit = 1; + // Reports by default; fails only under opt-in strict mode. See the + // SEVERITY note in scripts/live-config-guard.cjs for why. + if (process.env.GSD_STRICT_LIVE_CONFIG_GUARD === '1' && firstFailureExit === 0) { + firstFailureExit = 1; + } } } diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index b942342b6..0a757795a 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -152,9 +152,10 @@ describe('#2665: live-config hermeticity guard', () => { test('the report names the path and the remedy', () => { const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]); - assert.match(out, /HERMETICITY FAILURE/); + assert.match(out, /HERMETICITY WARNING/); assert.match(out, /\/live\/\.claude\/gsd-core/); assert.match(out, /scrubConfigLocationEnv/); assert.match(out, /GSD_SKIP_LIVE_CONFIG_GUARD/); + assert.match(out, /GSD_STRICT_LIVE_CONFIG_GUARD/); }); }); From 3c580b77dcc1cd3c22da51330aaa281716564350 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 1 Aug 2026 01:57:37 -0500 Subject: [PATCH 11/47] fix(#2665): derive the second config-location family instead of hand-adding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 named GSD_HOME and KIMI_SHARE_DIR as missing from the derived scrub set. Both premises confirmed; the prescribed remedy is not adopted verbatim, because adding two more literals to a four-item hand list is the pattern that reopened this bug three times. The census the derivation generalizes over was partial, so the census is what widens. Two structural gaps, both closed at the source: 1. KIMI_SHARE_DIR lived inside resolveKimiHooksTomlDir's body as an inline descriptor, resolvable but not ENUMERABLE. Hoisted to an exported KIMI_HOOKS_TOML_DESCRIPTOR and collected in NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, which TEST_ENV_BASE now derives from. kimi is the sharp case: it owns TWO config homes (KIMI_CONFIG_DIR, already registry-visible, and this one), so a registry-only derivation looks complete and is not. 2. GSD_HOME is a different FAMILY, not a missing registry entry. The registry describes where third-party runtimes keep config; GSD_HOME decides where GSD keeps its own user-owned state ($GSD_HOME/.gsd/ — consent.json, defaults.json, capability overlays), read env-first ahead of os.homedir() by capability-loader, capability-consent, capability-state, capability-writer, config-loader, install-profiles and bin/install.js. Named as GSD_LOCATION_ENV_KEYS rather than folded into the descriptor array, since it does not resolve through resolveConfigHomeFromDescriptor. GSD_AGENTS_DIR joins the same family (round 2, Minor): env-first and unconditional in getAgentsDir, misdirecting a read rather than a write. A census of every env-first first-party location var — the guard-shape question this PR owes each round — now yields exactly one remaining unguarded name, GSD_MODEL_CATALOG, and it is dead by precedence: the co-located candidate is index 0 and the loop breaks on first success, so the env var can only win on a tree that is already broken, and it redirects a read even then. Derived set: 39 -> 42 keys. resolveKimiHooksTomlDir behaviour unchanged on both the default and the KIMI_SHARE_DIR override path. --- src/runtime-homes.cts | 78 ++++++++++++++++++++++++++++++++++++++++--- tests/helpers.cjs | 20 ++++++++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index e8f37818e..0655d68fc 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -157,7 +157,7 @@ interface NoneDescriptor { skillsHome?: ConfigHomeDescriptor; } -type ConfigHomeDescriptor = +export type ConfigHomeDescriptor = | DotHomeDescriptor | DotHomeNestedDescriptor | XdgDescriptor @@ -480,6 +480,76 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string { ); } +/** + * Kimi CLI's own native config.toml home. Hoisted out of resolveKimiHooksTomlDir + * so it is ENUMERABLE, not merely resolvable. + * + * #2665 round 3: a config-location var that lives only inside a function body is + * invisible to every consumer that needs the SET rather than the path — the test + * scrub list and the hermeticity guard both derive from descriptors, and this one + * reached neither. `kimi` is the sharp case precisely because it owns TWO config + * homes: KIMI_CONFIG_DIR (registry-visible, already covered) and KIMI_SHARE_DIR + * (this one), so a derivation keyed only on the registry looks complete and is not. + */ +export const KIMI_HOOKS_TOML_DESCRIPTOR: ConfigHomeDescriptor = { + kind: 'dot-home', + name: '.kimi', + env: ['KIMI_SHARE_DIR'], +}; + +/** + * Kimi Code's native config.toml home — the `kimi-code` counterpart of the + * descriptor above, hoisted for exactly the same reason. + * + * #2755 landed kimi-code hooks support on `next` while this PR was open, and + * declared this descriptor as an inline object literal inside + * resolveKimiHooksTomlDir's body — the same resolvable-but-not-enumerable shape + * round 3 hoisted KIMI_SHARE_DIR out of. Hoisting it puts `KIMI_CODE_HOME` into + * the derived scrub set and the hermeticity guard's watch roots in the SAME + * commit, which is the property NON_REGISTRY_CONFIG_HOME_DESCRIPTORS exists to + * guarantee. Each product's env var stays scoped to that product (#2755). + */ +export const KIMI_CODE_HOOKS_TOML_DESCRIPTOR: ConfigHomeDescriptor = { + kind: 'dot-home', + name: '.kimi-code', + env: ['KIMI_CODE_HOME'], +}; + +/** + * Config-home descriptors resolved OUTSIDE the capability registry. + * + * Anything added here is picked up by every derived consumer in the same commit — + * which is the property that makes the derivation structurally incapable of being + * narrower than the surface it guards. Adding a hardcoded resolver WITHOUT adding + * its descriptor here is the defect this array exists to make hard. + */ +export const NON_REGISTRY_CONFIG_HOME_DESCRIPTORS: ConfigHomeDescriptor[] = [ + KIMI_HOOKS_TOML_DESCRIPTOR, + KIMI_CODE_HOOKS_TOML_DESCRIPTOR, +]; + +/** + * GSD's OWN location vars — a second family, not runtime configHomes. + * + * #2665 round 3: the registry describes where each *third-party runtime* keeps its + * config. It says nothing about where GSD keeps its own user-owned state, and that + * is a separate env-first surface: + * + * GSD_HOME — `process.env['GSD_HOME'] || os.homedir()`, the root of + * `$GSD_HOME/.gsd/` (consent.json, defaults.json, capability + * overlays). Read env-first by capability-loader, capability-consent, + * capability-state, capability-writer, config-loader, install-profiles + * and bin/install.js. A WRITE surface. + * GSD_AGENTS_DIR — `if (process.env['GSD_AGENTS_DIR']) return it`, priority 1 in + * getAgentsDir. Misdirects a READ rather than a write, hence lower + * severity — but it is env-first and unconditional, so it belongs + * to the same class. + * + * Deliberately NOT folded into the descriptor array above: these do not resolve + * through resolveConfigHomeFromDescriptor and have no `kind`/`name` shape. + */ +export const GSD_LOCATION_ENV_KEYS: readonly string[] = ['GSD_HOME', 'GSD_AGENTS_DIR']; + /** * Resolve the directory holding the Kimi product's OWN native config.toml — * the file that product itself reads for providers/models/hooks/etc, and the @@ -516,9 +586,9 @@ export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): st // Explicit comparison rather than an object lookup keyed on `runtime`: the // value originates from argv, and an index would resolve inherited keys // (`constructor`, `__proto__`) to something that is not a descriptor. - const descriptor: DotHomeDescriptor = opts.runtime === 'kimi-code' - ? { kind: 'dot-home', name: '.kimi-code', env: ['KIMI_CODE_HOME'] } - : { kind: 'dot-home', name: '.kimi', env: ['KIMI_SHARE_DIR'] }; + const descriptor: ConfigHomeDescriptor = opts.runtime === 'kimi-code' + ? KIMI_CODE_HOOKS_TOML_DESCRIPTOR + : KIMI_HOOKS_TOML_DESCRIPTOR; return resolveConfigHomeFromDescriptor(descriptor, { env, home }); } diff --git a/tests/helpers.cjs b/tests/helpers.cjs index db83f2779..8b4a27f42 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -42,12 +42,22 @@ const SESSION_IDENTITY_ENV_KEYS = [ // incapable of being narrower than the surface it guards: adding a capability // that declares a new configHome env var extends this set in the same commit. const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); +const { + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, + GSD_LOCATION_ENV_KEYS, +} = require('../gsd-core/bin/lib/runtime-homes.cjs'); -// Config-location vars the registry does NOT carry, each with its reader: +// Config-location vars that are neither in the registry nor descriptor-shaped, +// each with its reader: // GROK_AGENTS_HOME — hardcoded `grok` branch in getGlobalConfigDir (src/runtime-homes.cts) // GSD_RUNTIME — selects WHICH runtime home resolves (src/model-resolver.cts) // GSD_PROJECT — planningDir() project segment (src/planning-workspace.cts) // GSD_WORKSTREAM — planningDir() workstream segment (src/planning-workspace.cts) +// +// #2665 round 3: this list shrinks as sources become enumerable, and that direction +// is the point. KIMI_SHARE_DIR was NOT added here — it now derives from +// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, because hand-adding each var a reviewer +// names is precisely what reopened this bug three times. const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [ 'GROK_AGENTS_HOME', 'GSD_RUNTIME', @@ -57,7 +67,15 @@ const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [ const CONFIG_LOCATION_ENV_KEYS = [ ...new Set([ + // 1. Every runtime descriptor the capability registry carries. ...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []), + // 2. Descriptor-shaped config homes resolved OUTSIDE the registry (kimi's + // native config.toml home via KIMI_SHARE_DIR). Derived, not hand-listed. + ...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => d?.env ?? []), + // 3. GSD's OWN location vars — a different family: they decide where GSD keeps + // user-owned state ($GSD_HOME/.gsd/), not where a runtime keeps its config. + ...GSD_LOCATION_ENV_KEYS, + // 4. The residue that is neither registry-carried nor descriptor-shaped. ...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS, ]), ].sort(); From a294ec2a2b8bacc44eb1d326e2abb571ae5af820 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 1 Aug 2026 02:01:18 -0500 Subject: [PATCH 12/47] test(#2665): widen the hermeticity guard to its two blind surfaces, and cover its budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2, both Majors. They are one defect seen twice: the recurrence guard did not cover the surface it exists to guard. Blind surfaces. resolveLiveConfigRoots enumerates getGlobalConfigDir per registry runtime plus a hardcoded grok branch, so it can only ever see runtime config ROOTS. Two live write surfaces are not roots and passed through silently: $GSD_HOME/.gsd — GSD's user-owned store. Watched WHOLESALE: unlike ~/.claude this root is exclusively ours, so the shared-root false-positive trap the module documents does not apply. /config.toml — the file GSD writes its native [[hooks]] block into. The INVERSE case: ~/.kimi belongs to Kimi CLI, so only the one file GSD writes is watched, never the root. That asymmetry is why this is not a two-line "add two roots" patch — one target needs the whole tree, the other needs exactly one file, and collapsing them either under-watches the store or trips the guard's own documented false-positive trap on a third party's directory. Extras are passed to snapshotLiveConfig explicitly rather than resolved inside it, so a caller snapshotting a fixture root cannot silently pull the developer's real ~/.gsd into its own assertions. run-tests.cjs now snapshots when EITHER the roots or the extras are non-empty — previously an unbuilt tree yielding zero roots disabled the entire guard without saying so. Budget coverage. The MAX_ENTRIES/MAX_DEPTH bound and the truncated -> 'unverified' branch had zero tests, despite this module's own docstring naming "a truncated scan reading as clean" as the safety-critical case. Added per RULESET.TESTS.boundary-coverage (N in {limit-1, limit, limit+1}, exercised through newestMtime's injected budget so the boundary is real without materialising 20000 files) and RULESET.TESTS.property-based-testing (fast-check: truncation is monotone in the budget; reported newest never exceeds the true maximum). A regression flipping `truncated` to false on an exhausted budget now breaks the property for every budget below the tree size. Negative-controlled: neutering the extras wiring fails exactly the two new-surface tests and nothing else. 21/21 green with it restored. --- scripts/live-config-guard.cjs | 53 +++++++- scripts/run-tests.cjs | 17 ++- tests/live-config-guard.test.cjs | 218 +++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 3 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 5794718bf..b0e054bc4 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -111,6 +111,50 @@ function resolveLiveConfigRoots(deps = {}) { return [...roots].sort(); } +/** + * Watch targets that are NOT runtime config roots, and so cannot be expressed as + * `root x GSD_OWNED_ENTRIES`. + * + * #2665 round 3: resolveLiveConfigRoots enumerates getGlobalConfigDir per registry + * runtime plus grok. Two live write surfaces are invisible to that shape, so a leak + * on either passed through this guard — the PR's own safety net — silently: + * + * $GSD_HOME/.gsd — GSD's user-owned store (consent.json, defaults.json, capability + * overlays). Watched WHOLESALE: unlike ~/.claude this root is + * exclusively ours, so the shared-root false-positive trap in + * SCOPE above does not apply and an ownership filter would only + * narrow the guard for nothing. + * /config.toml — the file GSD writes its native [[hooks]] block into + * (resolveKimiHooksTomlDir, KIMI_SHARE_DIR). The INVERSE case: + * ~/.kimi belongs to Kimi CLI, so only the one file GSD writes is + * watched, never the root. This is the KNOWN GAP above accepted + * deliberately in one direction — GSD demonstrably writes this + * file (bin/install.js calls resolveKimiHooksTomlDir at two sites), + * so a concurrent Kimi CLI write is the only false positive, and + * Kimi is not running during the suite. + * + * @returns {string[]} absolute paths; empty if the built lib is absent. + */ +function resolveExtraWatchTargets(deps = {}) { + const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'); + const env = deps.env || process.env; + const homedir = (deps.os || os).homedir; + + const targets = [path.resolve(path.join(env.GSD_HOME || homedir(), '.gsd'))]; + + try { + const { resolveKimiHooksTomlDir } = require(path.join(libDir, 'runtime-homes.cjs')); + // Thread the SAME injected env/home the GSD_HOME line above uses. Calling it + // bare reads process.env and os.homedir() regardless of `deps`, which leaves + // the seam untestable and the two targets resolved against different worlds. + const kimiDir = resolveKimiHooksTomlDir({ env, home: homedir() }); + targets.push(path.resolve(path.join(kimiDir, 'config.toml'))); + } catch { + // Unbuilt tree — same posture as resolveLiveConfigRoots: advisory, never fatal. + } + return targets; +} + /** * Newest mtime within a tree, bounded. Returns `truncated: true` when a bound * was hit — the caller must NOT report such a result as clean, on the same @@ -151,7 +195,7 @@ function newestMtime(target, budget) { * @returns {Record} * keyed by absolute entry path. */ -function snapshotLiveConfig(roots) { +function snapshotLiveConfig(roots, extraTargets = []) { const budget = { remaining: MAX_ENTRIES }; const snap = {}; @@ -164,6 +208,12 @@ function snapshotLiveConfig(roots) { snap[target] = { exists: true, newest, truncated }; }; + // Non-root targets (resolveExtraWatchTargets) are recorded verbatim — they are + // already the exact path to watch, whole-dir or single-file. Passed explicitly + // rather than resolved here so a caller testing a fixture root does not silently + // pull the developer's real ~/.gsd into its snapshot. + for (const target of extraTargets) record(path.resolve(target)); + for (const root of roots) { for (const entry of GSD_OWNED_ENTRIES) record(path.join(root, entry)); @@ -251,6 +301,7 @@ module.exports = { MAX_ENTRIES, MAX_DEPTH, resolveLiveConfigRoots, + resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 1633c7d78..0fabccd13 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -41,6 +41,7 @@ const { execFileSync } = require('child_process'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const { resolveLiveConfigRoots, + resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, @@ -977,10 +978,19 @@ function main() { // scripts/lib/live-config-guard.cjs for why the scope is narrow. const liveConfigGuardEnabled = process.env.GSD_SKIP_LIVE_CONFIG_GUARD !== '1'; let liveConfigRoots = []; + let liveConfigExtras = []; let liveConfigBefore = null; if (liveConfigGuardEnabled) { liveConfigRoots = resolveLiveConfigRoots(); - if (liveConfigRoots.length > 0) liveConfigBefore = snapshotLiveConfig(liveConfigRoots); + // #2665 round 3: $GSD_HOME/.gsd and kimi's native config.toml are live write + // surfaces that are not runtime config ROOTS, so they are invisible to the + // line above. Watched independently — and note the OR: the extras alone are + // reason enough to snapshot, so an unbuilt tree that yields zero roots no + // longer silently disables the whole guard. + liveConfigExtras = resolveExtraWatchTargets(); + if (liveConfigRoots.length > 0 || liveConfigExtras.length > 0) { + liveConfigBefore = snapshotLiveConfig(liveConfigRoots, liveConfigExtras); + } } let firstFailureExit = 0; @@ -1052,7 +1062,10 @@ function main() { // global install is worth reporting alongside the failure that hid it, and // suppressing it on red would hide it exactly when the suite is least trusted. if (liveConfigBefore) { - const violations = diffLiveConfig(liveConfigBefore, snapshotLiveConfig(liveConfigRoots)); + const violations = diffLiveConfig( + liveConfigBefore, + snapshotLiveConfig(liveConfigRoots, liveConfigExtras), + ); if (violations.length > 0) { console.error(formatViolations(violations)); // Reports by default; fails only under opt-in strict mode. See the diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 0a757795a..9760f4767 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -6,12 +6,17 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const fc = require('fast-check'); + const { GSD_OWNED_ENTRIES, + MAX_DEPTH, resolveLiveConfigRoots, + resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, + newestMtime, } = require('../scripts/live-config-guard.cjs'); const { cleanup } = require('./helpers.cjs'); @@ -20,6 +25,13 @@ function tmpRoot() { return fs.mkdtempSync(path.join(os.tmpdir(), 'live-config-guard-')); } +/** Create `n` flat files under a fresh dir; returns [dir, entryCount-including-dir]. */ +function treeWithEntries(n) { + const dir = tmpRoot(); + for (let i = 0; i < n; i++) fs.writeFileSync(path.join(dir, `f${i}`), 'x'); + return [dir, n + 1]; // +1: the directory itself is lstat'd and costs budget +} + describe('#2665: live-config hermeticity guard', () => { test('resolves real runtime config roots via the product resolver', () => { const roots = resolveLiveConfigRoots(); @@ -159,3 +171,209 @@ describe('#2665: live-config hermeticity guard', () => { assert.match(out, /GSD_STRICT_LIVE_CONFIG_GUARD/); }); }); + +// ── Round 3: the two write surfaces that are not runtime config ROOTS ──────── +describe('#2665: guard watches non-root write surfaces', () => { + test('resolveExtraWatchTargets covers $GSD_HOME/.gsd and kimi config.toml', () => { + const home = tmpRoot(); + const share = tmpRoot(); + try { + const targets = resolveExtraWatchTargets({ + env: { GSD_HOME: home, KIMI_SHARE_DIR: share }, + os: { homedir: () => home }, + }); + assert.ok( + targets.includes(path.resolve(path.join(home, '.gsd'))), + `expected $GSD_HOME/.gsd in ${JSON.stringify(targets)}`, + ); + assert.ok( + targets.some((t) => t === path.resolve(path.join(share, 'config.toml'))), + `expected kimi config.toml in ${JSON.stringify(targets)}`, + ); + } finally { + cleanup(home); + cleanup(share); + } + }); + + test('GSD_HOME falls back to homedir when unset', () => { + const home = tmpRoot(); + try { + const targets = resolveExtraWatchTargets({ env: {}, os: { homedir: () => home } }); + assert.ok(targets.includes(path.resolve(path.join(home, '.gsd')))); + } finally { + cleanup(home); + } + }); + + test('detects a consent/defaults write into $GSD_HOME/.gsd', () => { + const home = tmpRoot(); + try { + const target = path.join(home, '.gsd'); + const before = snapshotLiveConfig([], [target]); + // The Blocker-1 shape one family over: an ambient GSD_HOME sends real + // consent records and defaults.json into the developer's own store. + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, 'consent.json'), '{}'); + + const violations = diffLiveConfig(before, snapshotLiveConfig([], [target])); + assert.strictEqual(violations.length, 1); + assert.strictEqual(violations[0].kind, 'created'); + } finally { + cleanup(home); + } + }); + + test('detects a [[hooks]] write into kimi config.toml', () => { + const share = tmpRoot(); + try { + const target = path.join(share, 'config.toml'); + const before = snapshotLiveConfig([], [target]); + fs.writeFileSync(target, '[[hooks]]\n'); + + const violations = diffLiveConfig(before, snapshotLiveConfig([], [target])); + assert.strictEqual(violations.length, 1); + assert.strictEqual(violations[0].kind, 'created'); + } finally { + cleanup(share); + } + }); + + test('NEGATIVE CONTROL: without the extras both leaks are silent', () => { + const home = tmpRoot(); + try { + // This is the pre-round-3 guard shape — roots only. It is what let a leak + // on either variable pass through the PR's own safety net unreported. + const before = snapshotLiveConfig([]); + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(home, '.gsd', 'consent.json'), '{}'); + + assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([])), []); + } finally { + cleanup(home); + } + }); + + test('a whole-dir extra target does not watch unrelated siblings', () => { + const home = tmpRoot(); + try { + const target = path.join(home, '.gsd'); + fs.mkdirSync(target, { recursive: true }); + const before = snapshotLiveConfig([], [target]); + // A sibling of .gsd is outside the watched target entirely. + fs.writeFileSync(path.join(home, 'unrelated.json'), '{}'); + + assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([], [target])), []); + } finally { + cleanup(home); + } + }); +}); + +// ── Round 3: the truncation budget — the module's own safety-critical case ─── +describe('#2665: scan-budget truncation', () => { + test('boundary: limit-1 truncates, limit and limit+1 do not', () => { + const [dir, entries] = treeWithEntries(24); + try { + // RULESET.TESTS.boundary-coverage: N in {limit-1, limit, limit+1}. The + // budget is injected, so the boundary is exercised at a real threshold + // without materialising MAX_ENTRIES files. + assert.strictEqual( + newestMtime(dir, { remaining: entries - 1 }).truncated, + true, + 'one entry short of the tree size MUST truncate', + ); + assert.strictEqual( + newestMtime(dir, { remaining: entries }).truncated, + false, + 'a budget exactly equal to the tree size must NOT truncate', + ); + assert.strictEqual( + newestMtime(dir, { remaining: entries + 1 }).truncated, + false, + 'a budget above the tree size must NOT truncate', + ); + } finally { + cleanup(dir); + } + }); + + test('exceeding MAX_DEPTH truncates', () => { + const dir = tmpRoot(); + try { + let deep = dir; + for (let i = 0; i <= MAX_DEPTH + 1; i++) deep = path.join(deep, `d${i}`); + fs.mkdirSync(deep, { recursive: true }); + + const res = newestMtime(dir, { remaining: 1e6 }); + assert.strictEqual(res.truncated, true, 'a tree deeper than MAX_DEPTH must truncate'); + } finally { + cleanup(dir); + } + }); + + test('a truncated scan reports UNVERIFIED, never clean', () => { + const [dir, entries] = treeWithEntries(10); + try { + // The safety-critical branch named in this module's own docstring: a scan + // that hit a bound must not read as an attestation of cleanliness. + const snap = { [dir]: { exists: true, newest: 1, truncated: true } }; + const violations = diffLiveConfig(snap, { + [dir]: { exists: true, newest: 1, truncated: true }, + }); + assert.strictEqual(violations.length, 1); + assert.strictEqual(violations[0].kind, 'unverified'); + assert.match(formatViolations(violations), /UNVERIFIED \(scan bound hit/); + assert.ok(entries > 0); + } finally { + cleanup(dir); + } + }); + + test('a modified path outranks unverified (a real leak is never downgraded)', () => { + const p = '/live/.claude/gsd-core'; + const violations = diffLiveConfig( + { [p]: { exists: true, newest: 1, truncated: true } }, + { [p]: { exists: true, newest: 2, truncated: true } }, + ); + assert.strictEqual(violations[0].kind, 'modified'); + }); + + test('property: truncation is monotone in the budget (boundary containment)', () => { + const [dir, entries] = treeWithEntries(12); + try { + fc.assert( + fc.property(fc.integer({ min: 1, max: entries * 3 }), (budget) => { + const { truncated } = newestMtime(dir, { remaining: budget }); + // The invariant: a budget at or above the tree size never truncates, + // and one below it always does. A regression flipping `truncated` to + // false on an exhausted budget — the exact silent-clean failure the + // module warns about — breaks this for every budget < entries. + return budget >= entries ? truncated === false : truncated === true; + }), + { numRuns: 100 }, + ); + } finally { + cleanup(dir); + } + }); + + test('property: newest mtime never exceeds the true maximum', () => { + const [dir, entries] = treeWithEntries(8); + try { + const trueMax = Math.max( + ...fs.readdirSync(dir).map((f) => fs.lstatSync(path.join(dir, f)).mtimeMs), + fs.lstatSync(dir).mtimeMs, + ); + fc.assert( + fc.property(fc.integer({ min: 1, max: entries * 2 }), (budget) => { + const { newest } = newestMtime(dir, { remaining: budget }); + return newest <= trueMax; + }), + { numRuns: 50 }, + ); + } finally { + cleanup(dir); + } + }); +}); From 1f6d827e481deb625959c4856d7a4a33cb9eb4eb Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 1 Aug 2026 02:02:18 -0500 Subject: [PATCH 13/47] fix(#2665): scrub config-location env in the #2624 in-process install block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-found during the rebase onto next, not from the review. The base range added `describe('#2624 .gsd-source marker is rewritten before staging reads it')`, which calls the real `install(true, 'claude')` IN-PROCESS and sandboxes HOME/USERPROFILE/GSD_EXPLICIT_CONFIG_DIR — but not CLAUDE_CONFIG_DIR. That is exactly the Blocker-1 shape this PR exists to close, reintroduced in new code written after the round-1 review. Measured on the rebased tree, same file, same commit: CLAUDE_CONFIG_DIR unset -> 50/50 pass CLAUDE_CONFIG_DIR set -> 47/50, and a COMPLETE global install lands in it (gsd-core/, agents/, skills/, hooks/, scripts/, gsd-file-manifest.json, gsd-install-state.json, .gsd-source, .gsd-profile) The three failures are the honest symptom rather than the problem: the install goes to the ambient config dir, so the assertions look for a marker under tmpRoot that was never written there. With scrubConfigLocationEnv() wired into the block's beforeEach/afterEach — the same pattern the sibling block at :753 already uses — the file is 50/50 under BOTH conditions and leaks zero entries. Worth stating plainly: CI cannot catch this class, since CI never has these vars set. It surfaced here only because the rebase brought the base's new tests under an ambient CLAUDE_CONFIG_DIR, which is the condition #2665's own acceptance criterion runs under. --- tests/runtime-artifact-layout.test.cjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index caae32441..20c79c1ee 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -946,6 +946,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () => let savedUserProfile; let savedExplicitConfigDir; let savedTestMode; + let restoreConfigLocationEnv; // Run install() with process.exit and console output mocked via t.mock (auto-restored), // per CONTRIBUTING.md test rules (no manual monkeypatch / try-finally in test bodies). @@ -970,6 +971,13 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () => delete process.env.GSD_EXPLICIT_CONFIG_DIR; savedTestMode = process.env.GSD_TEST_MODE; process.env.GSD_TEST_MODE = '1'; + // #2665: same in-process hazard as the block above. This one calls + // install(true, 'claude') via runInstall(), and HOME/USERPROFILE alone do + // not contain it — getGlobalConfigDir is env-FIRST, so an ambient + // CLAUDE_CONFIG_DIR beats the fixture, the install lands in the developer's + // live config dir, and these tests then fail looking for a marker under + // tmpRoot that was never written there. + restoreConfigLocationEnv = scrubConfigLocationEnv(); }); afterEach(() => { @@ -981,6 +989,7 @@ describe('#2624 .gsd-source marker is rewritten before staging reads it', () => else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir; if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE; else process.env.GSD_TEST_MODE = savedTestMode; + restoreConfigLocationEnv(); cleanup(tmpRoot); }); From fec42e9a7d6710733acfd8a611c3678fd60f5afe Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 1 Aug 2026 02:03:19 -0500 Subject: [PATCH 14/47] test(#2665): cover the widened derivation, and mark what these tests cannot prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests for round 3's change (round 2 Blockers 1 and 2): KIMI_SHARE_DIR must arrive via NON_REGISTRY_CONFIG_HOME_DESCRIPTORS rather than a literal, and every GSD_LOCATION_ENV_KEYS entry must be blanked. Both assert a floor on their source first, so a renamed export fails loudly instead of passing vacuously. Negative-controlled against the registry-only derivation: both fail there, and only those two. And the Nit, which is the more useful half. This block asserts that TEST_ENV_BASE is not narrower than the enumerations it derives from. It cannot prove those enumerations are complete — a var no enumeration carries is invisible to every test here, and they stay green. That is exactly how round 2 found GSD_HOME and KIMI_SHARE_DIR while this block was fully green: one belonged to no enumeration at all, the other sat inside a function body where nothing could enumerate it. So the scope boundary is now written down at the top of the block, naming where the completeness question is actually answered — a source census re-derived each round, and live-config-guard.cjs observing real writes at runtime — so that a green run here is not misread as "the set is exhaustive." Deliberately NOT added: an assertion per reviewer-named variable. That is the hand-maintained list wearing a test's clothes, and it fails the same way. --- tests/helpers-process-isolation.test.cjs | 68 ++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index 27685acb9..cd93b61d3 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -51,6 +51,29 @@ describe('withIsolatedProcessState', () => { // diagnosing this class and each fixing only the instance in front of them; // this is the third pass. A hand-maintained scrub list cannot be defended by // review alone, so the invariant is asserted instead of trusted. +// +// SCOPE BOUNDARY — read this before trusting a green run here. +// +// Every test below asserts that TEST_ENV_BASE covers some ENUMERATION (the +// capability registry, the non-registry descriptor set, GSD's own location +// keys). Each therefore proves only that the scrub set is not narrower than the +// enumeration it derives from. NONE of them can prove the enumeration is itself +// complete: a config-location var that no enumeration carries is invisible to +// all of them, and they stay green. +// +// That is not hypothetical — it is how round 2 found GSD_HOME and +// KIMI_SHARE_DIR while this block was fully green. GSD_HOME belonged to no +// enumeration at all (it is GSD's own store root, not a runtime configHome); +// KIMI_SHARE_DIR sat inside a function body where nothing could enumerate it. +// Round 3's fix was to make both enumerable rather than to add two assertions, +// precisely because an assertion added per reviewer-named var is the +// hand-maintained list wearing a test's clothes. +// +// The completeness question — "is every env-first first-party location var in +// SOME enumeration?" — is answered by a source census re-derived each round +// (see the PR discussion), and by scripts/live-config-guard.cjs at runtime, +// which observes actual writes rather than reasoning about names. Neither lives +// here, and this block should not be read as standing in for them. describe('#2665: TEST_ENV_BASE config-location coverage', () => { test('every runtime configHome env var in the registry is scrubbed', () => { const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); @@ -101,6 +124,51 @@ describe('#2665: TEST_ENV_BASE config-location coverage', () => { } }); + test('descriptor-shaped config homes OUTSIDE the registry are derived, not listed', () => { + const { + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, + } = require('../gsd-core/bin/lib/runtime-homes.cjs'); + + // Round 3. kimi owns TWO config homes: KIMI_CONFIG_DIR (registry-visible) and + // KIMI_SHARE_DIR (a hardcoded descriptor inside resolveKimiHooksTomlDir, which + // decides where its native config.toml — carrying GSD's [[hooks]] block — is + // written). The registry-only derivation reached the first and not the second, + // so it looked structurally complete while missing a live write surface. + const declared = [ + ...new Set(NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => d?.env ?? [])), + ]; + assert.ok( + declared.length >= 1, + 'expected at least one non-registry descriptor — an empty array makes this vacuous', + ); + assert.ok( + declared.includes('KIMI_SHARE_DIR'), + `KIMI_SHARE_DIR must come from the descriptor set, got ${declared.join(', ')}`, + ); + + const missing = declared.filter((k) => !(k in TEST_ENV_BASE)); + assert.deepStrictEqual( + missing, + [], + `descriptor-declared config-location vars not scrubbed: ${missing.join(', ')}`, + ); + }); + + test("GSD's OWN location vars are scrubbed (a second family, not a registry gap)", () => { + const { GSD_LOCATION_ENV_KEYS } = require('../gsd-core/bin/lib/runtime-homes.cjs'); + + // GSD_HOME decides where GSD keeps user-owned state ($GSD_HOME/.gsd/ — + // consent.json, defaults.json, capability overlays) and is read env-FIRST, + // ahead of os.homedir(), across capability-loader / capability-consent / + // capability-state / capability-writer / config-loader / install-profiles / + // bin/install.js. GSD_AGENTS_DIR is priority 1 in getAgentsDir. Neither is a + // runtime configHome, so no amount of registry derivation reaches them. + assert.ok(GSD_LOCATION_ENV_KEYS.includes('GSD_HOME')); + for (const key of GSD_LOCATION_ENV_KEYS) { + assert.strictEqual(TEST_ENV_BASE[key], '', `${key} must be scrubbed`); + } + }); + test('scrubConfigLocationEnv clears and restores the parent process env', () => { // The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches // children, so a test calling install() in-process needs the PARENT's env From 434d71b03b05d8901ea095d608179c4815903556 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 1 Aug 2026 02:05:12 -0500 Subject: [PATCH 15/47] docs(#2665): catalog the config-location and live-config-guard seams in CONTEXT.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2, Minor. "Workspace seams" carried WORKTREE.SEAM.* and CONFIG.SEAM.loadConfig-context but nothing for this PR's mechanism or its env vars, so the one machine-readable place a future author would look said nothing about the class that has now recurred three times. Ten predicates across two groups: CONFIG.LOCATION.SEAM.* — the scrub set's four derivation sources and the rule that a new var is made ENUMERABLE rather than appended; the two-families distinction (runtime configHomes vs GSD's own GSD_HOME/GSD_AGENTS_DIR) that round 2 turned on; kimi's two config-location vars; and the in-process scrub requirement, since HOME sandboxing alone is the trap that produced Blocker 1 twice. LIVE-CONFIG.GUARD.SEAM.* — module + exports + why it is scripts/ and not scripts/lib/; ownership-based scope; the two non-root targets and their asymmetric treatment; the truncation contract; the report-not-fatal severity ratchet; and that CI is structurally blind here, so green CI is not evidence. Both generated indexes regenerated. docs/CONTEXT-INDEX.json is checked by lint:generated-sync (`gen-context-index.cjs --check`) LINE-NUMBER-SENSITIVELY, and the example's own committed index is separately checked by lint-example-parser-parity.cjs, which the first regen did not satisfy — editing CONTEXT.md requires both, and only one of them says so in its error text. Verified: parity lint rc=0, gen-context-index --check rc=0, full lint:generated-sync rc=0. Regen diff audited — 10 predicates added, 0 removed, 0 values changed; the example index's remaining churn is line-number re-baking, which is exactly why the parity lint excludes line numbers. --- CONTEXT.md | 10 + docs/CONTEXT-INDEX.json | 62 +- .../CONTEXT-INDEX.json | 907 ++++++++++-------- 3 files changed, 545 insertions(+), 434 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2dae55b02..d1d29ad76 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -588,6 +588,16 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation` `WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream` `CONFIG.SEAM.loadConfig-context=loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites` +`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal` +`CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2` +`CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second` +`CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST` +`LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite` +`LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled` +`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot` +`LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing` +`LIVE-CONFIG.GUARD.SEAM.severity=reports by default; fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1, skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1; promote to fatal once the USERPROFILE sweep lands, mirroring local/no-source-grep warn->error (ADR 452)` +`LIVE-CONFIG.GUARD.SEAM.ci-blind=CI cannot catch the ambient-env half of this class at all — CI never has these vars set, so green CI is not evidence; the guard is the only loud signal` --- diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index b4efe1c43..ab98a38c5 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -1,14 +1,15 @@ { "schemaVersion": 1, - "count": 416, + "count": 425, "classes": { "ARCH": 1, "CI": 2, - "CONFIG": 1, - "DEFECT": 168, + "CONFIG": 5, + "DEFECT": 167, "EXEC": 8, "GSD-RESEARCH": 6, "LEARNING": 1, + "LIVE-CONFIG": 6, "META": 4, "PLANNING": 3, "PR": 2, @@ -39,6 +40,26 @@ "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #" }, + { + "id": "CONFIG.LOCATION.SEAM.in-process-scrub", + "klass": "CONFIG", + "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST" + }, + { + "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", + "klass": "CONFIG", + "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second" + }, + { + "id": "CONFIG.LOCATION.SEAM.scrub-set", + "klass": "CONFIG", + "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal" + }, + { + "id": "CONFIG.LOCATION.SEAM.two-families", + "klass": "CONFIG", + "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2" + }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", @@ -334,11 +355,6 @@ "klass": "DEFECT", "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks" }, - { - "id": "DEFECT.HOST-RESERVED-DIR-NAME", - "klass": "DEFECT", - "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees" - }, { "id": "DEFECT.INVENTORY-DRIFT.detect", "klass": "DEFECT", @@ -959,6 +975,36 @@ "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures" }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", + "klass": "LIVE-CONFIG", + "value": "CI cannot catch the ambient-env half of this class at all — CI never has these vars set, so green CI is not evidence; the guard is the only loud signal" + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.module", + "klass": "LIVE-CONFIG", + "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite" + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", + "klass": "LIVE-CONFIG", + "value": "resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot" + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.scope", + "klass": "LIVE-CONFIG", + "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled" + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.severity", + "klass": "LIVE-CONFIG", + "value": "reports by default; fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1, skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1; promote to fatal once the USERPROFILE sweep lands, mirroring local/no-source-grep warn->error (ADR 452)" + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.truncation", + "klass": "LIVE-CONFIG", + "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing" + }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index dc011439c..d26d8e127 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -1,14 +1,15 @@ { "schemaVersion": 1, - "count": 416, + "count": 425, "classes": { "ARCH": 1, "CI": 2, - "CONFIG": 1, - "DEFECT": 168, + "CONFIG": 5, + "DEFECT": 167, "EXEC": 8, "GSD-RESEARCH": 6, "LEARNING": 1, + "LIVE-CONFIG": 6, "META": 4, "PLANNING": 3, "PR": 2, @@ -28,2497 +29,2551 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 567 + "line": 548 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 551 + "line": 532 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 550 + "line": 531 + }, + { + "id": "CONFIG.LOCATION.SEAM.in-process-scrub", + "klass": "CONFIG", + "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST", + "line": 566 + }, + { + "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", + "klass": "CONFIG", + "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second", + "line": 565 + }, + { + "id": "CONFIG.LOCATION.SEAM.scrub-set", + "klass": "CONFIG", + "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", + "line": 563 + }, + { + "id": "CONFIG.LOCATION.SEAM.two-families", + "klass": "CONFIG", + "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2", + "line": 564 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 581 + "line": 562 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect", "klass": "DEFECT", "value": "tests/planner-decomposition.test.cjs (\"planner is under 45K chars (proves mode sections were extracted)\") and tests/reachability-check.test.cjs (\"file stays under 50000 char limit\")", - "line": 783 + "line": 774 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward", "klass": "DEFECT", "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file", - "line": 784 + "line": 775 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state", "klass": "DEFECT", "value": "gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says \"45K\" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over", - "line": 782 + "line": 773 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom", "klass": "DEFECT", "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold", - "line": 781 + "line": 772 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect", "klass": "DEFECT", "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations", - "line": 991 + "line": 980 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples", "klass": "DEFECT", "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)", - "line": 990 + "line": 979 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward", "klass": "DEFECT", "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct", - "line": 992 + "line": 981 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson", "klass": "DEFECT", "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes", - "line": 993 + "line": 982 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom", "klass": "DEFECT", "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff", - "line": 989 + "line": 978 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect", "klass": "DEFECT", "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale", - "line": 763 + "line": 754 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples", "klass": "DEFECT", "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)", - "line": 762 + "line": 753 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease", - "line": 764 + "line": 755 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom", "klass": "DEFECT", "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved", - "line": 761 + "line": 752 }, { "id": "DEFECT.CANARY-VERSION-LEAK.detect", "klass": "DEFECT", "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version", - "line": 946 + "line": 935 }, { "id": "DEFECT.CANARY-VERSION-LEAK.examples", "klass": "DEFECT", "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base", - "line": 945 + "line": 934 }, { "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward", "klass": "DEFECT", "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main", - "line": 947 + "line": 936 }, { "id": "DEFECT.CANARY-VERSION-LEAK.symptom", "klass": "DEFECT", "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label", - "line": 944 + "line": 933 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect", "klass": "DEFECT", "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls", - "line": 788 + "line": 779 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples", "klass": "DEFECT", "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle", - "line": 787 + "line": 778 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward", "klass": "DEFECT", "value": "author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess", - "line": 789 + "line": 780 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom", "klass": "DEFECT", "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number", - "line": 786 + "line": 777 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect", "klass": "DEFECT", "value": "any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts", - "line": 823 + "line": 814 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", "klass": "DEFECT", "value": "#3309 v2 default flip from mid-flight to end-of-phase", - "line": 822 + "line": 813 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward", "klass": "DEFECT", "value": "template — \"new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set \"", - "line": 824 + "line": 815 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom", "klass": "DEFECT", "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)", - "line": 821 + "line": 812 }, { "id": "DEFECT.FORMAT", "klass": "DEFECT", "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable", - "line": 738 + "line": 729 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect", "klass": "DEFECT", "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it", - "line": 836 + "line": 827 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples", "klass": "DEFECT", "value": "#586/PR #650 ship.md verification gate — grep \"^status:\" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)", - "line": 835 + "line": 826 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward", "klass": "DEFECT", "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)", - "line": 837 + "line": 828 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom", "klass": "DEFECT", "value": "a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep \"^key:\" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted", - "line": 834 + "line": 825 }, { "id": "DEFECT.GENERATIVE-EXEMPLAR", "klass": "DEFECT", "value": "tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)", - "line": 832 + "line": 823 }, { "id": "DEFECT.GENERATIVE-FIX", "klass": "DEFECT", "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge", - "line": 831 + "line": 822 }, { "id": "DEFECT.GENERATIVE-PRIORITY", "klass": "DEFECT", "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer", - "line": 830 + "line": 821 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect", "klass": "DEFECT", "value": "two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted", - "line": 982 + "line": 971 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward", "klass": "DEFECT", "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)", - "line": 983 + "line": 972 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause", "klass": "DEFECT", "value": "gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()", - "line": 981 + "line": 970 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom", "klass": "DEFECT", "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine", - "line": 980 + "line": 969 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream", "klass": "DEFECT", "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)", - "line": 984 + "line": 973 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect", "klass": "DEFECT", "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out", - "line": 950 + "line": 939 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples", "klass": "DEFECT", "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes", - "line": 949 + "line": 938 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward", "klass": "DEFECT", "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds", - "line": 951 + "line": 940 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related", "klass": "DEFECT", "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month", - "line": 952 + "line": 941 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom", "klass": "DEFECT", "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all", - "line": 948 + "line": 937 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect", "klass": "DEFECT", "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed", - "line": 974 + "line": 963 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery", "klass": "DEFECT", "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)", - "line": 976 + "line": 965 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause", "klass": "DEFECT", "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts", - "line": 975 + "line": 964 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom", "klass": "DEFECT", "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs", - "line": 973 + "line": 962 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", "klass": "DEFECT", "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe", - "line": 977 + "line": 966 }, { "id": "DEFECT.HALT-COST-PATTERN.detect", "klass": "DEFECT", "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context", - "line": 813 + "line": 804 }, { "id": "DEFECT.HALT-COST-PATTERN.examples", "klass": "DEFECT", "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)", - "line": 812 + "line": 803 }, { "id": "DEFECT.HALT-COST-PATTERN.fix-forward", "klass": "DEFECT", "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer", - "line": 814 + "line": 805 }, { "id": "DEFECT.HALT-COST-PATTERN.symptom", "klass": "DEFECT", "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn", - "line": 811 + "line": 802 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", "klass": "DEFECT", "value": "hook re-fires on each invocation regardless of session-state read receipts", - "line": 818 + "line": 809 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples", "klass": "DEFECT", "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file", - "line": 817 + "line": 808 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward", "klass": "DEFECT", "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match", - "line": 819 + "line": 810 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking", "klass": "DEFECT", "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files", - "line": 979 + "line": 968 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom", "klass": "DEFECT", "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session", - "line": 816 + "line": 807 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass", "klass": "DEFECT", "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks", - "line": 998 - }, - { - "id": "DEFECT.HOST-RESERVED-DIR-NAME", - "klass": "DEFECT", - "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees", - "line": 881 + "line": 987 }, { "id": "DEFECT.INVENTORY-DRIFT.detect", "klass": "DEFECT", "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading", - "line": 778 + "line": 769 }, { "id": "DEFECT.INVENTORY-DRIFT.examples", "klass": "DEFECT", "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)", - "line": 777 + "line": 768 }, { "id": "DEFECT.INVENTORY-DRIFT.fix-forward", "klass": "DEFECT", - "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path", - "line": 779 + "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all six families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY)", + "line": 770 }, { "id": "DEFECT.INVENTORY-DRIFT.symptom", "klass": "DEFECT", "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json", - "line": 776 + "line": 767 }, { "id": "DEFECT.NAME-COLLISION.detect", "klass": "DEFECT", "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract", - "line": 921 + "line": 910 }, { "id": "DEFECT.NAME-COLLISION.examples", "klass": "DEFECT", "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section
\")", - "line": 920 + "line": 909 }, { "id": "DEFECT.NAME-COLLISION.fix-forward", "klass": "DEFECT", "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract", - "line": 922 + "line": 911 }, { "id": "DEFECT.NAME-COLLISION.symptom", "klass": "DEFECT", "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw", - "line": 919 + "line": 908 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect", "klass": "DEFECT", "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning", - "line": 808 + "line": 799 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", "klass": "DEFECT", "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants", - "line": 807 + "line": 798 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward", "klass": "DEFECT", "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source", - "line": 809 + "line": 800 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom", "klass": "DEFECT", "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed", - "line": 806 + "line": 797 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor", "klass": "DEFECT", "value": "tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)", - "line": 754 + "line": 745 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect", "klass": "DEFECT", "value": "grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting", - "line": 752 + "line": 743 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples", "klass": "DEFECT", "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)", - "line": 751 + "line": 742 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward", "klass": "DEFECT", "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps", - "line": 753 + "line": 744 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom", "klass": "DEFECT", "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces", - "line": 750 + "line": 741 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect", "klass": "DEFECT", "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST", - "line": 871 + "line": 862 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples", "klass": "DEFECT", "value": "PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control", - "line": 870 + "line": 861 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward", "klass": "DEFECT", "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)", - "line": 872 + "line": 863 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention", "klass": "DEFECT", "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)", - "line": 873 + "line": 864 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom", "klass": "DEFECT", "value": "scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation", - "line": 869 + "line": 860 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", "klass": "DEFECT", "value": "any new bare tag in agents/*.md", - "line": 773 + "line": 764 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples", "klass": "DEFECT", "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)", - "line": 772 + "line": 763 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", "klass": "DEFECT", "value": "hyphenate the tag (, ) — scanner regex matches bare names only", - "line": 774 + "line": 765 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom", "klass": "DEFECT", "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate", - "line": 771 + "line": 762 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.detect", "klass": "DEFECT", "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete", - "line": 742 + "line": 733 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.examples", "klass": "DEFECT", "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace", - "line": 741 + "line": 732 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward", "klass": "DEFECT", "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility", - "line": 743 + "line": 734 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.symptom", "klass": "DEFECT", "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)", - "line": 740 + "line": 731 }, { "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", "klass": "DEFECT", "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)", - "line": 355 + "line": 339 }, { "id": "DEFECT.SCOPE.window", "klass": "DEFECT", "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287", - "line": 737 + "line": 728 }, { "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie", "klass": "DEFECT", "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open", - "line": 923 + "line": 912 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect", "klass": "DEFECT", "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)", - "line": 934 + "line": 923 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples", "klass": "DEFECT", "value": "#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002", - "line": 933 + "line": 922 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward", "klass": "DEFECT", "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class", - "line": 935 + "line": 924 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom", "klass": "DEFECT", "value": "a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with \"Cannot find module\" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT", - "line": 932 + "line": 921 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)", - "line": 936 + "line": 925 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect", "klass": "DEFECT", "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation", - "line": 827 + "line": 818 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward", "klass": "DEFECT", "value": "replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification", - "line": 828 + "line": 819 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom", "klass": "DEFECT", "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script", - "line": 826 + "line": 817 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect", "klass": "DEFECT", "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts", - "line": 758 + "line": 749 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples", "klass": "DEFECT", "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged", - "line": 757 + "line": 748 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward", "klass": "DEFECT", "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)", - "line": 759 + "line": 750 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom", "klass": "DEFECT", "value": "PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts", - "line": 756 + "line": 747 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern", "klass": "DEFECT", "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing", - "line": 942 + "line": 931 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect", "klass": "DEFECT", "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\"", - "line": 940 + "line": 929 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples", "klass": "DEFECT", "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all", - "line": 939 + "line": 928 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward", "klass": "DEFECT", "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")", - "line": 941 + "line": 930 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom", "klass": "DEFECT", "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first", - "line": 938 + "line": 927 }, { "id": "DEFECT.STATE-TRAMPLE.detect", "klass": "DEFECT", "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress", - "line": 747 + "line": 738 }, { "id": "DEFECT.STATE-TRAMPLE.examples", "klass": "DEFECT", "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)", - "line": 746 + "line": 737 }, { "id": "DEFECT.STATE-TRAMPLE.fix-forward", "klass": "DEFECT", "value": "route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)", - "line": 748 + "line": 739 }, { "id": "DEFECT.STATE-TRAMPLE.symptom", "klass": "DEFECT", "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter", - "line": 745 + "line": 736 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor", "klass": "DEFECT", "value": "lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)", - "line": 988 + "line": 977 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect", "klass": "DEFECT", "value": "sub-agent returns prematurely with text like \"I should wait for the notification per CLAUDE.md\" and incomplete work in its worktree (commits absent, push absent, PR absent)", - "line": 986 + "line": 975 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward", "klass": "DEFECT", "value": "keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task", - "line": 987 + "line": 976 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom", "klass": "DEFECT", "value": "spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness \"you will be notified\" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator", - "line": 985 + "line": 974 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect", "klass": "DEFECT", "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded", - "line": 768 + "line": 759 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", "klass": "DEFECT", "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)", - "line": 767 + "line": 758 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward", "klass": "DEFECT", "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history", - "line": 769 + "line": 760 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom", "klass": "DEFECT", "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts", - "line": 766 + "line": 757 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect", "klass": "DEFECT", "value": "test does readFileSync(md).match for a bash fence with literal \\n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703", - "line": 840 + "line": 831 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples", "klass": "DEFECT", "value": "#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \\n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite", - "line": 839 + "line": 830 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward", "klass": "DEFECT", "value": "match the fence with \\r?\\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file", - "line": 841 + "line": 832 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom", "klass": "DEFECT", "value": "a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \\n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\\...) is un-globbable in bash so the pipeline returns empty and assertions fail", - "line": 838 + "line": 829 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect", "klass": "DEFECT", "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view", - "line": 803 + "line": 794 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", "klass": "DEFECT", "value": "a33cbe72 worktree fix bound git subprocesses with timeout", - "line": 802 + "line": 793 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward", "klass": "DEFECT", "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw", - "line": 804 + "line": 795 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom", "klass": "DEFECT", "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network", - "line": 801 + "line": 792 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect", "klass": "DEFECT", "value": "Windows CI job at \"Run unit tests\" exits with code 1 within seconds of starting, no node:test output between \"run-tests: suite=… files=N: …\" line and \"Process completed with exit code 1\"; same job on Linux/macOS runs full duration", - "line": 927 + "line": 916 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples", "klass": "DEFECT", "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed", - "line": 926 + "line": 915 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward", "klass": "DEFECT", "value": "chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths", - "line": 928 + "line": 917 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention", "klass": "DEFECT", "value": "a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)", - "line": 930 + "line": 919 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom", "klass": "DEFECT", "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)", - "line": 925 + "line": 914 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs \"Windows argv-overflow chunking (issue #3597)\" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform", - "line": 929 + "line": 918 }, { "id": "DEFECT.WINDOWS-FS-OPS.detect", "klass": "DEFECT", "value": "ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape", - "line": 798 + "line": 789 }, { "id": "DEFECT.WINDOWS-FS-OPS.examples", "klass": "DEFECT", "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim", - "line": 797 + "line": 788 }, { "id": "DEFECT.WINDOWS-FS-OPS.fix-forward", "klass": "DEFECT", "value": "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop", - "line": 799 + "line": 790 }, { "id": "DEFECT.WINDOWS-FS-OPS.symptom", "klass": "DEFECT", "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target", - "line": 796 + "line": 787 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect", "klass": "DEFECT", "value": "any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\\.md or /…\\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)", - "line": 857 + "line": 848 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples", "klass": "DEFECT", "value": "PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\\...\\gsd-ial-windsurf-XXX\\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only", - "line": 856 + "line": 847 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward", "klass": "DEFECT", "value": "normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always", - "line": 858 + "line": 849 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention", "klass": "DEFECT", "value": "enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\\\/g,'/'))", - "line": 859 + "line": 850 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom", "klass": "DEFECT", "value": "path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected", - "line": 855 + "line": 846 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect", "klass": "DEFECT", "value": "any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)", - "line": 865 + "line": 856 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples", "klass": "DEFECT", "value": "PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side", - "line": 864 + "line": 855 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward", "klass": "DEFECT", "value": "normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.", - "line": 866 + "line": 857 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention", "klass": "DEFECT", "value": "enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT", - "line": 867 + "line": 858 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom", "klass": "DEFECT", "value": "an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual", - "line": 863 + "line": 854 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect", "klass": "DEFECT", "value": "grep tests for \\`.mode & 0o777\\` / \\`.mode) === 0o\\` / \\`writeFileSync(...{ mode: 0o\\` / \\`chmodSync\\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)", - "line": 851 + "line": 842 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples", "klass": "DEFECT", "value": "#1634/PR #1638 tests/capability-lifecycle.test.cjs \"a .cjs hook command is node-prefixed so it runs without the executable bit\" failed windows-latest,24 on \"precondition: file staged without +x\" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact", - "line": 850 + "line": 841 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward", "klass": "DEFECT", "value": "gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX", - "line": 852 + "line": 843 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention", "klass": "DEFECT", "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", - "line": 853 + "line": 844 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom", "klass": "DEFECT", "value": "a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755", - "line": 849 + "line": 840 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", "klass": "DEFECT", "value": "npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done", - "line": 845 + "line": 836 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples", "klass": "DEFECT", "value": "PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes", - "line": 844 + "line": 835 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward", "klass": "DEFECT", "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)", - "line": 846 + "line": 837 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention", "klass": "DEFECT", "value": "run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it", - "line": 847 + "line": 838 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom", "klass": "DEFECT", "value": "local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \\ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally", - "line": 843 + "line": 834 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect", "klass": "DEFECT", "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present", - "line": 877 + "line": 868 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples", "klass": "DEFECT", "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge", - "line": 876 + "line": 867 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward", "klass": "DEFECT", "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves", - "line": 878 + "line": 869 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention", "klass": "DEFECT", "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime", - "line": 879 + "line": 870 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom", "klass": "DEFECT", "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail", - "line": 875 + "line": 866 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect", "klass": "DEFECT", "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook", - "line": 793 + "line": 784 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", "klass": "DEFECT", "value": "this session, branch fix/3309-... and pr-3316", - "line": 792 + "line": 783 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:", - "line": 794 + "line": 785 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom", "klass": "DEFECT", "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch", - "line": 791 + "line": 782 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 966 + "line": 955 }, { "id": "EXEC.CLASSIFY.cross-runtime", "klass": "EXEC", "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests", - "line": 968 + "line": 957 }, { "id": "EXEC.CLASSIFY.handler", "klass": "EXEC", "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)", - "line": 964 + "line": 953 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 969 + "line": 958 }, { "id": "EXEC.CLASSIFY.proactive-signal-not-usable", "klass": "EXEC", "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)", - "line": 971 + "line": 960 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 970 + "line": 959 }, { "id": "EXEC.CLASSIFY.sentinel-order", "klass": "EXEC", "value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form", - "line": 967 + "line": 956 }, { "id": "EXEC.CLASSIFY.workflow", "klass": "EXEC", "value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)", - "line": 965 + "line": 954 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", "klass": "GSD-RESEARCH", "value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob", - "line": 354 + "line": 338 }, { "id": "GSD-RESEARCH.INTEGRATION.L2-hybrid", "klass": "GSD-RESEARCH", "value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches", - "line": 352 + "line": 336 }, { "id": "GSD-RESEARCH.MODULE.package-legitimacy", "klass": "GSD-RESEARCH", "value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate", - "line": 351 + "line": 335 }, { "id": "GSD-RESEARCH.MODULE.research-provider", "klass": "GSD-RESEARCH", "value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)", - "line": 350 + "line": 334 }, { "id": "GSD-RESEARCH.MODULE.research-store", "klass": "GSD-RESEARCH", "value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache", - "line": 349 + "line": 333 }, { "id": "GSD-RESEARCH.PROVIDER.availability", "klass": "GSD-RESEARCH", "value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal", - "line": 353 + "line": 337 }, { "id": "LEARNING.prompt-budget.boundary-gap", "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures", - "line": 498 + "line": 479 + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", + "klass": "LIVE-CONFIG", + "value": "CI cannot catch the ambient-env half of this class at all — CI never has these vars set, so green CI is not evidence; the guard is the only loud signal", + "line": 572 + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.module", + "klass": "LIVE-CONFIG", + "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", + "line": 567 + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", + "klass": "LIVE-CONFIG", + "value": "resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", + "line": 569 + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.scope", + "klass": "LIVE-CONFIG", + "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", + "line": 568 + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.severity", + "klass": "LIVE-CONFIG", + "value": "reports by default; fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1, skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1; promote to fatal once the USERPROFILE sweep lands, mirroring local/no-source-grep warn->error (ADR 452)", + "line": 571 + }, + { + "id": "LIVE-CONFIG.GUARD.SEAM.truncation", + "klass": "LIVE-CONFIG", + "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing", + "line": 570 }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations", - "line": 632 + "line": 623 }, { "id": "META.RULE.brief-no-paraphrase", "klass": "META", "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110", - "line": 633 + "line": 624 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 630 + "line": 621 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 631 + "line": 622 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 576 + "line": 557 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 577 + "line": 558 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 578 + "line": 559 }, { "id": "PR.3267.POSTMORTEM.recovery", "klass": "PR", "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]", - "line": 555 + "line": 536 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 554 + "line": 535 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 636 + "line": 627 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 642 + "line": 633 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 643 + "line": 634 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 638 + "line": 629 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 645 + "line": 636 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 641 + "line": 632 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 644 + "line": 635 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 637 + "line": 628 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 635 + "line": 626 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 639 + "line": 630 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 640 + "line": 631 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 651 + "line": 642 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 649 + "line": 640 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 650 + "line": 641 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 648 + "line": 639 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 647 + "line": 638 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 656 + "line": 647 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 657 + "line": 648 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 654 + "line": 645 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 659 + "line": 650 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 658 + "line": 649 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 655 + "line": 646 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 653 + "line": 644 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 664 + "line": 655 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 663 + "line": 654 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 666 + "line": 657 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 665 + "line": 656 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 662 + "line": 653 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 661 + "line": 652 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 670 + "line": 661 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 672 + "line": 663 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 669 + "line": 660 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 671 + "line": 662 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 668 + "line": 659 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 677 + "line": 668 }, { "id": "PRED.k325.cure", "klass": "PRED", "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/", - "line": 676 + "line": 667 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 678 + "line": 669 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 675 + "line": 666 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 674 + "line": 665 }, { "id": "PRED.k326.cure", "klass": "PRED", "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"", - "line": 682 + "line": 673 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 683 + "line": 674 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 681 + "line": 672 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 680 + "line": 671 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 686 + "line": 677 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 689 + "line": 680 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 690 + "line": 681 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 688 + "line": 679 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 687 + "line": 678 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 685 + "line": 676 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 695 + "line": 686 }, { "id": "PRED.k328.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)", - "line": 693 + "line": 684 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 694 + "line": 685 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 692 + "line": 683 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 701 + "line": 692 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 698 + "line": 689 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 699 + "line": 690 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 700 + "line": 691 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 702 + "line": 693 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 697 + "line": 688 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 706 + "line": 697 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 705 + "line": 696 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 704 + "line": 695 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 711 + "line": 702 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 713 + "line": 704 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 710 + "line": 701 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 712 + "line": 703 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 709 + "line": 700 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 708 + "line": 699 }, { "id": "PROBE.ci.surface", "klass": "PROBE", "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)", - "line": 469 + "line": 450 }, { "id": "PROBE.core.seam", "klass": "PROBE", "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)", - "line": 462 + "line": 443 }, { "id": "PROBE.edge.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 464 + "line": 445 }, { "id": "PROBE.family", "klass": "PROBE", "value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)", - "line": 460 + "line": 441 }, { "id": "PROBE.item.axes", "klass": "PROBE", "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)", - "line": 463 + "line": 444 }, { "id": "PROBE.principle", "klass": "PROBE", "value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md", - "line": 459 + "line": 440 }, { "id": "PROBE.prohib.verification", "klass": "PROBE", "value": "test|judgment", - "line": 465 + "line": 446 }, { "id": "PROBE.protocol", "klass": "PROBE", "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason", - "line": 461 + "line": 442 }, { "id": "PROBE.ui.axis", "klass": "PROBE", "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)", - "line": 467 + "line": 448 }, { "id": "PROBE.ui.seam", "klass": "PROBE", "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)", - "line": 468 + "line": 449 }, { "id": "PROBE.ui.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 466 + "line": 447 }, { "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 717 + "line": 708 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 716 + "line": 707 }, { "id": "PROC.AGENT-DISPATCH.preflight", "klass": "PROC", "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]", - "line": 715 + "line": 706 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 721 + "line": 712 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 722 + "line": 713 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 723 + "line": 714 }, { "id": "PROC.MERGE-WAVE.ordering", "klass": "PROC", "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]", - "line": 719 + "line": 710 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 720 + "line": 711 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.observed", "klass": "PROC", "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened", - "line": 996 + "line": 985 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", "klass": "PROC", "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill", - "line": 994 + "line": 983 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", "klass": "PROC", "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION", - "line": 995 + "line": 984 }, { "id": "PROC.TRIAGE.comment-shape", "klass": "PROC", "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close", - "line": 1001 + "line": 990 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 1002 + "line": 991 }, { "id": "PROC.TRIAGE.routing-incoming", "klass": "PROC", "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest", - "line": 1000 + "line": 989 }, { "id": "PROHIB.canon-referral", "klass": "PROHIB", "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)", - "line": 471 + "line": 452 }, { "id": "PROHIB.descriptor.shape", "klass": "PROHIB", "value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)", - "line": 476 + "line": 457 }, { "id": "PROHIB.enforce.adr", "klass": "PROHIB", "value": "docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)", - "line": 479 + "line": 460 }, { "id": "PROHIB.enforce.causation", "klass": "PROHIB", "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)", - "line": 475 + "line": 456 }, { "id": "PROHIB.enforce.failfirst", "klass": "PROHIB", "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)", - "line": 474 + "line": 455 }, { "id": "PROHIB.enforce.green-rule", "klass": "PROHIB", "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default", - "line": 472 + "line": 453 }, { "id": "PROHIB.enforce.kinds", "klass": "PROHIB", "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)", - "line": 473 + "line": 454 }, { "id": "PROHIB.judgment-tier", "klass": "PROHIB", "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)", - "line": 478 + "line": 459 }, { "id": "PROHIB.rail", "klass": "PROHIB", "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability", - "line": 477 + "line": 458 }, { "id": "PROHIB.recall", "klass": "PROHIB", "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)", - "line": 470 + "line": 451 }, { "id": "RELEASE-NOTES.ANTI-PATTERN", "klass": "RELEASE-NOTES", "value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes", - "line": 612 + "line": 603 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", "klass": "RELEASE-NOTES", "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior", - "line": 613 + "line": 604 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", "klass": "RELEASE-NOTES", "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong", - "line": 614 + "line": 605 }, { "id": "RELEASE-NOTES.DEFAULT-STATE", "klass": "RELEASE-NOTES", "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final", - "line": 588 + "line": 579 }, { "id": "RELEASE-NOTES.EXAMPLE.hotfix", "klass": "RELEASE-NOTES", "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups", - "line": 616 + "line": 607 }, { "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", "klass": "RELEASE-NOTES", "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles", - "line": 618 + "line": 609 }, { "id": "RELEASE-NOTES.EXAMPLE.rc", "klass": "RELEASE-NOTES", "value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy", - "line": 617 + "line": 608 }, { "id": "RELEASE-NOTES.GATE.hotfix", "klass": "RELEASE-NOTES", "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body", - "line": 589 + "line": 580 }, { "id": "RELEASE-NOTES.GATE.minor", "klass": "RELEASE-NOTES", "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix", - "line": 591 + "line": 582 }, { "id": "RELEASE-NOTES.GATE.rc", "klass": "RELEASE-NOTES", "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard", - "line": 590 + "line": 581 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 623 + "line": 614 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 624 + "line": 615 }, { "id": "RELEASE-NOTES.SCOPE", "klass": "RELEASE-NOTES", "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)", - "line": 587 + "line": 578 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 603 + "line": 594 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 602 + "line": 593 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 604 + "line": 595 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 605 + "line": 596 }, { "id": "RELEASE-NOTES.STANDARD.bullet-shape", "klass": "RELEASE-NOTES", "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.", - "line": 595 + "line": 586 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 599 + "line": 590 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 597 + "line": 588 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 598 + "line": 589 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 594 + "line": 585 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 600 + "line": 591 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 596 + "line": 587 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 593 + "line": 584 }, { "id": "RELEASE-NOTES.TEMPLATE.hotfix", "klass": "RELEASE-NOTES", "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ", - "line": 620 + "line": 611 }, { "id": "RELEASE-NOTES.TEMPLATE.rc", "klass": "RELEASE-NOTES", "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 621 + "line": 612 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 607 + "line": 598 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 610 + "line": 601 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 609 + "line": 600 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 608 + "line": 599 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 522 + "line": 503 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 511 + "line": 492 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 518 + "line": 499 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 519 + "line": 500 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 507 + "line": 488 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", "klass": "RULESET", "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.", - "line": 304 + "line": 288 }, { "id": "RULESET.CAPABILITY.off-means-off", "klass": "RULESET", "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.", - "line": 302 + "line": 286 }, { "id": "RULESET.CAPABILITY.precedence-engine-single-owner", "klass": "RULESET", "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.", - "line": 308 + "line": 292 }, { "id": "RULESET.CAPABILITY.step-additive-gate-blocks", "klass": "RULESET", "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.", - "line": 306 + "line": 290 }, { "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 544 + "line": 525 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 545 + "line": 526 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 543 + "line": 524 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 546 + "line": 527 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 547 + "line": 528 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 548 + "line": 529 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)", - "line": 861 + "line": 852 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 537 + "line": 518 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 538 + "line": 519 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 536 + "line": 517 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 535 + "line": 516 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 529 + "line": 510 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other, and a fragment left on `next` after merge is inert rather than a shared cell; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins. `tests/emitted-drift-ack.json` (the LEGACY file specifically, NOT the fragment directory) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; a persisting FRAGMENT is harmless by construction and is deliberately not what this guard checks. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next` (`assertAbsentOnNext`), which fails on the LEGACY file's PRESENCE alone, valid or not — a PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", - "line": 512 + "line": 493 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 542 + "line": 523 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM", - "line": 954 + "line": 943 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", - "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib", - "line": 523 + "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL SIX families.* arrays (agents/commands/workflows/references/cli_modules/hooks) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all six, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write", + "line": 504 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — \"we don't set a timer we actively watch and record results in real time as possible\". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:\"passed\" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.", - "line": 956 + "line": 945 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 958 + "line": 947 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 525 + "line": 506 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 520 + "line": 501 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 549 + "line": 530 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 494 + "line": 475 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 497 + "line": 478 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 496 + "line": 477 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 501 + "line": 482 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 492 + "line": 473 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 504 + "line": 485 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 493 + "line": 474 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 489 + "line": 470 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)", - "line": 505 + "line": 486 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 495 + "line": 476 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 491 + "line": 472 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 503 + "line": 484 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 490 + "line": 471 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 485 + "line": 466 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 486 + "line": 467 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 487 + "line": 468 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 500 + "line": 481 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 502 + "line": 483 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 527 + "line": 508 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 516 + "line": 497 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 515 + "line": 496 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 514 + "line": 495 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 513 + "line": 494 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 509 + "line": 490 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 510 + "line": 491 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 909 + "line": 898 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 910 + "line": 899 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 911 + "line": 900 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 912 + "line": 901 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 913 + "line": 902 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 914 + "line": 903 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 915 + "line": 904 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]", - "line": 916 + "line": 905 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]", - "line": 917 + "line": 906 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 730 + "line": 721 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 727 + "line": 718 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 728 + "line": 719 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 731 + "line": 722 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 729 + "line": 720 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 563 + "line": 544 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 564 + "line": 545 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 579 + "line": 560 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 580 + "line": 561 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 565 + "line": 546 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 573 + "line": 554 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 557 + "line": 538 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 561 + "line": 542 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 560 + "line": 541 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 558 + "line": 539 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 559 + "line": 540 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 571 + "line": 552 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 572 + "line": 553 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 575 + "line": 556 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 574 + "line": 555 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 570 + "line": 551 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 569 + "line": 550 } ], "duplicates": [] From fb02fa57220a2c4fd560bd029a44f6b3cbefac22 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 1 Aug 2026 02:27:21 -0500 Subject: [PATCH 16/47] chore(#2665): add the changeset fragment this PR now needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by CI on the round-3 push, not by review: `changeset-lint` went from OK_NO_USER_FACING_CHANGES to FAIL_MISSING_FRAGMENT. Both prior rounds passed that gate because the diff was tests/ + scripts/ only, and neither is a USER_FACING_PREFIX. Round 3 is the first commit in this PR to touch src/ — the descriptor hoist in src/runtime-homes.cts — and src/ is on the list. So the fragment requirement is a direct consequence of the remedy shape, not something the earlier rounds missed. src/runtime-homes.cts is the ONLY user-facing file in the whole PR diff (23 changed files); everything else is tests/, scripts/, CONTEXT.md, the two generated CONTEXT-INDEX.json artifacts, and examples/. Typed `Added` rather than `Fixed` because that is what actually reaches a user: new exported constants on a shipped module. The fix itself (#2665) is test hermeticity, which changes no shipped behaviour — resolveKimiHooksTomlDir() resolves identically before and after. Worth noting the local lint disagreed with CI and was wrong: it diffs `origin/main...HEAD`, which sweeps in the base's own committed fragments and returns a false ok_fragment_present. CI diffs `origin/$GITHUB_BASE_REF...HEAD` (next) and sees only this PR's files, which is the correct question. --- .changeset/quiet-moons-derive.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-moons-derive.md diff --git a/.changeset/quiet-moons-derive.md b/.changeset/quiet-moons-derive.md new file mode 100644 index 000000000..319ada4f0 --- /dev/null +++ b/.changeset/quiet-moons-derive.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2677 +--- +**`runtime-homes` now exports its non-registry config-home descriptors** — `KIMI_HOOKS_TOML_DESCRIPTOR`, `NON_REGISTRY_CONFIG_HOME_DESCRIPTORS`, `GSD_LOCATION_ENV_KEYS`, and the `ConfigHomeDescriptor` type are public, so consumers that need the *set* of config-location env vars (rather than a single resolved path) can derive it instead of hand-maintaining a copy. `resolveKimiHooksTomlDir()` behaviour is unchanged; its descriptor is simply named rather than inline (#2665). From 706bd2ab4e2e0a0b63a9422598e57f961a435ee2 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 1 Aug 2026 03:14:21 -0500 Subject: [PATCH 17/47] refactor(#2665): derive the guard's non-root targets from the descriptor array too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 38c9395d, found while fact-checking the round-3 response rather than by a test. That commit made TEST_ENV_BASE derive its keys from NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, but had the guard call resolveKimiHooksTomlDir directly. Both halves covered kimi, so nothing was broken — but only one of them would pick up a SECOND descriptor. That is the same partial-enumeration defect that put KIMI_SHARE_DIR outside the scrub set, reintroduced one layer over, in the very commit that closed it. resolveExtraWatchTargets now iterates the array and resolves each descriptor through resolveConfigHomeFromDescriptor, so the scrub set and the guard derive from one source and cannot drift apart. Verified: a synthetic second descriptor is picked up automatically (it was not before); kimi's target is unchanged on both the default (~/.kimi/config.toml) and KIMI_SHARE_DIR override paths. The new test asserts one target per descriptor plus the store root. The COUNT is the load-bearing half — every per-descriptor assertion passes vacuously today with a single entry, so only the count fails when the array grows and the guard does not follow. NAMED RESIDUAL, documented at NON_REGISTRY_OWNED_FILE: this assumes every non-registry descriptor is written the same way (config.toml). A descriptor whose owned file differs needs a per-descriptor mapping. It fails toward under-watching rather than false positives, so it is called out rather than left to be discovered. --- scripts/live-config-guard.cjs | 40 +++++++++++++++++++++++++++----- tests/live-config-guard.test.cjs | 33 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index b0e054bc4..883aab154 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -74,6 +74,19 @@ const GSD_OWNED_ENTRIES = ['gsd-core', 'gsd-file-manifest.json', 'gsd-pristine'] const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills']; const GSD_ARTIFACT_PREFIX = 'gsd-'; +/** + * The file GSD writes into a NON-REGISTRY config home. + * + * Today's only such descriptor is kimi's `~/.kimi` (KIMI_SHARE_DIR), where GSD + * writes its native `[[hooks]]` block into `config.toml`. NAMED RESIDUAL: this + * assumes every non-registry descriptor is written the same way. A future + * descriptor whose owned file differs needs a per-descriptor mapping here — the + * consequence of getting it wrong is under-watching (a missed leak), not a false + * positive, so it fails in the quiet direction and is called out rather than + * left to be discovered. + */ +const NON_REGISTRY_OWNED_FILE = 'config.toml'; + /** Bounds on the recursive walk, so a pathological tree cannot stall the suite. */ const MAX_ENTRIES = 20000; const MAX_DEPTH = 12; @@ -143,12 +156,27 @@ function resolveExtraWatchTargets(deps = {}) { const targets = [path.resolve(path.join(env.GSD_HOME || homedir(), '.gsd'))]; try { - const { resolveKimiHooksTomlDir } = require(path.join(libDir, 'runtime-homes.cjs')); - // Thread the SAME injected env/home the GSD_HOME line above uses. Calling it - // bare reads process.env and os.homedir() regardless of `deps`, which leaves - // the seam untestable and the two targets resolved against different worlds. - const kimiDir = resolveKimiHooksTomlDir({ env, home: homedir() }); - targets.push(path.resolve(path.join(kimiDir, 'config.toml'))); + const { + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, + resolveConfigHomeFromDescriptor, + } = require(path.join(libDir, 'runtime-homes.cjs')); + + // ITERATE the descriptor array rather than naming one resolver. Calling + // resolveKimiHooksTomlDir directly would cover today's only entry and + // silently miss tomorrow's — the same partial-enumeration defect that put + // KIMI_SHARE_DIR outside the scrub set in the first place, reintroduced one + // layer over. TEST_ENV_BASE derives its keys from this array; deriving the + // guard's paths from it keeps the two halves from drifting apart. + // + // Thread the SAME injected env/home used above: resolving bare would read + // process.env and os.homedir() regardless of `deps`, leaving the seam + // untestable and the targets resolved against different worlds. + for (const descriptor of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) { + const dir = resolveConfigHomeFromDescriptor(descriptor, { env, home: homedir() }); + // GSD writes ONE named file into these third-party roots; the root itself + // belongs to the runtime, so it is never watched wholesale. + targets.push(path.resolve(path.join(dir, NON_REGISTRY_OWNED_FILE))); + } } catch { // Unbuilt tree — same posture as resolveLiveConfigRoots: advisory, never fatal. } diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 9760f4767..41ebccca0 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -196,6 +196,39 @@ describe('#2665: guard watches non-root write surfaces', () => { } }); + test('extra targets are DERIVED from the descriptor array, not a named resolver', () => { + const { + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, + resolveConfigHomeFromDescriptor, + } = require('../gsd-core/bin/lib/runtime-homes.cjs'); + const home = tmpRoot(); + try { + const env = { GSD_HOME: home }; + const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } }); + + // Every descriptor in the array must contribute a target. Calling one + // named resolver instead would cover today's single entry and silently + // miss tomorrow's — the same partial-enumeration defect that put + // KIMI_SHARE_DIR outside the scrub set, one layer over. + for (const d of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) { + const dir = resolveConfigHomeFromDescriptor(d, { env, home }); + assert.ok( + targets.includes(path.resolve(path.join(dir, 'config.toml'))), + `descriptor ${JSON.stringify(d.env)} contributed no watch target`, + ); + } + // The count is what actually catches a regression to a hardcoded call: + // it fails the moment the array grows and the guard does not follow. + assert.strictEqual( + targets.length, + 1 + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.length, + 'expected the GSD store root plus exactly one target per descriptor', + ); + } finally { + cleanup(home); + } + }); + test('GSD_HOME falls back to homedir when unset', () => { const home = tmpRoot(); try { From 209f2fe9830216cac3d5ee1bdd852da494baf493 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 03:03:15 -0500 Subject: [PATCH 18/47] fix(#2665): exclude the test-instrumentation chain from the npm tarball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4, Major 1: scripts/live-config-guard.cjs is pure test instrumentation and was shipping to every npm install. The repo already carries the exclusion convention (gen-emitted-baseline, qa-smell-ratchet) in the same files[] array. Excluding the guard alone would trip the #2858 shipped-requires-only-shipped gate: run-tests.cjs (shipped) requires it at load time, and affected-tests-lib.cjs / run-affected-tests.cjs sit on the same chain. The four files are one closed require chain of test instrumentation, so the exclusion covers the chain, not one link. The guard's LOCATION header cited affected-tests-lib.cjs as "the precedent for a non-shipped helper", which npm pack disproves — rewritten to the tarball-exclusion fact. --- .changeset/quiet-moons-derive.md | 2 ++ CONTEXT.md | 2 +- package.json | 4 ++++ scripts/live-config-guard.cjs | 7 +++++-- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.changeset/quiet-moons-derive.md b/.changeset/quiet-moons-derive.md index 319ada4f0..822cfb724 100644 --- a/.changeset/quiet-moons-derive.md +++ b/.changeset/quiet-moons-derive.md @@ -3,3 +3,5 @@ type: Added pr: 2677 --- **`runtime-homes` now exports its non-registry config-home descriptors** — `KIMI_HOOKS_TOML_DESCRIPTOR`, `NON_REGISTRY_CONFIG_HOME_DESCRIPTORS`, `GSD_LOCATION_ENV_KEYS`, and the `ConfigHomeDescriptor` type are public, so consumers that need the *set* of config-location env vars (rather than a single resolved path) can derive it instead of hand-maintaining a copy. `resolveKimiHooksTomlDir()` behaviour is unchanged; its descriptor is simply named rather than inline (#2665). + +**The test-instrumentation scripts no longer ship in the npm package** — `scripts/run-tests.cjs`, `scripts/live-config-guard.cjs`, `scripts/affected-tests-lib.cjs`, and `scripts/run-affected-tests.cjs` are now excluded from the tarball (they are one closed require chain of repo-only test tooling). `npm test` in an installed package was already inoperable (`tests/` has never shipped); a deep import of `scripts/run-tests.cjs` from the published package — an unsupported surface — will now be `MODULE_NOT_FOUND` (#2665). diff --git a/CONTEXT.md b/CONTEXT.md index d1d29ad76..f4941a753 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -592,7 +592,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2` `CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second` `CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST` -`LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite` +`LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite` `LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled` `LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot` `LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing` diff --git a/package.json b/package.json index 88d2694b7..5e086764c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ "scripts", "!scripts/gen-emitted-baseline.cjs", "!scripts/qa-smell-ratchet.cjs", + "!scripts/live-config-guard.cjs", + "!scripts/run-tests.cjs", + "!scripts/affected-tests-lib.cjs", + "!scripts/run-affected-tests.cjs", "pi", "vscode" ], diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 883aab154..8b645d099 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -22,8 +22,11 @@ * LOCATION — `scripts/`, deliberately NOT `scripts/lib/`. The installer copies * `scripts/lib/` into every user's config dir wholesale (readdirSync), while * uninstall removes only an explicit allowlist, so a test-only module placed - * there would ship to users AND survive uninstall. `scripts/affected-tests-lib.cjs` - * is the precedent for a non-shipped helper at this level. + * there would ship to users AND survive uninstall. This file is also excluded + * from the npm tarball (`package.json` `files[]` `!scripts/live-config-guard.cjs`, + * alongside its whole require chain: run-tests.cjs, affected-tests-lib.cjs, + * run-affected-tests.cjs — excluding one link alone would trip the #2858 + * shipped-requires-only-shipped gate on the links that still shipped). * * SCOPE — ownership-based, not whole-root. It watches entries GSD unambiguously * owns: the top-level install footprint (`GSD_OWNED_ENTRIES`) plus `gsd-`-prefixed From 253250580e835e47833084429dbd897423ced72b Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 03:05:29 -0500 Subject: [PATCH 19/47] fix(#2665): wire the live-config guard to strict mode on Linux/macOS CI lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4, Major 2, answering the explicit report-only-vs-strict question: strict now. A future regression of the class this PR closes should fail CI, not print a warning nobody reads — that is what the PR title promises. Scoped deliberately: GSD_STRICT_LIVE_CONFIG_GUARD=1 on the Linux/macOS lanes of all three test jobs; Windows lanes stay report-only because the guard's first run found pre-existing USERPROFILE leaks there (~190 test sites sandbox HOME alone) — flipping them strict today reddens next on a defect class this PR does not carry. Promote once that sweep lands (the SEVERITY note in live-config-guard.cjs and the CONTEXT.md seam both now record that state). --- .github/workflows/test.yml | 9 +++++++++ CONTEXT.md | 4 ++-- scripts/live-config-guard.cjs | 10 ++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24d8e8912..7af090cce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,6 +140,11 @@ jobs: timeout-minutes: 15 env: GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled + # #2665: a live-config leak fails the run on Linux/macOS lanes. Windows + # stays report-only: the guard's first run found PRE-EXISTING USERPROFILE + # leaks there (~190 test sites sandbox HOME alone), a documented separate + # class — promote once that sweep lands (see live-config-guard.cjs SEVERITY). + GSD_STRICT_LIVE_CONFIG_GUARD: ${{ matrix.os != 'windows-latest' && '1' || '' }} # #2854: pin the emitted gate's baseline to the SAME commit the tree was merged # with. "Rebase check" merges `pull_request.base.sha` (pinned by #2472 so all 12 # matrix jobs agree on one tree), but `resolveBase()` otherwise falls through to @@ -333,6 +338,8 @@ jobs: timeout-minutes: 15 env: GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled + # #2665: ubuntu-only lane — strict unconditionally (see the `test` job note). + GSD_STRICT_LIVE_CONFIG_GUARD: '1' # #2854: same pin as the other rebase-merged lanes. This lane runs a targeted # list that can include the emitted gate, and the invariant is easier to keep # with no exceptions: if a job merges a pinned base, the gate uses that base. @@ -410,6 +417,8 @@ jobs: timeout-minutes: 30 env: GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled + # #2665: strict on Linux/macOS, report-only on Windows (see the `test` job note). + GSD_STRICT_LIVE_CONFIG_GUARD: ${{ matrix.os != 'windows-latest' && '1' || '' }} # #2854: pin the emitted gate's baseline to the SAME commit the tree was merged # with. "Rebase check" merges `pull_request.base.sha` (pinned by #2472 so all 12 # matrix jobs agree on one tree), but `resolveBase()` otherwise falls through to diff --git a/CONTEXT.md b/CONTEXT.md index f4941a753..34d4f7ae8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -596,8 +596,8 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled` `LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot` `LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing` -`LIVE-CONFIG.GUARD.SEAM.severity=reports by default; fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1, skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1; promote to fatal once the USERPROFILE sweep lands, mirroring local/no-source-grep warn->error (ADR 452)` -`LIVE-CONFIG.GUARD.SEAM.ci-blind=CI cannot catch the ambient-env half of this class at all — CI never has these vars set, so green CI is not evidence; the guard is the only loud signal` +`LIVE-CONFIG.GUARD.SEAM.severity=reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1` +`LIVE-CONFIG.GUARD.SEAM.ci-blind=the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes` --- diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 8b645d099..cdfe4d65f 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -317,9 +317,15 @@ function formatViolations(violations) { lines.push(` ${label}: ${v.path}`); } lines.push(''); + // The footer must state the mode the run is ACTUALLY in — a strict-mode + // failure captioned "Reporting only" sends the reader away from the very + // violation that just reddened their run. lines.push( - 'Reporting only. Set GSD_STRICT_LIVE_CONFIG_GUARD=1 to make this fail the run, ' + - 'or GSD_SKIP_LIVE_CONFIG_GUARD=1 to skip the check entirely.', + process.env.GSD_STRICT_LIVE_CONFIG_GUARD === '1' + ? 'STRICT MODE (GSD_STRICT_LIVE_CONFIG_GUARD=1): these violations fail the run. ' + + 'GSD_SKIP_LIVE_CONFIG_GUARD=1 skips the check entirely.' + : 'Reporting only. Set GSD_STRICT_LIVE_CONFIG_GUARD=1 to make this fail the run, ' + + 'or GSD_SKIP_LIVE_CONFIG_GUARD=1 to skip the check entirely.', ); lines.push(''); return lines.join('\n'); From 7b8c36f904e26d9c7b90df07378f4510b0cec4a5 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 03:06:34 -0500 Subject: [PATCH 20/47] fix(#2665): walk skillsHome.env on both descriptor rungs of the scrub derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4, Minor 3. A configHome descriptor can nest a second, independently-resolved descriptor (skillsHome -> resolveSkillsBaseFromDescriptor) carrying its own env array, and the derivation walked configHome.env alone — the identical walk-one-field gap-shape rounds 2-3 closed for the registry and the non-registry set. Inert today (only kilo declares skillsHome, with env: []), closed before it is live rather than after. The guard's root enumeration deliberately does NOT gain the skills base: getGlobalSkillsBase returns a skills directory (codex: ~/.agents/skills), not a config root, and the snapshot applies the config-root layout beneath every root — adding it false-positives on /gsd-core while missing a real /gsd-help write (found by this round's pre-push adversarial review). Watching skills bases needs its own layout, like resolveExtraWatchTargets; a comment in resolveLiveConfigRoots records the non-action. New derivation test asserts both skillsHome rungs land in TEST_ENV_BASE, with an anti-vacuity check that at least one runtime actually declares the field. --- scripts/live-config-guard.cjs | 8 +++++ tests/helpers-process-isolation.test.cjs | 44 ++++++++++++++++++++++++ tests/helpers.cjs | 16 +++++++-- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index cdfe4d65f..eec8f65f5 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -116,6 +116,14 @@ function resolveLiveConfigRoots(deps = {}) { const roots = new Set(); // 'grok' is a hardcoded branch of getGlobalConfigDir with no registry entry. + // + // DELIBERATE NON-ROOT: getGlobalSkillsBase(runtime) is NOT added here. The + // skills base (e.g. codex's ~/.agents/skills) is not a config ROOT, and the + // snapshot applies the config-root layout (GSD_OWNED_ENTRIES x + // GSD_PREFIXED_PARENTS) beneath every root it is given — measured on a + // sandboxed HOME, adding it both false-positives on `/gsd-core` + // and misses a real `/gsd-help` write. Watching skills bases + // needs its own layout, like resolveExtraWatchTargets — a separate change. for (const runtime of [...Object.keys(runtimes || {}), 'grok']) { try { const dir = getGlobalConfigDir(runtime); diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index cd93b61d3..d18e4f2b9 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -154,6 +154,50 @@ describe('#2665: TEST_ENV_BASE config-location coverage', () => { ); }); + test('skillsHome env vars are walked on BOTH descriptor rungs', () => { + // Round 4. A configHome descriptor can nest a second, independently-resolved + // descriptor (skillsHome → resolveSkillsBaseFromDescriptor), which carries + // its own env array. Walking configHome.env alone is the identical + // walk-one-field gap-shape rounds 2-3 closed for the registry and the + // non-registry set. Inert today — only kilo declares skillsHome, with + // env: [] — so this asserts the DERIVATION reaches the field, not that any + // var currently flows from it: every skillsHome-declared var (registry and + // non-registry alike) must land in TEST_ENV_BASE the moment one exists. + const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + const { + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, + } = require('../gsd-core/bin/lib/runtime-homes.cjs'); + + const declared = [ + ...new Set([ + ...Object.values(runtimes).flatMap( + (r) => r?.runtime?.configHome?.skillsHome?.env ?? [], + ), + ...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap( + (d) => d?.skillsHome?.env ?? [], + ), + ]), + ]; + + // Anti-vacuity: at least one runtime must actually DECLARE skillsHome, or a + // registry reshape could rename the field and retire this test silently. + const declaringRuntimes = Object.values(runtimes).filter( + (r) => r?.runtime?.configHome?.skillsHome !== undefined, + ); + assert.ok( + declaringRuntimes.length >= 1, + 'expected at least one registry runtime to declare configHome.skillsHome — ' + + 'if the field moved, this derivation needs updating, not deleting', + ); + + const missing = declared.filter((k) => !(k in TEST_ENV_BASE)); + assert.deepStrictEqual( + missing, + [], + `skillsHome-declared config-location vars not scrubbed: ${missing.join(', ')}`, + ); + }); + test("GSD's OWN location vars are scrubbed (a second family, not a registry gap)", () => { const { GSD_LOCATION_ENV_KEYS } = require('../gsd-core/bin/lib/runtime-homes.cjs'); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 8b4a27f42..64161dee9 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -67,11 +67,23 @@ const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [ const CONFIG_LOCATION_ENV_KEYS = [ ...new Set([ - // 1. Every runtime descriptor the capability registry carries. + // 1. Every runtime descriptor the capability registry carries — including + // the nested skillsHome descriptor, which resolves independently of + // configHome (resolveSkillsBaseFromDescriptor) and can carry its own + // env array. Inert today (only kilo declares skillsHome, with env: []), + // but walking configHome.env alone is the identical gap-shape this PR + // closed twice already, one field over. (#2665 round 4) ...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []), + ...Object.values(runtimes).flatMap( + (r) => r?.runtime?.configHome?.skillsHome?.env ?? [], + ), // 2. Descriptor-shaped config homes resolved OUTSIDE the registry (kimi's // native config.toml home via KIMI_SHARE_DIR). Derived, not hand-listed. - ...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => d?.env ?? []), + // Same skillsHome walk as rung 1 — a descriptor is a descriptor. + ...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => [ + ...(d?.env ?? []), + ...(d?.skillsHome?.env ?? []), + ]), // 3. GSD's OWN location vars — a different family: they decide where GSD keeps // user-owned state ($GSD_HOME/.gsd/), not where a runtime keeps its config. ...GSD_LOCATION_ENV_KEYS, From 652b99b71b820cb24206128e7b4ad66f65e5bfdb Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 03:09:17 -0500 Subject: [PATCH 21/47] test(#2665): reversion guards for all three round-4 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of the round-4 fixes had a test that fails on reversion: re-shipping the test-instrumentation chain passes #2858 (everything ships, so every require resolves), dropping the strict env from test.yml demotes the guard to report-only with nothing red, and the skillsHome derivation tests are enumeration-relative over declarations that are all empty today. One guard each, every one negative-controlled against its reverted fix (fails pre-fix, passes post-fix): - packaging-shipped-scripts-require-only-shipped.test.cjs asserts the four chain files are absent from the npm pack file list (reuses the tarball set the #2858 gate already resolves — no second npm pack). - live-config-guard.test.cjs asserts all three test jobs wire GSD_STRICT_LIVE_CONFIG_GUARD, matching the WHOLE expression anchored — a prefix match accepted both a Windows-silently-strict tail and a malformed one. - helpers-process-isolation.test.cjs cold-requires helpers.cjs in a child with sentinel skillsHome env vars injected into both enumerations, so the walk itself is under test rather than today's empty declarations. --- tests/helpers-process-isolation.test.cjs | 54 +++++++++++++++++++ tests/live-config-guard.test.cjs | 37 +++++++++++++ ...pped-scripts-require-only-shipped.test.cjs | 21 ++++++++ 3 files changed, 112 insertions(+) diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index d18e4f2b9..d6554813f 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -235,3 +235,57 @@ describe('#2665: TEST_ENV_BASE config-location coverage', () => { }); }); }); + +describe('#2665 round 4: the skillsHome walk is reversion-sensitive', () => { + // The coverage tests above are enumeration-relative, and skillsHome.env is + // empty everywhere today — so reverting the skillsHome rungs from the + // derivation leaves every one of them green (measured by this round's + // pre-push adversarial review). This test closes that: it cold-requires + // helpers.cjs in a child process after injecting sentinel skillsHome env + // vars into BOTH enumerations (registry and non-registry), so the walk + // itself is what is under test, not today's empty declarations. + test('sentinel skillsHome vars flow into TEST_ENV_BASE on both rungs', () => { + const { execFileSync } = require('node:child_process'); + const regPath = require.resolve('../gsd-core/bin/lib/capability-registry.cjs'); + const rhPath = require.resolve('../gsd-core/bin/lib/runtime-homes.cjs'); + const helpersPath = require.resolve('./helpers.cjs'); + + const script = ` + 'use strict'; + const reg = require(${JSON.stringify(regPath)}); + const rh = require(${JSON.stringify(rhPath)}); + // Rung 1 (registry): give one runtime a skillsHome env var. kilo already + // declares skillsHome (env: []); push a sentinel into whichever runtime + // declares it, or graft one onto the first runtime if none does. + const declaring = Object.values(reg.runtimes).find( + (r) => r?.runtime?.configHome?.skillsHome, + ) ?? Object.values(reg.runtimes)[0]; + if (!declaring.runtime.configHome.skillsHome) { + declaring.runtime.configHome.skillsHome = { kind: 'dot-home', name: '.x', env: [] }; + } + declaring.runtime.configHome.skillsHome.env = ['GSD_TEST_SENTINEL_REGISTRY_SKILLS']; + // Rung 2 (non-registry): graft a skillsHome onto the first descriptor. + rh.NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[0].skillsHome = { + kind: 'dot-home', name: '.x', env: ['GSD_TEST_SENTINEL_NONREG_SKILLS'], + }; + const { TEST_ENV_BASE } = require(${JSON.stringify(helpersPath)}); + const missing = [ + 'GSD_TEST_SENTINEL_REGISTRY_SKILLS', + 'GSD_TEST_SENTINEL_NONREG_SKILLS', + ].filter((k) => TEST_ENV_BASE[k] !== ''); + if (missing.length > 0) { + console.error('skillsHome walk missed: ' + missing.join(', ')); + process.exit(1); + } + process.exit(0); + `; + + const out = execFileSync(process.execPath, ['-e', script], { + cwd: __dirname, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30_000, + }); + void out; // exit 0 is the assertion; execFileSync throws on nonzero + }); +}); diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 41ebccca0..a12c46006 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -410,3 +410,40 @@ describe('#2665: scan-budget truncation', () => { } }); }); + +describe('#2665 round 4: CI wires the guard to strict mode', () => { + // The reversion this guards: dropping GSD_STRICT_LIVE_CONFIG_GUARD from + // test.yml silently demotes the guard back to report-only, and a future + // leak of exactly the class #2665 closes prints a warning and CI stays + // green. Windows lanes are deliberately report-only until the documented + // pre-existing USERPROFILE leak class is swept (SEVERITY note in + // scripts/live-config-guard.cjs) — so the assertion is per-OS, not global. + test('all three test jobs set GSD_STRICT_LIVE_CONFIG_GUARD (Windows carved out)', () => { + const yaml = require('js-yaml'); + const wf = yaml.load( + fs.readFileSync( + path.join(__dirname, '..', '.github', 'workflows', 'test.yml'), + 'utf8', + ), + ); + + for (const job of ['test', 'test-full']) { + const v = String(wf.jobs?.[job]?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? ''); + assert.match( + v, + // The WHOLE expression, anchored — a prefix match accepted both + // `&& '1' || '1'` (Windows silently strict) and a malformed tail + // (found by this round's pre-push adversarial review). + /^\$\{\{\s*matrix\.os\s*!=\s*'windows-latest'\s*&&\s*'1'\s*\|\|\s*''\s*\}\}$/, + `jobs.${job}.env.GSD_STRICT_LIVE_CONFIG_GUARD must be strict on ` + + `non-Windows lanes and empty on windows-latest; got: ${JSON.stringify(v)}`, + ); + } + + assert.strictEqual( + String(wf.jobs?.['test-inert']?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? ''), + '1', + 'jobs.test-inert (ubuntu-only) must set GSD_STRICT_LIVE_CONFIG_GUARD: 1', + ); + }); +}); diff --git a/tests/packaging-shipped-scripts-require-only-shipped.test.cjs b/tests/packaging-shipped-scripts-require-only-shipped.test.cjs index 8d3142c31..774a3d4df 100644 --- a/tests/packaging-shipped-scripts-require-only-shipped.test.cjs +++ b/tests/packaging-shipped-scripts-require-only-shipped.test.cjs @@ -135,6 +135,27 @@ describe('#2858 — shipped scripts require only shipped paths', () => { .sort(); }); + test('#2665: the test-instrumentation chain does not ship', () => { + // These four are one closed require chain of test instrumentation + // (run-tests -> live-config-guard, affected-tests-lib -> run-tests, + // run-affected-tests -> affected-tests-lib). Excluding a strict subset + // re-trips the shipped-requires-only-shipped gate above on whichever links + // still ship, so the exclusion set and this assertion cover the chain. + const TEST_INSTRUMENTATION = [ + 'scripts/live-config-guard.cjs', + 'scripts/run-tests.cjs', + 'scripts/affected-tests-lib.cjs', + 'scripts/run-affected-tests.cjs', + ]; + for (const f of TEST_INSTRUMENTATION) { + assert.ok( + !shippedFiles.has(f), + `${f} is test instrumentation and must not ship — restore its ` + + "package.json files[] '!'-exclusion (and keep the whole chain excluded)", + ); + } + }); + test('every shipped scripts/*.{cjs,js} is require-able from a shipped-only tree', () => { assert.ok(shippedScripts.length > 0, 'expected at least one shipped script'); From 123ba26fc5079abd3b7d457c147a57625de7feac Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 03:48:00 -0500 Subject: [PATCH 22/47] chore(#2665): regenerate docs/CONTEXT-INDEX.json for the round-4 CONTEXT.md edits The round-4 seam-entry rewrites (packaging fact on SEAM.module, strict-mode state on SEAM.severity/ci-blind) left the derived index stale, which failed lint:generated-sync and the #2944 sync test across ten CI contexts. Derived artifact regen only; no content change beyond what CONTEXT.md already says. --- docs/CONTEXT-INDEX.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index ab98a38c5..e59217410 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -978,12 +978,12 @@ { "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", "klass": "LIVE-CONFIG", - "value": "CI cannot catch the ambient-env half of this class at all — CI never has these vars set, so green CI is not evidence; the guard is the only loud signal" + "value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes" }, { "id": "LIVE-CONFIG.GUARD.SEAM.module", "klass": "LIVE-CONFIG", - "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite" + "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite" }, { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", @@ -998,7 +998,7 @@ { "id": "LIVE-CONFIG.GUARD.SEAM.severity", "klass": "LIVE-CONFIG", - "value": "reports by default; fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1, skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1; promote to fatal once the USERPROFILE sweep lands, mirroring local/no-source-grep warn->error (ADR 452)" + "value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1" }, { "id": "LIVE-CONFIG.GUARD.SEAM.truncation", From 111733a057158f4d43da9ea4a2cdb775c56ddf98 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 04:08:27 -0500 Subject: [PATCH 23/47] chore(#2665): regenerate the examples CONTEXT-INDEX.json mirror too lint-example-parser-parity holds examples/dynamic-context-management/ CONTEXT-INDEX.json to a fresh parse of CONTEXT.md; the round-4 seam-entry rewrites needed this second derived artifact regenerated alongside docs/CONTEXT-INDEX.json. Derived regen only. --- .../CONTEXT-INDEX.json | 856 +++++++++--------- 1 file changed, 428 insertions(+), 428 deletions(-) diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index d26d8e127..8921a245c 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -29,2551 +29,2551 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 548 + "line": 561 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 532 + "line": 545 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 531 + "line": 544 }, { "id": "CONFIG.LOCATION.SEAM.in-process-scrub", "klass": "CONFIG", "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST", - "line": 566 + "line": 579 }, { "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", "klass": "CONFIG", "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second", - "line": 565 + "line": 578 }, { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", - "line": 563 + "line": 576 }, { "id": "CONFIG.LOCATION.SEAM.two-families", "klass": "CONFIG", "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2", - "line": 564 + "line": 577 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 562 + "line": 575 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect", "klass": "DEFECT", "value": "tests/planner-decomposition.test.cjs (\"planner is under 45K chars (proves mode sections were extracted)\") and tests/reachability-check.test.cjs (\"file stays under 50000 char limit\")", - "line": 774 + "line": 787 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward", "klass": "DEFECT", "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file", - "line": 775 + "line": 788 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state", "klass": "DEFECT", "value": "gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says \"45K\" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over", - "line": 773 + "line": 786 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom", "klass": "DEFECT", "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold", - "line": 772 + "line": 785 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect", "klass": "DEFECT", "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations", - "line": 980 + "line": 993 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples", "klass": "DEFECT", "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)", - "line": 979 + "line": 992 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward", "klass": "DEFECT", "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct", - "line": 981 + "line": 994 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson", "klass": "DEFECT", "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes", - "line": 982 + "line": 995 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom", "klass": "DEFECT", "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff", - "line": 978 + "line": 991 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect", "klass": "DEFECT", "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale", - "line": 754 + "line": 767 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples", "klass": "DEFECT", "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)", - "line": 753 + "line": 766 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease", - "line": 755 + "line": 768 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom", "klass": "DEFECT", "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved", - "line": 752 + "line": 765 }, { "id": "DEFECT.CANARY-VERSION-LEAK.detect", "klass": "DEFECT", "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version", - "line": 935 + "line": 948 }, { "id": "DEFECT.CANARY-VERSION-LEAK.examples", "klass": "DEFECT", "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base", - "line": 934 + "line": 947 }, { "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward", "klass": "DEFECT", "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main", - "line": 936 + "line": 949 }, { "id": "DEFECT.CANARY-VERSION-LEAK.symptom", "klass": "DEFECT", "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label", - "line": 933 + "line": 946 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect", "klass": "DEFECT", "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls", - "line": 779 + "line": 792 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples", "klass": "DEFECT", "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle", - "line": 778 + "line": 791 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward", "klass": "DEFECT", "value": "author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess", - "line": 780 + "line": 793 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom", "klass": "DEFECT", "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number", - "line": 777 + "line": 790 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect", "klass": "DEFECT", "value": "any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts", - "line": 814 + "line": 827 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", "klass": "DEFECT", "value": "#3309 v2 default flip from mid-flight to end-of-phase", - "line": 813 + "line": 826 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward", "klass": "DEFECT", "value": "template — \"new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set \"", - "line": 815 + "line": 828 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom", "klass": "DEFECT", "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)", - "line": 812 + "line": 825 }, { "id": "DEFECT.FORMAT", "klass": "DEFECT", "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable", - "line": 729 + "line": 742 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect", "klass": "DEFECT", "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it", - "line": 827 + "line": 840 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples", "klass": "DEFECT", "value": "#586/PR #650 ship.md verification gate — grep \"^status:\" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)", - "line": 826 + "line": 839 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward", "klass": "DEFECT", "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)", - "line": 828 + "line": 841 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom", "klass": "DEFECT", "value": "a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep \"^key:\" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted", - "line": 825 + "line": 838 }, { "id": "DEFECT.GENERATIVE-EXEMPLAR", "klass": "DEFECT", "value": "tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)", - "line": 823 + "line": 836 }, { "id": "DEFECT.GENERATIVE-FIX", "klass": "DEFECT", "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge", - "line": 822 + "line": 835 }, { "id": "DEFECT.GENERATIVE-PRIORITY", "klass": "DEFECT", "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer", - "line": 821 + "line": 834 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect", "klass": "DEFECT", "value": "two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted", - "line": 971 + "line": 984 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward", "klass": "DEFECT", "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)", - "line": 972 + "line": 985 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause", "klass": "DEFECT", "value": "gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()", - "line": 970 + "line": 983 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom", "klass": "DEFECT", "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine", - "line": 969 + "line": 982 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream", "klass": "DEFECT", "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)", - "line": 973 + "line": 986 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect", "klass": "DEFECT", "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out", - "line": 939 + "line": 952 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples", "klass": "DEFECT", "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes", - "line": 938 + "line": 951 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward", "klass": "DEFECT", "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds", - "line": 940 + "line": 953 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related", "klass": "DEFECT", "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month", - "line": 941 + "line": 954 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom", "klass": "DEFECT", "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all", - "line": 937 + "line": 950 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect", "klass": "DEFECT", "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed", - "line": 963 + "line": 976 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery", "klass": "DEFECT", "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)", - "line": 965 + "line": 978 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause", "klass": "DEFECT", "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts", - "line": 964 + "line": 977 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom", "klass": "DEFECT", "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs", - "line": 962 + "line": 975 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", "klass": "DEFECT", "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe", - "line": 966 + "line": 979 }, { "id": "DEFECT.HALT-COST-PATTERN.detect", "klass": "DEFECT", "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context", - "line": 804 + "line": 817 }, { "id": "DEFECT.HALT-COST-PATTERN.examples", "klass": "DEFECT", "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)", - "line": 803 + "line": 816 }, { "id": "DEFECT.HALT-COST-PATTERN.fix-forward", "klass": "DEFECT", "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer", - "line": 805 + "line": 818 }, { "id": "DEFECT.HALT-COST-PATTERN.symptom", "klass": "DEFECT", "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn", - "line": 802 + "line": 815 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", "klass": "DEFECT", "value": "hook re-fires on each invocation regardless of session-state read receipts", - "line": 809 + "line": 822 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples", "klass": "DEFECT", "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file", - "line": 808 + "line": 821 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward", "klass": "DEFECT", "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match", - "line": 810 + "line": 823 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking", "klass": "DEFECT", "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files", - "line": 968 + "line": 981 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom", "klass": "DEFECT", "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session", - "line": 807 + "line": 820 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass", "klass": "DEFECT", "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks", - "line": 987 + "line": 1000 }, { "id": "DEFECT.INVENTORY-DRIFT.detect", "klass": "DEFECT", "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading", - "line": 769 + "line": 782 }, { "id": "DEFECT.INVENTORY-DRIFT.examples", "klass": "DEFECT", "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)", - "line": 768 + "line": 781 }, { "id": "DEFECT.INVENTORY-DRIFT.fix-forward", "klass": "DEFECT", "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all six families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY)", - "line": 770 + "line": 783 }, { "id": "DEFECT.INVENTORY-DRIFT.symptom", "klass": "DEFECT", "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json", - "line": 767 + "line": 780 }, { "id": "DEFECT.NAME-COLLISION.detect", "klass": "DEFECT", "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract", - "line": 910 + "line": 923 }, { "id": "DEFECT.NAME-COLLISION.examples", "klass": "DEFECT", "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section
\")", - "line": 909 + "line": 922 }, { "id": "DEFECT.NAME-COLLISION.fix-forward", "klass": "DEFECT", "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract", - "line": 911 + "line": 924 }, { "id": "DEFECT.NAME-COLLISION.symptom", "klass": "DEFECT", "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw", - "line": 908 + "line": 921 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect", "klass": "DEFECT", "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning", - "line": 799 + "line": 812 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", "klass": "DEFECT", "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants", - "line": 798 + "line": 811 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward", "klass": "DEFECT", "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source", - "line": 800 + "line": 813 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom", "klass": "DEFECT", "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed", - "line": 797 + "line": 810 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor", "klass": "DEFECT", "value": "tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)", - "line": 745 + "line": 758 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect", "klass": "DEFECT", "value": "grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting", - "line": 743 + "line": 756 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples", "klass": "DEFECT", "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)", - "line": 742 + "line": 755 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward", "klass": "DEFECT", "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps", - "line": 744 + "line": 757 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom", "klass": "DEFECT", "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces", - "line": 741 + "line": 754 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect", "klass": "DEFECT", "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST", - "line": 862 + "line": 875 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples", "klass": "DEFECT", "value": "PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control", - "line": 861 + "line": 874 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward", "klass": "DEFECT", "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)", - "line": 863 + "line": 876 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention", "klass": "DEFECT", "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)", - "line": 864 + "line": 877 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom", "klass": "DEFECT", "value": "scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation", - "line": 860 + "line": 873 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", "klass": "DEFECT", "value": "any new bare tag in agents/*.md", - "line": 764 + "line": 777 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples", "klass": "DEFECT", "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)", - "line": 763 + "line": 776 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", "klass": "DEFECT", "value": "hyphenate the tag (, ) — scanner regex matches bare names only", - "line": 765 + "line": 778 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom", "klass": "DEFECT", "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate", - "line": 762 + "line": 775 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.detect", "klass": "DEFECT", "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete", - "line": 733 + "line": 746 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.examples", "klass": "DEFECT", "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace", - "line": 732 + "line": 745 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward", "klass": "DEFECT", "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility", - "line": 734 + "line": 747 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.symptom", "klass": "DEFECT", "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)", - "line": 731 + "line": 744 }, { "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", "klass": "DEFECT", "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)", - "line": 339 + "line": 352 }, { "id": "DEFECT.SCOPE.window", "klass": "DEFECT", "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287", - "line": 728 + "line": 741 }, { "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie", "klass": "DEFECT", "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open", - "line": 912 + "line": 925 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect", "klass": "DEFECT", "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)", - "line": 923 + "line": 936 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples", "klass": "DEFECT", "value": "#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002", - "line": 922 + "line": 935 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward", "klass": "DEFECT", "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class", - "line": 924 + "line": 937 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom", "klass": "DEFECT", "value": "a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with \"Cannot find module\" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT", - "line": 921 + "line": 934 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)", - "line": 925 + "line": 938 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect", "klass": "DEFECT", "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation", - "line": 818 + "line": 831 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward", "klass": "DEFECT", "value": "replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification", - "line": 819 + "line": 832 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom", "klass": "DEFECT", "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script", - "line": 817 + "line": 830 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect", "klass": "DEFECT", "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts", - "line": 749 + "line": 762 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples", "klass": "DEFECT", "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged", - "line": 748 + "line": 761 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward", "klass": "DEFECT", "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)", - "line": 750 + "line": 763 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom", "klass": "DEFECT", "value": "PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts", - "line": 747 + "line": 760 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern", "klass": "DEFECT", "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing", - "line": 931 + "line": 944 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect", "klass": "DEFECT", "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\"", - "line": 929 + "line": 942 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples", "klass": "DEFECT", "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all", - "line": 928 + "line": 941 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward", "klass": "DEFECT", "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")", - "line": 930 + "line": 943 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom", "klass": "DEFECT", "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first", - "line": 927 + "line": 940 }, { "id": "DEFECT.STATE-TRAMPLE.detect", "klass": "DEFECT", "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress", - "line": 738 + "line": 751 }, { "id": "DEFECT.STATE-TRAMPLE.examples", "klass": "DEFECT", "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)", - "line": 737 + "line": 750 }, { "id": "DEFECT.STATE-TRAMPLE.fix-forward", "klass": "DEFECT", "value": "route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)", - "line": 739 + "line": 752 }, { "id": "DEFECT.STATE-TRAMPLE.symptom", "klass": "DEFECT", "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter", - "line": 736 + "line": 749 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor", "klass": "DEFECT", "value": "lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)", - "line": 977 + "line": 990 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect", "klass": "DEFECT", "value": "sub-agent returns prematurely with text like \"I should wait for the notification per CLAUDE.md\" and incomplete work in its worktree (commits absent, push absent, PR absent)", - "line": 975 + "line": 988 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward", "klass": "DEFECT", "value": "keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task", - "line": 976 + "line": 989 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom", "klass": "DEFECT", "value": "spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness \"you will be notified\" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator", - "line": 974 + "line": 987 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect", "klass": "DEFECT", "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded", - "line": 759 + "line": 772 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", "klass": "DEFECT", "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)", - "line": 758 + "line": 771 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward", "klass": "DEFECT", "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history", - "line": 760 + "line": 773 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom", "klass": "DEFECT", "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts", - "line": 757 + "line": 770 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect", "klass": "DEFECT", "value": "test does readFileSync(md).match for a bash fence with literal \\n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703", - "line": 831 + "line": 844 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples", "klass": "DEFECT", "value": "#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \\n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite", - "line": 830 + "line": 843 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward", "klass": "DEFECT", "value": "match the fence with \\r?\\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file", - "line": 832 + "line": 845 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom", "klass": "DEFECT", "value": "a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \\n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\\...) is un-globbable in bash so the pipeline returns empty and assertions fail", - "line": 829 + "line": 842 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect", "klass": "DEFECT", "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view", - "line": 794 + "line": 807 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", "klass": "DEFECT", "value": "a33cbe72 worktree fix bound git subprocesses with timeout", - "line": 793 + "line": 806 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward", "klass": "DEFECT", "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw", - "line": 795 + "line": 808 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom", "klass": "DEFECT", "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network", - "line": 792 + "line": 805 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect", "klass": "DEFECT", "value": "Windows CI job at \"Run unit tests\" exits with code 1 within seconds of starting, no node:test output between \"run-tests: suite=… files=N: …\" line and \"Process completed with exit code 1\"; same job on Linux/macOS runs full duration", - "line": 916 + "line": 929 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples", "klass": "DEFECT", "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed", - "line": 915 + "line": 928 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward", "klass": "DEFECT", "value": "chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths", - "line": 917 + "line": 930 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention", "klass": "DEFECT", "value": "a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)", - "line": 919 + "line": 932 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom", "klass": "DEFECT", "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)", - "line": 914 + "line": 927 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs \"Windows argv-overflow chunking (issue #3597)\" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform", - "line": 918 + "line": 931 }, { "id": "DEFECT.WINDOWS-FS-OPS.detect", "klass": "DEFECT", "value": "ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape", - "line": 789 + "line": 802 }, { "id": "DEFECT.WINDOWS-FS-OPS.examples", "klass": "DEFECT", "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim", - "line": 788 + "line": 801 }, { "id": "DEFECT.WINDOWS-FS-OPS.fix-forward", "klass": "DEFECT", "value": "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop", - "line": 790 + "line": 803 }, { "id": "DEFECT.WINDOWS-FS-OPS.symptom", "klass": "DEFECT", "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target", - "line": 787 + "line": 800 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect", "klass": "DEFECT", "value": "any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\\.md or /…\\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)", - "line": 848 + "line": 861 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples", "klass": "DEFECT", "value": "PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\\...\\gsd-ial-windsurf-XXX\\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only", - "line": 847 + "line": 860 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward", "klass": "DEFECT", "value": "normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always", - "line": 849 + "line": 862 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention", "klass": "DEFECT", "value": "enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\\\/g,'/'))", - "line": 850 + "line": 863 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom", "klass": "DEFECT", "value": "path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected", - "line": 846 + "line": 859 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect", "klass": "DEFECT", "value": "any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)", - "line": 856 + "line": 869 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples", "klass": "DEFECT", "value": "PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side", - "line": 855 + "line": 868 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward", "klass": "DEFECT", "value": "normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.", - "line": 857 + "line": 870 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention", "klass": "DEFECT", "value": "enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT", - "line": 858 + "line": 871 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom", "klass": "DEFECT", "value": "an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual", - "line": 854 + "line": 867 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect", "klass": "DEFECT", "value": "grep tests for \\`.mode & 0o777\\` / \\`.mode) === 0o\\` / \\`writeFileSync(...{ mode: 0o\\` / \\`chmodSync\\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)", - "line": 842 + "line": 855 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples", "klass": "DEFECT", "value": "#1634/PR #1638 tests/capability-lifecycle.test.cjs \"a .cjs hook command is node-prefixed so it runs without the executable bit\" failed windows-latest,24 on \"precondition: file staged without +x\" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact", - "line": 841 + "line": 854 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward", "klass": "DEFECT", "value": "gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX", - "line": 843 + "line": 856 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention", "klass": "DEFECT", "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", - "line": 844 + "line": 857 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom", "klass": "DEFECT", "value": "a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755", - "line": 840 + "line": 853 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", "klass": "DEFECT", "value": "npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done", - "line": 836 + "line": 849 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples", "klass": "DEFECT", "value": "PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes", - "line": 835 + "line": 848 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward", "klass": "DEFECT", "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)", - "line": 837 + "line": 850 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention", "klass": "DEFECT", "value": "run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it", - "line": 838 + "line": 851 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom", "klass": "DEFECT", "value": "local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \\ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally", - "line": 834 + "line": 847 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect", "klass": "DEFECT", "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present", - "line": 868 + "line": 881 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples", "klass": "DEFECT", "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge", - "line": 867 + "line": 880 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward", "klass": "DEFECT", "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves", - "line": 869 + "line": 882 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention", "klass": "DEFECT", "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime", - "line": 870 + "line": 883 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom", "klass": "DEFECT", "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail", - "line": 866 + "line": 879 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect", "klass": "DEFECT", "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook", - "line": 784 + "line": 797 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", "klass": "DEFECT", "value": "this session, branch fix/3309-... and pr-3316", - "line": 783 + "line": 796 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:", - "line": 785 + "line": 798 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom", "klass": "DEFECT", "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch", - "line": 782 + "line": 795 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 955 + "line": 968 }, { "id": "EXEC.CLASSIFY.cross-runtime", "klass": "EXEC", "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests", - "line": 957 + "line": 970 }, { "id": "EXEC.CLASSIFY.handler", "klass": "EXEC", "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)", - "line": 953 + "line": 966 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 958 + "line": 971 }, { "id": "EXEC.CLASSIFY.proactive-signal-not-usable", "klass": "EXEC", "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)", - "line": 960 + "line": 973 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 959 + "line": 972 }, { "id": "EXEC.CLASSIFY.sentinel-order", "klass": "EXEC", "value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form", - "line": 956 + "line": 969 }, { "id": "EXEC.CLASSIFY.workflow", "klass": "EXEC", "value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)", - "line": 954 + "line": 967 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", "klass": "GSD-RESEARCH", "value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob", - "line": 338 + "line": 351 }, { "id": "GSD-RESEARCH.INTEGRATION.L2-hybrid", "klass": "GSD-RESEARCH", "value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches", - "line": 336 + "line": 349 }, { "id": "GSD-RESEARCH.MODULE.package-legitimacy", "klass": "GSD-RESEARCH", "value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate", - "line": 335 + "line": 348 }, { "id": "GSD-RESEARCH.MODULE.research-provider", "klass": "GSD-RESEARCH", "value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)", - "line": 334 + "line": 347 }, { "id": "GSD-RESEARCH.MODULE.research-store", "klass": "GSD-RESEARCH", "value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache", - "line": 333 + "line": 346 }, { "id": "GSD-RESEARCH.PROVIDER.availability", "klass": "GSD-RESEARCH", "value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal", - "line": 337 + "line": 350 }, { "id": "LEARNING.prompt-budget.boundary-gap", "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures", - "line": 479 + "line": 492 }, { "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", "klass": "LIVE-CONFIG", - "value": "CI cannot catch the ambient-env half of this class at all — CI never has these vars set, so green CI is not evidence; the guard is the only loud signal", - "line": 572 + "value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes", + "line": 585 }, { "id": "LIVE-CONFIG.GUARD.SEAM.module", "klass": "LIVE-CONFIG", - "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", - "line": 567 + "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", + "line": 580 }, { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", "value": "resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", - "line": 569 + "line": 582 }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", - "line": 568 + "line": 581 }, { "id": "LIVE-CONFIG.GUARD.SEAM.severity", "klass": "LIVE-CONFIG", - "value": "reports by default; fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1, skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1; promote to fatal once the USERPROFILE sweep lands, mirroring local/no-source-grep warn->error (ADR 452)", - "line": 571 + "value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1", + "line": 584 }, { "id": "LIVE-CONFIG.GUARD.SEAM.truncation", "klass": "LIVE-CONFIG", "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing", - "line": 570 + "line": 583 }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations", - "line": 623 + "line": 636 }, { "id": "META.RULE.brief-no-paraphrase", "klass": "META", "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110", - "line": 624 + "line": 637 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 621 + "line": 634 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 622 + "line": 635 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 557 + "line": 570 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 558 + "line": 571 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 559 + "line": 572 }, { "id": "PR.3267.POSTMORTEM.recovery", "klass": "PR", "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]", - "line": 536 + "line": 549 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 535 + "line": 548 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 627 + "line": 640 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 633 + "line": 646 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 634 + "line": 647 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 629 + "line": 642 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 636 + "line": 649 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 632 + "line": 645 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 635 + "line": 648 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 628 + "line": 641 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 626 + "line": 639 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 630 + "line": 643 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 631 + "line": 644 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 642 + "line": 655 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 640 + "line": 653 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 641 + "line": 654 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 639 + "line": 652 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 638 + "line": 651 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 647 + "line": 660 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 648 + "line": 661 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 645 + "line": 658 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 650 + "line": 663 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 649 + "line": 662 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 646 + "line": 659 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 644 + "line": 657 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 655 + "line": 668 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 654 + "line": 667 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 657 + "line": 670 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 656 + "line": 669 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 653 + "line": 666 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 652 + "line": 665 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 661 + "line": 674 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 663 + "line": 676 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 660 + "line": 673 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 662 + "line": 675 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 659 + "line": 672 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 668 + "line": 681 }, { "id": "PRED.k325.cure", "klass": "PRED", "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/", - "line": 667 + "line": 680 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 669 + "line": 682 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 666 + "line": 679 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 665 + "line": 678 }, { "id": "PRED.k326.cure", "klass": "PRED", "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"", - "line": 673 + "line": 686 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 674 + "line": 687 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 672 + "line": 685 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 671 + "line": 684 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 677 + "line": 690 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 680 + "line": 693 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 681 + "line": 694 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 679 + "line": 692 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 678 + "line": 691 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 676 + "line": 689 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 686 + "line": 699 }, { "id": "PRED.k328.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)", - "line": 684 + "line": 697 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 685 + "line": 698 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 683 + "line": 696 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 692 + "line": 705 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 689 + "line": 702 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 690 + "line": 703 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 691 + "line": 704 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 693 + "line": 706 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 688 + "line": 701 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 697 + "line": 710 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 696 + "line": 709 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 695 + "line": 708 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 702 + "line": 715 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 704 + "line": 717 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 701 + "line": 714 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 703 + "line": 716 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 700 + "line": 713 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 699 + "line": 712 }, { "id": "PROBE.ci.surface", "klass": "PROBE", "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)", - "line": 450 + "line": 463 }, { "id": "PROBE.core.seam", "klass": "PROBE", "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)", - "line": 443 + "line": 456 }, { "id": "PROBE.edge.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 445 + "line": 458 }, { "id": "PROBE.family", "klass": "PROBE", "value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)", - "line": 441 + "line": 454 }, { "id": "PROBE.item.axes", "klass": "PROBE", "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)", - "line": 444 + "line": 457 }, { "id": "PROBE.principle", "klass": "PROBE", "value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md", - "line": 440 + "line": 453 }, { "id": "PROBE.prohib.verification", "klass": "PROBE", "value": "test|judgment", - "line": 446 + "line": 459 }, { "id": "PROBE.protocol", "klass": "PROBE", "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason", - "line": 442 + "line": 455 }, { "id": "PROBE.ui.axis", "klass": "PROBE", "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)", - "line": 448 + "line": 461 }, { "id": "PROBE.ui.seam", "klass": "PROBE", "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)", - "line": 449 + "line": 462 }, { "id": "PROBE.ui.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 447 + "line": 460 }, { "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 708 + "line": 721 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 707 + "line": 720 }, { "id": "PROC.AGENT-DISPATCH.preflight", "klass": "PROC", "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]", - "line": 706 + "line": 719 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 712 + "line": 725 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 713 + "line": 726 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 714 + "line": 727 }, { "id": "PROC.MERGE-WAVE.ordering", "klass": "PROC", "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]", - "line": 710 + "line": 723 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 711 + "line": 724 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.observed", "klass": "PROC", "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened", - "line": 985 + "line": 998 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", "klass": "PROC", "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill", - "line": 983 + "line": 996 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", "klass": "PROC", "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION", - "line": 984 + "line": 997 }, { "id": "PROC.TRIAGE.comment-shape", "klass": "PROC", "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close", - "line": 990 + "line": 1003 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 991 + "line": 1004 }, { "id": "PROC.TRIAGE.routing-incoming", "klass": "PROC", "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest", - "line": 989 + "line": 1002 }, { "id": "PROHIB.canon-referral", "klass": "PROHIB", "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)", - "line": 452 + "line": 465 }, { "id": "PROHIB.descriptor.shape", "klass": "PROHIB", "value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)", - "line": 457 + "line": 470 }, { "id": "PROHIB.enforce.adr", "klass": "PROHIB", "value": "docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)", - "line": 460 + "line": 473 }, { "id": "PROHIB.enforce.causation", "klass": "PROHIB", "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)", - "line": 456 + "line": 469 }, { "id": "PROHIB.enforce.failfirst", "klass": "PROHIB", "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)", - "line": 455 + "line": 468 }, { "id": "PROHIB.enforce.green-rule", "klass": "PROHIB", "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default", - "line": 453 + "line": 466 }, { "id": "PROHIB.enforce.kinds", "klass": "PROHIB", "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)", - "line": 454 + "line": 467 }, { "id": "PROHIB.judgment-tier", "klass": "PROHIB", "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)", - "line": 459 + "line": 472 }, { "id": "PROHIB.rail", "klass": "PROHIB", "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability", - "line": 458 + "line": 471 }, { "id": "PROHIB.recall", "klass": "PROHIB", "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)", - "line": 451 + "line": 464 }, { "id": "RELEASE-NOTES.ANTI-PATTERN", "klass": "RELEASE-NOTES", "value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes", - "line": 603 + "line": 616 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", "klass": "RELEASE-NOTES", "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior", - "line": 604 + "line": 617 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", "klass": "RELEASE-NOTES", "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong", - "line": 605 + "line": 618 }, { "id": "RELEASE-NOTES.DEFAULT-STATE", "klass": "RELEASE-NOTES", "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final", - "line": 579 + "line": 592 }, { "id": "RELEASE-NOTES.EXAMPLE.hotfix", "klass": "RELEASE-NOTES", "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups", - "line": 607 + "line": 620 }, { "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", "klass": "RELEASE-NOTES", "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles", - "line": 609 + "line": 622 }, { "id": "RELEASE-NOTES.EXAMPLE.rc", "klass": "RELEASE-NOTES", "value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy", - "line": 608 + "line": 621 }, { "id": "RELEASE-NOTES.GATE.hotfix", "klass": "RELEASE-NOTES", "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body", - "line": 580 + "line": 593 }, { "id": "RELEASE-NOTES.GATE.minor", "klass": "RELEASE-NOTES", "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix", - "line": 582 + "line": 595 }, { "id": "RELEASE-NOTES.GATE.rc", "klass": "RELEASE-NOTES", "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard", - "line": 581 + "line": 594 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 614 + "line": 627 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 615 + "line": 628 }, { "id": "RELEASE-NOTES.SCOPE", "klass": "RELEASE-NOTES", "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)", - "line": 578 + "line": 591 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 594 + "line": 607 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 593 + "line": 606 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 595 + "line": 608 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 596 + "line": 609 }, { "id": "RELEASE-NOTES.STANDARD.bullet-shape", "klass": "RELEASE-NOTES", "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.", - "line": 586 + "line": 599 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 590 + "line": 603 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 588 + "line": 601 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 589 + "line": 602 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 585 + "line": 598 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 591 + "line": 604 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 587 + "line": 600 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 584 + "line": 597 }, { "id": "RELEASE-NOTES.TEMPLATE.hotfix", "klass": "RELEASE-NOTES", "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ", - "line": 611 + "line": 624 }, { "id": "RELEASE-NOTES.TEMPLATE.rc", "klass": "RELEASE-NOTES", "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 612 + "line": 625 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 598 + "line": 611 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 601 + "line": 614 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 600 + "line": 613 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 599 + "line": 612 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 503 + "line": 516 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 492 + "line": 505 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 499 + "line": 512 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 500 + "line": 513 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 488 + "line": 501 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", "klass": "RULESET", "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.", - "line": 288 + "line": 301 }, { "id": "RULESET.CAPABILITY.off-means-off", "klass": "RULESET", "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.", - "line": 286 + "line": 299 }, { "id": "RULESET.CAPABILITY.precedence-engine-single-owner", "klass": "RULESET", "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.", - "line": 292 + "line": 305 }, { "id": "RULESET.CAPABILITY.step-additive-gate-blocks", "klass": "RULESET", "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.", - "line": 290 + "line": 303 }, { "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 525 + "line": 538 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 526 + "line": 539 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 524 + "line": 537 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 527 + "line": 540 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 528 + "line": 541 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 529 + "line": 542 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)", - "line": 852 + "line": 865 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 518 + "line": 531 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 519 + "line": 532 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 517 + "line": 530 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 516 + "line": 529 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 510 + "line": 523 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other, and a fragment left on `next` after merge is inert rather than a shared cell; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins. `tests/emitted-drift-ack.json` (the LEGACY file specifically, NOT the fragment directory) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; a persisting FRAGMENT is harmless by construction and is deliberately not what this guard checks. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next` (`assertAbsentOnNext`), which fails on the LEGACY file's PRESENCE alone, valid or not — a PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", - "line": 493 + "line": 506 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 523 + "line": 536 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM", - "line": 943 + "line": 956 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL SIX families.* arrays (agents/commands/workflows/references/cli_modules/hooks) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all six, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write", - "line": 504 + "line": 517 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — \"we don't set a timer we actively watch and record results in real time as possible\". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:\"passed\" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.", - "line": 945 + "line": 958 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 947 + "line": 960 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 506 + "line": 519 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 501 + "line": 514 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 530 + "line": 543 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 475 + "line": 488 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 478 + "line": 491 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 477 + "line": 490 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 482 + "line": 495 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 473 + "line": 486 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 485 + "line": 498 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 474 + "line": 487 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 470 + "line": 483 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)", - "line": 486 + "line": 499 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 476 + "line": 489 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 472 + "line": 485 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 484 + "line": 497 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 471 + "line": 484 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 466 + "line": 479 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 467 + "line": 480 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 468 + "line": 481 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 481 + "line": 494 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 483 + "line": 496 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 508 + "line": 521 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 497 + "line": 510 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 496 + "line": 509 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 495 + "line": 508 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 494 + "line": 507 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 490 + "line": 503 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 491 + "line": 504 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 898 + "line": 911 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 899 + "line": 912 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 900 + "line": 913 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 901 + "line": 914 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 902 + "line": 915 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 903 + "line": 916 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 904 + "line": 917 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]", - "line": 905 + "line": 918 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]", - "line": 906 + "line": 919 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 721 + "line": 734 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 718 + "line": 731 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 719 + "line": 732 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 722 + "line": 735 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 720 + "line": 733 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 544 + "line": 557 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 545 + "line": 558 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 560 + "line": 573 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 561 + "line": 574 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 546 + "line": 559 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 554 + "line": 567 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 538 + "line": 551 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 542 + "line": 555 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 541 + "line": 554 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 539 + "line": 552 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 540 + "line": 553 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 552 + "line": 565 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 553 + "line": 566 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 556 + "line": 569 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 555 + "line": 568 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 551 + "line": 564 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 550 + "line": 563 } ], "duplicates": [] From d1c8b326892cad65c6a4c8af0632340f272aec57 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 18:24:10 -0500 Subject: [PATCH 24/47] fix(#2665): watch kimi-code's config.toml, and pin it by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto next brought in #2755, which added a SECOND Kimi config home — kimi-code's `~/.kimi-code`, overridden by KIMI_CODE_HOME — declared as an inline object literal inside resolveKimiHooksTomlDir's body. That is the resolvable-but-not-enumerable shape round 3 hoisted KIMI_SHARE_DIR out of, so the hoist is extended to cover both descriptors rather than reverting #2755's parameterization. The scrub set was already complete: KIMI_CODE_HOME is declared in capabilities/kimi-code/capability.json, so the registry rung covered it and CONFIG_LOCATION_ENV_KEYS is 28 keys both before and after the rebase. What was NOT covered is the guard — resolveExtraWatchTargets iterates NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, so with only one entry it watched Kimi CLI's config.toml and never Kimi Code's. Targets go 2 -> 3. The existing 'extra targets are DERIVED from the descriptor array' test cannot catch this: it builds its expectation FROM the array, so removing an entry shrinks the expectation with it. Verified — with the kimi-code descriptor removed that test still passes while the new named test fails. This is the enumeration-relative scope boundary the suite already documents one layer down, biting one layer up. Also rewrites NON_REGISTRY_OWNED_FILE's docblock, which asserted "today's only such descriptor is kimi's ~/.kimi". There are now two, and its named residual is load-bearing rather than vacuous. --- scripts/live-config-guard.cjs | 16 +++++++++------- tests/live-config-guard.test.cjs | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index eec8f65f5..3577b329c 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -80,13 +80,15 @@ const GSD_ARTIFACT_PREFIX = 'gsd-'; /** * The file GSD writes into a NON-REGISTRY config home. * - * Today's only such descriptor is kimi's `~/.kimi` (KIMI_SHARE_DIR), where GSD - * writes its native `[[hooks]]` block into `config.toml`. NAMED RESIDUAL: this - * assumes every non-registry descriptor is written the same way. A future - * descriptor whose owned file differs needs a per-descriptor mapping here — the - * consequence of getting it wrong is under-watching (a missed leak), not a false - * positive, so it fails in the quiet direction and is called out rather than - * left to be discovered. + * Both current descriptors are Kimi's — Kimi CLI's `~/.kimi` (KIMI_SHARE_DIR) and + * Kimi Code's `~/.kimi-code` (KIMI_CODE_HOME) — and GSD writes its native + * `[[hooks]]` block into `config.toml` in each, so the single filename below holds + * for both. NAMED RESIDUAL: this assumes every non-registry descriptor is written + * the same way. That assumption is now load-bearing rather than vacuous — it is + * carrying two descriptors, not one — and a future descriptor whose owned file + * differs needs a per-descriptor mapping here. The consequence of getting it wrong + * is under-watching (a missed leak), not a false positive, so it fails in the quiet + * direction and is called out rather than left to be discovered. */ const NON_REGISTRY_OWNED_FILE = 'config.toml'; diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index a12c46006..d54f836cc 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -229,6 +229,31 @@ describe('#2665: guard watches non-root write surfaces', () => { } }); + test('#2755: kimi-code config.toml is watched — named, not enumeration-relative', () => { + // The test above derives its expectation FROM the descriptor array, so it + // passes for whatever that array happens to contain and cannot see a + // descriptor that was never added — the enumeration-relative scope boundary + // this suite already calls out one layer down. #2755 landed kimi-code's + // `~/.kimi-code` (KIMI_CODE_HOME) on `next` as an inline literal inside + // resolveKimiHooksTomlDir's body; until it was hoisted into + // NON_REGISTRY_CONFIG_HOME_DESCRIPTORS the guard watched Kimi CLI's + // config.toml and not Kimi Code's. Naming the path is what makes dropping + // the descriptor fail loudly instead of quietly shrinking the expectation. + const home = tmpRoot(); + const codeHome = tmpRoot(); + try { + const env = { GSD_HOME: home, KIMI_CODE_HOME: codeHome }; + const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } }); + assert.ok( + targets.includes(path.resolve(path.join(codeHome, 'config.toml'))), + `expected kimi-code config.toml in ${JSON.stringify(targets)}`, + ); + } finally { + cleanup(home); + cleanup(codeHome); + } + }); + test('GSD_HOME falls back to homedir when unset', () => { const home = tmpRoot(); try { From 2a98b6b0b1541c33a4ee50b50869d7590c39a6ee Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 18:43:23 -0500 Subject: [PATCH 25/47] fix(#2665): keep the dot-home type guarantee #2755 chose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase resolution annotated both hoisted descriptors and the local in resolveKimiHooksTomlDir as ConfigHomeDescriptor. #2755 used DotHomeDescriptor there deliberately: the union permits xdg / dot-home-nested / generic-agents-root shapes, so the broader annotation drops the compile-time guarantee that this resolver selects a dot-home descriptor and nothing else. Runtime behaviour was already correct — all eleven paths resolve identically — so this restores a type-level property, not a behavioural one. Both exported constants are now pinned to DotHomeDescriptor as well, which is stricter than the ConfigHomeDescriptor they carried since round 3; the interface stays unexported and NON_REGISTRY_CONFIG_HOME_DESCRIPTORS keeps its ConfigHomeDescriptor[] type, which the narrower constants satisfy as subtypes. Found by the pre-push adversarial review of this round, which is the one claim of six it refuted. --- src/runtime-homes.cts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 0655d68fc..0605d9055 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -491,7 +491,7 @@ export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string { * homes: KIMI_CONFIG_DIR (registry-visible, already covered) and KIMI_SHARE_DIR * (this one), so a derivation keyed only on the registry looks complete and is not. */ -export const KIMI_HOOKS_TOML_DESCRIPTOR: ConfigHomeDescriptor = { +export const KIMI_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = { kind: 'dot-home', name: '.kimi', env: ['KIMI_SHARE_DIR'], @@ -509,7 +509,7 @@ export const KIMI_HOOKS_TOML_DESCRIPTOR: ConfigHomeDescriptor = { * commit, which is the property NON_REGISTRY_CONFIG_HOME_DESCRIPTORS exists to * guarantee. Each product's env var stays scoped to that product (#2755). */ -export const KIMI_CODE_HOOKS_TOML_DESCRIPTOR: ConfigHomeDescriptor = { +export const KIMI_CODE_HOOKS_TOML_DESCRIPTOR: DotHomeDescriptor = { kind: 'dot-home', name: '.kimi-code', env: ['KIMI_CODE_HOME'], @@ -586,7 +586,7 @@ export function resolveKimiHooksTomlDir(opts: ResolveKimiHooksTomlOpts = {}): st // Explicit comparison rather than an object lookup keyed on `runtime`: the // value originates from argv, and an index would resolve inherited keys // (`constructor`, `__proto__`) to something that is not a descriptor. - const descriptor: ConfigHomeDescriptor = opts.runtime === 'kimi-code' + const descriptor: DotHomeDescriptor = opts.runtime === 'kimi-code' ? KIMI_CODE_HOOKS_TOML_DESCRIPTOR : KIMI_HOOKS_TOML_DESCRIPTOR; return resolveConfigHomeFromDescriptor(descriptor, { env, home }); From c95b817cb64169bc00447cb0651f33fc73cacd57 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Mon, 3 Aug 2026 19:27:02 -0500 Subject: [PATCH 26/47] =?UTF-8?q?fix(#2665):=20scrub=20GSD=5FALLOW=5FSYMLI?= =?UTF-8?q?NKED=5FDEST=20=E2=80=94=20a=20write-escape=20permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the pre-publication claim audit of this round's response comment, which refuted the sentence "none of the unscrubbed env reads names a write destination" on the grounds that naming a path is not the same property as influencing where writes land. GSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so every rung of the derivation is structurally incapable of reaching it: not a registry configHome, not descriptor-shaped, not one of GSD's own location vars. It is still a #2665 leak vector. install-engine.cts reads it env-first (:214) and threads it as allowOptInFollow into the symlink-escape guard at four call sites, each gating a write (:361/:367, :416/:424, :785/:790, :927/:932). That guard is what stops a write leaving the install root, so an ambient =1 disarms it for the whole suite — the #2665 hazard arriving through a permission rather than a path. Added as its own named family (WRITE_ESCAPE_PERMISSION_ENV_KEYS) rather than folded into a location rung, for the same reason GSD_HOME got its own family in round 3: the list should not misdescribe what its members are. Blanking is fail-safe in the only direction that matters — '' is neither '1' nor 'true', so a blanked value makes the guard stricter, never looser. That asymmetry is what licenses scrubbing it wholesale rather than reasoning about each call site. The guard test names the variable literally rather than iterating the family constant: a test that asserts over the constant shrinks its own expectation when the family is emptied, which is the enumeration-relative failure that let the kimi-code descriptor go unwatched earlier in this same round. #2393's opt-in suite is unaffected (99/100, 0 fail): it sets process.env directly in-process and never routes through scrubConfigLocationEnv, and an explicit env argument still wins over TEST_ENV_BASE in childEnv. --- tests/helpers-process-isolation.test.cjs | 23 +++++++++++++++++++++++ tests/helpers.cjs | 20 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index d6554813f..a7fa38430 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -213,6 +213,29 @@ describe('#2665: TEST_ENV_BASE config-location coverage', () => { } }); + test('write-escape PERMISSIONS are scrubbed (a fifth family — not a location var)', () => { + // #2665 round 5. GSD_ALLOW_SYMLINKED_DEST names no path, so every rung of the + // derivation above is structurally incapable of reaching it — it is not a + // registry configHome, not descriptor-shaped, not one of GSD's own location + // vars. It is still a #2665 leak vector: install-engine.cts reads it env-first + // and threads it into the symlink-escape guard that stops a write leaving the + // install root, so an ambient `=1` disarms that guard for the whole suite. + // + // Named literally rather than derived from the family constant on purpose: a + // test that reads WRITE_ESCAPE_PERMISSION_ENV_KEYS and asserts over it shrinks + // its own expectation when the family is emptied — the enumeration-relative + // failure this suite already documents, and the one that let the kimi-code + // descriptor go unwatched. Naming it is what makes removal fail loudly. + assert.strictEqual( + TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST, + '', + 'GSD_ALLOW_SYMLINKED_DEST must be blanked: ambient =1 disarms the symlink-escape guard', + ); + // Blanking must be fail-SAFE — '' is neither '1' nor 'true', so the guard gets + // stricter, never looser. This is what licenses scrubbing it wholesale. + assert.ok(!['1', 'true'].includes(TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST)); + }); + test('scrubConfigLocationEnv clears and restores the parent process env', () => { // The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches // children, so a test calling install() in-process needs the PARENT's env diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 64161dee9..fdbd22381 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -65,6 +65,22 @@ const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [ 'GSD_WORKSTREAM', ]; +// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT +// folded into any of the four rungs below. +// +// #2665 round 5: GSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so it is +// not a config-location var by any honest reading. But install-engine.cts reads it +// env-first (`:214`) and threads it as `allowOptInFollow` into the symlink-escape +// guard at four call sites, each gating a write (`:361/:367`, `:416/:424`, +// `:785/:790`, `:927/:932`). That guard is what stops a write leaving the install +// root, so an ambient `=1` disarms it for the whole suite — the #2665 hazard +// exactly, arriving through a permission rather than a path. +// +// Blanking is fail-safe in the only direction that matters: '' is neither '1' nor +// 'true', so a blanked value makes the guard STRICTER, never looser. That asymmetry +// is why this can be scrubbed wholesale without reasoning about each call site. +const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['GSD_ALLOW_SYMLINKED_DEST']; + const CONFIG_LOCATION_ENV_KEYS = [ ...new Set([ // 1. Every runtime descriptor the capability registry carries — including @@ -89,6 +105,10 @@ const CONFIG_LOCATION_ENV_KEYS = [ ...GSD_LOCATION_ENV_KEYS, // 4. The residue that is neither registry-carried nor descriptor-shaped. ...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS, + // 5. Write-escape permissions — NOT locations. Same mechanism because the + // hazard is identical (ambient env lets a suite write outside the sandbox); + // named separately above so the list does not misdescribe what they are. + ...WRITE_ESCAPE_PERMISSION_ENV_KEYS, ]), ].sort(); From d43f781530f314a573901e180d47943f7aacccc4 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Wed, 5 Aug 2026 06:39:20 -0500 Subject: [PATCH 27/47] chore(#2665): regenerate the example CONTEXT-INDEX mirror after the rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto next conflicted in this generated file; per the generated-file discipline the conflict was resolved arbitrarily and the artifact regenerated from its producer rather than hand-merged. Regen delta against the base's committed copy is exactly this PR's own 10 predicates (CONFIG.LOCATION.SEAM.* x4, LIVE-CONFIG.GUARD.SEAM.* x6), 415 -> 425 keys, zero removed and zero foreign — so the merge introduced no drift the PR does not own. --- .../CONTEXT-INDEX.json | 854 +++++++++--------- 1 file changed, 427 insertions(+), 427 deletions(-) diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 8921a245c..73aa2e800 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -29,2551 +29,2551 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 561 + "line": 567 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 545 + "line": 551 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 544 + "line": 550 }, { "id": "CONFIG.LOCATION.SEAM.in-process-scrub", "klass": "CONFIG", "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST", - "line": 579 + "line": 585 }, { "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", "klass": "CONFIG", "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second", - "line": 578 + "line": 584 }, { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", - "line": 576 + "line": 582 }, { "id": "CONFIG.LOCATION.SEAM.two-families", "klass": "CONFIG", "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2", - "line": 577 + "line": 583 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 575 + "line": 581 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect", "klass": "DEFECT", "value": "tests/planner-decomposition.test.cjs (\"planner is under 45K chars (proves mode sections were extracted)\") and tests/reachability-check.test.cjs (\"file stays under 50000 char limit\")", - "line": 787 + "line": 793 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward", "klass": "DEFECT", "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file", - "line": 788 + "line": 794 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state", "klass": "DEFECT", "value": "gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says \"45K\" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over", - "line": 786 + "line": 792 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom", "klass": "DEFECT", "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold", - "line": 785 + "line": 791 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect", "klass": "DEFECT", "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations", - "line": 993 + "line": 999 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples", "klass": "DEFECT", "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)", - "line": 992 + "line": 998 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward", "klass": "DEFECT", "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct", - "line": 994 + "line": 1000 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson", "klass": "DEFECT", "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes", - "line": 995 + "line": 1001 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom", "klass": "DEFECT", "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff", - "line": 991 + "line": 997 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect", "klass": "DEFECT", "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale", - "line": 767 + "line": 773 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples", "klass": "DEFECT", "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)", - "line": 766 + "line": 772 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease", - "line": 768 + "line": 774 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom", "klass": "DEFECT", "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved", - "line": 765 + "line": 771 }, { "id": "DEFECT.CANARY-VERSION-LEAK.detect", "klass": "DEFECT", "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version", - "line": 948 + "line": 954 }, { "id": "DEFECT.CANARY-VERSION-LEAK.examples", "klass": "DEFECT", "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base", - "line": 947 + "line": 953 }, { "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward", "klass": "DEFECT", "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main", - "line": 949 + "line": 955 }, { "id": "DEFECT.CANARY-VERSION-LEAK.symptom", "klass": "DEFECT", "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label", - "line": 946 + "line": 952 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect", "klass": "DEFECT", "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls", - "line": 792 + "line": 798 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples", "klass": "DEFECT", "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle", - "line": 791 + "line": 797 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward", "klass": "DEFECT", "value": "author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess", - "line": 793 + "line": 799 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom", "klass": "DEFECT", "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number", - "line": 790 + "line": 796 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect", "klass": "DEFECT", "value": "any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts", - "line": 827 + "line": 833 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", "klass": "DEFECT", "value": "#3309 v2 default flip from mid-flight to end-of-phase", - "line": 826 + "line": 832 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward", "klass": "DEFECT", "value": "template — \"new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set \"", - "line": 828 + "line": 834 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom", "klass": "DEFECT", "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)", - "line": 825 + "line": 831 }, { "id": "DEFECT.FORMAT", "klass": "DEFECT", "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable", - "line": 742 + "line": 748 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect", "klass": "DEFECT", "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it", - "line": 840 + "line": 846 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples", "klass": "DEFECT", "value": "#586/PR #650 ship.md verification gate — grep \"^status:\" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)", - "line": 839 + "line": 845 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward", "klass": "DEFECT", "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)", - "line": 841 + "line": 847 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom", "klass": "DEFECT", "value": "a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep \"^key:\" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted", - "line": 838 + "line": 844 }, { "id": "DEFECT.GENERATIVE-EXEMPLAR", "klass": "DEFECT", "value": "tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)", - "line": 836 + "line": 842 }, { "id": "DEFECT.GENERATIVE-FIX", "klass": "DEFECT", "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge", - "line": 835 + "line": 841 }, { "id": "DEFECT.GENERATIVE-PRIORITY", "klass": "DEFECT", "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer", - "line": 834 + "line": 840 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect", "klass": "DEFECT", "value": "two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted", - "line": 984 + "line": 990 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward", "klass": "DEFECT", "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)", - "line": 985 + "line": 991 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause", "klass": "DEFECT", "value": "gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()", - "line": 983 + "line": 989 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom", "klass": "DEFECT", "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine", - "line": 982 + "line": 988 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream", "klass": "DEFECT", "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)", - "line": 986 + "line": 992 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect", "klass": "DEFECT", "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out", - "line": 952 + "line": 958 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples", "klass": "DEFECT", "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes", - "line": 951 + "line": 957 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward", "klass": "DEFECT", "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds", - "line": 953 + "line": 959 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related", "klass": "DEFECT", "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month", - "line": 954 + "line": 960 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom", "klass": "DEFECT", "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all", - "line": 950 + "line": 956 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect", "klass": "DEFECT", "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed", - "line": 976 + "line": 982 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery", "klass": "DEFECT", "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)", - "line": 978 + "line": 984 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause", "klass": "DEFECT", "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts", - "line": 977 + "line": 983 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom", "klass": "DEFECT", "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs", - "line": 975 + "line": 981 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", "klass": "DEFECT", "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe", - "line": 979 + "line": 985 }, { "id": "DEFECT.HALT-COST-PATTERN.detect", "klass": "DEFECT", "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context", - "line": 817 + "line": 823 }, { "id": "DEFECT.HALT-COST-PATTERN.examples", "klass": "DEFECT", "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)", - "line": 816 + "line": 822 }, { "id": "DEFECT.HALT-COST-PATTERN.fix-forward", "klass": "DEFECT", "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer", - "line": 818 + "line": 824 }, { "id": "DEFECT.HALT-COST-PATTERN.symptom", "klass": "DEFECT", "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn", - "line": 815 + "line": 821 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", "klass": "DEFECT", "value": "hook re-fires on each invocation regardless of session-state read receipts", - "line": 822 + "line": 828 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples", "klass": "DEFECT", "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file", - "line": 821 + "line": 827 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward", "klass": "DEFECT", "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match", - "line": 823 + "line": 829 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking", "klass": "DEFECT", "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files", - "line": 981 + "line": 987 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom", "klass": "DEFECT", "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session", - "line": 820 + "line": 826 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass", "klass": "DEFECT", "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks", - "line": 1000 + "line": 1006 }, { "id": "DEFECT.INVENTORY-DRIFT.detect", "klass": "DEFECT", "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading", - "line": 782 + "line": 788 }, { "id": "DEFECT.INVENTORY-DRIFT.examples", "klass": "DEFECT", "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)", - "line": 781 + "line": 787 }, { "id": "DEFECT.INVENTORY-DRIFT.fix-forward", "klass": "DEFECT", - "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all six families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY)", - "line": 783 + "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path", + "line": 789 }, { "id": "DEFECT.INVENTORY-DRIFT.symptom", "klass": "DEFECT", "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json", - "line": 780 + "line": 786 }, { "id": "DEFECT.NAME-COLLISION.detect", "klass": "DEFECT", "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract", - "line": 923 + "line": 929 }, { "id": "DEFECT.NAME-COLLISION.examples", "klass": "DEFECT", "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section
\")", - "line": 922 + "line": 928 }, { "id": "DEFECT.NAME-COLLISION.fix-forward", "klass": "DEFECT", "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract", - "line": 924 + "line": 930 }, { "id": "DEFECT.NAME-COLLISION.symptom", "klass": "DEFECT", "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw", - "line": 921 + "line": 927 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect", "klass": "DEFECT", "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning", - "line": 812 + "line": 818 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", "klass": "DEFECT", "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants", - "line": 811 + "line": 817 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward", "klass": "DEFECT", "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source", - "line": 813 + "line": 819 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom", "klass": "DEFECT", "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed", - "line": 810 + "line": 816 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor", "klass": "DEFECT", "value": "tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)", - "line": 758 + "line": 764 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect", "klass": "DEFECT", "value": "grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting", - "line": 756 + "line": 762 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples", "klass": "DEFECT", "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)", - "line": 755 + "line": 761 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward", "klass": "DEFECT", "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps", - "line": 757 + "line": 763 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom", "klass": "DEFECT", "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces", - "line": 754 + "line": 760 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect", "klass": "DEFECT", "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST", - "line": 875 + "line": 881 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples", "klass": "DEFECT", "value": "PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control", - "line": 874 + "line": 880 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward", "klass": "DEFECT", "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)", - "line": 876 + "line": 882 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention", "klass": "DEFECT", "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)", - "line": 877 + "line": 883 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom", "klass": "DEFECT", "value": "scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation", - "line": 873 + "line": 879 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", "klass": "DEFECT", "value": "any new bare tag in agents/*.md", - "line": 777 + "line": 783 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples", "klass": "DEFECT", "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)", - "line": 776 + "line": 782 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", "klass": "DEFECT", "value": "hyphenate the tag (, ) — scanner regex matches bare names only", - "line": 778 + "line": 784 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom", "klass": "DEFECT", "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate", - "line": 775 + "line": 781 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.detect", "klass": "DEFECT", "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete", - "line": 746 + "line": 752 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.examples", "klass": "DEFECT", "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace", - "line": 745 + "line": 751 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward", "klass": "DEFECT", "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility", - "line": 747 + "line": 753 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.symptom", "klass": "DEFECT", "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)", - "line": 744 + "line": 750 }, { "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", "klass": "DEFECT", "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)", - "line": 352 + "line": 355 }, { "id": "DEFECT.SCOPE.window", "klass": "DEFECT", "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287", - "line": 741 + "line": 747 }, { "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie", "klass": "DEFECT", "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open", - "line": 925 + "line": 931 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect", "klass": "DEFECT", "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)", - "line": 936 + "line": 942 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples", "klass": "DEFECT", "value": "#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002", - "line": 935 + "line": 941 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward", "klass": "DEFECT", "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class", - "line": 937 + "line": 943 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom", "klass": "DEFECT", "value": "a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with \"Cannot find module\" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT", - "line": 934 + "line": 940 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)", - "line": 938 + "line": 944 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect", "klass": "DEFECT", "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation", - "line": 831 + "line": 837 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward", "klass": "DEFECT", "value": "replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification", - "line": 832 + "line": 838 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom", "klass": "DEFECT", "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script", - "line": 830 + "line": 836 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect", "klass": "DEFECT", "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts", - "line": 762 + "line": 768 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples", "klass": "DEFECT", "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged", - "line": 761 + "line": 767 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward", "klass": "DEFECT", "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)", - "line": 763 + "line": 769 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom", "klass": "DEFECT", "value": "PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts", - "line": 760 + "line": 766 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern", "klass": "DEFECT", "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing", - "line": 944 + "line": 950 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect", "klass": "DEFECT", "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\"", - "line": 942 + "line": 948 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples", "klass": "DEFECT", "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all", - "line": 941 + "line": 947 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward", "klass": "DEFECT", "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")", - "line": 943 + "line": 949 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom", "klass": "DEFECT", "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first", - "line": 940 + "line": 946 }, { "id": "DEFECT.STATE-TRAMPLE.detect", "klass": "DEFECT", "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress", - "line": 751 + "line": 757 }, { "id": "DEFECT.STATE-TRAMPLE.examples", "klass": "DEFECT", "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)", - "line": 750 + "line": 756 }, { "id": "DEFECT.STATE-TRAMPLE.fix-forward", "klass": "DEFECT", "value": "route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)", - "line": 752 + "line": 758 }, { "id": "DEFECT.STATE-TRAMPLE.symptom", "klass": "DEFECT", "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter", - "line": 749 + "line": 755 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor", "klass": "DEFECT", "value": "lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)", - "line": 990 + "line": 996 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect", "klass": "DEFECT", "value": "sub-agent returns prematurely with text like \"I should wait for the notification per CLAUDE.md\" and incomplete work in its worktree (commits absent, push absent, PR absent)", - "line": 988 + "line": 994 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward", "klass": "DEFECT", "value": "keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task", - "line": 989 + "line": 995 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom", "klass": "DEFECT", "value": "spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness \"you will be notified\" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator", - "line": 987 + "line": 993 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect", "klass": "DEFECT", "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded", - "line": 772 + "line": 778 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", "klass": "DEFECT", "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)", - "line": 771 + "line": 777 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward", "klass": "DEFECT", "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history", - "line": 773 + "line": 779 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom", "klass": "DEFECT", "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts", - "line": 770 + "line": 776 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect", "klass": "DEFECT", "value": "test does readFileSync(md).match for a bash fence with literal \\n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703", - "line": 844 + "line": 850 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples", "klass": "DEFECT", "value": "#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \\n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite", - "line": 843 + "line": 849 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward", "klass": "DEFECT", "value": "match the fence with \\r?\\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file", - "line": 845 + "line": 851 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom", "klass": "DEFECT", "value": "a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \\n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\\...) is un-globbable in bash so the pipeline returns empty and assertions fail", - "line": 842 + "line": 848 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect", "klass": "DEFECT", "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view", - "line": 807 + "line": 813 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", "klass": "DEFECT", "value": "a33cbe72 worktree fix bound git subprocesses with timeout", - "line": 806 + "line": 812 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward", "klass": "DEFECT", "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw", - "line": 808 + "line": 814 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom", "klass": "DEFECT", "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network", - "line": 805 + "line": 811 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect", "klass": "DEFECT", "value": "Windows CI job at \"Run unit tests\" exits with code 1 within seconds of starting, no node:test output between \"run-tests: suite=… files=N: …\" line and \"Process completed with exit code 1\"; same job on Linux/macOS runs full duration", - "line": 929 + "line": 935 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples", "klass": "DEFECT", "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed", - "line": 928 + "line": 934 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward", "klass": "DEFECT", "value": "chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths", - "line": 930 + "line": 936 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention", "klass": "DEFECT", "value": "a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)", - "line": 932 + "line": 938 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom", "klass": "DEFECT", "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)", - "line": 927 + "line": 933 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs \"Windows argv-overflow chunking (issue #3597)\" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform", - "line": 931 + "line": 937 }, { "id": "DEFECT.WINDOWS-FS-OPS.detect", "klass": "DEFECT", "value": "ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape", - "line": 802 + "line": 808 }, { "id": "DEFECT.WINDOWS-FS-OPS.examples", "klass": "DEFECT", "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim", - "line": 801 + "line": 807 }, { "id": "DEFECT.WINDOWS-FS-OPS.fix-forward", "klass": "DEFECT", "value": "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop", - "line": 803 + "line": 809 }, { "id": "DEFECT.WINDOWS-FS-OPS.symptom", "klass": "DEFECT", "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target", - "line": 800 + "line": 806 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect", "klass": "DEFECT", "value": "any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\\.md or /…\\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)", - "line": 861 + "line": 867 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples", "klass": "DEFECT", "value": "PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\\...\\gsd-ial-windsurf-XXX\\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only", - "line": 860 + "line": 866 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward", "klass": "DEFECT", "value": "normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always", - "line": 862 + "line": 868 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention", "klass": "DEFECT", "value": "enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\\\/g,'/'))", - "line": 863 + "line": 869 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom", "klass": "DEFECT", "value": "path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected", - "line": 859 + "line": 865 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect", "klass": "DEFECT", "value": "any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)", - "line": 869 + "line": 875 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples", "klass": "DEFECT", "value": "PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side", - "line": 868 + "line": 874 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward", "klass": "DEFECT", "value": "normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.", - "line": 870 + "line": 876 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention", "klass": "DEFECT", "value": "enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT", - "line": 871 + "line": 877 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom", "klass": "DEFECT", "value": "an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual", - "line": 867 + "line": 873 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect", "klass": "DEFECT", "value": "grep tests for \\`.mode & 0o777\\` / \\`.mode) === 0o\\` / \\`writeFileSync(...{ mode: 0o\\` / \\`chmodSync\\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)", - "line": 855 + "line": 861 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples", "klass": "DEFECT", "value": "#1634/PR #1638 tests/capability-lifecycle.test.cjs \"a .cjs hook command is node-prefixed so it runs without the executable bit\" failed windows-latest,24 on \"precondition: file staged without +x\" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact", - "line": 854 + "line": 860 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward", "klass": "DEFECT", "value": "gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX", - "line": 856 + "line": 862 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention", "klass": "DEFECT", "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", - "line": 857 + "line": 863 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom", "klass": "DEFECT", "value": "a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755", - "line": 853 + "line": 859 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", "klass": "DEFECT", "value": "npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done", - "line": 849 + "line": 855 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples", "klass": "DEFECT", "value": "PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes", - "line": 848 + "line": 854 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward", "klass": "DEFECT", "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)", - "line": 850 + "line": 856 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention", "klass": "DEFECT", "value": "run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it", - "line": 851 + "line": 857 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom", "klass": "DEFECT", "value": "local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \\ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally", - "line": 847 + "line": 853 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect", "klass": "DEFECT", "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present", - "line": 881 + "line": 887 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples", "klass": "DEFECT", "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge", - "line": 880 + "line": 886 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward", "klass": "DEFECT", "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves", - "line": 882 + "line": 888 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention", "klass": "DEFECT", "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime", - "line": 883 + "line": 889 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom", "klass": "DEFECT", "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail", - "line": 879 + "line": 885 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect", "klass": "DEFECT", "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook", - "line": 797 + "line": 803 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", "klass": "DEFECT", "value": "this session, branch fix/3309-... and pr-3316", - "line": 796 + "line": 802 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:", - "line": 798 + "line": 804 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom", "klass": "DEFECT", "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch", - "line": 795 + "line": 801 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 968 + "line": 974 }, { "id": "EXEC.CLASSIFY.cross-runtime", "klass": "EXEC", "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests", - "line": 970 + "line": 976 }, { "id": "EXEC.CLASSIFY.handler", "klass": "EXEC", "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)", - "line": 966 + "line": 972 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 971 + "line": 977 }, { "id": "EXEC.CLASSIFY.proactive-signal-not-usable", "klass": "EXEC", "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)", - "line": 973 + "line": 979 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 972 + "line": 978 }, { "id": "EXEC.CLASSIFY.sentinel-order", "klass": "EXEC", "value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form", - "line": 969 + "line": 975 }, { "id": "EXEC.CLASSIFY.workflow", "klass": "EXEC", "value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)", - "line": 967 + "line": 973 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", "klass": "GSD-RESEARCH", "value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob", - "line": 351 + "line": 354 }, { "id": "GSD-RESEARCH.INTEGRATION.L2-hybrid", "klass": "GSD-RESEARCH", "value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches", - "line": 349 + "line": 352 }, { "id": "GSD-RESEARCH.MODULE.package-legitimacy", "klass": "GSD-RESEARCH", "value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate", - "line": 348 + "line": 351 }, { "id": "GSD-RESEARCH.MODULE.research-provider", "klass": "GSD-RESEARCH", "value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)", - "line": 347 + "line": 350 }, { "id": "GSD-RESEARCH.MODULE.research-store", "klass": "GSD-RESEARCH", "value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache", - "line": 346 + "line": 349 }, { "id": "GSD-RESEARCH.PROVIDER.availability", "klass": "GSD-RESEARCH", "value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal", - "line": 350 + "line": 353 }, { "id": "LEARNING.prompt-budget.boundary-gap", "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures", - "line": 492 + "line": 498 }, { "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", "klass": "LIVE-CONFIG", "value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes", - "line": 585 + "line": 591 }, { "id": "LIVE-CONFIG.GUARD.SEAM.module", "klass": "LIVE-CONFIG", "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", - "line": 580 + "line": 586 }, { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", "value": "resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", - "line": 582 + "line": 588 }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", - "line": 581 + "line": 587 }, { "id": "LIVE-CONFIG.GUARD.SEAM.severity", "klass": "LIVE-CONFIG", "value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1", - "line": 584 + "line": 590 }, { "id": "LIVE-CONFIG.GUARD.SEAM.truncation", "klass": "LIVE-CONFIG", "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing", - "line": 583 + "line": 589 }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations", - "line": 636 + "line": 642 }, { "id": "META.RULE.brief-no-paraphrase", "klass": "META", "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110", - "line": 637 + "line": 643 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 634 + "line": 640 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 635 + "line": 641 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 570 + "line": 576 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 571 + "line": 577 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 572 + "line": 578 }, { "id": "PR.3267.POSTMORTEM.recovery", "klass": "PR", "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]", - "line": 549 + "line": 555 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 548 + "line": 554 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 640 + "line": 646 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 646 + "line": 652 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 647 + "line": 653 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 642 + "line": 648 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 649 + "line": 655 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 645 + "line": 651 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 648 + "line": 654 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 641 + "line": 647 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 639 + "line": 645 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 643 + "line": 649 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 644 + "line": 650 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 655 + "line": 661 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 653 + "line": 659 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 654 + "line": 660 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 652 + "line": 658 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 651 + "line": 657 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 660 + "line": 666 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 661 + "line": 667 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 658 + "line": 664 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 663 + "line": 669 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 662 + "line": 668 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 659 + "line": 665 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 657 + "line": 663 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 668 + "line": 674 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 667 + "line": 673 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 670 + "line": 676 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 669 + "line": 675 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 666 + "line": 672 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 665 + "line": 671 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 674 + "line": 680 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 676 + "line": 682 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 673 + "line": 679 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 675 + "line": 681 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 672 + "line": 678 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 681 + "line": 687 }, { "id": "PRED.k325.cure", "klass": "PRED", "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/", - "line": 680 + "line": 686 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 682 + "line": 688 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 679 + "line": 685 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 678 + "line": 684 }, { "id": "PRED.k326.cure", "klass": "PRED", "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"", - "line": 686 + "line": 692 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 687 + "line": 693 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 685 + "line": 691 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 684 + "line": 690 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 690 + "line": 696 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 693 + "line": 699 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 694 + "line": 700 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 692 + "line": 698 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 691 + "line": 697 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 689 + "line": 695 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 699 + "line": 705 }, { "id": "PRED.k328.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)", - "line": 697 + "line": 703 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 698 + "line": 704 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 696 + "line": 702 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 705 + "line": 711 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 702 + "line": 708 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 703 + "line": 709 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 704 + "line": 710 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 706 + "line": 712 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 701 + "line": 707 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 710 + "line": 716 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 709 + "line": 715 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 708 + "line": 714 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 715 + "line": 721 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 717 + "line": 723 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 714 + "line": 720 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 716 + "line": 722 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 713 + "line": 719 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 712 + "line": 718 }, { "id": "PROBE.ci.surface", "klass": "PROBE", "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)", - "line": 463 + "line": 469 }, { "id": "PROBE.core.seam", "klass": "PROBE", "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)", - "line": 456 + "line": 462 }, { "id": "PROBE.edge.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 458 + "line": 464 }, { "id": "PROBE.family", "klass": "PROBE", "value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)", - "line": 454 + "line": 460 }, { "id": "PROBE.item.axes", "klass": "PROBE", "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)", - "line": 457 + "line": 463 }, { "id": "PROBE.principle", "klass": "PROBE", "value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md", - "line": 453 + "line": 459 }, { "id": "PROBE.prohib.verification", "klass": "PROBE", "value": "test|judgment", - "line": 459 + "line": 465 }, { "id": "PROBE.protocol", "klass": "PROBE", "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason", - "line": 455 + "line": 461 }, { "id": "PROBE.ui.axis", "klass": "PROBE", "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)", - "line": 461 + "line": 467 }, { "id": "PROBE.ui.seam", "klass": "PROBE", "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)", - "line": 462 + "line": 468 }, { "id": "PROBE.ui.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 460 + "line": 466 }, { "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 721 + "line": 727 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 720 + "line": 726 }, { "id": "PROC.AGENT-DISPATCH.preflight", "klass": "PROC", "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]", - "line": 719 + "line": 725 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 725 + "line": 731 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 726 + "line": 732 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 727 + "line": 733 }, { "id": "PROC.MERGE-WAVE.ordering", "klass": "PROC", "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]", - "line": 723 + "line": 729 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 724 + "line": 730 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.observed", "klass": "PROC", "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened", - "line": 998 + "line": 1004 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", "klass": "PROC", "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill", - "line": 996 + "line": 1002 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", "klass": "PROC", "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION", - "line": 997 + "line": 1003 }, { "id": "PROC.TRIAGE.comment-shape", "klass": "PROC", "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close", - "line": 1003 + "line": 1009 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 1004 + "line": 1010 }, { "id": "PROC.TRIAGE.routing-incoming", "klass": "PROC", "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest", - "line": 1002 + "line": 1008 }, { "id": "PROHIB.canon-referral", "klass": "PROHIB", "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)", - "line": 465 + "line": 471 }, { "id": "PROHIB.descriptor.shape", "klass": "PROHIB", "value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)", - "line": 470 + "line": 476 }, { "id": "PROHIB.enforce.adr", "klass": "PROHIB", "value": "docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)", - "line": 473 + "line": 479 }, { "id": "PROHIB.enforce.causation", "klass": "PROHIB", "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)", - "line": 469 + "line": 475 }, { "id": "PROHIB.enforce.failfirst", "klass": "PROHIB", "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)", - "line": 468 + "line": 474 }, { "id": "PROHIB.enforce.green-rule", "klass": "PROHIB", "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default", - "line": 466 + "line": 472 }, { "id": "PROHIB.enforce.kinds", "klass": "PROHIB", "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)", - "line": 467 + "line": 473 }, { "id": "PROHIB.judgment-tier", "klass": "PROHIB", "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)", - "line": 472 + "line": 478 }, { "id": "PROHIB.rail", "klass": "PROHIB", "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability", - "line": 471 + "line": 477 }, { "id": "PROHIB.recall", "klass": "PROHIB", "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)", - "line": 464 + "line": 470 }, { "id": "RELEASE-NOTES.ANTI-PATTERN", "klass": "RELEASE-NOTES", "value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes", - "line": 616 + "line": 622 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", "klass": "RELEASE-NOTES", "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior", - "line": 617 + "line": 623 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", "klass": "RELEASE-NOTES", "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong", - "line": 618 + "line": 624 }, { "id": "RELEASE-NOTES.DEFAULT-STATE", "klass": "RELEASE-NOTES", "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final", - "line": 592 + "line": 598 }, { "id": "RELEASE-NOTES.EXAMPLE.hotfix", "klass": "RELEASE-NOTES", "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups", - "line": 620 + "line": 626 }, { "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", "klass": "RELEASE-NOTES", "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles", - "line": 622 + "line": 628 }, { "id": "RELEASE-NOTES.EXAMPLE.rc", "klass": "RELEASE-NOTES", "value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy", - "line": 621 + "line": 627 }, { "id": "RELEASE-NOTES.GATE.hotfix", "klass": "RELEASE-NOTES", "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body", - "line": 593 + "line": 599 }, { "id": "RELEASE-NOTES.GATE.minor", "klass": "RELEASE-NOTES", "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix", - "line": 595 + "line": 601 }, { "id": "RELEASE-NOTES.GATE.rc", "klass": "RELEASE-NOTES", "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard", - "line": 594 + "line": 600 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 627 + "line": 633 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 628 + "line": 634 }, { "id": "RELEASE-NOTES.SCOPE", "klass": "RELEASE-NOTES", "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)", - "line": 591 + "line": 597 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 607 + "line": 613 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 606 + "line": 612 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 608 + "line": 614 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 609 + "line": 615 }, { "id": "RELEASE-NOTES.STANDARD.bullet-shape", "klass": "RELEASE-NOTES", "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.", - "line": 599 + "line": 605 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 603 + "line": 609 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 601 + "line": 607 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 602 + "line": 608 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 598 + "line": 604 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 604 + "line": 610 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 600 + "line": 606 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 597 + "line": 603 }, { "id": "RELEASE-NOTES.TEMPLATE.hotfix", "klass": "RELEASE-NOTES", "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ", - "line": 624 + "line": 630 }, { "id": "RELEASE-NOTES.TEMPLATE.rc", "klass": "RELEASE-NOTES", "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 625 + "line": 631 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 611 + "line": 617 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 614 + "line": 620 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 613 + "line": 619 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 612 + "line": 618 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 516 + "line": 522 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 505 + "line": 511 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 512 + "line": 518 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 513 + "line": 519 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 501 + "line": 507 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", "klass": "RULESET", "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.", - "line": 301 + "line": 304 }, { "id": "RULESET.CAPABILITY.off-means-off", "klass": "RULESET", "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.", - "line": 299 + "line": 302 }, { "id": "RULESET.CAPABILITY.precedence-engine-single-owner", "klass": "RULESET", "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.", - "line": 305 + "line": 308 }, { "id": "RULESET.CAPABILITY.step-additive-gate-blocks", "klass": "RULESET", "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.", - "line": 303 + "line": 306 }, { "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 538 + "line": 544 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 539 + "line": 545 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 537 + "line": 543 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 540 + "line": 546 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 541 + "line": 547 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 542 + "line": 548 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)", - "line": 865 + "line": 871 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 531 + "line": 537 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 532 + "line": 538 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 530 + "line": 536 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 529 + "line": 535 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 523 + "line": 529 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other, and a fragment left on `next` after merge is inert rather than a shared cell; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins. `tests/emitted-drift-ack.json` (the LEGACY file specifically, NOT the fragment directory) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; a persisting FRAGMENT is harmless by construction and is deliberately not what this guard checks. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next` (`assertAbsentOnNext`), which fails on the LEGACY file's PRESENCE alone, valid or not — a PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", - "line": 506 + "line": 512 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 536 + "line": 542 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM", - "line": 956 + "line": 962 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", - "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL SIX families.* arrays (agents/commands/workflows/references/cli_modules/hooks) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all six, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write", - "line": 517 + "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib", + "line": 523 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — \"we don't set a timer we actively watch and record results in real time as possible\". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:\"passed\" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.", - "line": 958 + "line": 964 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 960 + "line": 966 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 519 + "line": 525 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 514 + "line": 520 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 543 + "line": 549 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 488 + "line": 494 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 491 + "line": 497 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 490 + "line": 496 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 495 + "line": 501 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 486 + "line": 492 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 498 + "line": 504 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 487 + "line": 493 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 483 + "line": 489 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)", - "line": 499 + "line": 505 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 489 + "line": 495 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 485 + "line": 491 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 497 + "line": 503 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 484 + "line": 490 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 479 + "line": 485 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 480 + "line": 486 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 481 + "line": 487 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 494 + "line": 500 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 496 + "line": 502 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 521 + "line": 527 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 510 + "line": 516 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 509 + "line": 515 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 508 + "line": 514 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 507 + "line": 513 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 503 + "line": 509 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 504 + "line": 510 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 911 + "line": 917 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 912 + "line": 918 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 913 + "line": 919 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 914 + "line": 920 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 915 + "line": 921 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 916 + "line": 922 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 917 + "line": 923 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]", - "line": 918 + "line": 924 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]", - "line": 919 + "line": 925 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 734 + "line": 740 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 731 + "line": 737 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 732 + "line": 738 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 735 + "line": 741 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 733 + "line": 739 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 557 + "line": 563 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 558 + "line": 564 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 573 + "line": 579 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 574 + "line": 580 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 559 + "line": 565 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 567 + "line": 573 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 551 + "line": 557 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 555 + "line": 561 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 554 + "line": 560 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 552 + "line": 558 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 553 + "line": 559 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 565 + "line": 571 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 566 + "line": 572 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 569 + "line": 575 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 568 + "line": 574 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 564 + "line": 570 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 563 + "line": 569 } ], "duplicates": [] From 664bab3b48a160fc51546f99d64f39aebb03bd75 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Wed, 5 Aug 2026 07:01:40 -0500 Subject: [PATCH 28/47] docs(#2665): the scrub-set and guard-target seams understated their own mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both predicates were written in round 3 and not updated when round 4 widened what they describe, so the catalog that exists to stop a future omission had two of its own. CONFIG.LOCATION.SEAM.scrub-set said "four sources" and listed four. There are five rungs: the two descriptor rungs each additionally walk skillsHome.env (the maintainer's round-4 Minor 3), and the fifth is WRITE_ESCAPE_PERMISSION_ENV_KEYS, which is a permission rather than a location and so is reachable by no other rung. LIVE-CONFIG.GUARD.SEAM.non-root-targets said "the two live write surfaces" and named a single config.toml via resolveKimiHooksTomlDir. Since #2755 landed KIMI_CODE_HOOKS_TOML_DESCRIPTOR there are three, and the guard derives them by iterating NON_REGISTRY_CONFIG_HOME_DESCRIPTORS rather than calling a named resolver. Both bounds are stated rather than left open: skills bases are a deliberate non-target (the config-root layout misfires beneath them), and a further descriptor is only free if it owns the same NON_REGISTRY_OWNED_FILE — the residual the guard already names at its own definition. LIVE-CONFIG.GUARD.SEAM.scope's "gsd--prefixed" read as a two-hyphen prefix; the selector is startsWith(GSD_ARTIFACT_PREFIX) where that constant is 'gsd-'. CONFIG.LOCATION.SEAM.kimi-two-homes was checked and is NOT stale: #2755 made Kimi Code a separate runtime, so Kimi CLI still declares exactly two. Both CONTEXT-INDEX mirrors regenerated; predicate ids unchanged (425), only their descriptions move. --- CONTEXT.md | 6 +++--- docs/CONTEXT-INDEX.json | 6 +++--- examples/dynamic-context-management/CONTEXT-INDEX.json | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 34d4f7ae8..3f7d1de5a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -588,13 +588,13 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation` `WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream` `CONFIG.SEAM.loadConfig-context=loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites` -`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal` +`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal` `CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2` `CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second` `CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST` `LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite` -`LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled` -`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot` +`LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled` +`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot` `LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing` `LIVE-CONFIG.GUARD.SEAM.severity=reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1` `LIVE-CONFIG.GUARD.SEAM.ci-blind=the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes` diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index e59217410..14bdf45d6 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -53,7 +53,7 @@ { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", - "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal" + "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal" }, { "id": "CONFIG.LOCATION.SEAM.two-families", @@ -988,12 +988,12 @@ { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", - "value": "resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot" + "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot" }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", - "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled" + "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled" }, { "id": "LIVE-CONFIG.GUARD.SEAM.severity", diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 73aa2e800..17f723f29 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -58,7 +58,7 @@ { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", - "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from four sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", + "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", "line": 582 }, { @@ -1180,13 +1180,13 @@ { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", - "value": "resolveExtraWatchTargets covers the two live write surfaces that are not runtime config ROOTS: $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) and /config.toml watched as a SINGLE FILE (the root belongs to Kimi CLI); passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", + "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", "line": 588 }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", - "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + gsd--prefixed children of GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", + "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", "line": 587 }, { From 12cfd27f5341e57a08b6c82e2b5f90d9b0972336 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Wed, 5 Aug 2026 07:10:09 -0500 Subject: [PATCH 29/47] docs(#2665): the guard's own comments still described one kimi home, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same drift as the CONTEXT.md seams, one layer over: #2755 took NON_REGISTRY_CONFIG_HOME_DESCRIPTORS from one entry to two, and five comments across three files were left describing the one-entry world — "two live write surfaces", "today's only entry", "today's single entry", and a /config.toml bullet naming only Kimi CLI's KIMI_SHARE_DIR. The sharpest one was a wrong pointer rather than a stale count: run-tests.cjs cited "scripts/lib/live-config-guard.cjs" for why the scope is narrow. That path does not exist, and it names the one directory this module is deliberately NOT in — the installer copies scripts/lib/ to users wholesale while uninstall removes only an allowlist, which is the whole reason the guard lives one level up. A reader following that pointer would have concluded the opposite of the decision. Comments only; no behaviour change. lint:ci rc=0, tests/live-config-guard.test.cjs 24/24, tests/run-tests-harness.test.cjs 138/138. --- scripts/live-config-guard.cjs | 24 ++++++++++++++---------- scripts/run-tests.cjs | 8 +++++--- tests/live-config-guard.test.cjs | 4 ++-- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 3577b329c..7290371b8 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -142,22 +142,26 @@ function resolveLiveConfigRoots(deps = {}) { * `root x GSD_OWNED_ENTRIES`. * * #2665 round 3: resolveLiveConfigRoots enumerates getGlobalConfigDir per registry - * runtime plus grok. Two live write surfaces are invisible to that shape, so a leak - * on either passed through this guard — the PR's own safety net — silently: + * runtime plus grok. A live write surface that is not a config ROOT is invisible to + * that shape, so a leak on one passed through this guard — the PR's own safety net — + * silently. There are THREE today ($GSD_HOME/.gsd, plus one config.toml per entry in + * NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, which #2755 took from one entry to two): * * $GSD_HOME/.gsd — GSD's user-owned store (consent.json, defaults.json, capability * overlays). Watched WHOLESALE: unlike ~/.claude this root is * exclusively ours, so the shared-root false-positive trap in * SCOPE above does not apply and an ownership filter would only * narrow the guard for nothing. - * /config.toml — the file GSD writes its native [[hooks]] block into - * (resolveKimiHooksTomlDir, KIMI_SHARE_DIR). The INVERSE case: - * ~/.kimi belongs to Kimi CLI, so only the one file GSD writes is + * /config.toml — the file GSD writes its native [[hooks]] block + * into, one per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry: Kimi + * CLI's ~/.kimi (KIMI_SHARE_DIR) and, since #2755, Kimi Code's + * ~/.kimi-code (KIMI_CODE_HOME). The INVERSE case: those roots + * belong to their products, so only the one file GSD writes is * watched, never the root. This is the KNOWN GAP above accepted - * deliberately in one direction — GSD demonstrably writes this - * file (bin/install.js calls resolveKimiHooksTomlDir at two sites), - * so a concurrent Kimi CLI write is the only false positive, and - * Kimi is not running during the suite. + * deliberately in one direction — GSD demonstrably writes these + * files (bin/install.js resolves the hooks-toml dir at two sites), + * so a concurrent write by those products is the only false + * positive, and neither runs during the suite. * * @returns {string[]} absolute paths; empty if the built lib is absent. */ @@ -175,7 +179,7 @@ function resolveExtraWatchTargets(deps = {}) { } = require(path.join(libDir, 'runtime-homes.cjs')); // ITERATE the descriptor array rather than naming one resolver. Calling - // resolveKimiHooksTomlDir directly would cover today's only entry and + // resolveKimiHooksTomlDir directly would cover one of today's two entries and // silently miss tomorrow's — the same partial-enumeration defect that put // KIMI_SHARE_DIR outside the scrub set in the first place, reintroduced one // layer over. TEST_ENV_BASE derives its keys from this array; deriving the diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 0fabccd13..24d12767f 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -975,15 +975,17 @@ function main() { // #2665: snapshot GSD's install footprint in every LIVE runtime config dir // before a single test runs. The suite must not write there; the check after // the chunk loop is what makes a violation loud instead of silent. See - // scripts/lib/live-config-guard.cjs for why the scope is narrow. + // scripts/live-config-guard.cjs for why the scope is narrow (it is deliberately + // NOT under scripts/lib/, which the installer copies to users wholesale). const liveConfigGuardEnabled = process.env.GSD_SKIP_LIVE_CONFIG_GUARD !== '1'; let liveConfigRoots = []; let liveConfigExtras = []; let liveConfigBefore = null; if (liveConfigGuardEnabled) { liveConfigRoots = resolveLiveConfigRoots(); - // #2665 round 3: $GSD_HOME/.gsd and kimi's native config.toml are live write - // surfaces that are not runtime config ROOTS, so they are invisible to the + // #2665 round 3: $GSD_HOME/.gsd, and one native config.toml per non-registry + // config-home descriptor (Kimi CLI's and, since #2755, Kimi Code's), are live + // write surfaces that are not runtime config ROOTS, so they are invisible to the // line above. Watched independently — and note the OR: the extras alone are // reason enough to snapshot, so an unbuilt tree that yields zero roots no // longer silently disables the whole guard. diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index d54f836cc..1f8e1a1a5 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -172,7 +172,7 @@ describe('#2665: live-config hermeticity guard', () => { }); }); -// ── Round 3: the two write surfaces that are not runtime config ROOTS ──────── +// ── Round 3: the write surfaces that are not runtime config ROOTS ─────────── describe('#2665: guard watches non-root write surfaces', () => { test('resolveExtraWatchTargets covers $GSD_HOME/.gsd and kimi config.toml', () => { const home = tmpRoot(); @@ -207,7 +207,7 @@ describe('#2665: guard watches non-root write surfaces', () => { const targets = resolveExtraWatchTargets({ env, os: { homedir: () => home } }); // Every descriptor in the array must contribute a target. Calling one - // named resolver instead would cover today's single entry and silently + // named resolver instead would cover one of today's two entries and silently // miss tomorrow's — the same partial-enumeration defect that put // KIMI_SHARE_DIR outside the scrub set, one layer over. for (const d of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) { From ecea537194ddfbcc12742b58b9dbf594079edcb3 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Wed, 5 Aug 2026 07:23:25 -0500 Subject: [PATCH 30/47] docs(#2665): the guard watches config.toml but GSD also writes /hooks/ there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found pre-push by this round's third adversarial review pass. Not a rebase regression — round 3 shipped it and #2755 doubled it. resolveExtraWatchTargets watches one config.toml per non-registry descriptor, and its comment asserted "GSD writes ONE named file into these third-party roots". That is false: bin/install.js also calls installSharedHooksBundle on the same root, populating /hooks/ with GSD's hook scripts and a CommonJS marker. So a suite-produced leak of a hook bundle into a developer's real ~/.kimi or ~/.kimi-code passes this guard silently — #2665's own hazard, in #2665's own safety net. Behaviour is deliberately unchanged and the gap is disclosed instead. Closing it is a layout decision rather than one more path, for the same reason getGlobalSkillsBase is already a deliberate non-target: the snapshot applies the config-root layout beneath every root it is given, and these roots are not ours. Happy to fix it here or take it as a separate issue — the maintainer's call. The enumeration-relative test could not have caught this: it asserts one target PER DESCRIPTOR and nothing about whether one per descriptor is enough, because its expectation is derived from the same array it checks. That is exactly the scope boundary round-2 Nit 7 asked to be marked, biting one layer up from where it was marked; the test now says so. 479979c4's message says "there are three" — that is three WATCHED targets, not a count of write surfaces. The hooks bundle is a fourth, and unwatched. lint:ci rc=0; tests/live-config-guard.test.cjs 24/24. Comments and catalog only. --- CONTEXT.md | 4 +-- docs/CONTEXT-INDEX.json | 4 +-- .../CONTEXT-INDEX.json | 4 +-- scripts/live-config-guard.cjs | 30 ++++++++++++++----- tests/live-config-guard.test.cjs | 6 ++++ 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3f7d1de5a..89a482b9b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -588,13 +588,13 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr `WORKSTREAM.NAME.POLICY.cjs-module=gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation` `WORKSTREAM.POINTER.SEAM.cjs-module=gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream` `CONFIG.SEAM.loadConfig-context=loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites` -`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal` +`CONFIG.LOCATION.SEAM.scrub-set=tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal` `CONFIG.LOCATION.SEAM.two-families=runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2` `CONFIG.LOCATION.SEAM.kimi-two-homes=kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second` `CONFIG.LOCATION.SEAM.in-process-scrub=TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST` `LIVE-CONFIG.GUARD.SEAM.module=scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite` `LIVE-CONFIG.GUARD.SEAM.scope=ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled` -`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot` +`LIVE-CONFIG.GUARD.SEAM.non-root-targets=resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot` `LIVE-CONFIG.GUARD.SEAM.truncation=MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing` `LIVE-CONFIG.GUARD.SEAM.severity=reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1` `LIVE-CONFIG.GUARD.SEAM.ci-blind=the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes` diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index 14bdf45d6..f8f430ba9 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -53,7 +53,7 @@ { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", - "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal" + "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal" }, { "id": "CONFIG.LOCATION.SEAM.two-families", @@ -988,7 +988,7 @@ { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", - "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot" + "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot" }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 17f723f29..19885c75b 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -58,7 +58,7 @@ { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", - "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources, never hand-listed: capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", + "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", "line": 582 }, { @@ -1180,7 +1180,7 @@ { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", - "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", + "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", "line": 588 }, { diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 7290371b8..85711c772 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -156,12 +156,16 @@ function resolveLiveConfigRoots(deps = {}) { * into, one per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry: Kimi * CLI's ~/.kimi (KIMI_SHARE_DIR) and, since #2755, Kimi Code's * ~/.kimi-code (KIMI_CODE_HOME). The INVERSE case: those roots - * belong to their products, so only the one file GSD writes is - * watched, never the root. This is the KNOWN GAP above accepted - * deliberately in one direction — GSD demonstrably writes these - * files (bin/install.js resolves the hooks-toml dir at two sites), - * so a concurrent write by those products is the only false - * positive, and neither runs during the suite. + * belong to their products, so the root is never watched + * wholesale. This is the KNOWN GAP above accepted deliberately + * in one direction — GSD demonstrably writes these files + * (bin/install.js resolves the hooks-toml dir at two sites), so + * a concurrent write by those products is the only false + * positive, and neither runs during the suite. NOT watched, and + * it is a real residual rather than a bound: /hooks/, the + * bundle installSharedHooksBundle writes into the same roots — + * see resolveExtraWatchTargets for why closing it is a layout + * decision. * * @returns {string[]} absolute paths; empty if the built lib is absent. */ @@ -190,8 +194,18 @@ function resolveExtraWatchTargets(deps = {}) { // untestable and the targets resolved against different worlds. for (const descriptor of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) { const dir = resolveConfigHomeFromDescriptor(descriptor, { env, home: homedir() }); - // GSD writes ONE named file into these third-party roots; the root itself - // belongs to the runtime, so it is never watched wholesale. + // The root belongs to the runtime, so it is never watched wholesale — only + // the named file below. + // + // NAMED RESIDUAL (#2665, found pre-push while rebasing): config.toml is NOT + // the only thing GSD writes here. bin/install.js also calls + // installSharedHooksBundle(kimiHooksRoot), which populates /hooks/ + // with GSD's hook scripts and a CommonJS marker. That subtree is UNWATCHED, + // so a suite-produced leak of a hook bundle into a developer's real ~/.kimi + // or ~/.kimi-code passes this guard silently. Closing it needs a layout + // decision, not one more path: the same reason getGlobalSkillsBase is a + // deliberate non-target above. Under-watching fails quiet, like the + // NON_REGISTRY_OWNED_FILE residual it sits beside. targets.push(path.resolve(path.join(dir, NON_REGISTRY_OWNED_FILE))); } } catch { diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 1f8e1a1a5..8e00e0b96 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -210,6 +210,12 @@ describe('#2665: guard watches non-root write surfaces', () => { // named resolver instead would cover one of today's two entries and silently // miss tomorrow's — the same partial-enumeration defect that put // KIMI_SHARE_DIR outside the scrub set, one layer over. + // + // SCOPE BOUNDARY (per round-2 Nit 7, and it bites here): this asserts one + // target PER DESCRIPTOR and nothing about whether one target per descriptor + // is ENOUGH. It is not — /hooks/ is also GSD-written and unwatched + // (named residual in resolveExtraWatchTargets). A test whose expectation is + // derived from the same array it checks cannot see that class. for (const d of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) { const dir = resolveConfigHomeFromDescriptor(d, { env, home }); assert.ok( From 6a1fbf96fdbd3f66b2b8c0aa79306abc8dce11ac Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:30:04 -0500 Subject: [PATCH 31/47] fix(#2665): let the guard see deletions, in both shapes it can take diffLiveConfig walked `after` alone, so it had no branch for a path that existed before the run and does not after. A test run that DELETES a file from the developer's real config dir passed the guard silently -- the least recoverable case in the threat model this guard exists to cover. The review named the missing `pre.exists && !post.exists` branch. That branch is necessary and not sufficient: deletion arrives in two shapes and it reaches only one of them. - A FIXED owned entry (GSD_OWNED_ENTRIES x roots, plus every extra target) is recorded at both ends whether it exists or not, so a deletion reads {exists:true} -> {exists:false}. This is the shape the named branch fixes. - A gsd-prefixed child is DISCOVERED by readdirSync, so a deleted one is absent from `after` entirely and never enters an after-keyed loop at all. The named branch is unreachable for it. So the walk is now over the UNION of both key sets, with the explicit branch for the first shape and an `!post` branch for the second. Both are covered by a test, and reverting the fix fails both -- the prefixed-child test is the one that would still fail with only the prescribed branch in place. Addresses review finding: Blocker 2. --- scripts/live-config-guard.cjs | 32 +++++++++++++++++++++---- tests/live-config-guard.test.cjs | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 85711c772..8d71341e3 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -297,23 +297,47 @@ function snapshotLiveConfig(roots, extraTargets = []) { /** * Compare two snapshots. A path is a violation when it was created during the - * run, or when its newest mtime advanced. + * run, when its newest mtime advanced, or when it was DELETED by the run. * - * @returns {{path: string, kind: 'created'|'modified'|'unverified'}[]} + * Iterate the UNION of both key sets, never `after` alone. Deletion reaches this + * function in two shapes and an after-only walk sees neither: + * + * - A FIXED owned entry (GSD_OWNED_ENTRIES x roots, and every extra target) is + * recorded at both ends whether or not it exists, so a deletion reads + * {exists:true} -> {exists:false} and falls through every branch — silently. + * - A gsd-prefixed child is DISCOVERED by readdir, so a deleted one is absent + * from `after` entirely and never enters an after-keyed loop at all. + * + * The second shape is why adding a `pre.exists && !post.exists` branch is not on + * its own sufficient: that branch is unreachable for exactly the discovered + * children the prefix scan exists to catch. + * + * @returns {{path: string, kind: 'created'|'modified'|'deleted'|'unverified'}[]} */ function diffLiveConfig(before, after) { const violations = []; - for (const [target, post] of Object.entries(after)) { + const targets = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const target of targets) { const pre = before[target]; + const post = after[target]; // Absent from `before` entirely: a gsd-prefixed child that did not exist // when the run started. Both snapshots cover the same roots, so an // after-only path was created BY the run — never skip it. if (!pre) { - if (post.exists) violations.push({ path: target, kind: 'created' }); + if (post && post.exists) violations.push({ path: target, kind: 'created' }); + continue; + } + // Absent from `after` entirely: a discovered child that existed when the run + // started and does not now. Deletion is the least recoverable outcome in this + // threat model, so it is never inferred as clean. + if (!post) { + if (pre.exists) violations.push({ path: target, kind: 'deleted' }); continue; } if (!pre.exists && post.exists) { violations.push({ path: target, kind: 'created' }); + } else if (pre.exists && !post.exists) { + violations.push({ path: target, kind: 'deleted' }); } else if (pre.exists && post.exists && post.newest > pre.newest) { violations.push({ path: target, kind: 'modified' }); } else if (pre.truncated || post.truncated) { diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 8e00e0b96..0ee8b92c1 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -162,6 +162,46 @@ describe('#2665: live-config hermeticity guard', () => { } }); + test('detects a DELETED top-level GSD entry', () => { + const root = tmpRoot(); + try { + // A fixed owned entry is recorded at BOTH ends whether or not it exists, + // so a deletion reads {exists:true} -> {exists:false}. Before the union + // walk that pair matched no branch at all and the run passed silently. + fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true }); + fs.writeFileSync(path.join(root, 'gsd-core', 'x'), 'x'); + const before = snapshotLiveConfig([root]); + fs.rmSync(path.join(root, 'gsd-core'), { recursive: true, force: true }); + + const violations = diffLiveConfig(before, snapshotLiveConfig([root])); + const deleted = violations.filter((v) => v.kind === 'deleted'); + assert.strictEqual(deleted.length, 1, JSON.stringify(violations)); + assert.strictEqual(path.basename(deleted[0].path), 'gsd-core'); + } finally { + cleanup(root); + } + }); + + test('detects a DELETED gsd-prefixed child of a shared dir', () => { + const root = tmpRoot(); + try { + // The shape a `pre.exists && !post.exists` branch cannot reach on its own: + // prefixed children are DISCOVERED by readdir, so a deleted one is absent + // from the `after` snapshot entirely and never enters an after-keyed loop. + fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true }); + fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x'); + const before = snapshotLiveConfig([root]); + fs.rmSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true, force: true }); + + const violations = diffLiveConfig(before, snapshotLiveConfig([root])); + const deleted = violations.filter((v) => v.kind === 'deleted'); + assert.strictEqual(deleted.length, 1, JSON.stringify(violations)); + assert.strictEqual(path.basename(deleted[0].path), 'gsd-dev-preferences'); + } finally { + cleanup(root); + } + }); + test('the report names the path and the remedy', () => { const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]); assert.match(out, /HERMETICITY WARNING/); From e82a15a8529e55c864e63361ea55800c078aeeb1 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:31:40 -0500 Subject: [PATCH 32/47] fix(#2665): watch the fallback root a scrubbing child actually resolves to resolveLiveConfigRoots resolves what THIS process sees, and getGlobalConfigDir is env-first -- so with an ambient CLAUDE_CONFIG_DIR the guard watched that path. A spawned child does not see it: TEST_ENV_BASE blanks the config-location vars precisely so the child cannot follow them, and a blanked var is falsy, so the child resolves its HOME-derived root instead. A child that blanks the var and does NOT also sandbox HOME therefore writes into the developer's real ~/.claude, which the guard was not watching. That is this PR's own escape route, taken one process deeper -- and the guard is the artifact that is supposed to make it loud. Both resolutions are now unioned: the ambient one, and the fallback one obtained by handing the REAL descriptor resolver an EMPTY env. Deriving it that way is deliberate -- a hand-listed copy of the scrub set inside the guard is a second list to drift, which is the defect this PR spent three rounds closing one layer up. grok resolves through a hardcoded branch rather than a descriptor, so its fallback is stated explicitly for the same reason it is named in the ambient loop. Addresses review finding: Blocker 3. --- scripts/live-config-guard.cjs | 40 +++++++++++++++++++++++++++++++- tests/live-config-guard.test.cjs | 28 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 8d71341e3..4a6b93514 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -101,14 +101,23 @@ const MAX_DEPTH = 12; * resolver rather than a reimplementation — the guard must watch wherever the * product actually points, including through an ambient env var. * + * TWO resolutions, unioned, because the parent and its children do not resolve + * the same way: the AMBIENT one (what this process sees, env-first) and the + * FALLBACK one (what a child that BLANKED the config-location vars resolves to, + * i.e. HOME-derived). Watching only the first leaves the second unwatched, which + * is where a child that scrubs the var but not HOME actually writes. + * * @returns {string[]} deduped, sorted roots; empty if the built lib is absent. */ function resolveLiveConfigRoots(deps = {}) { const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'); + const homedir = (deps.os || os).homedir; let getGlobalConfigDir; + let resolveConfigHomeFromDescriptor; let runtimes; try { - ({ getGlobalConfigDir } = require(path.join(libDir, 'runtime-homes.cjs'))); + ({ getGlobalConfigDir, resolveConfigHomeFromDescriptor } = + require(path.join(libDir, 'runtime-homes.cjs'))); ({ runtimes } = require(path.join(libDir, 'capability-registry.cjs'))); } catch { // Unbuilt tree: the guard is advisory infrastructure and must never be the @@ -117,6 +126,35 @@ function resolveLiveConfigRoots(deps = {}) { } const roots = new Set(); + + // ── The FALLBACK roots, which the ambient resolution above cannot reach ──── + // + // #2665 round 5: getGlobalConfigDir is env-first, so the loop below resolves + // whatever THIS process sees. A spawned child does not see that — TEST_ENV_BASE + // blanks the config-location vars precisely so the child cannot follow them — + // and a blanked var is falsy, so the child falls back to its HOME-derived root + // instead. A child that blanks the var and does NOT also sandbox HOME therefore + // writes into the developer's real ~/.claude while the guard is watching the + // ambient path, one process shallower. That is the exact escape route this PR + // exists to close, taken one layer down. + // + // Derived, never re-listed: passing an EMPTY env to the real descriptor resolver + // IS "what a child with no config-location vars resolves to". Deriving it this + // way keeps the guard from carrying a second copy of the scrub set to drift + // against -- the defect this PR spent three rounds closing one layer up. + for (const entry of Object.values(runtimes || {})) { + const descriptor = entry?.runtime?.configHome; + if (!descriptor) continue; + try { + const dir = resolveConfigHomeFromDescriptor(descriptor, { env: {}, home: homedir() }); + if (typeof dir === 'string' && dir.length > 0) roots.add(path.resolve(dir)); + } catch { + // Same posture as the ambient loop below. + } + } + // grok resolves through a hardcoded branch rather than a descriptor, so its + // fallback is stated here for the same reason it is named in the loop below. + roots.add(path.resolve(path.join(homedir(), '.agents'))); // 'grok' is a hardcoded branch of getGlobalConfigDir with no registry entry. // // DELIBERATE NON-ROOT: getGlobalSkillsBase(runtime) is NOT added here. The diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 0ee8b92c1..5f3c22c28 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -43,6 +43,34 @@ describe('#2665: live-config hermeticity guard', () => { } }); + test('watches the HOME-derived fallback root, not only the ambient one', () => { + // #2665 round 5: the guard resolves env-first, so it sees the AMBIENT root. + // A child that blanks CLAUDE_CONFIG_DIR (which is exactly what TEST_ENV_BASE + // does) falls back to /.claude instead. Watching only the ambient path + // leaves that fallback unwatched -- the escape route this PR closes, one + // process deeper. + const ambient = tmpRoot(); + const fakeHome = tmpRoot(); + const saved = process.env.CLAUDE_CONFIG_DIR; + try { + process.env.CLAUDE_CONFIG_DIR = ambient; + const roots = resolveLiveConfigRoots({ os: { homedir: () => fakeHome } }); + assert.ok( + roots.includes(path.resolve(ambient)), + `ambient root missing from ${JSON.stringify(roots)}`, + ); + assert.ok( + roots.includes(path.resolve(path.join(fakeHome, '.claude'))), + `HOME-derived fallback root missing from ${JSON.stringify(roots)}`, + ); + } finally { + if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = saved; + cleanup(ambient); + cleanup(fakeHome); + } + }); + test('a clean run produces no violations', () => { const root = tmpRoot(); try { From f054c85fb0bb62815b411fef8626be843f28008c Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:33:10 -0500 Subject: [PATCH 33/47] fix(#2665): budget the scan per target, so order stops deciding the verdict MAX_ENTRIES was a single running budget threaded across every watch target. One large early target exhausted it, and every target scanned afterwards reported truncated -> `unverified` -- which under GSD_STRICT_LIVE_CONFIG_GUARD=1 is a failed run. The guard's verdict therefore depended on directory iteration order and on unrelated local state, neither of which says anything about whether the suite leaked. Each target now draws a fresh allotment, so a pathological tree truncates itself and nothing else. MAX_TOTAL_ENTRIES keeps the aggregate bounded -- which is what the single budget was actually for -- and when that ceiling engages, the targets it curtails are still reported `unverified` rather than attested clean. The limits are injectable so the boundary is testable without materialising 20000 entries, matching the `deps` seam the resolvers already use. Two of the three new tests fail when the shared budget is restored; the third asserts the retained global ceiling, which is deliberately unchanged behaviour. Addresses review finding: Major 6. --- scripts/live-config-guard.cjs | 30 +++++++++++++++-- tests/live-config-guard.test.cjs | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 4a6b93514..e6793fff4 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -92,8 +92,23 @@ const GSD_ARTIFACT_PREFIX = 'gsd-'; */ const NON_REGISTRY_OWNED_FILE = 'config.toml'; -/** Bounds on the recursive walk, so a pathological tree cannot stall the suite. */ +/** + * Bounds on the recursive walk, so a pathological tree cannot stall the suite. + * + * MAX_ENTRIES is PER WATCH TARGET, not per snapshot. It was a single running + * budget threaded across every target, which made the guard's verdict depend on + * directory ORDER and on unrelated local state: one large early target exhausted + * it, and every target scanned afterwards reported `truncated` -> `unverified`, + * which under GSD_STRICT_LIVE_CONFIG_GUARD=1 is a failed run. Per-target means a + * pathological tree truncates ITSELF and nothing else. + * + * MAX_TOTAL_ENTRIES keeps the aggregate bounded, which is what the single budget + * was really for. It engages only when the per-target bounds together exceed it; + * when it does, the targets it curtails are reported `unverified` -- never + * silently clean. + */ const MAX_ENTRIES = 20000; +const MAX_TOTAL_ENTRIES = 200000; const MAX_DEPTH = 12; /** @@ -292,8 +307,9 @@ function newestMtime(target, budget) { * @returns {Record} * keyed by absolute entry path. */ -function snapshotLiveConfig(roots, extraTargets = []) { - const budget = { remaining: MAX_ENTRIES }; +function snapshotLiveConfig(roots, extraTargets = [], limits = {}) { + const perTarget = limits.perTarget ?? MAX_ENTRIES; + const total = { remaining: limits.total ?? MAX_TOTAL_ENTRIES }; const snap = {}; const record = (target) => { @@ -301,7 +317,14 @@ function snapshotLiveConfig(roots, extraTargets = []) { snap[target] = { exists: false, newest: 0, truncated: false }; return; } + // A FRESH budget per target, drawn against the global ceiling. See the + // MAX_ENTRIES docblock: a shared running budget let one large early target + // cascade `unverified` over every target scanned after it, so the verdict + // depended on iteration order rather than on what the run actually touched. + const budget = { remaining: Math.min(perTarget, total.remaining) }; + const allotted = budget.remaining; const { newest, truncated } = newestMtime(target, budget); + total.remaining -= allotted - budget.remaining; snap[target] = { exists: true, newest, truncated }; }; @@ -426,6 +449,7 @@ module.exports = { GSD_PREFIXED_PARENTS, GSD_ARTIFACT_PREFIX, MAX_ENTRIES, + MAX_TOTAL_ENTRIES, MAX_DEPTH, resolveLiveConfigRoots, resolveExtraWatchTargets, diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 5f3c22c28..2efed27c0 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -230,6 +230,61 @@ describe('#2665: live-config hermeticity guard', () => { } }); + test('the scan budget is PER TARGET, so one big tree cannot cascade unverified', () => { + // #2665 round 5: with a single running budget, target A exhausting it made + // target B report `truncated` -> `unverified` -- a strict-mode FAILURE caused + // by an unrelated directory. Each target now gets its own allotment. + const [big] = treeWithEntries(8); + const [small] = treeWithEntries(2); + try { + const snap = snapshotLiveConfig([], [big, small], { perTarget: 9, total: 1000 }); + assert.strictEqual(snap[path.resolve(big)].truncated, false, 'big target should fit its own budget'); + assert.strictEqual( + snap[path.resolve(small)].truncated, + false, + 'small target must NOT inherit exhaustion from a target scanned before it', + ); + } finally { + cleanup(big); + cleanup(small); + } + }); + + test('the truncation verdict does not depend on target ORDER', () => { + const [big] = treeWithEntries(8); + const [small] = treeWithEntries(2); + try { + const limits = { perTarget: 9, total: 1000 }; + const a = snapshotLiveConfig([], [big, small], limits); + const b = snapshotLiveConfig([], [small, big], limits); + assert.strictEqual(a[path.resolve(small)].truncated, b[path.resolve(small)].truncated); + assert.strictEqual(a[path.resolve(big)].truncated, b[path.resolve(big)].truncated); + } finally { + cleanup(big); + cleanup(small); + } + }); + + test('the GLOBAL ceiling still bounds the aggregate, and reports unverified', () => { + // The bound the single budget was really for is kept -- but when it engages, + // the curtailed target is reported rather than silently attested clean. + const [a] = treeWithEntries(5); + const [b] = treeWithEntries(5); + try { + const snap = snapshotLiveConfig([], [a, b], { perTarget: 6, total: 6 }); + assert.strictEqual(snap[path.resolve(a)].truncated, false); + assert.strictEqual(snap[path.resolve(b)].truncated, true, 'ceiling-curtailed target must be truncated'); + const violations = diffLiveConfig(snap, snap); + assert.ok( + violations.some((v) => v.kind === 'unverified' && v.path === path.resolve(b)), + `expected an unverified violation for the curtailed target: ${JSON.stringify(violations)}`, + ); + } finally { + cleanup(a); + cleanup(b); + } + }); + test('the report names the path and the remedy', () => { const out = formatViolations([{ path: '/live/.claude/gsd-core', kind: 'created' }]); assert.match(out, /HERMETICITY WARNING/); From 4eb29b974132760ba7cd924c804896a093773c40 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:33:44 -0500 Subject: [PATCH 34/47] test(#2665): pin the MAX_DEPTH boundary on both sides, not just above it RULESET.TESTS.boundary-coverage asks for {limit-1, limit, limit+1}. The depth bound was exercised only at limit+2, which pins neither side of the edge: an off-by-one that truncated a tree sitting exactly AT MAX_DEPTH would have passed, and a truncation is not a cosmetic miss here -- it reports `unverified`, which in strict mode fails the run. Negative-controlled by weakening the guard to `depth >= MAX_DEPTH`: the new limit case fails, where the previous single limit+2 assertion did not. Addresses review finding: Minor 8. --- tests/live-config-guard.test.cjs | 48 +++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 2efed27c0..40cb9d0a9 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -485,15 +485,49 @@ describe('#2665: scan-budget truncation', () => { } }); - test('exceeding MAX_DEPTH truncates', () => { + // RULESET.TESTS.boundary-coverage asks for {limit-1, limit, limit+1}. This was + // exercised only at limit+2, which pins neither side of the edge: an off-by-one + // that truncated a legal depth would have passed. `nestedDepth(n)` builds a tree + // whose deepest entry sits at walk-depth n below the scanned root, and + // newestMtime truncates iff that depth EXCEEDS MAX_DEPTH. + const nestedDepth = (n) => { const dir = tmpRoot(); - try { - let deep = dir; - for (let i = 0; i <= MAX_DEPTH + 1; i++) deep = path.join(deep, `d${i}`); - fs.mkdirSync(deep, { recursive: true }); + let deep = dir; + for (let i = 0; i < n; i++) deep = path.join(deep, `d${i}`); + fs.mkdirSync(deep, { recursive: true }); + return dir; + }; - const res = newestMtime(dir, { remaining: 1e6 }); - assert.strictEqual(res.truncated, true, 'a tree deeper than MAX_DEPTH must truncate'); + test(`MAX_DEPTH boundary: depth ${MAX_DEPTH - 1} (limit-1) does NOT truncate`, () => { + const dir = nestedDepth(MAX_DEPTH - 1); + try { + assert.strictEqual(newestMtime(dir, { remaining: 1e6 }).truncated, false); + } finally { + cleanup(dir); + } + }); + + test(`MAX_DEPTH boundary: depth ${MAX_DEPTH} (limit) does NOT truncate`, () => { + const dir = nestedDepth(MAX_DEPTH); + try { + assert.strictEqual( + newestMtime(dir, { remaining: 1e6 }).truncated, + false, + 'a tree exactly at MAX_DEPTH is within bounds and must be attested', + ); + } finally { + cleanup(dir); + } + }); + + test(`MAX_DEPTH boundary: depth ${MAX_DEPTH + 1} (limit+1) truncates`, () => { + const dir = nestedDepth(MAX_DEPTH + 1); + try { + assert.strictEqual( + newestMtime(dir, { remaining: 1e6 }).truncated, + true, + 'a tree deeper than MAX_DEPTH must truncate', + ); } finally { cleanup(dir); } From 4bc6b0a2a3b23ceb5a8567cfbfda3a3b958deffd Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:36:06 -0500 Subject: [PATCH 35/47] fix(#2665): defer the built-lib require so an unbuilt tree fails one test, not all tests/helpers.cjs required gsd-core/bin/lib at module scope to derive the config-location scrub set. That lib is BUILT, so on an unbuilt tree the require threw inside `require('./helpers.cjs')` -- before a single test() had registered -- turning one missing `npm run build:lib` into a whole-suite crash with no message naming the remedy. This is the file ~370 test files import, so the blast radius is the suite. `npm test` builds via its pretest hook; the shape that reaches this is a direct `node --test` invocation, which is exactly what a contributor reaches for when running one file. The require is now memoized behind builtLib(), and the two derived exports (TEST_ENV_BASE, CONFIG_LOCATION_ENV_KEYS) are enumerable lazy getters, so destructuring and Object.keys() behave as before. Reading either is what forces the build; a test file that needs neither now imports cleanly. When the build IS missing, the error names `npm run build:lib` instead of surfacing a bare MODULE_NOT_FOUND. Verified by a cold-child probe rather than by inspection -- this process has already loaded everything, so an in-process assertion would pass vacuously. The probe checks require.cache before and after touching TEST_ENV_BASE, and fails when the require is moved back to module scope. Addresses review finding: Major 7. --- tests/helpers-process-isolation.test.cjs | 30 ++++++++ tests/helpers.cjs | 93 ++++++++++++++++++------ 2 files changed, 99 insertions(+), 24 deletions(-) diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index a7fa38430..7eceb1f72 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -1,6 +1,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); +const { spawnSync } = require('node:child_process'); const { withIsolatedProcessState, @@ -9,6 +10,35 @@ const { scrubConfigLocationEnv, } = require('./helpers.cjs'); +describe('#2665: the built-lib require is deferred', () => { + // The scrub set derives from gsd-core/bin/lib, which is BUILT. Requiring it at + // module scope made an unbuilt tree throw inside `require('./helpers.cjs')` — + // before any test() registered — so one missing `npm run build:lib` became a + // whole-suite crash in the file ~370 test files import. A cold child is the only + // honest probe: this process has already loaded everything. + const probe = (touch) => { + const src = [ + "const path = require('node:path');", + `require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});`, + touch, + "const needle = path.join('gsd-core', 'bin', 'lib', 'capability-registry.cjs');", + 'process.stdout.write(String(Object.keys(require.cache).some((m) => m.endsWith(needle))));', + ].join('\n'); + const r = spawnSync(process.execPath, ['-e', src], { encoding: 'utf8' }); + assert.strictEqual(r.status, 0, `probe failed: ${r.stderr}`); + return r.stdout === 'true'; + }; + + test('requiring helpers.cjs alone does NOT load the built runtime lib', () => { + assert.strictEqual(probe(''), false, 'the built lib was loaded at import time'); + }); + + test('reading TEST_ENV_BASE is what loads it', () => { + const touch = `require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))}).TEST_ENV_BASE;`; + assert.strictEqual(probe(touch), true, 'reading the scrub set must resolve the built lib'); + }); +}); + describe('withIsolatedProcessState', () => { test('restores env, cwd, and exitCode after callback', () => { const originalCwd = process.cwd(); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index fdbd22381..e17c57b59 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -30,22 +30,35 @@ const SESSION_IDENTITY_ENV_KEYS = [ 'SSH_TTY', ]; -// Config-LOCATION vars — distinct in kind from the session-identity vars above: -// these decide WHERE a child writes, so leaving one ambient lets a test that -// sandboxes HOME still escape into the developer's real config dir. +// LAZY, and memoized. These live in the BUILT runtime lib, so requiring them at +// module scope made an unbuilt tree throw during `require('./helpers.cjs')` — +// before a single test() had registered — which turns one missing +// `npm run build:lib` into a whole-suite crash with no actionable message, in the +// file ~370 test files import. `npm test` builds via its pretest hook, so the +// shape that hits this is a direct `node --test` invocation. // -// #2665: this list is DERIVED, not hand-maintained. A hand-written list is -// exactly what reopened this bug twice — it can only ever be as complete as the -// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a -// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing -// it from the same registry the resolver reads makes the scrub list structurally -// incapable of being narrower than the surface it guards: adding a capability -// that declares a new configHome env var extends this set in the same commit. -const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); -const { - NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, - GSD_LOCATION_ENV_KEYS, -} = require('../gsd-core/bin/lib/runtime-homes.cjs'); +// Deferring the require means only the tests that actually need the derived scrub +// set pay for the build, and they fail with a message that names the remedy. +let _builtLib = null; +function builtLib() { + if (_builtLib) return _builtLib; + try { + const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + const { + NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, + GSD_LOCATION_ENV_KEYS, + } = require('../gsd-core/bin/lib/runtime-homes.cjs'); + _builtLib = { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS }; + } catch (cause) { + throw new Error( + 'tests/helpers.cjs derives the config-location scrub set from the built runtime ' + + 'lib (gsd-core/bin/lib), which is not present. Run `npm run build:lib` first — ' + + '`npm test` does this for you via its pretest script.', + { cause }, + ); + } + return _builtLib; +} // Config-location vars that are neither in the registry nor descriptor-shaped, // each with its reader: @@ -81,7 +94,22 @@ const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [ // is why this can be scrubbed wholesale without reasoning about each call site. const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['GSD_ALLOW_SYMLINKED_DEST']; -const CONFIG_LOCATION_ENV_KEYS = [ +// Config-LOCATION vars — distinct in kind from the session-identity vars above: +// these decide WHERE a child writes, so leaving one ambient lets a test that +// sandboxes HOME still escape into the developer's real config dir. +// +// #2665: this list is DERIVED, not hand-maintained. A hand-written list is +// exactly what reopened this bug twice — it can only ever be as complete as the +// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a +// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing +// it from the same registry the resolver reads makes the scrub list structurally +// incapable of being narrower than the surface it guards: adding a capability +// that declares a new configHome env var extends this set in the same commit. +let _configLocationEnvKeys = null; +function configLocationEnvKeys() { + if (_configLocationEnvKeys) return _configLocationEnvKeys; + const { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS } = builtLib(); + _configLocationEnvKeys = [ ...new Set([ // 1. Every runtime descriptor the capability registry carries — including // the nested skillsHome descriptor, which resolves independently of @@ -110,11 +138,18 @@ const CONFIG_LOCATION_ENV_KEYS = [ // named separately above so the list does not misdescribe what they are. ...WRITE_ESCAPE_PERMISSION_ENV_KEYS, ]), -].sort(); + ].sort(); + return _configLocationEnvKeys; +} -const TEST_ENV_BASE = Object.fromEntries( - [...SESSION_IDENTITY_ENV_KEYS, ...CONFIG_LOCATION_ENV_KEYS].map((k) => [k, '']), -); +let _testEnvBase = null; +function testEnvBase() { + if (_testEnvBase) return _testEnvBase; + _testEnvBase = Object.fromEntries( + [...SESSION_IDENTITY_ENV_KEYS, ...configLocationEnvKeys()].map((k) => [k, '']), + ); + return _testEnvBase; +} /** * Save + clear every config-LOCATION env var on THIS process; returns a restorer. @@ -134,12 +169,13 @@ const TEST_ENV_BASE = Object.fromEntries( */ function scrubConfigLocationEnv() { const saved = {}; - for (const key of CONFIG_LOCATION_ENV_KEYS) { + const keys = configLocationEnvKeys(); + for (const key of keys) { saved[key] = process.env[key]; delete process.env[key]; } return function restoreConfigLocationEnv() { - for (const key of CONFIG_LOCATION_ENV_KEYS) { + for (const key of keys) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } @@ -158,7 +194,7 @@ function scrubConfigLocationEnv() { */ function runGsdTools(args, cwd = process.cwd(), env = {}) { // Resolve argv once so both the first attempt and the retry use the same vector. - const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env }; + const childEnv = { ...process.env, ...testEnvBase(), ...env }; const argv = Array.isArray(args) ? args : (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) @@ -875,4 +911,13 @@ function clearSessionEnv() { for (const k of SESSION_ENV_KEYS) delete process.env[k]; } -module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, TEST_ENV_BASE, SESSION_IDENTITY_ENV_KEYS, CONFIG_LOCATION_ENV_KEYS, scrubConfigLocationEnv }; +module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv }; + +// Lazy, for the reason builtLib() is lazy: reading either of these is what +// forces the built-lib require, so a test file that needs neither can still +// import this helper on an unbuilt tree. Enumerable, so destructuring and +// Object.keys() behave exactly as they did when these were plain properties. +Object.defineProperties(module.exports, { + TEST_ENV_BASE: { enumerable: true, get: testEnvBase }, + CONFIG_LOCATION_ENV_KEYS: { enumerable: true, get: configLocationEnvKeys }, +}); From e4f79c32b0634ab96e45d07ebf2257cee7133014 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:37:28 -0500 Subject: [PATCH 36/47] fix(#2665): wire the fourth suite lane, and derive the lane list instead of naming it qa-loop-walk runs `npm run test:qa`, which is `run-tests.cjs --suite qa` -- so it runs the live-config guard like every other suite lane, and it set no GSD_STRICT_LIVE_CONFIG_GUARD. A leak of exactly the class this PR closes would have printed a warning there and left the lane green. The test that is supposed to prove the guard is wired everywhere could not detect that, because its job list was three literals (`test`, `test-full`, `test-inert`). A hand-list certifying its own completeness is the defect this whole PR is about, reproduced inside the test guarding the fix -- so the list is now DERIVED from the workflow: every job with a step reaching run-tests.cjs, directly or through an npm script resolved transitively through package.json. The indirection is the load-bearing half; a grep for the filename alone is what made qa-loop-walk invisible. The derivation asserts a floor (>= 4 jobs) before ruling on any of them, so a selector that silently matched nothing fails loudly instead of passing vacuously. Windows lanes keep their carve-out, keyed on whether the job's matrix mentions windows rather than on the job's name. Negative-controlled: un-wiring qa-loop-walk fails the new test. The literal version passed with that lane unwired, which is how it shipped. Addresses review finding: Major 5. --- .github/workflows/test.yml | 4 ++ tests/live-config-guard.test.cjs | 79 ++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7af090cce..19240a797 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -619,6 +619,10 @@ jobs: timeout-minutes: 15 env: GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled + # `npm run test:qa` is `run-tests.cjs --suite qa`, so this lane runs the + # live-config guard like every other suite lane. ubuntu-only, so strict + # unconditionally — the Windows carve-out does not apply here. + GSD_STRICT_LIVE_CONFIG_GUARD: '1' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 40cb9d0a9..4e79bd046 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -199,7 +199,7 @@ describe('#2665: live-config hermeticity guard', () => { fs.mkdirSync(path.join(root, 'gsd-core'), { recursive: true }); fs.writeFileSync(path.join(root, 'gsd-core', 'x'), 'x'); const before = snapshotLiveConfig([root]); - fs.rmSync(path.join(root, 'gsd-core'), { recursive: true, force: true }); + cleanup(path.join(root, 'gsd-core')); const violations = diffLiveConfig(before, snapshotLiveConfig([root])); const deleted = violations.filter((v) => v.kind === 'deleted'); @@ -219,7 +219,7 @@ describe('#2665: live-config hermeticity guard', () => { fs.mkdirSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true }); fs.writeFileSync(path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'), '# x'); const before = snapshotLiveConfig([root]); - fs.rmSync(path.join(root, 'skills', 'gsd-dev-preferences'), { recursive: true, force: true }); + cleanup(path.join(root, 'skills', 'gsd-dev-preferences')); const violations = diffLiveConfig(before, snapshotLiveConfig([root])); const deleted = violations.filter((v) => v.kind === 'deleted'); @@ -606,32 +606,65 @@ describe('#2665 round 4: CI wires the guard to strict mode', () => { // green. Windows lanes are deliberately report-only until the documented // pre-existing USERPROFILE leak class is swept (SEVERITY note in // scripts/live-config-guard.cjs) — so the assertion is per-OS, not global. - test('all three test jobs set GSD_STRICT_LIVE_CONFIG_GUARD (Windows carved out)', () => { + // DERIVED, not hand-listed. The previous version named three jobs as literals, + // so it could not see a FOURTH lane that runs the suite — and there was one: + // qa-loop-walk reaches run-tests.cjs through `npm run test:qa` and escaped + // strict mode entirely while this test stayed green. A hand-list that certifies + // its own completeness is the exact defect this PR exists to fix, reproduced in + // the test that guards the fix. + test('EVERY job that runs the suite wires GSD_STRICT_LIVE_CONFIG_GUARD', () => { const yaml = require('js-yaml'); - const wf = yaml.load( - fs.readFileSync( - path.join(__dirname, '..', '.github', 'workflows', 'test.yml'), - 'utf8', - ), + const root = path.join(__dirname, '..'); + const wf = yaml.load(fs.readFileSync(path.join(root, '.github', 'workflows', 'test.yml'), 'utf8')); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + + // A step reaches the runner directly OR through an npm script, transitively. + // Grepping the filename alone misses the indirection that hid qa-loop-walk. + const scriptRunsSuite = (name, seen = new Set()) => { + if (seen.has(name)) return false; + seen.add(name); + const body = pkg.scripts?.[name]; + if (!body) return false; + if (/run-tests\.cjs/.test(body)) return true; + return [...body.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1], seen)); + }; + const runsSuite = (run) => + /run-tests\.cjs/.test(run) + || [...run.matchAll(/npm run ([\w:.-]+)/g)].some((m) => scriptRunsSuite(m[1])); + + const suiteJobs = Object.entries(wf.jobs ?? {}) + .filter(([, job]) => (job?.steps ?? []).some((s) => typeof s?.run === 'string' && runsSuite(s.run))) + .map(([name]) => name) + .sort(); + + // Guards the guard: an empty derivation would make every assertion below + // vacuously true, which is the failure mode of the literal list it replaces. + assert.ok( + suiteJobs.length >= 4, + `expected at least 4 suite-running jobs, derived ${JSON.stringify(suiteJobs)}`, ); - for (const job of ['test', 'test-full']) { - const v = String(wf.jobs?.[job]?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? ''); - assert.match( - v, + const windowsMatrix = (job) => JSON.stringify(job?.strategy?.matrix ?? {}).includes('windows'); + const problems = []; + for (const name of suiteJobs) { + const job = wf.jobs[name]; + const v = String(job?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? ''); + // Windows lanes stay report-only until the pre-existing USERPROFILE leak + // class is swept (SEVERITY note in scripts/live-config-guard.cjs), so a + // job whose matrix includes Windows carries the conditional; an + // ubuntu/macOS-only lane must be strict outright. + const ok = windowsMatrix(job) // The WHOLE expression, anchored — a prefix match accepted both - // `&& '1' || '1'` (Windows silently strict) and a malformed tail - // (found by this round's pre-push adversarial review). - /^\$\{\{\s*matrix\.os\s*!=\s*'windows-latest'\s*&&\s*'1'\s*\|\|\s*''\s*\}\}$/, - `jobs.${job}.env.GSD_STRICT_LIVE_CONFIG_GUARD must be strict on ` + - `non-Windows lanes and empty on windows-latest; got: ${JSON.stringify(v)}`, - ); + // `&& '1' || '1'` (Windows silently strict) and a malformed tail. + ? /^\$\{\{\s*matrix\.os\s*!=\s*'windows-latest'\s*&&\s*'1'\s*\|\|\s*''\s*\}\}$/.test(v) + : v === '1'; + if (!ok) problems.push(`jobs.${name}: ${JSON.stringify(v)}`); } - - assert.strictEqual( - String(wf.jobs?.['test-inert']?.env?.GSD_STRICT_LIVE_CONFIG_GUARD ?? ''), - '1', - 'jobs.test-inert (ubuntu-only) must set GSD_STRICT_LIVE_CONFIG_GUARD: 1', + assert.deepStrictEqual( + problems, + [], + `every job running run-tests.cjs must wire the guard to strict mode ` + + `(Windows matrices carved out). Derived jobs: ${JSON.stringify(suiteJobs)}`, ); }); }); From f0ef9063d5fdc64d45b02af21b72982fa29d79b3 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:38:53 -0500 Subject: [PATCH 37/47] test(#2665): restore the three agent-skills tests this PR deleted Commit 2bed9fd8 ("replace the hand-synced TEST_ENV_BASE copies with the canonical import") also removed markLocalGsdInstall and three behavioural tests from tests/agent-skills.test.cjs -- 79 lines, no replacement, and no mention in the commit message or the PR body: - unconfigured Codex reads its local companion agent from a descendant cwd - workstream runtime selects the local Codex companion when root config differs - unconfigured Claude remains empty when a local Codex companion exists RULESET.TESTS.delete-bad-tests permits deleting a bad test only when it is replaced with compliant tests in the same PR. Nothing was replaced, and these were not bad tests -- they were collateral in a mechanical edit. A silent net loss of behavioural coverage inside a PR whose subject is test hygiene is the one thing that should not pass here, and the reviewer was right to block on it. Restored verbatim. They need no adaptation to the canonical TEST_ENV_BASE import: they pass their env explicitly, and an explicit env still spreads last over the base. The file goes 80 -> 83 tests, all green. Non-vacuity checked rather than assumed: dropping the local-install marker the first two depend on fails both. The third is a negative assertion and correctly stays green, which is why it is named here rather than counted as covered. Addresses review finding: Blocker 1. --- tests/agent-skills.test.cjs | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/agent-skills.test.cjs b/tests/agent-skills.test.cjs index ac2c52eae..9da0ad0f1 100644 --- a/tests/agent-skills.test.cjs +++ b/tests/agent-skills.test.cjs @@ -49,6 +49,13 @@ function readConfig(tmpDir) { return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } +function markLocalGsdInstall(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.codex', 'gsd-file-manifest.json'), + JSON.stringify({ files: {} }), + ); +} + // Run agent-skills with --json for typed IR assertions function runAgentSkillsJson(args, tmpDir, env) { // Insert --json after 'agent-skills' subcommand @@ -106,6 +113,59 @@ describe('agent-skills command', () => { assert.strictEqual(r.ir.block, ''); }); + test('unconfigured Codex reads its local companion agent from a descendant cwd', () => { + const agentsDir = path.join(tmpDir, '.codex', 'agents'); + const descendant = path.join(tmpDir, 'src', 'feature'); + const localPersona = '# Local Codex executor\nUse the project-local agent.\n'; + fs.mkdirSync(agentsDir, { recursive: true }); + fs.mkdirSync(descendant, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), localPersona); + markLocalGsdInstall(tmpDir); + writeConfig(tmpDir, { runtime: 'codex' }); + + const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], descendant, { + HOME: tmpDir, + USERPROFILE: tmpDir, + CODEX_HOME: path.join(tmpDir, 'global-codex'), + GSD_RUNTIME: '', + }); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, localPersona); + }); + + test('workstream runtime selects the local Codex companion when root config differs', () => { + const agentsDir = path.join(tmpDir, '.codex', 'agents'); + const localPersona = '# Local Codex executor\nUse the overridden runtime.\n'; + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), localPersona); + markLocalGsdInstall(tmpDir); + writeConfig(tmpDir, { runtime: 'claude' }); + const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'feature-x'); + fs.mkdirSync(workstreamDir, { recursive: true }); + fs.writeFileSync(path.join(workstreamDir, 'config.json'), JSON.stringify({ runtime: 'codex' })); + + const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir, { + HOME: tmpDir, + USERPROFILE: tmpDir, + CODEX_HOME: path.join(tmpDir, 'global-codex'), + GSD_RUNTIME: '', + GSD_WORKSTREAM: 'feature-x', + }); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, localPersona); + }); + + test('unconfigured Claude remains empty when a local Codex companion exists', () => { + const agentsDir = path.join(tmpDir, '.codex', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), '# Local Codex executor\n'); + writeConfig(tmpDir, { runtime: 'claude' }); + + const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir, { GSD_RUNTIME: 'claude' }); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, ''); + }); + test('returns block containing agent_skills XML for configured agent', () => { const skillDir = path.join(tmpDir, 'skills', 'test-skill'); fs.mkdirSync(skillDir, { recursive: true }); From e31f706cebc2d07c8c8ef5daf20c5b4f46902524 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:40:34 -0500 Subject: [PATCH 38/47] docs(#2665): document the two live-config-guard env vars The changeset for this PR is typed `Added`, and CONTRIBUTING requires a docs/ change for that type. The only docs/ file in the diff was CONTEXT-INDEX.json -- a GENERATED index -- so the Docs Required gate passed while no human-readable documentation existed for either new variable. A gate satisfied by a generated artifact is satisfied vacuously. docs/TESTING-SUITES.md now carries a section on the guard: what it watches and why it is ownership-scoped rather than whole-root, the two env vars in a table, why the default is report-only and what the promotion condition is, and what each violation label means (including that UNVERIFIED is not clean). GSD_SKIP_LIVE_CONFIG_GUARD is named explicitly because it is a bypass on a safety check. An undocumented bypass is one people eventually set without knowing what they turned off. A test asserts both variables appear in that doc -- checked as permitted by local/no-source-grep before writing it, rather than assumed forbidden. It fails when the section is removed, so the doc cannot rot back to the state the review found. Addresses review finding: Major 4. --- docs/TESTING-SUITES.md | 41 ++++++++++++++++++++++++++++++++ tests/live-config-guard.test.cjs | 11 +++++++++ 2 files changed, 52 insertions(+) diff --git a/docs/TESTING-SUITES.md b/docs/TESTING-SUITES.md index aa126fdf2..fa4e199cb 100644 --- a/docs/TESTING-SUITES.md +++ b/docs/TESTING-SUITES.md @@ -219,6 +219,47 @@ disagree, trust (and fix) the rule table. Unknown suites exit non-zero with the list of valid suites. Empty suites (e.g. `--suite security` before any security-tagged file exists) exit `0` with a `no tests in suite "..."` notice on stderr so CI lanes don't go red while a suite is being populated. +## The live-config hermeticity guard + +Every `run-tests.cjs` invocation snapshots GSD's own install footprint in each +live runtime config directory before the suite and re-checks it afterwards. It +exists because the failure it catches is silent by construction: a test that +resolves a config directory from the ambient environment instead of a sandbox +writes into *your real* `~/.claude` (or `$GSD_HOME/.gsd`, or a Kimi +`config.toml`), and nothing reports it. CI cannot catch this class at all — +CI never has `CLAUDE_CONFIG_DIR` and friends set. + +The guard watches only what GSD unambiguously owns — its top-level install +footprint plus `gsd-`-prefixed children of directories shared with the host +agent — never whole config roots, because a host agent legitimately writing +`history.jsonl` mid-run would make the guard cry wolf, and a guard that cries +wolf gets switched off. + +Two environment variables control it: + +| Variable | Effect | +|---|---| +| `GSD_STRICT_LIVE_CONFIG_GUARD=1` | A detected write **fails the run**. Set on the Linux/macOS lanes of every CI job that runs the suite. | +| `GSD_SKIP_LIVE_CONFIG_GUARD=1` | Skips the check entirely. | + +Unset, the guard **reports and does not fail** — deliberately, not timidly. On +its first CI run it surfaced pre-existing leaks on the Windows lane, where +`os.homedir()` reads `USERPROFILE` and ~190 test sites sandbox `HOME` alone. +Those are real and worth fixing, but they are a different defect class, and a +brand-new gate that instantly reds an unrelated lane gets reverted rather than +obeyed. Windows lanes therefore stay report-only until that sweep lands; this +repo has the pattern already, in the `local/no-source-grep` ESLint rule that +shipped at `warn` and was promoted to `error` after its cleanup (ADR 452). + +`GSD_SKIP_LIVE_CONFIG_GUARD` is a bypass on a safety check, so it is documented +here rather than left to be discovered in the source: an undocumented bypass is +one people eventually set without knowing what they turned off. If you need it +routinely, that is a bug report, not a workflow. + +Reported paths are labelled `CREATED`, `MODIFIED`, `DELETED`, or `UNVERIFIED`. +`UNVERIFIED` means a scan bound was hit and the path could not be attested +either way — it is never the same as clean. + ## CI matrix The `Tests` workflow runs every PR through a scoped gate generated by diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 4e79bd046..6514949ea 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -606,6 +606,17 @@ describe('#2665 round 4: CI wires the guard to strict mode', () => { // green. Windows lanes are deliberately report-only until the documented // pre-existing USERPROFILE leak class is swept (SEVERITY note in // scripts/live-config-guard.cjs) — so the assertion is per-OS, not global. + test('both guard env vars are documented for humans, not just in the source', () => { + // A skip-switch on a safety guard has to be discoverable: an undocumented + // bypass is one people eventually set without knowing what they turned off. + // The Docs Required gate gets satisfied by ANY docs/ file in the diff -- + // including a generated index -- so it cannot stand in for this. + const doc = fs.readFileSync(path.join(__dirname, '..', 'docs', 'TESTING-SUITES.md'), 'utf8'); + for (const v of ['GSD_STRICT_LIVE_CONFIG_GUARD', 'GSD_SKIP_LIVE_CONFIG_GUARD']) { + assert.ok(doc.includes(v), `${v} must be documented in docs/TESTING-SUITES.md`); + } + }); + // DERIVED, not hand-listed. The previous version named three jobs as literals, // so it could not see a FOURTH lane that runs the suite — and there was one: // qa-loop-walk reaches run-tests.cjs through `npm run test:qa` and escaped From 104fc76f70a843e9dddb42e00c2da4294fe3a9ea Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 16:43:32 -0500 Subject: [PATCH 39/47] fix(#2665): watch the hook bundle and the install markers the census found Self-found by re-deriving the guard-shape census against bin/install.js's own write sites, not by a review finding. Three artifacts a global install writes into a live config ROOT were watched by nothing: hooks/gsd-check-update.js, hooks/gsd-context-monitor.js, hooks/gsd-update-banner.js -- `hooks` was absent from GSD_PREFIXED_PARENTS .gsd-source, .gsd-profile -- absent from GSD_OWNED_ENTRIES, and an exact-name list does not match a dot-prefixed name via the `gsd-` prefix rule This is the SAME shape as the leak that motivated the prefixed-parent scan in round 1 -- a gsd-prefixed child under a parent nobody had listed -- one parent over. That it recurred is the argument for re-deriving this list from the installer each round instead of trusting it: the enumeration is the weak point of an enumerate-and-block mechanism, and it does not announce when it falls behind. Ownership is unchanged, only coverage: `hooks/` is shared with the host agent, so only `gsd-`-prefixed children are watched. A test asserts a host-owned hook is still ignored, because widening the parent list must not widen ownership -- a guard that flags the host's own files gets switched off, and then catches nothing at all. Reverting the widening fails the new test. --- scripts/live-config-guard.cjs | 26 ++++++++++++++++++--- tests/live-config-guard.test.cjs | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index e6793fff4..9a118a391 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -61,8 +61,20 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -/** Top-level entries only a GSD install creates. See SCOPE above before widening. */ -const GSD_OWNED_ENTRIES = ['gsd-core', 'gsd-file-manifest.json', 'gsd-pristine']; +/** + * Top-level entries only a GSD install creates. See SCOPE above before widening. + * + * `.gsd-source` and `.gsd-profile` were added by the round-5 census (see below): + * bin/install.js writes both at the config ROOT for a global install, and an + * exact-name list does not match a dot-prefixed name by the `gsd-` prefix rule. + */ +const GSD_OWNED_ENTRIES = [ + 'gsd-core', + 'gsd-file-manifest.json', + 'gsd-pristine', + '.gsd-source', + '.gsd-profile', +]; /** * Directories GSD SHARES with the host agent. Watching them wholesale would @@ -73,8 +85,16 @@ const GSD_OWNED_ENTRIES = ['gsd-core', 'gsd-file-manifest.json', 'gsd-pristine'] * `spawnSync` that sandboxed HOME but inherited an ambient CLAUDE_CONFIG_DIR * wrote `/skills/gsd-dev-preferences/SKILL.md`, which sits under none of * the three top-level entries above. + * + * `hooks` joined them in round 5, found by re-deriving the census rather than by + * a review finding — the SAME shape one parent over. bin/install.js writes + * `hooks/gsd-check-update.js`, `hooks/gsd-context-monitor.js` and + * `hooks/gsd-update-banner.js` into the config root, and with `hooks` absent from + * this list a leak of any of them passed the guard silently. The lesson the first + * miss taught is that this list is the weak point, so it is re-derived from the + * installer's own write sites each round rather than trusted. */ -const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills']; +const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills', 'hooks']; const GSD_ARTIFACT_PREFIX = 'gsd-'; /** diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 6514949ea..bfe07fc0c 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -190,6 +190,46 @@ describe('#2665: live-config hermeticity guard', () => { } }); + test('detects a leaked hook script and the install marker files', () => { + // Self-found by re-deriving the census at round 5 rather than by a review + // finding. bin/install.js writes hooks/gsd-*.js, .gsd-source and .gsd-profile + // into the config ROOT; `hooks` was absent from GSD_PREFIXED_PARENTS and the + // two dot-prefixed markers from GSD_OWNED_ENTRIES, so all three leaked past + // the guard silently -- the same shape as the skills/gsd-dev-preferences miss + // that motivated the prefixed-parent scan in the first place. + const root = tmpRoot(); + try { + fs.mkdirSync(path.join(root, 'hooks'), { recursive: true }); + const before = snapshotLiveConfig([root]); + fs.writeFileSync(path.join(root, 'hooks', 'gsd-check-update.js'), '// x'); + fs.writeFileSync(path.join(root, '.gsd-source'), 'npm'); + fs.writeFileSync(path.join(root, '.gsd-profile'), 'default'); + + const created = diffLiveConfig(before, snapshotLiveConfig([root])) + .filter((v) => v.kind === 'created') + .map((v) => path.basename(v.path)) + .sort(); + assert.deepStrictEqual(created, ['.gsd-profile', '.gsd-source', 'gsd-check-update.js']); + } finally { + cleanup(root); + } + }); + + test('a NON-gsd hook belonging to the host agent is still ignored', () => { + // Widening GSD_PREFIXED_PARENTS must not widen ownership: `hooks/` is shared + // with the host agent, and a guard that flags its files gets switched off. + const root = tmpRoot(); + try { + fs.mkdirSync(path.join(root, 'hooks'), { recursive: true }); + const before = snapshotLiveConfig([root]); + fs.writeFileSync(path.join(root, 'hooks', 'my-own-hook.js'), '// mine'); + + assert.deepStrictEqual(diffLiveConfig(before, snapshotLiveConfig([root])), []); + } finally { + cleanup(root); + } + }); + test('detects a DELETED top-level GSD entry', () => { const root = tmpRoot(); try { From 766480967eb64e6439840059eedd82b229fde7ad Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 17:02:13 -0500 Subject: [PATCH 40/47] fix(#2665): derive the guard's artifact targets, and close the fallback hole in the extras A pre-push adversarial review refuted this round's own completeness claim, and it was right on all three counts. Fixes, in the order they matter: 1. The watch enumeration was still a hand-list, and it was measurably incomplete. It missed kilo's SINGULAR `command/`, hermes' `skills/gsd` (a whole directory whose name carries no `gsd-` prefix, so no prefix rule could ever reach it), `plugins/gsd-core.js`, and the unprefixed subtrees the installer fills -- `hooks/lib`, `hooks/package.json`, `scripts/lib`, `scripts/changeset`. The parents are now DERIVED from the capability registry's own artifactLayout.global destSubpath values, exactly as TEST_ENV_BASE derives its keys, plus a named list for the non-registry paths the installer writes directly. A capability declaring a new destination now extends the watch set in the commit that declares it. Scope note: only `global` is walked -- `workflows` is declared LOCAL-only (windsurf) and is not a config-root parent. 2. resolveExtraWatchTargets carried the identical ambient-only defect that Blocker 3 closed one function over: it resolved $GSD_HOME/.gsd and each kimi descriptor from the ambient env alone, so a child that BLANKED those vars wrote to the HOME-derived fallback while the guard watched the override. Both legs are now unioned, matching resolveLiveConfigRoots. 3. The order-independence claim for the scan budget was too strong. It holds BELOW the global ceiling; once MAX_TOTAL_ENTRIES is exhausted, which targets get curtailed still depends on iteration order -- inherent to any shared aggregate bound. The residual is now named in the docblock and the test title says which regime it pins, instead of asserting the general claim. Negative limits are clamped at 0 so an injected value cannot masquerade as a scan bound. The module's KNOWN GAP now names its remaining residuals (the loose generator scripts, the kimi native-root hook bundle) rather than implying completeness -- an unqualified claim here just invites the same refutation next round. Both under-watch, which fails quiet. Reverting the derivation fails two tests; reverting the fallback leg fails a third. --- scripts/live-config-guard.cjs | 133 +++++++++++++++++++++++++++++-- tests/live-config-guard.test.cjs | 81 ++++++++++++++++++- 2 files changed, 204 insertions(+), 10 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index 9a118a391..e280810b8 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -42,8 +42,20 @@ * entries and MISSED a real leak into `/skills/gsd-dev-preferences/`. * * KNOWN GAP — a leak into a file GSD does not own (e.g. mutating the host's own - * `.claude.json`) is outside this guard by construction. Closing it would require - * watching shared files, which is the false-positive trap above. + * `.claude.json`, `settings.json`, `hooks.json`, `kilo.json`, `opencode.json`) is + * outside this guard by construction. Closing it would require watching shared + * files, which is the false-positive trap above. + * + * NAMED RESIDUALS — stated rather than implied, because round 5's adversarial + * review refuted a completeness claim made here and an unqualified one would + * simply invite the same refutation again: + * - the loose generator scripts the installer copies to `/scripts/*.cjs` + * (`fix-slash-commands.cjs` is watched by name; the capability generators are + * not, and they carry no `gsd-` prefix to scan for); + * - the shared-hooks bundle written into a NON-registry root's `/hooks/` + * (kimi) — see resolveExtraWatchTargets, where closing it is a layout + * decision rather than one more path. + * Both under-watch, which fails quiet: a missed leak, never a false alarm. * * SEVERITY — reports by default, fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1. * Not timidity: on its first CI run this guard found PRE-EXISTING leaks on the @@ -97,6 +109,79 @@ const GSD_OWNED_ENTRIES = [ const GSD_PREFIXED_PARENTS = ['agents', 'commands', 'skills', 'hooks']; const GSD_ARTIFACT_PREFIX = 'gsd-'; +/** + * Artifact parents that are NOT registry-declared — the installer writes these + * directly rather than through a capability's artifactLayout. + */ +const NON_REGISTRY_ARTIFACT_PARENTS = ['hooks', 'plugins', 'scripts']; + +/** + * Paths GSD owns WHOLESALE inside a shared root, which the `gsd-` prefix rule + * cannot see because their names carry no prefix. Each is created and filled by + * the installer: + * hooks/lib, hooks/package.json -- GSD_HOOK_LIB_FILES + the CommonJS marker + * scripts/lib, scripts/changeset, -- copied wholesale into the config dir + * scripts/fix-slash-commands.cjs + * Watched as exact paths rather than by widening the parent scan, so a host + * agent's own `scripts/` or `hooks/` entries are still ignored. + */ +const GSD_OWNED_NESTED = [ + 'hooks/lib', + 'hooks/package.json', + 'scripts/lib', + 'scripts/changeset', + 'scripts/fix-slash-commands.cjs', +]; + +/** + * DERIVE the artifact parents from the capability registry rather than naming + * them, for the same reason TEST_ENV_BASE derives its keys: a hand-list is only + * ever as complete as its author's recall, and this one was measurably not. + * + * Round 5's adversarial review found the hand-list missing Kilo's SINGULAR + * `command/`, `workflows/`, and Hermes' `skills/gsd` -- the last being a whole + * directory whose name carries no `gsd-` prefix, so no prefix rule reaches it. + * A capability that declares a new destSubpath now extends this set in the same + * commit that declares it. + * + * @returns {{parents: string[], owned: string[]}} parents = watch gsd-prefixed + * children only; owned = watch the path wholesale (its own name is GSD's). + */ +function deriveArtifactTargets(runtimes) { + const parents = new Set([...GSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS]); + const owned = new Set(GSD_OWNED_NESTED); + for (const entry of Object.values(runtimes || {})) { + const layouts = entry?.runtime?.artifactLayout?.global ?? []; + for (const layout of layouts) { + const dest = layout?.destSubpath; + if (typeof dest !== 'string' || !dest) continue; + const last = dest.split('/').pop() || ''; + // A destination whose own final segment is GSD's (e.g. hermes' `skills/gsd`) + // is owned wholesale; anything else is a shared parent we scan by prefix. + if (last.startsWith('gsd')) owned.add(dest); + else parents.add(dest); + } + } + return { parents: [...parents].sort(), owned: [...owned].sort() }; +} + +/** Memoized registry-derived targets; falls back to the static lists unbuilt. */ +let _artifactTargets = null; +function artifactTargets(deps = {}) { + if (_artifactTargets && !deps.libDir) return _artifactTargets; + const libDir = deps.libDir || path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'); + let runtimes; + try { + ({ runtimes } = require(path.join(libDir, 'capability-registry.cjs'))); + } catch { + // Unbuilt tree: the static lists are a strict subset, never a wrong answer. + return { parents: [...GSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS].sort(), owned: [...GSD_OWNED_NESTED] }; + } + const derived = deriveArtifactTargets(runtimes); + if (!deps.libDir) _artifactTargets = derived; + return derived; +} + /** * The file GSD writes into a NON-REGISTRY config home. * @@ -126,6 +211,14 @@ const NON_REGISTRY_OWNED_FILE = 'config.toml'; * was really for. It engages only when the per-target bounds together exceed it; * when it does, the targets it curtails are reported `unverified` -- never * silently clean. + * + * NAMED RESIDUAL, because the obvious stronger claim is FALSE: order-independence + * holds BELOW the global ceiling, not above it. Once MAX_TOTAL_ENTRIES is + * exhausted, which targets get curtailed still depends on iteration order -- that + * is inherent to any shared aggregate bound, and the fix here removes the ordinary + * case (one large target cascading over everything after it) rather than the + * pathological one. The curtailed targets are reported `unverified`, so the + * residual costs legibility, never a false clean. */ const MAX_ENTRIES = 20000; const MAX_TOTAL_ENTRIES = 200000; @@ -247,7 +340,14 @@ function resolveExtraWatchTargets(deps = {}) { const env = deps.env || process.env; const homedir = (deps.os || os).homedir; - const targets = [path.resolve(path.join(env.GSD_HOME || homedir(), '.gsd'))]; + // BOTH resolutions, exactly as resolveLiveConfigRoots does — the ambient one and + // the one a child that BLANKED the var falls back to. Watching only the ambient + // path leaves the fallback unwatched, which is the same defect B3 closed for the + // registry roots; it lived here too until round 5's adversarial review found it. + const targets = [ + path.resolve(path.join(env.GSD_HOME || homedir(), '.gsd')), + path.resolve(path.join(homedir(), '.gsd')), + ]; try { const { @@ -266,6 +366,12 @@ function resolveExtraWatchTargets(deps = {}) { // process.env and os.homedir() regardless of `deps`, leaving the seam // untestable and the targets resolved against different worlds. for (const descriptor of NON_REGISTRY_CONFIG_HOME_DESCRIPTORS) { + // The fallback leg, per the note above: a child that blanks KIMI_SHARE_DIR / + // KIMI_CODE_HOME writes to the HOME-derived root instead of the ambient one. + const fallbackDir = resolveConfigHomeFromDescriptor(descriptor, { env: {}, home: homedir() }); + if (typeof fallbackDir === 'string' && fallbackDir.length > 0) { + targets.push(path.resolve(path.join(fallbackDir, NON_REGISTRY_OWNED_FILE))); + } const dir = resolveConfigHomeFromDescriptor(descriptor, { env, home: homedir() }); // The root belongs to the runtime, so it is never watched wholesale — only // the named file below. @@ -284,7 +390,9 @@ function resolveExtraWatchTargets(deps = {}) { } catch { // Unbuilt tree — same posture as resolveLiveConfigRoots: advisory, never fatal. } - return targets; + // Both legs can coincide when no override is set; the snapshot keys on path, + // but dedupe anyway so the RV-facing target count means what it says. + return [...new Set(targets)]; } /** @@ -328,8 +436,10 @@ function newestMtime(target, budget) { * keyed by absolute entry path. */ function snapshotLiveConfig(roots, extraTargets = [], limits = {}) { - const perTarget = limits.perTarget ?? MAX_ENTRIES; - const total = { remaining: limits.total ?? MAX_TOTAL_ENTRIES }; + // Clamp at 0: a negative injected limit would start the ceiling below empty and + // make every target report truncated for a reason that is not a scan bound. + const perTarget = Math.max(0, limits.perTarget ?? MAX_ENTRIES); + const total = { remaining: Math.max(0, limits.total ?? MAX_TOTAL_ENTRIES) }; const snap = {}; const record = (target) => { @@ -354,13 +464,18 @@ function snapshotLiveConfig(roots, extraTargets = [], limits = {}) { // pull the developer's real ~/.gsd into its snapshot. for (const target of extraTargets) record(path.resolve(target)); + const { parents: watchParents, owned: watchOwned } = artifactTargets(); + for (const root of roots) { for (const entry of GSD_OWNED_ENTRIES) record(path.join(root, entry)); + // Paths whose own name is GSD's, nested inside a shared root (hermes' + // `skills/gsd`, `hooks/lib`, `scripts/lib`, …) — no prefix rule sees these. + for (const entry of watchOwned) record(path.join(root, ...entry.split('/'))); // Shared dirs: enumerate only gsd-prefixed children. A child that appears // between the two snapshots is absent from `before` entirely — diffLiveConfig // treats after-only paths as created, which is exactly the leak signal. - for (const parent of GSD_PREFIXED_PARENTS) { + for (const parent of watchParents) { const parentDir = path.join(root, parent); let children; try { @@ -467,6 +582,10 @@ function formatViolations(violations) { module.exports = { GSD_OWNED_ENTRIES, GSD_PREFIXED_PARENTS, + GSD_OWNED_NESTED, + NON_REGISTRY_ARTIFACT_PARENTS, + deriveArtifactTargets, + artifactTargets, GSD_ARTIFACT_PREFIX, MAX_ENTRIES, MAX_TOTAL_ENTRIES, diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index bfe07fc0c..56c021853 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -10,6 +10,7 @@ const fc = require('fast-check'); const { GSD_OWNED_ENTRIES, + artifactTargets, MAX_DEPTH, resolveLiveConfigRoots, resolveExtraWatchTargets, @@ -147,8 +148,14 @@ describe('#2665: live-config hermeticity guard', () => { const root = tmpRoot(); try { const snap = snapshotLiveConfig([root]); - const watched = Object.keys(snap).map((p) => path.basename(p)).sort(); - assert.deepStrictEqual(watched, [...GSD_OWNED_ENTRIES].sort()); + const watched = Object.keys(snap).map((p) => path.relative(root, p)).sort(); + // Top-level owned entries PLUS the nested paths GSD owns wholesale inside a + // shared root; prefixed children contribute nothing in an empty root. + const expected = [ + ...GSD_OWNED_ENTRIES, + ...artifactTargets().owned.map((o) => path.join(...o.split('/'))), + ].sort(); + assert.deepStrictEqual(watched, expected); } finally { cleanup(root); } @@ -230,6 +237,74 @@ describe('#2665: live-config hermeticity guard', () => { } }); + test('artifact parents are DERIVED from the registry, not hand-listed', () => { + // Round 5's adversarial review refuted the completeness claim of the + // hand-list: it missed Kilo's SINGULAR `command/`, `workflows/`, and hermes' + // `skills/gsd` -- a whole directory whose name carries no `gsd-` prefix, so + // no prefix rule could ever reach it. + const { parents, owned } = artifactTargets(); + // NB: `workflows` is declared only in LOCAL scope (windsurf), so it is + // deliberately NOT a global config-root parent -- the derivation walks + // artifactLayout.global only. + for (const p of ['agents', 'commands', 'command', 'skills', 'hooks', 'plugins', 'scripts']) { + assert.ok(parents.includes(p), `expected derived parent ${p} in ${JSON.stringify(parents)}`); + } + assert.ok(owned.includes('skills/gsd'), `expected hermes skills/gsd in ${JSON.stringify(owned)}`); + for (const o of ['hooks/lib', 'scripts/lib']) { + assert.ok(owned.includes(o), `expected owned nested ${o} in ${JSON.stringify(owned)}`); + } + }); + + test('detects leaks the gsd- prefix rule structurally cannot see', () => { + const root = tmpRoot(); + try { + for (const d of ['skills/gsd', 'hooks/lib', 'plugins', 'command', 'scripts/lib']) { + fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true }); + } + const before = snapshotLiveConfig([root]); + // Pre-existing dirs register as MODIFIED, so bump mtimes explicitly rather + // than racing a coarse filesystem clock -- same reason the `modified` test + // above uses utimesSync. + const future = new Date(Date.now() + 10000); + const leaks = [ + ['skills', 'gsd', 'executor.md'], + ['hooks', 'lib', 'git-cmd.js'], + ['plugins', 'gsd-core.js'], + ['command', 'gsd-plan.md'], + ['scripts', 'lib', 'io.cjs'], + ]; + for (const seg of leaks) { + const f = path.join(root, ...seg); + fs.writeFileSync(f, '// leaked'); + fs.utimesSync(f, future, future); + fs.utimesSync(path.dirname(f), future, future); + } + + const hit = diffLiveConfig(before, snapshotLiveConfig([root])).map((v) => v.path); + for (const expected of ['skills/gsd', 'hooks/lib', 'plugins/gsd-core.js', 'command/gsd-plan.md', 'scripts/lib']) { + const abs = path.join(root, ...expected.split('/')); + assert.ok(hit.includes(abs), `${expected} leaked undetected; got ${JSON.stringify(hit)}`); + } + } finally { + cleanup(root); + } + }); + + test('extra watch targets cover the fallback root as well as the ambient one', () => { + // The MISSED finding from round 5's review: B3 closed this for the registry + // roots and left the identical hole in resolveExtraWatchTargets. + const targets = resolveExtraWatchTargets({ + env: { GSD_HOME: '/ambient-gsd-home', KIMI_SHARE_DIR: '/ambient-kimi' }, + os: { homedir: () => '/fallback-home' }, + }); + assert.ok(targets.includes(path.resolve('/ambient-gsd-home/.gsd')), 'ambient $GSD_HOME/.gsd'); + assert.ok(targets.includes(path.resolve('/fallback-home/.gsd')), 'HOME-derived .gsd fallback'); + assert.ok( + targets.some((t) => t === path.resolve('/fallback-home/.kimi/config.toml')), + `HOME-derived kimi fallback missing from ${JSON.stringify(targets)}`, + ); + }); + test('detects a DELETED top-level GSD entry', () => { const root = tmpRoot(); try { @@ -290,7 +365,7 @@ describe('#2665: live-config hermeticity guard', () => { } }); - test('the truncation verdict does not depend on target ORDER', () => { + test('the truncation verdict does not depend on target ORDER (below the global ceiling)', () => { const [big] = treeWithEntries(8); const [small] = treeWithEntries(2); try { From fae0c6ae1a2cd832d7c69691359a7e3006bce564 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Thu, 6 Aug 2026 17:51:52 -0500 Subject: [PATCH 41/47] fix(#2665): stop watching shared ground, and derive the artifact prefix too The previous commit widened the guard's watch set and claimed the enumeration was complete. Re-running the pre-push adversarial gate on that commit -- which I should have done before pushing it, and did not -- refuted the claim on four counts. All four were real. 1. FALSE POSITIVES, which is the worse polarity. `hooks/lib`, `hooks/package.json`, `scripts/lib` and `scripts/changeset` were watched WHOLESALE. The installer preserves foreign files in every one of them -- it removes the CommonJS marker only on an exact content match, because "a user-authored package.json is never deleted" -- so a user editing their own helper mid-suite tripped the guard. A driven probe produced four violations from touching only user-owned files. Watching shared ground is exactly what the module's SCOPE note refuses: a guard that cries wolf gets switched off, and then catches nothing at all. Now only exact GSD filenames inside those dirs are watched, and a test asserts foreign edits stay silent. 2. THE PREFIX WAS HARDCODED, which is this PR's own defect one level down. Each artifactLayout declares its OWN prefix, and kimi's `kimi-agents` layout declares `gsd` with no hyphen, writing `agents/gsd.yaml` and `agents/gsd.md`. A fixed `gsd-` scan is structurally blind to both, as it is to pi's `extensions/gsd.js`. The prefix is now derived per parent, as a SET -- the same destSubpath carries different prefixes across runtimes (`agents` appears with both `gsd` and `gsd-`). `extensions` joins the non-registry parents; pi declares no artifactLayout at all, so no registry walk could find it. 3. THE ENTRY BOUND FAILED OPEN on a non-finite limit: `Math.max(0, NaN)` is NaN, and every budget comparison against NaN is false, so the walk was unbounded -- the single thing the constant exists to prevent. Clamped with Number.isFinite. The walk also kept invoking itself for every remaining sibling after the budget was gone; it now returns. 4. THE RESIDUAL LIST WAS WRONG AGAIN. `agents/subagents/**` (kimi stages under an unprefixed intermediate dir), the loose capability generators, and the `extensions`/`plugins` CommonJS markers are all unwatched and were unnamed. They are named now, and the four shared dirs are recorded as DELIBERATELY not watched -- a different thing from missed. Each fix is negative-controlled and each control fires. The NaN control did not fire on its first form: the test asserted `truncated: false`, which the broken code also produces on a small tree, so it discriminated nothing. Repaired with a NaN perTarget against a small finite ceiling, where the two behaviours differ. --- scripts/live-config-guard.cjs | 116 +++++++++++++++++++++---------- tests/live-config-guard.test.cjs | 87 ++++++++++++++++++++--- 2 files changed, 154 insertions(+), 49 deletions(-) diff --git a/scripts/live-config-guard.cjs b/scripts/live-config-guard.cjs index e280810b8..e611fb53f 100644 --- a/scripts/live-config-guard.cjs +++ b/scripts/live-config-guard.cjs @@ -46,16 +46,23 @@ * outside this guard by construction. Closing it would require watching shared * files, which is the false-positive trap above. * - * NAMED RESIDUALS — stated rather than implied, because round 5's adversarial - * review refuted a completeness claim made here and an unqualified one would - * simply invite the same refutation again: - * - the loose generator scripts the installer copies to `/scripts/*.cjs` - * (`fix-slash-commands.cjs` is watched by name; the capability generators are - * not, and they carry no `gsd-` prefix to scan for); - * - the shared-hooks bundle written into a NON-registry root's `/hooks/` - * (kimi) — see resolveExtraWatchTargets, where closing it is a layout - * decision rather than one more path. - * Both under-watch, which fails quiet: a missed leak, never a false alarm. + * NAMED RESIDUALS — stated rather than implied, because two successive rounds + * asserted this list was complete and both were refuted. Still NOT watched: + * - `agents/subagents/**` (kimi stages `subagents/gsd-executor.yaml` under an + * UNPREFIXED intermediate dir, so no prefix scan of `agents/` reaches it); + * - the loose capability generators copied to `/scripts/*.cjs` + * (`fix-slash-commands.cjs` is watched by name; the generators are not); + * - `extensions/package.json` and `plugins/package.json` — CommonJS markers in + * dirs GSD fills but does not own, so they fall under the shared-ground rule + * below rather than being watched; + * - the shared-hooks bundle in a NON-registry root's `/hooks/` (kimi) — + * see resolveExtraWatchTargets; closing it is a layout decision. + * DELIBERATELY not watched, which is a different thing from missed: `hooks/lib`, + * `hooks/package.json`, `scripts/lib` and `scripts/changeset`. The installer + * preserves foreign files in each, so watching them wholesale produces false + * positives — and a guard that cries wolf gets switched off. + * Every item above under-watches, which fails quiet: a missed leak, never a + * false alarm. * * SEVERITY — reports by default, fails only under GSD_STRICT_LIVE_CONFIG_GUARD=1. * Not timidity: on its first CI run this guard found PRE-EXISTING leaks on the @@ -113,24 +120,31 @@ const GSD_ARTIFACT_PREFIX = 'gsd-'; * Artifact parents that are NOT registry-declared — the installer writes these * directly rather than through a capability's artifactLayout. */ -const NON_REGISTRY_ARTIFACT_PARENTS = ['hooks', 'plugins', 'scripts']; +const NON_REGISTRY_ARTIFACT_PARENTS = ['hooks', 'plugins', 'scripts', 'extensions']; /** - * Paths GSD owns WHOLESALE inside a shared root, which the `gsd-` prefix rule - * cannot see because their names carry no prefix. Each is created and filled by - * the installer: - * hooks/lib, hooks/package.json -- GSD_HOOK_LIB_FILES + the CommonJS marker - * scripts/lib, scripts/changeset, -- copied wholesale into the config dir - * scripts/fix-slash-commands.cjs - * Watched as exact paths rather than by widening the parent scan, so a host - * agent's own `scripts/` or `hooks/` entries are still ignored. + * Prefixes used for a parent with no registry-declared one. BOTH forms are the + * point: GSD writes `gsd-`-hyphen artifacts (`hooks/gsd-check-update.js`) AND + * bare `gsd.`-dotted ones (pi's `extensions/gsd.js`), and a lone `gsd-` sees + * only the first. + */ +const DEFAULT_ARTIFACT_PREFIXES = ['gsd-', 'gsd.']; + +/** + * Files GSD owns by EXACT NAME inside a directory it shares — deliberately NOT + * the directories themselves. + * + * `hooks/lib`, `hooks/package.json`, `scripts/lib` and `scripts/changeset` were + * watched wholesale for exactly one commit, and that was wrong: the installer's + * own uninstall path preserves foreign files in every one of them (it removes the + * CommonJS marker only on an exact content match — "a user-authored package.json + * is never deleted"). Watching them wholesale turns a user editing their own + * helper into a violation, which is the false-positive trap the SCOPE note above + * exists to refuse. Under-watching fails quiet; over-watching disarms the guard. */ const GSD_OWNED_NESTED = [ - 'hooks/lib', - 'hooks/package.json', - 'scripts/lib', - 'scripts/changeset', 'scripts/fix-slash-commands.cjs', + 'hooks/managed-hooks-registry.cjs', ]; /** @@ -148,21 +162,36 @@ const GSD_OWNED_NESTED = [ * children only; owned = watch the path wholesale (its own name is GSD's). */ function deriveArtifactTargets(runtimes) { - const parents = new Set([...GSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS]); + const parents = new Map(); + const addParent = (dest, prefixes) => { + if (!parents.has(dest)) parents.set(dest, new Set()); + for (const pre of prefixes) parents.get(dest).add(pre); + }; + for (const dest of [...GSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS]) { + addParent(dest, DEFAULT_ARTIFACT_PREFIXES); + } const owned = new Set(GSD_OWNED_NESTED); for (const entry of Object.values(runtimes || {})) { - const layouts = entry?.runtime?.artifactLayout?.global ?? []; - for (const layout of layouts) { + for (const layout of entry?.runtime?.artifactLayout?.global ?? []) { const dest = layout?.destSubpath; if (typeof dest !== 'string' || !dest) continue; const last = dest.split('/').pop() || ''; - // A destination whose own final segment is GSD's (e.g. hermes' `skills/gsd`) - // is owned wholesale; anything else is a shared parent we scan by prefix. - if (last.startsWith('gsd')) owned.add(dest); - else parents.add(dest); + // A destination whose own final segment is GSD's (hermes' `skills/gsd`) is + // owned wholesale — that directory is ours, not shared. + if (last.startsWith('gsd')) { owned.add(dest); continue; } + // The layout declares its OWN prefix, and it varies: kimi's `kimi-agents` + // layout declares `gsd` (no hyphen) and writes `agents/gsd.yaml` + + // `agents/gsd.md`, invisible to a fixed `gsd-` scan. The same destSubpath + // also carries different prefixes across runtimes, so a parent maps to a SET. + const declared = typeof layout?.prefix === 'string' && layout.prefix + ? [layout.prefix] + : DEFAULT_ARTIFACT_PREFIXES; + addParent(dest, declared); } } - return { parents: [...parents].sort(), owned: [...owned].sort() }; + const out = {}; + for (const [dest, set] of [...parents.entries()].sort()) out[dest] = [...set].sort(); + return { parents: out, owned: [...owned].sort() }; } /** Memoized registry-derived targets; falls back to the static lists unbuilt. */ @@ -175,7 +204,7 @@ function artifactTargets(deps = {}) { ({ runtimes } = require(path.join(libDir, 'capability-registry.cjs'))); } catch { // Unbuilt tree: the static lists are a strict subset, never a wrong answer. - return { parents: [...GSD_PREFIXED_PARENTS, ...NON_REGISTRY_ARTIFACT_PARENTS].sort(), owned: [...GSD_OWNED_NESTED] }; + return deriveArtifactTargets(null); } const derived = deriveArtifactTargets(runtimes); if (!deps.libDir) _artifactTargets = derived; @@ -422,7 +451,13 @@ function newestMtime(target, budget) { } catch { return; } - for (const entry of entries) walk(path.join(current, entry), depth + 1); + for (const entry of entries) { + // RETURN, not continue: without this the loop keeps invoking walk() for + // every remaining sibling after the budget is gone, so the ceiling bounds + // what is RECORDED but not the work done getting there. + if (budget.remaining <= 0) { truncated = true; return; } + walk(path.join(current, entry), depth + 1); + } }; walk(target, 0); @@ -438,8 +473,12 @@ function newestMtime(target, budget) { function snapshotLiveConfig(roots, extraTargets = [], limits = {}) { // Clamp at 0: a negative injected limit would start the ceiling below empty and // make every target report truncated for a reason that is not a scan bound. - const perTarget = Math.max(0, limits.perTarget ?? MAX_ENTRIES); - const total = { remaining: Math.max(0, limits.total ?? MAX_TOTAL_ENTRIES) }; + // Number.isFinite, NOT Math.max: `Math.max(0, NaN)` is NaN, and every budget + // comparison against NaN is false — the bound then fails OPEN and the walk is + // unbounded, which is the single thing these constants exist to prevent. + const finite = (v, fallback) => (Number.isFinite(v) && v >= 0 ? v : fallback); + const perTarget = finite(limits.perTarget, MAX_ENTRIES); + const total = { remaining: finite(limits.total, MAX_TOTAL_ENTRIES) }; const snap = {}; const record = (target) => { @@ -475,8 +514,8 @@ function snapshotLiveConfig(roots, extraTargets = [], limits = {}) { // Shared dirs: enumerate only gsd-prefixed children. A child that appears // between the two snapshots is absent from `before` entirely — diffLiveConfig // treats after-only paths as created, which is exactly the leak signal. - for (const parent of watchParents) { - const parentDir = path.join(root, parent); + for (const [parent, prefixes] of Object.entries(watchParents)) { + const parentDir = path.join(root, ...parent.split('/')); let children; try { children = fs.readdirSync(parentDir); @@ -484,7 +523,7 @@ function snapshotLiveConfig(roots, extraTargets = [], limits = {}) { continue; // parent absent — nothing of ours can be in it yet } for (const child of children) { - if (child.startsWith(GSD_ARTIFACT_PREFIX)) record(path.join(parentDir, child)); + if (prefixes.some((pre) => child.startsWith(pre))) record(path.join(parentDir, child)); } } } @@ -584,6 +623,7 @@ module.exports = { GSD_PREFIXED_PARENTS, GSD_OWNED_NESTED, NON_REGISTRY_ARTIFACT_PARENTS, + DEFAULT_ARTIFACT_PREFIXES, deriveArtifactTargets, artifactTargets, GSD_ARTIFACT_PREFIX, diff --git a/tests/live-config-guard.test.cjs b/tests/live-config-guard.test.cjs index 56c021853..4bea10d07 100644 --- a/tests/live-config-guard.test.cjs +++ b/tests/live-config-guard.test.cjs @@ -246,32 +246,41 @@ describe('#2665: live-config hermeticity guard', () => { // NB: `workflows` is declared only in LOCAL scope (windsurf), so it is // deliberately NOT a global config-root parent -- the derivation walks // artifactLayout.global only. - for (const p of ['agents', 'commands', 'command', 'skills', 'hooks', 'plugins', 'scripts']) { - assert.ok(parents.includes(p), `expected derived parent ${p} in ${JSON.stringify(parents)}`); + for (const p of ['agents', 'commands', 'command', 'skills', 'hooks', 'plugins', 'scripts', 'extensions']) { + assert.ok(p in parents, `expected derived parent ${p} in ${JSON.stringify(Object.keys(parents))}`); } + // kimi's kimi-agents layout declares prefix `gsd` (no hyphen); a fixed `gsd-` + // scan cannot see agents/gsd.yaml, which is the defect this derivation closes. + assert.ok( + parents.agents.includes('gsd'), + `agents must carry kimi's bare 'gsd' prefix; got ${JSON.stringify(parents.agents)}`, + ); assert.ok(owned.includes('skills/gsd'), `expected hermes skills/gsd in ${JSON.stringify(owned)}`); - for (const o of ['hooks/lib', 'scripts/lib']) { + // Exact GSD filenames only — never the shared directories that contain them. + for (const o of ['scripts/fix-slash-commands.cjs', 'hooks/managed-hooks-registry.cjs']) { assert.ok(owned.includes(o), `expected owned nested ${o} in ${JSON.stringify(owned)}`); } + for (const shared of ['hooks/lib', 'hooks/package.json', 'scripts/lib', 'scripts/changeset']) { + assert.ok(!owned.includes(shared), `${shared} is shared ground and must NOT be watched wholesale`); + } }); - test('detects leaks the gsd- prefix rule structurally cannot see', () => { + test('detects leaks a single hardcoded gsd- prefix cannot see', () => { const root = tmpRoot(); try { - for (const d of ['skills/gsd', 'hooks/lib', 'plugins', 'command', 'scripts/lib']) { + for (const d of ['skills/gsd', 'agents', 'plugins', 'command', 'extensions']) { fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true }); } const before = snapshotLiveConfig([root]); - // Pre-existing dirs register as MODIFIED, so bump mtimes explicitly rather - // than racing a coarse filesystem clock -- same reason the `modified` test - // above uses utimesSync. const future = new Date(Date.now() + 10000); + // kimi declares prefix `gsd` (no hyphen) and writes agents/gsd.yaml; + // pi writes extensions/gsd.js. A fixed `gsd-` scan sees neither. const leaks = [ ['skills', 'gsd', 'executor.md'], - ['hooks', 'lib', 'git-cmd.js'], + ['agents', 'gsd.yaml'], + ['extensions', 'gsd.js'], ['plugins', 'gsd-core.js'], ['command', 'gsd-plan.md'], - ['scripts', 'lib', 'io.cjs'], ]; for (const seg of leaks) { const f = path.join(root, ...seg); @@ -281,7 +290,7 @@ describe('#2665: live-config hermeticity guard', () => { } const hit = diffLiveConfig(before, snapshotLiveConfig([root])).map((v) => v.path); - for (const expected of ['skills/gsd', 'hooks/lib', 'plugins/gsd-core.js', 'command/gsd-plan.md', 'scripts/lib']) { + for (const expected of ['skills/gsd', 'agents/gsd.yaml', 'extensions/gsd.js', 'plugins/gsd-core.js', 'command/gsd-plan.md']) { const abs = path.join(root, ...expected.split('/')); assert.ok(hit.includes(abs), `${expected} leaked undetected; got ${JSON.stringify(hit)}`); } @@ -290,6 +299,42 @@ describe('#2665: live-config hermeticity guard', () => { } }); + test('a user editing their OWN files in a shared dir is not a violation', () => { + // The false-positive case a prior commit shipped: hooks/lib, hooks/package.json, + // scripts/lib and scripts/changeset were watched WHOLESALE, so touching a + // user-authored helper in any of them tripped the guard. The installer itself + // preserves foreign files in all four, so they are not GSD's to watch. + const root = tmpRoot(); + try { + for (const d of ['hooks/lib', 'scripts/lib', 'scripts/changeset']) { + fs.mkdirSync(path.join(root, ...d.split('/')), { recursive: true }); + } + const foreign = [ + ['hooks', 'package.json'], + ['hooks', 'lib', 'user-helper.js'], + ['scripts', 'lib', 'user-helper.cjs'], + ['scripts', 'changeset', 'user-tool.cjs'], + ]; + for (const seg of foreign) fs.writeFileSync(path.join(root, ...seg), 'mine'); + const before = snapshotLiveConfig([root]); + const future = new Date(Date.now() + 10000); + for (const seg of foreign) { + const f = path.join(root, ...seg); + fs.writeFileSync(f, 'mine, edited'); + fs.utimesSync(f, future, future); + fs.utimesSync(path.dirname(f), future, future); + } + + assert.deepStrictEqual( + diffLiveConfig(before, snapshotLiveConfig([root])), + [], + 'editing user-owned files in a shared dir must not trip the guard', + ); + } finally { + cleanup(root); + } + }); + test('extra watch targets cover the fallback root as well as the ambient one', () => { // The MISSED finding from round 5's review: B3 closed this for the registry // roots and left the identical hole in resolveExtraWatchTargets. @@ -380,6 +425,26 @@ describe('#2665: live-config hermeticity guard', () => { } }); + test('a non-finite injected limit falls back to the real bound, never fails open', () => { + // Math.max(0, NaN) is NaN, and every budget comparison against NaN is false, + // so the walk becomes unbounded — the one thing the bound exists to prevent. + // The discriminator has to be a case where the two behaviours DIFFER: pair a + // NaN perTarget with a small finite ceiling. Fixed, perTarget falls back to + // MAX_ENTRIES and the ceiling still bites (truncated). Broken, min(NaN, 3) is + // NaN and nothing truncates at all. + const [dir] = treeWithEntries(6); + try { + const snap = snapshotLiveConfig([], [dir], { perTarget: NaN, total: 3 }); + assert.strictEqual( + snap[path.resolve(dir)].truncated, + true, + 'a NaN perTarget must fall back to a real bound, not disable budgeting', + ); + } finally { + cleanup(dir); + } + }); + test('the GLOBAL ceiling still bounds the aggregate, and reports unverified', () => { // The bound the single budget was really for is kept -- but when it engages, // the curtailed target is reported rather than silently attested clean. From 87f6af282ae5dc70725454c5726c6e69062536ae Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 8 Aug 2026 05:51:57 -0500 Subject: [PATCH 42/47] chore(#3156): rebake both CONTEXT-INDEX mirrors after the rebase The rebase conflicted on both generated indexes. Resolved arbitrarily during the replay and regenerated from their producers rather than hand-merged -- gen-context-index.cjs for docs/, and the example's own generator for the mirror, which a separate lint checks. 426 predicates, 21 classes, 0 duplicate ids. lint:generated-sync and lint-example-parser-parity both pass. --- docs/CONTEXT-INDEX.json | 9 +- .../CONTEXT-INDEX.json | 860 +++++++++--------- 2 files changed, 440 insertions(+), 429 deletions(-) diff --git a/docs/CONTEXT-INDEX.json b/docs/CONTEXT-INDEX.json index f8f430ba9..9956a6ff9 100644 --- a/docs/CONTEXT-INDEX.json +++ b/docs/CONTEXT-INDEX.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "count": 425, + "count": 426, "classes": { "ARCH": 1, "CI": 2, "CONFIG": 5, - "DEFECT": 167, + "DEFECT": 168, "EXEC": 8, "GSD-RESEARCH": 6, "LEARNING": 1, @@ -355,6 +355,11 @@ "klass": "DEFECT", "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks" }, + { + "id": "DEFECT.HOST-RESERVED-DIR-NAME", + "klass": "DEFECT", + "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees" + }, { "id": "DEFECT.INVENTORY-DRIFT.detect", "klass": "DEFECT", diff --git a/examples/dynamic-context-management/CONTEXT-INDEX.json b/examples/dynamic-context-management/CONTEXT-INDEX.json index 19885c75b..b64b5063f 100644 --- a/examples/dynamic-context-management/CONTEXT-INDEX.json +++ b/examples/dynamic-context-management/CONTEXT-INDEX.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "count": 425, + "count": 426, "classes": { "ARCH": 1, "CI": 2, "CONFIG": 5, - "DEFECT": 167, + "DEFECT": 168, "EXEC": 8, "GSD-RESEARCH": 6, "LEARNING": 1, @@ -29,2551 +29,2557 @@ "id": "ARCH.SKILL.improve-codebase.next-candidates", "klass": "ARCH", "value": "[Workstream Progress Projection Module]", - "line": 567 + "line": 576 }, { "id": "CI.GATE.changeset-lint", "klass": "CI", "value": "hard-fail for user-facing code diffs unless .changeset/* or PR has no-changelog label", - "line": 551 + "line": 560 }, { "id": "CI.GATE.issue-link-required", "klass": "CI", "value": "hard-fail if PR body lacks closes/fixes/resolves #", - "line": 550 + "line": 559 }, { "id": "CONFIG.LOCATION.SEAM.in-process-scrub", "klass": "CONFIG", "value": "TEST_ENV_BASE reaches CHILD env only; a test calling install() IN-PROCESS must additionally use helpers.scrubConfigLocationEnv() in beforeEach + its restorer in afterEach — HOME/USERPROFILE sandboxing is NOT sufficient because getGlobalConfigDir is env-FIRST", - "line": 585 + "line": 594 }, { "id": "CONFIG.LOCATION.SEAM.kimi-two-homes", "klass": "CONFIG", "value": "kimi declares TWO config-location vars: KIMI_CONFIG_DIR (registry, generic Agent-Skills root via resolveKimiGlobalDir) and KIMI_SHARE_DIR (KIMI_HOOKS_TOML_DESCRIPTOR, kimi's OWN native config.toml carrying GSD's [[hooks]] block via resolveKimiHooksTomlDir); a registry-only derivation covers the first and silently misses the second", - "line": 584 + "line": 593 }, { "id": "CONFIG.LOCATION.SEAM.scrub-set", "klass": "CONFIG", "value": "tests/helpers.cjs CONFIG_LOCATION_ENV_KEYS is DERIVED from five sources rather than maintained as one hand-written list (source 4 IS a literal residue list, for vars that fit no other rung — what is never hand-listed is the SET): capability-registry runtimes[].runtime.configHome.env AND [].configHome.skillsHome.env + runtime-homes NON_REGISTRY_CONFIG_HOME_DESCRIPTORS[].env AND [].skillsHome.env (a descriptor is a descriptor — BOTH descriptor rungs walk skillsHome, which resolves independently via resolveSkillsBaseFromDescriptor) + runtime-homes GSD_LOCATION_ENV_KEYS + a residue list (GROK_AGENTS_HOME, GSD_RUNTIME, GSD_PROJECT, GSD_WORKSTREAM) + WRITE_ESCAPE_PERMISSION_ENV_KEYS (GSD_ALLOW_SYMLINKED_DEST — a permission, not a location: it names no path but disarms the symlink-escape guard, so blanking it makes the guard STRICTER, never looser); adding a config-location var means making it ENUMERABLE at one of those sources, not appending a literal", - "line": 582 + "line": 591 }, { "id": "CONFIG.LOCATION.SEAM.two-families", "klass": "CONFIG", "value": "runtime configHomes (where a third-party runtime keeps config, registry- or descriptor-declared) and GSD's OWN location vars (GSD_HOME -> $GSD_HOME/.gsd store, GSD_AGENTS_DIR -> getAgentsDir priority 1) are DISTINCT families; no registry derivation reaches the second, and treating a miss there as a registry gap is what produced review round 2", - "line": 583 + "line": 592 }, { "id": "CONFIG.SEAM.loadConfig-context", "klass": "CONFIG", "value": "loadConfig(cwd,{workstream}) replaces env-mutation fallback; no temporary process.env GSD_WORKSTREAM rewrites", - "line": 581 + "line": 590 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.detect", "klass": "DEFECT", "value": "tests/planner-decomposition.test.cjs (\"planner is under 45K chars (proves mode sections were extracted)\") and tests/reachability-check.test.cjs (\"file stays under 50000 char limit\")", - "line": 793 + "line": 802 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.fix-forward", "klass": "DEFECT", "value": "mirror MVP mode pattern — extract full rules to gsd-core/references/planner-.md, leave a slim Detection section in the agent file with @-reference to the new file", - "line": 794 + "line": 803 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.state", "klass": "DEFECT", "value": "gsd-planner.md is 49,125 chars on main, just under the test's actual PLANNER_EXTRACTED_LIMIT of 48K (49,152 chars — the test's own title still says \"45K\" but the enforced constant was raised in #2341); the test currently passes, but any further net-new content risks pushing it over", - "line": 792 + "line": 801 }, { "id": "DEFECT.AGENT-FILE-SIZE-CAP-BREACH.symptom", "klass": "DEFECT", "value": "adding to agents/gsd-planner.md (or other large agent files) exceeds the 45K char extraction-evidence threshold", - "line": 791 + "line": 800 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.detect", "klass": "DEFECT", "value": "tests/slash-command-namespace.test.cjs prints \"Found N retired /gsd- reference(s) — use /gsd: instead\" with line-number-precise violations", - "line": 999 + "line": 1010 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.examples", "klass": "DEFECT", "value": "#3541 implementation included a typical /gsd-update path comment in installer-migration-report.cjs; caught by tests/slash-command-namespace.test.cjs (#3443 invariant)", - "line": 998 + "line": 1009 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.fix-forward", "klass": "DEFECT", "value": "replace /gsd- with /gsd: at the cited file:line; healthy emergent property — project-wide invariant test catches drift agents would never self-correct", - "line": 1000 + "line": 1011 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.lesson", "klass": "DEFECT", "value": "agent-trust-but-verify is load-bearing — sub-agent reporting \"done\" is not a substitute for running the full suite; the invariant test surfaces drift even in doc-only changes", - "line": 1001 + "line": 1012 }, { "id": "DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT.symptom", "klass": "DEFECT", "value": "sub-agent writes /gsd- (legacy hyphen syntax) in code comments or doc strings while implementing a fix; lands as part of the implementation diff", - "line": 997 + "line": 1008 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.detect", "klass": "DEFECT", "value": "git merge-base origin/ origin/main returns the bot branch tip — confirms the bot branch is an ancestor of main, just stale", - "line": 773 + "line": 782 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.examples", "klass": "DEFECT", "value": "#3309 fix/3309-checkpoint-type-human-verify-burns-token (was at e14ef535; main at 2e87c60a)", - "line": 772 + "line": 781 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/main; do work; git checkout -b ; force-push with --force-with-lease", - "line": 774 + "line": 783 }, { "id": "DEFECT.BOT-BRANCH-STALE-BASE.symptom", "klass": "DEFECT", "value": "auto-branch.yml creates fix/{N}-{slug} when issue is filed; branch is anchored to issue-creation main; by the time work begins, main has moved", - "line": 771 + "line": 780 }, { "id": "DEFECT.CANARY-VERSION-LEAK.detect", "klass": "DEFECT", "value": "jq -r .version package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version", - "line": 954 + "line": 965 }, { "id": "DEFECT.CANARY-VERSION-LEAK.examples", "klass": "DEFECT", "value": "2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at \"version\": \"1.50.0-canary.0\" in sdk/package.json AND root package.json; npm view @opengsd/gsd-sdk versions returned [\"0.1.0\"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '\"version\": \"1.50.0-canary.0\"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base", - "line": 953 + "line": 964 }, { "id": "DEFECT.CANARY-VERSION-LEAK.fix-forward", "klass": "DEFECT", "value": "open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main", - "line": 955 + "line": 966 }, { "id": "DEFECT.CANARY-VERSION-LEAK.symptom", "klass": "DEFECT", "value": "package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label", - "line": 952 + "line": 963 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.detect", "klass": "DEFECT", "value": "changeset pr: value mismatches the actual PR number returned by gh api POST /pulls", - "line": 798 + "line": 807 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.examples", "klass": "DEFECT", "value": "#3316 (pr:3312 was the issue), #3325 (pr:3319 was a guess); recurs every cycle", - "line": 797 + "line": 806 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward", "klass": "DEFECT", "value": "author changeset with placeholder pr:0; immediately after gh api POST /pulls returns the number, edit changeset and amend or follow-up commit; never guess", - "line": 799 + "line": 808 }, { "id": "DEFECT.CHANGESET-PR-FIELD-DRIFT.symptom", "klass": "DEFECT", "value": ".changeset/*.md frontmatter pr: value is the issue number, a guess made before PR opened, or a stale stacked-PR number", - "line": 796 + "line": 805 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.detect", "klass": "DEFECT", "value": "any PR that changes a default value in CONFIG_DEFAULTS or buildNewProjectConfig; check that PR body Breaking Changes section explicitly covers (a) when the new default takes effect, (b) opt-back-in command, (c) effect on in-flight artifacts", - "line": 833 + "line": 842 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.examples", "klass": "DEFECT", "value": "#3309 v2 default flip from mid-flight to end-of-phase", - "line": 832 + "line": 841 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.fix-forward", "klass": "DEFECT", "value": "template — \"new default takes effect when .planning/config.json is rewritten (config-set, fresh project, regenerated config); existing artifacts continue to work; opt-back-in: gsd config-set \"", - "line": 834 + "line": 843 }, { "id": "DEFECT.DEFAULT-FLIP-DOCUMENTATION.symptom", "klass": "DEFECT", "value": "PR flips a config default but does not call out the migration semantics (when does the new default take effect; existing configs vs new configs; what the opt-back-in looks like)", - "line": 831 + "line": 840 }, { "id": "DEFECT.FORMAT", "klass": "DEFECT", "value": "class.sub-key=value | classes are greppable; each class carries detect / fix / anchor sub-keys when applicable", - "line": 748 + "line": 757 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect", "klass": "DEFECT", "value": "grep \"^:\" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it", - "line": 846 + "line": 855 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples", "klass": "DEFECT", "value": "#586/PR #650 ship.md verification gate — grep \"^status:\" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; execute-phase.md has since been fixed to the frontmatter-scoped form (#651)", - "line": 845 + "line": 854 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward", "klass": "DEFECT", "value": "scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' \"$f\" | grep -m1 \"^:\" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)", - "line": 847 + "line": 856 }, { "id": "DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom", "klass": "DEFECT", "value": "a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep \"^key:\" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted", - "line": 844 + "line": 853 }, { "id": "DEFECT.GENERATIVE-EXEMPLAR", "klass": "DEFECT", "value": "tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)", - "line": 842 + "line": 851 }, { "id": "DEFECT.GENERATIVE-FIX", "klass": "DEFECT", "value": "for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge", - "line": 841 + "line": 850 }, { "id": "DEFECT.GENERATIVE-PRIORITY", "klass": "DEFECT", "value": "these defect classes share a common root: parallel implementations diverge silently because no parity test enforces equality at the test layer", - "line": 840 + "line": 849 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.detect", "klass": "DEFECT", "value": "two gsd-test-summary --both runs in flight; UnicodeDecodeError in parse_events_from_string traceback; /tmp/gsd-test-*.jsonl size mismatch vs total events emitted", - "line": 990 + "line": 1001 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.fix-forward", "klass": "DEFECT", "value": "set per-invocation LOCAL_OUT=/tmp/gsd-test--local.jsonl DOCKER_OUT=/tmp/gsd-test--docker.jsonl env vars; or serialize the runs; upstream fix tracked in #3545 (default to tempfile.mkstemp + advisory flock)", - "line": 991 + "line": 1002 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.root-cause", "klass": "DEFECT", "value": "gsd-test-summary lines 126-127 default LOCAL_OUT/DOCKER_OUT to fixed /tmp/gsd-test-{local,docker}.jsonl; concurrent line-buffered writers interleave bytes mid-multibyte → split UTF-8 sequence → decoder explodes on f.read()", - "line": 989 + "line": 1000 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.symptom", "klass": "DEFECT", "value": "two simultaneous gsd-test-summary --both invocations (e.g. one per worktree) both crash with UnicodeDecodeError in parse_events_from_file; \"local exit=1 docker exit=1\" reported even though remote containers ran fine", - "line": 988 + "line": 999 }, { "id": "DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION.upstream", "klass": "DEFECT", "value": "open-gsd/gsd-test-runner#4 (moved from #3545 in the predecessor repo, filed in the wrong repo; now CLOSED/COMPLETED — fix shipped)", - "line": 992 + "line": 1003 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect", "klass": "DEFECT", "value": "gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out", - "line": 958 + "line": 969 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples", "klass": "DEFECT", "value": "2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes", - "line": 957 + "line": 968 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward", "klass": "DEFECT", "value": "TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f \"ssh \"; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds", - "line": 959 + "line": 970 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related", "klass": "DEFECT", "value": "DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month", - "line": 960 + "line": 971 }, { "id": "DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom", "klass": "DEFECT", "value": "pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes \"$h\" true); subsequent ssh \"$h\" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all", - "line": 956 + "line": 967 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.detect", "klass": "DEFECT", "value": "docker stderr shows rsync: [generator] delete_file: unlink(...) failed: Permission denied (13) OR [receiver] mkstemp \".gsd-*.\" failed", - "line": 982 + "line": 993 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.recovery", "klass": "DEFECT", "value": "ssh 'docker run --rm -v ~/gsd-mirror-gsd-core:/work gsd-test:node22 chown -R : /work'; remote-uid is the SSH user's uid on the remote (1000 on holodeck, NOT local Mac 501)", - "line": 984 + "line": 995 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.root-cause", "klass": "DEFECT", "value": "container ran without --user; build:hooks wrote into bind-mount as root; chown-back-before-exec patch closes forward path but not legacy hosts", - "line": 983 + "line": 994 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.symptom", "klass": "DEFECT", "value": "gsd-test-summary --both exits docker=23 (rsync partial transfer) with mkstemp Permission denied on remote mirror files; mirror has root-owned artifacts from prior cold runs", - "line": 981 + "line": 992 }, { "id": "DEFECT.GSD-TEST-MIRROR-POISONED.upstream", "klass": "DEFECT", "value": "trek-e/gsd-test-runner#1 — proposes self-healing init-time chown probe", - "line": 985 + "line": 996 }, { "id": "DEFECT.HALT-COST-PATTERN.detect", "klass": "DEFECT", "value": "any subagent-spawning workflow with mid-flight pause-and-resume that does not preserve subagent context", - "line": 823 + "line": 832 }, { "id": "DEFECT.HALT-COST-PATTERN.examples", "klass": "DEFECT", "value": "#3309 checkpoint:human-verify (mid-flight halt = full executor cold-start per round-trip; reporter measured \"tens of thousands of tokens\" per halt)", - "line": 822 + "line": 831 }, { "id": "DEFECT.HALT-COST-PATTERN.fix-forward", "klass": "DEFECT", "value": "offer config flag for end-of-phase aggregation; if cost dominates make end-of-phase the default; route deferred items through existing verifier surface, do not invent new writer", - "line": 824 + "line": 833 }, { "id": "DEFECT.HALT-COST-PATTERN.symptom", "klass": "DEFECT", "value": "architecturally-sound checkpoint pattern produces hidden token cost because subagent context is discarded across the pause and respawn", - "line": 821 + "line": 830 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.detect", "klass": "DEFECT", "value": "hook re-fires on each invocation regardless of session-state read receipts", - "line": 828 + "line": 837 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.examples", "klass": "DEFECT", "value": "this session repeatedly hit \"Refusing to run gh issue create|edit / gh pr create|edit\" despite reading every listed file", - "line": 827 + "line": 836 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.fix-forward", "klass": "DEFECT", "value": "use gh api -X PATCH repos/{owner}/{repo}/pulls/{N} or repos/{owner}/{repo}/issues/{N} directly — same effect, hook regex does not match", - "line": 829 + "line": 838 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking", "klass": "DEFECT", "value": "gh-templates-first PreToolUse hook tracks Read tool invocations specifically; Bash cat/head of the same file does NOT satisfy the hook; future-self must use Read tool from the first contact with template files", - "line": 987 + "line": 998 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.symptom", "klass": "DEFECT", "value": "PreToolUse hook keeps blocking gh pr edit / gh issue edit even after all required files are read in the session", - "line": 826 + "line": 835 }, { "id": "DEFECT.HOOK-OVER-ENFORCEMENT.write-bypass", "klass": "DEFECT", "value": "security_reminder_hook can block Write on substring match (e.g. a literal child-process call-expression token); workaround is heredoc to /tmp then mv into place, or use Edit instead — Edit hooks are more lenient than Write hooks", - "line": 1006 + "line": 1017 + }, + { + "id": "DEFECT.HOST-RESERVED-DIR-NAME", + "klass": "DEFECT", + "value": "a host runtime reserves a directory NAME that GSD also writes verbatim, so the mere presence of GSD's directory trips the host's own reserved-name detection regardless of contents; example: pi (#3023) treats GSD's shared-hooks bundle dir hooks/ as its own deprecated extension location and printed a startup warning purely because checkDeprecatedExtensionDirs() in packages/coding-agent/src/migrations.ts gates on a bare existsSync(hooksDir) with no readdir/emptiness check (unlike its tools/ sibling); fix-forward=make the shared-hooks directory name descriptor-driven (hostBehaviors.sharedHooksDirName, default hooks) and override it per-runtime when a name collision is detected (pi sets gsd-hooks), with adapters probing the new name then falling back to the legacy name for dev/half-upgraded trees", + "line": 900 }, { "id": "DEFECT.INVENTORY-DRIFT.detect", "klass": "DEFECT", "value": "tests/inventory-manifest-sync.test.cjs fails with \"New surfaces not in manifest\"; tests/inventory-headings-countfree.test.cjs fails if a (N shipped) count is re-added to a heading", - "line": 788 + "line": 797 }, { "id": "DEFECT.INVENTORY-DRIFT.examples", "klass": "DEFECT", "value": "#3309 planner-human-verify-mode.md (caught by tests/inventory-manifest-sync.test.cjs)", - "line": 787 + "line": 796 }, { "id": "DEFECT.INVENTORY-DRIFT.fix-forward", "klass": "DEFECT", "value": "update INVENTORY.md row entry; run node scripts/gen-inventory-manifest.cjs --write to regen INVENTORY-MANIFEST.json (all eight families.* arrays are canonical — see RULESET.MANIFEST-CANONICAL-KEY); a workflow SUB-file (gsd-core/workflows//steps/*.md or modes/*.md) lands in workflow_steps/workflow_modes, not in workflows, which is keyed by bare basename and cannot hold a nested path", - "line": 789 + "line": 798 }, { "id": "DEFECT.INVENTORY-DRIFT.symptom", "klass": "DEFECT", "value": "new file added under gsd-core/references/ or gsd-core/workflows/ without updating docs/INVENTORY.md row AND docs/INVENTORY-MANIFEST.json", - "line": 786 + "line": 795 }, { "id": "DEFECT.NAME-COLLISION.detect", "klass": "DEFECT", "value": "trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the rebound handler's args[0] expectation, the migration broke the legacy contract", - "line": 929 + "line": 940 }, { "id": "DEFECT.NAME-COLLISION.examples", "klass": "DEFECT", "value": "#3577 config-ensure-section (legacy = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; the rebound configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; handler throws \"Usage: config-ensure-section
\")", - "line": 928 + "line": 939 }, { "id": "DEFECT.NAME-COLLISION.fix-forward", "klass": "DEFECT", "value": "either (a) bind the dispatch to a handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the original handler directly (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract", - "line": 930 + "line": 941 }, { "id": "DEFECT.NAME-COLLISION.symptom", "klass": "DEFECT", "value": "a router migration rebinds CLI dispatch for a canonical command name to a handler with a different positional-arg shape; every legacy no-arg / wrong-arg caller then errors out at the new handler's own validation throw", - "line": 927 + "line": 938 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.detect", "klass": "DEFECT", "value": "any parser with hard-coded marker list; any parser that returns empty for non-matching input without warning", - "line": 818 + "line": 827 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.examples", "klass": "DEFECT", "value": "ac518646/#3263 code-review SUMMARY parser rejected BL-/blocker variants", - "line": 817 + "line": 826 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.fix-forward", "klass": "DEFECT", "value": "accept variants explicitly (case-insensitive, hyphen/space alternatives); on unknown marker emit a structured WARN with the original line so the human can fix the source", - "line": 819 + "line": 828 }, { "id": "DEFECT.PARSER-BRITTLE-MARKER-WHITELIST.symptom", "klass": "DEFECT", "value": "human-output parser whitelists known markers (severity, status); silently drops unfamiliar markers as malformed", - "line": 816 + "line": 825 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.anchor", "klass": "DEFECT", "value": "tests/phase.test.cjs (expected_phase_dir assertions; consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs into the Phase Lifecycle Module test suite in #3741)", - "line": 764 + "line": 773 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.detect", "klass": "DEFECT", "value": "grep mkdir/touch/path.join with {NN}-{slug} or padded_phase + phase_slug; if not consuming expected_phase_dir from init.* JSON it is drifting", - "line": 762 + "line": 771 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.examples", "klass": "DEFECT", "value": "#3287 (init.phase-op + init.plan-phase first-touch), #3306/PRED.k015 (plan-milestone-gaps + import + add-backlog), #3297/#3298 (sibling reports)", - "line": 761 + "line": 770 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.fix-forward", "klass": "DEFECT", "value": "consume expected_phase_dir from init.phase-op / init.plan-phase output; never re-construct from padded_phase + slug in workflow steps", - "line": 763 + "line": 772 }, { "id": "DEFECT.PHASE-DIR-PREFIX-DRIFT.symptom", "klass": "DEFECT", "value": "multiple workflow files independently construct .planning/phases/{NN}-{slug} paths; project_code prefix or slug normalization missing in some surfaces", - "line": 760 + "line": 769 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.detect", "klass": "DEFECT", "value": "CI security lane (Prompt injection scan step) reports FAIL: tests/.test.cjs with a line number pointing at a string literal; the literal is inside an assert.throws() or array of malicious inputs; the test file name is not in scripts/prompt-injection-scan.sh ALLOWLIST", - "line": 881 + "line": 890 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.examples", "klass": "DEFECT", "value": "PR #1622 commit 4ed208e74 added convertClaudeCommandToWindsurfWorkflow commandName validation with 22 malicious-name fixtures; scanner matched an instruction-override phrase at tests/windsurf-conversion.test.cjs:122; CI security lane failed even though the test is the security control", - "line": 880 + "line": 889 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.fix-forward", "klass": "DEFECT", "value": "ADD the test file to scripts/prompt-injection-scan.sh ALLOWLIST array with a comment citing this defect class; for large fixture sets, move them to tests/fixtures/adversarial/security/ (auto-allowlisted dir) and load via readFileSync; never weaken or fragment the payload to evade the scanner — that defeats the test's purpose; ALSO when documenting this defect in CONTEXT.md, do NOT quote the literal pattern — describe it generically (the scanner scans CONTEXT.md too)", - "line": 882 + "line": 891 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.prevention", "klass": "DEFECT", "value": "when writing a security regression test that uses real injection payloads as fixtures, immediately add the test file path to scripts/prompt-injection-scan.sh ALLOWLIST in the same commit; when documenting this defect class anywhere under scanner scope (CONTEXT.md, docs/, agent .md), use descriptive references like 'scanner-matching payload' rather than quoting the literal pattern; ref DEFECT.PROMPT-INJECTION-SCAN-COLLISION (the older XML-tag-collision variant)", - "line": 883 + "line": 892 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION-WITH-TESTS.symptom", "klass": "DEFECT", "value": "scripts/prompt-injection-scan.sh flags a NEW test file as a finding because the test contains real injection payloads as fixtures (strings that match one of the scanner's PATTERNS — see scripts/prompt-injection-scan.sh lines 18-64) to prove the validator under test rejects them; scanner cannot distinguish fixture from real injection; CI security lane fails on the test that ADDS the security validation", - "line": 879 + "line": 888 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.detect", "klass": "DEFECT", "value": "any new bare tag in agents/*.md", - "line": 783 + "line": 792 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.examples", "klass": "DEFECT", "value": "#3309 added a bare 'human' element (angle-bracket-wrapped) for verify-block harvesting; tests/prompt-injection-scan.security.test.cjs flags angle-bracket-wrapped names matching system|assistant|human (open or close form)", - "line": 782 + "line": 791 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.fix-forward", "klass": "DEFECT", "value": "hyphenate the tag (, ) — scanner regex matches bare names only", - "line": 784 + "line": 793 }, { "id": "DEFECT.PROMPT-INJECTION-SCAN-COLLISION.symptom", "klass": "DEFECT", "value": "custom XML element name in agent .md file matches scripts/scan-prompt-injection regex; legitimate agent vocabulary trips the security gate", - "line": 781 + "line": 790 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.detect", "klass": "DEFECT", "value": "before deletion, grep filename across .github/workflows, gsd-core/, docs/, package.json scripts; if any reference exists removal is incomplete", - "line": 752 + "line": 761 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.examples", "klass": "DEFECT", "value": "#3316 root package-lock.json (root package.json declares deps; workflows use cache:'npm' + npm ci), e3b52c70 docs referenced removed /gsd-new-workspace", - "line": 751 + "line": 760 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.fix-forward", "klass": "DEFECT", "value": "restore the file or update every consumer in the same commit; do not paper over with --no-package-lock or workflow workarounds that lose reproducibility", - "line": 753 + "line": 762 }, { "id": "DEFECT.REMOVED-BUT-NEEDED.symptom", "klass": "DEFECT", "value": "file/key removed because \"no longer used\" without verifying every consumer (workflows, docs, manifests, npm scripts)", - "line": 750 + "line": 759 }, { "id": "DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT", "klass": "DEFECT", "value": "provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)", - "line": 355 + "line": 358 }, { "id": "DEFECT.SCOPE.window", "klass": "DEFECT", "value": "PRs #3306..#3325 + sibling fixes #3240/#3242/#3245/#3257/#3261/#3267/#3286/#3287", - "line": 747 + "line": 756 }, { "id": "DEFECT.SDK-PORT-NAME-COLLISION.generative-tie", "klass": "DEFECT", "value": "instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open", - "line": 931 + "line": 942 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.detect", "klass": "DEFECT", "value": "grep tests for fs.unlinkSync|rmSync|writeFileSync|renameSync|cpSync targeting paths resolved from the repo root (join(__dirname,'..',...)) under gsd-core/bin/lib or a shared committed fixture, instead of a mkdtempSync temp dir; any build helper (e.g. ensureBuiltArtifacts) invoked with real-tree paths during the concurrent test phase; any tsBuildInfoFile / build-cache path that lands inside a copied/shipped dir (gsd-core/bin/)", - "line": 942 + "line": 953 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.examples", "klass": "DEFECT", "value": "#996/88e30d53 — bug-969 hardening tests fs.unlinkSync'd + restored the real gsd-core/bin/lib/core.cjs and set tsBuildInfoFile inside gsd-core/bin/ → next red across the full-test matrix (macOS/Windows) + ubuntu-24 coverage leg, ~40-50 MODULE_NOT_FOUND/ENOENT per leg; reproduced locally on iteration 1; fixed #1001/#1002", - "line": 941 + "line": 952 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.fix-forward", "klass": "DEFECT", "value": "tests mutate ONLY isolated mkdtempSync copies — never delete/rewrite shared real build outputs while node --test runs files concurrently; parameterize build helpers to accept {root,srcDir,outDir,tsBuildInfoPath,tsconfigPath} overrides and point the test at a throwaway temp project (precedent: #1002 ensureBuiltArtifacts(overrides)); keep mutable build state (tsbuildinfo) OUTSIDE copied/shipped trees (repo root, gitignored) + best-effort self-heal of stale bin-local copies; this is the concrete instance of the RULESET.TESTS.delete-bad-tests real-race class", - "line": 943 + "line": 954 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.symptom", "klass": "DEFECT", "value": "a test deletes/rewrites a SHARED REAL build artifact or fixture (e.g. gsd-core/bin/lib/*.cjs, the build tsbuildinfo) that other test files require; node --test runs files concurrently, so innocent concurrent tests intermittently fail with \"Cannot find module\" / ENOENT while the racy test itself passes (victim-not-culprit, leg-asymmetric red); placing mutable build state inside a copied/shipped tree (gsd-core/bin/) additionally races install-test fs.cpSync copies → copyfile ENOENT", - "line": 940 + "line": 951 }, { "id": "DEFECT.SHARED-ARTIFACT-MUTATION-IN-CONCURRENT-TEST.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs (hermetic temp-project rewrite); regression gate = 10x concurrent run of that suite + tests/state.test.cjs + tests/install.test.cjs must be clean (reproduces on iter 1 when racy)", - "line": 944 + "line": 955 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.detect", "klass": "DEFECT", "value": "npm run lint (AST ESLint rule local/no-source-grep, eslint-rules/no-source-grep.cjs) fails with a line-number-precise violation", - "line": 837 + "line": 846 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.fix-forward", "klass": "DEFECT", "value": "replace with runGsdTools(...) behavioral test capturing JSON; if asserting agent .md content (which IS the runtime contract) add // allow-test-rule: source-text-is-the-product with one-line justification", - "line": 838 + "line": 847 }, { "id": "DEFECT.SOURCE-GREP-IN-NEW-TESTS.symptom", "klass": "DEFECT", "value": "new test file uses readFileSync + .includes() / .match() against source code (RULESET.TESTS.no-source-grep); contradicts the test rule lint script", - "line": 836 + "line": 845 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.detect", "klass": "DEFECT", "value": "ls-remote shows base ref absent; PR base still points at the deleted ref; mergeable=CONFLICTING with no real diff conflicts", - "line": 768 + "line": 777 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.examples", "klass": "DEFECT", "value": "#3311 base fix/3255-add-json-errors-mode-gsd-tools deleted after #3304 merged", - "line": 767 + "line": 776 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.fix-forward", "klass": "DEFECT", "value": "PATCH /repos/{owner}/{repo}/pulls/{N} -f base=main; rebase head onto current main; resolve carry-over commits (parent commits will auto-drop as patch contents already upstream)", - "line": 769 + "line": 778 }, { "id": "DEFECT.STACKED-PR-AUTO-RETARGET.symptom", "klass": "DEFECT", "value": "PR #N is stacked on branch B; branch B merges to main and is deleted; GitHub does not reliably auto-retarget #N to main; PR shows DIRTY/CONFLICTING with phantom conflicts", - "line": 766 + "line": 775 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern", "klass": "DEFECT", "value": "blindly running git rebase --onto origin/main on the patch branch — produces \"conflicts\" that are really \"the scaffolding doesn't exist yet\"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing", - "line": 950 + "line": 961 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect", "klass": "DEFECT", "value": "gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with \"does not exist in origin/main\"", - "line": 948 + "line": 959 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples", "klass": "DEFECT", "value": "#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all", - "line": 947 + "line": 958 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward", "klass": "DEFECT", "value": "user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with \"subsumed by #\". Alternatives explicitly rejected: leaving stacked open (\"no, fold them in\") and closing-without-folding (\"we want the fix\")", - "line": 949 + "line": 960 }, { "id": "DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom", "klass": "DEFECT", "value": "patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's \"base\" on GitHub is the feature branch, not main; merging requires the upstream PR to land first", - "line": 946 + "line": 957 }, { "id": "DEFECT.STATE-TRAMPLE.detect", "klass": "DEFECT", "value": "any state writer that calls buildStateFrontmatter without preserving existing progress.* keys; any mutation surface that does not honor shouldPreserveExistingProgress", - "line": 757 + "line": 766 }, { "id": "DEFECT.STATE-TRAMPLE.examples", "klass": "DEFECT", "value": "#3242 (Last Activity overwrote progress.completed_plans), #3257 (nested plans/ files uncounted), #3261 (buildStateFrontmatter), #3265 (canonical fields), #3286 (record-metric/add-decision sections)", - "line": 756 + "line": 765 }, { "id": "DEFECT.STATE-TRAMPLE.fix-forward", "klass": "DEFECT", "value": "route through state-document.cjs/.ts shouldPreserveExistingProgress + normalizeProgressNumbers (extracted in #3316; the sdk/ tree that PR originally targeted has since been fully retired per ADR-0174 — these functions now live solely in src/state-document.cts)", - "line": 758 + "line": 767 }, { "id": "DEFECT.STATE-TRAMPLE.symptom", "klass": "DEFECT", "value": "state-mutation paths overwrite curated values when body-derived computation is narrower than what's stored in frontmatter", - "line": 755 + "line": 764 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.anchor", "klass": "DEFECT", "value": "lesson: cross-turn task notifications are delivered only to the top-level orchestrator, never to a sub-agent — load-bearing for multi-worktree parallel fix dispatch (the CLAUDE.md passage this entry previously quoted verbatim has since been removed/rewritten; no live replacement citation exists)", - "line": 996 + "line": 1007 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.detect", "klass": "DEFECT", "value": "sub-agent returns prematurely with text like \"I should wait for the notification per CLAUDE.md\" and incomplete work in its worktree (commits absent, push absent, PR absent)", - "line": 994 + "line": 1005 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.fix-forward", "klass": "DEFECT", "value": "keep gsd-test-summary --both at the top-level orchestrator; sub-agents either run it foreground with timeout: 1500000 (25min) and block, OR delegate the test step back to the orchestrator (write commits + return); never have a sub-agent fire-and-await a backgrounded long task", - "line": 995 + "line": 1006 }, { "id": "DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL.symptom", "klass": "DEFECT", "value": "spawned sub-agent kicks off gsd-test-summary --both via Bash run_in_background, then stops on the harness \"you will be notified\" message; never receives the notification because cross-turn task-notifications are only delivered to the top-level orchestrator", - "line": 993 + "line": 1004 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.detect", "klass": "DEFECT", "value": "after a fix lands on main, grep recently-merged PR title for shared keyword/issue; check open PRs touching same files; if open PRs are subsets of merged work they are superseded", - "line": 778 + "line": 787 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.examples", "klass": "DEFECT", "value": "#3303 + #3307 superseded by #3306 (all addressing #3297/#3298 project_code prefix family)", - "line": 777 + "line": 786 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.fix-forward", "klass": "DEFECT", "value": "close superseded PRs via gh api PATCH state=closed; do not comment on self-authored PRs (k101); the link to the merged PR makes supersession discoverable in PR history", - "line": 779 + "line": 788 }, { "id": "DEFECT.SUPERSEDED-CONCURRENT-PRS.symptom", "klass": "DEFECT", "value": "multiple in-flight PRs attack overlapping subsets of the same issue; the broadest one merges first; narrower siblings remain open with phantom conflicts", - "line": 776 + "line": 785 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect", "klass": "DEFECT", "value": "test does readFileSync(md).match for a bash fence with literal \\n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703", - "line": 850 + "line": 859 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples", "klass": "DEFECT", "value": "#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \\n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite", - "line": 849 + "line": 858 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward", "klass": "DEFECT", "value": "match the fence with \\r?\\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file", - "line": 851 + "line": 860 }, { "id": "DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom", "klass": "DEFECT", "value": "a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \\n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\\...) is un-globbable in bash so the pipeline returns empty and assertions fail", - "line": 848 + "line": 857 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.detect", "klass": "DEFECT", "value": "execSync/execFileSync/spawnSync without timeout option in non-test code; especially git list-worktrees, git fetch, npm view", - "line": 813 + "line": 822 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.examples", "klass": "DEFECT", "value": "a33cbe72 worktree fix bound git subprocesses with timeout", - "line": 812 + "line": 821 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.fix-forward", "klass": "DEFECT", "value": "add timeout (5-30s for git, 60s for npm); on timeout return degraded result + structured warning rather than throw", - "line": 814 + "line": 823 }, { "id": "DEFECT.UNBOUNDED-SUBPROCESS.symptom", "klass": "DEFECT", "value": "git/npm subprocess shelled out without timeout; CLI hangs indefinitely on stuck remote, large repo, or missing network", - "line": 811 + "line": 820 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.detect", "klass": "DEFECT", "value": "Windows CI job at \"Run unit tests\" exits with code 1 within seconds of starting, no node:test output between \"run-tests: suite=… files=N: …\" line and \"Process completed with exit code 1\"; same job on Linux/macOS runs full duration", - "line": 935 + "line": 946 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.examples", "klass": "DEFECT", "value": "#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed", - "line": 934 + "line": 945 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward", "klass": "DEFECT", "value": "chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths", - "line": 936 + "line": 947 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.prevention", "klass": "DEFECT", "value": "a RUNTIME argv-length property (args-array size not statically knowable) — NOT AST-lint-enforceable; addressed at the source by the production run-tests.cjs chunking under RUN_TESTS_MAX_CMDLINE_CHARS plus its test-anchor (tests/run-tests-harness.test.cjs). ADR-1703 Phase 3 (#1720) evaluated and dropped a no-oversized-test-argv lint rule as unsound (it could not detect the canonical execFileSync(node,[...paths]) array overflow)", - "line": 938 + "line": 949 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.symptom", "klass": "DEFECT", "value": "execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)", - "line": 933 + "line": 944 }, { "id": "DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor", "klass": "DEFECT", "value": "tests/run-tests-harness.test.cjs \"Windows argv-overflow chunking (issue #3597)\" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform", - "line": 937 + "line": 948 }, { "id": "DEFECT.WINDOWS-FS-OPS.detect", "klass": "DEFECT", "value": "ADR-1703 Phase 6: enforced by local/require-fs-op-fallback (AST ESLint rule, error) over src/**/*.cts + bin/install.js + scripts/build-hooks.js — flags an unguarded fs.rename/fs.renameSync (the atomic-publish primitive named in .symptom) that lacks a transient-errno retry or a Windows platform guard; a catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the .fix-forward clause. copyFile/unlink are the fallback primitives (out of scope); delegated retry helpers (retryRenameSync from shell-command-projection) are the recognized compliant shape", - "line": 808 + "line": 817 }, { "id": "DEFECT.WINDOWS-FS-OPS.examples", "klass": "DEFECT", "value": "c47c2c5d build-hooks rename → copy fallback, d2412271 install Windows persistent SDK shim", - "line": 807 + "line": 816 }, { "id": "DEFECT.WINDOWS-FS-OPS.fix-forward", "klass": "DEFECT", "value": "catch EPERM/EBUSY/EACCES, fall back to copy + unlink with retry, surface degraded-mode message; never silently swallow; the canonical production cure is retryRenameSync (shell-command-projection.cjs) or a bounded RENAME_RETRY_ERRNOS = new Set(['EPERM','EBUSY','EACCES']) loop", - "line": 809 + "line": 818 }, { "id": "DEFECT.WINDOWS-FS-OPS.symptom", "klass": "DEFECT", "value": "fs.renameSync / fs.copyFileSync hits EPERM/EBUSY on Windows when antivirus or another process holds a transient handle on the target", - "line": 806 + "line": 815 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect", "klass": "DEFECT", "value": "any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization; NOW enforced at write-time + CI by local/normalize-path-in-content (eslint, error, src/**/*.cts; ADR-1703 Phase 5 #1733) — flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated DIRECTLY into @-reference content (shape a: @~/, @$, @/) or into a template immediately followed by a /…\\.md or /…\\.json quasi (shape b); INDIRECT data-flow (path stored in a variable/object field then interpolated, e.g. ${entry.ref}) is NOT detected by the rule — normalize at the assignment source or at the emit site; one known indirect leak (src/init.cts cmdAgentSkills entry.ref) fixed in PR #1733 by normalizing at emit; zero opt-out (the out-of-band disable-ban scans src/**/*.cts too)", - "line": 867 + "line": 876 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples", "klass": "DEFECT", "value": "PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\\...\\gsd-ial-windsurf-XXX\\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only", - "line": 866 + "line": 875 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward", "klass": "DEFECT", "value": "normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always", - "line": 868 + "line": 877 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention", "klass": "DEFECT", "value": "enforced by local/normalize-path-in-content (eslint, error; ADR-1703 Phase 5 #1733) per RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\\\/g,'/'))", - "line": 869 + "line": 878 }, { "id": "DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom", "klass": "DEFECT", "value": "path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected", - "line": 865 + "line": 874 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.detect", "klass": "DEFECT", "value": "any assert*/expect call whose ACTUAL operand is a call to a path-returning fn (path.join, path.resolve, resolveAgentDir, getPathX, computePathPrefix, os.homedir(), path.dirname/basename) AND whose EXPECTED operand is a string literal containing '/' that does NOT first flow through .replace(/\\\\/g,'/'); the literal-vs-fnCall shape is the tripwire — assert.equal(pathFn(...), '/hardcoded/posix/path') is the violation; assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/hardcoded/posix/path') is the compliant form; NOW mechanically enforced by the AST ESLint rule local/no-path-literal-in-assert (eslint-rules/no-path-literal-in-assert.cjs, ADR-1703 Phase 1 #1707) — platform-guard-aware (won't flag an assertion control-dependent on a process.platform !== 'win32' guard; eslint-rules/lib/platform-guard.cjs), fn list single-sourced as eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS (drift-guarded vs src/runtime-homes.cts)", - "line": 875 + "line": 884 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.examples", "klass": "DEFECT", "value": "PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir suite: assert.equal(resolveAgentDir('opencode',{homedir:()=>'/H'}), '/H/.config/opencode/agent') — green on macOS+ubuntu (docker gate PASS 21101/21101), red on test (windows-latest,24) + full test (windows-latest,22, shard 2/3); same root cause as DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT but on the TEST side against a function return, not the production-markdown side", - "line": 874 + "line": 883 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.fix-forward", "klass": "DEFECT", "value": "normalize the ACTUAL value to POSIX before comparing: assert.equal(String(pathFn(...)).replace(/\\\\/g,'/'), '/posix/literal'). Do NOT instead path.join the expected value to match the platform separator — that passes on every platform but masks a malformed backslash-on-POSIX return (both sides wrong together). The .replace is idempotent on POSIX so it is safe unconditionally. For values that are conceptually never paths (null/undefined/numbers), no normalization needed.", - "line": 876 + "line": 885 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.prevention", "klass": "DEFECT", "value": "enforced at write-time (editor) and in CI by the AST ESLint rule local/no-path-literal-in-assert (error, scoped to tests/**/*.test.cjs in eslint.config.mjs; ADR-1703 Phase 1 #1707); inline suppression is banned out-of-band by tests/portability-rule-disable-ban.test.cjs (zero escape hatches — structure platform-specific code behind a recognized process.platform guard, never opt out); run npm run lint before push; treat the CI windows-latest lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute; ref umbrella DEFECT.WINDOWS-TEST-PORTABILITY and production-side analogue DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT", - "line": 877 + "line": 886 }, { "id": "DEFECT.WINDOWS-PATH-LITERAL-IN-ASSERT.symptom", "klass": "DEFECT", "value": "an assertion compares the return value of a path-returning function (resolveAgentDir, path.join, path.resolve, getPathX, computePathPrefix, etc.) to a HARDCODED forward-slash string literal like '/H/.config/opencode/agent' or 'C:/Users/...' — passes on POSIX (macOS/linux/ubuntu CI incl. gsd-test docker mirror, where path.join emits forward slashes so literal == actual), FAILS on windows-latest CI lane where path.join emits backslashes so literal != actual", - "line": 873 + "line": 882 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.detect", "klass": "DEFECT", "value": "grep tests for \\`.mode & 0o777\\` / \\`.mode) === 0o\\` / \\`writeFileSync(...{ mode: 0o\\` / \\`chmodSync\\` paired with a strict-equality assertion on the resulting mode; any such assertion is a POSIX-only fact that will diverge on Windows (write reads back as 0o666); NOW mechanically enforced by the AST ESLint rule local/no-posix-mode-bit-assert (eslint-rules/no-posix-mode-bit-assert.cjs, ADR-1703 Phase 2 #1711) — flags a .mode-vs-octal-literal equality assertion unless control-dependent on a process.platform !== 'win32' guard (eslint-rules/lib/platform-guard.cjs); zero opt-outs (tests/portability-rule-disable-ban.test.cjs)", - "line": 861 + "line": 870 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.examples", "klass": "DEFECT", "value": "#1634/PR #1638 tests/capability-lifecycle.test.cjs \"a .cjs hook command is node-prefixed so it runs without the executable bit\" failed windows-latest,24 on \"precondition: file staged without +x\" (expected 420/0o644, got 438/0o666); the node-prefix behavioral assertion was correct — only the mode-bit precondition was the POSIX-only fact", - "line": 860 + "line": 869 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.fix-forward", "klass": "DEFECT", "value": "gate the mode-bit precondition on if (process.platform !== 'win32') — the executable-bit/mode is a POSIX concept meaningless on Windows; KEEP the platform-independent behavioral assertion (the actual behavior under test) running on every OS; do NOT delete the precondition, scope it to POSIX", - "line": 862 + "line": 871 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.prevention", "klass": "DEFECT", "value": "ref DEFECT.WINDOWS-TEST-PORTABILITY — gsd-test is Mac/Linux only (no Windows host), only the CI windows-latest lane catches this; enforced at write-time + CI by the AST ESLint rule local/no-posix-mode-bit-assert (eslint, error; ADR-1703 Phase 2 #1711); run npm run lint before push; prefer asserting the BEHAVIOR (command shape, runnability) over the filesystem mode bit", - "line": 863 + "line": 872 }, { "id": "DEFECT.WINDOWS-POSIX-MODE-BIT-ASSERT.symptom", "klass": "DEFECT", "value": "a test writes a file with a POSIX mode (fs.writeFileSync(p, data, {mode: 0o644}) or fs.chmodSync) then asserts fs.statSync(p).mode & 0o777 === ; passes on macOS/Linux/ubuntu CI, FAILS on the windows-latest CI lane — Windows fs does NOT honor POSIX write modes, Node reports the mode derived from the DOS readonly attribute (0o666 for writable / 0o444 for readonly), never the requested 0o644/0o755", - "line": 859 + "line": 868 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.detect", "klass": "DEFECT", "value": "npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done", - "line": 855 + "line": 864 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.examples", "klass": "DEFECT", "value": "PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes", - "line": 854 + "line": 863 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward", "klass": "DEFECT", "value": "gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)", - "line": 856 + "line": 865 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.prevention", "klass": "DEFECT", "value": "run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it", - "line": 857 + "line": 866 }, { "id": "DEFECT.WINDOWS-TEST-PORTABILITY.symptom", "klass": "DEFECT", "value": "local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \\ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally", - "line": 853 + "line": 862 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.detect", "klass": "DEFECT", "value": "after install, for every workflow .md file under //workflows/, extract the @ reference from the body and assert fs.existsSync(path); if any reference target is absent, this defect is present", - "line": 887 + "line": 896 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.examples", "klass": "DEFECT", "value": "PR #1622 (issue #1615) shipped Windsurf /gsd-* workflow wrappers that all reference /.windsurf/gsd-core/commands/gsd/X.md; that directory was never populated; none of the reviews (security, Codex adversarial, Memtrace) caught it; a #1629 regression test verifying 'every workflow @- reference target exists on disk' surfaced it post-merge", - "line": 886 + "line": 895 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.fix-forward", "klass": "DEFECT", "value": "copy the canonical command source (commands/gsd/*.md) into /gsd-core/commands/gsd/ during install, gated on the runtime that uses workflow delegation (currently Windsurf local only); use copyWithPathReplacement to apply the same path+brand rewrites as the rest of the install; verify with a regression test that every workflow's @-reference resolves", - "line": 888 + "line": 897 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.prevention", "klass": "DEFECT", "value": "any new converter that emits a wrapper file delegating to another file MUST verify the delegation target is actually written by the same install; add a post-install invariant test: for every @ reference in every generated wrapper, assert the target exists; the workflow converter's hardcoded path was copy-pasted from Claude's skill pattern without verifying the target exists for the new runtime", - "line": 889 + "line": 898 }, { "id": "DEFECT.WORKFLOW-DELEGATION-TARGET-NOT-INSTALLED.symptom", "klass": "DEFECT", "value": "workflow wrapper file (e.g. Windsurf convertClaudeCommandToWindsurfWorkflow) delegates to a command body at /gsd-core/commands/gsd/X.md via a hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites to the install target; the source gsd-core/ dir ships without commands/ (it lives at package-root commands/gsd/); install completes successfully, workflow files appear in the / menu, but invocation tells the LLM to read a file that does not exist; the slash commands silently fail", - "line": 885 + "line": 894 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.detect", "klass": "DEFECT", "value": "git rev-parse HEAD~1 vs git rev-parse origin/ — if they differ despite fetch the local copy was rewritten by some checkout-time hook", - "line": 803 + "line": 812 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.examples", "klass": "DEFECT", "value": "this session, branch fix/3309-... and pr-3316", - "line": 802 + "line": 811 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.fix-forward", "klass": "DEFECT", "value": "git checkout --detach origin/ directly; do work from detached HEAD; push HEAD:", - "line": 804 + "line": 813 }, { "id": "DEFECT.WORKTREE-FETCH-SHA-DIVERGENCE.symptom", "klass": "DEFECT", "value": "in a worktree, git fetch origin pull/N/head:pr-N produces commits with SHAs different from the actual remote PR head SHA; force-push rejected as non-fast-forward despite recent fetch", - "line": 801 + "line": 810 }, { "id": "EXEC.CLASSIFY.classes", "klass": "EXEC", "value": "{class:'quota-exceeded'|'classify-handoff-bug'|'unknown-failure', sentinel?, retryAfterSeconds?}", - "line": 974 + "line": 985 }, { "id": "EXEC.CLASSIFY.cross-runtime", "klass": "EXEC", "value": "Anthropic/CC: usage limit|rate limit|quota|429|retry-after; Copilot CLI: rate_limit (stem); Codex CLI: 429|usage_limit_reached|too many requests", - "line": 976 + "line": 987 }, { "id": "EXEC.CLASSIFY.handler", "klass": "EXEC", "value": "gsd-core/bin/lib/agent-command-router.cjs:classifyAgentFailure (registered via command-aliases.cjs; mutation:false outputMode:json)", - "line": 972 + "line": 983 }, { "id": "EXEC.CLASSIFY.precedence", "klass": "EXEC", "value": "quota sentinel wins over classifyHandoffIfNeeded bug when both appear", - "line": 977 + "line": 988 }, { "id": "EXEC.CLASSIFY.proactive-signal-not-usable", "klass": "EXEC", "value": "Anthropic exposes anthropic-ratelimit-* headers + Agent SDK RateLimitEvent; Claude Code subprocess does NOT forward to hooks/statusline today (upstream #33820, #22407, #32796)", - "line": 979 + "line": 990 }, { "id": "EXEC.CLASSIFY.retry-after-parser", "klass": "EXEC", "value": "\\bretry[-_ ]after[:\\s]+(\\d+)\\b avoids embedded-word false matches like noretry-after", - "line": 978 + "line": 989 }, { "id": "EXEC.CLASSIFY.sentinel-order", "klass": "EXEC", "value": "most specific first: 429 beats too-many-requests; resource_exhausted beats quota (array order in src/agent-command-router.cts QUOTA_SENTINELS checks resource_exhausted before quota); case-insensitive; canonical sentinel value is lower-cased form", - "line": 975 + "line": 986 }, { "id": "EXEC.CLASSIFY.workflow", "klass": "EXEC", "value": "gsd-core/workflows/execute-phase.md step 7; class-distinct prompts (quota-to-wait-for-reset; classify-handoff-bug-to-spot-check; unknown-to-continue/stop)", - "line": 973 + "line": 984 }, { "id": "GSD-RESEARCH.CONTEXT-DISCIPLINE", "klass": "GSD-RESEARCH", "value": "less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob", - "line": 354 + "line": 357 }, { "id": "GSD-RESEARCH.INTEGRATION.L2-hybrid", "klass": "GSD-RESEARCH", "value": "code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches", - "line": 352 + "line": 355 }, { "id": "GSD-RESEARCH.MODULE.package-legitimacy", "klass": "GSD-RESEARCH", "value": "registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate", - "line": 351 + "line": 354 }, { "id": "GSD-RESEARCH.MODULE.research-provider", "klass": "GSD-RESEARCH", "value": "single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)", - "line": 350 + "line": 353 }, { "id": "GSD-RESEARCH.MODULE.research-store", "klass": "GSD-RESEARCH", "value": "content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache", - "line": 349 + "line": 352 }, { "id": "GSD-RESEARCH.PROVIDER.availability", "klass": "GSD-RESEARCH", "value": "config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal", - "line": 353 + "line": 356 }, { "id": "LEARNING.prompt-budget.boundary-gap", "klass": "LEARNING", "value": "PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures", - "line": 498 + "line": 507 }, { "id": "LIVE-CONFIG.GUARD.SEAM.ci-blind", "klass": "LIVE-CONFIG", "value": "the AMBIENT-ENV half stays CI-blind — CI never has these vars set, so green CI is not evidence for it; what strict mode catches in CI is the suite's own default-root leaks (HOME/USERPROFILE-derived), the guard remains the only loud signal for ambient-var escapes", - "line": 591 + "line": 600 }, { "id": "LIVE-CONFIG.GUARD.SEAM.module", "klass": "LIVE-CONFIG", "value": "scripts/live-config-guard.cjs (deliberately NOT scripts/lib/, which the installer copies to users wholesale while uninstall removes only an allowlist; excluded from the npm tarball via package.json files[] together with its whole require chain run-tests.cjs/affected-tests-lib.cjs/run-affected-tests.cjs — a partial exclusion trips the #2858 shipped-requires-only-shipped gate); exports [resolveLiveConfigRoots, resolveExtraWatchTargets, snapshotLiveConfig, diffLiveConfig, formatViolations, newestMtime]; driven by scripts/run-tests.cjs pre/post suite", - "line": 586 + "line": 595 }, { "id": "LIVE-CONFIG.GUARD.SEAM.non-root-targets", "klass": "LIVE-CONFIG", "value": "resolveExtraWatchTargets covers THREE live write surfaces that are not runtime config ROOTS (skills bases are a DELIBERATE non-target — the config-root layout misfires beneath them, so they need their own layout): $GSD_HOME/.gsd watched WHOLESALE (exclusively GSD-owned, so the shared-root trap does not apply) plus ONE config.toml per NON_REGISTRY_CONFIG_HOME_DESCRIPTORS entry, each watched as a SINGLE FILE (those roots belong to their products) — today three targets, since #2755 split Kimi CLI (~/.kimi, KIMI_SHARE_DIR) from Kimi Code (~/.kimi-code, KIMI_CODE_HOME); the targets are DERIVED by iterating that array, never by calling a named resolver, so a further descriptor is picked up without editing the guard PROVIDED it owns the same NON_REGISTRY_OWNED_FILE ('config.toml') — one that owns a different filename needs a per-descriptor mapping, the named residual the guard states at its own definition. SECOND RESIDUAL: config.toml is not all GSD writes into those roots — installSharedHooksBundle also populates /hooks/, which is UNWATCHED; closing it is a layout decision, like skills bases; passed to snapshotLiveConfig explicitly so a fixture-root caller cannot pull the real ~/.gsd into its snapshot", - "line": 588 + "line": 597 }, { "id": "LIVE-CONFIG.GUARD.SEAM.scope", "klass": "LIVE-CONFIG", "value": "ownership-based, never whole-root: GSD_OWNED_ENTRIES top-level footprint + children whose name startsWith GSD_ARTIFACT_PREFIX ('gsd-') under GSD_PREFIXED_PARENTS (dirs shared with the host agent); watching a shared root wholesale false-positives on the host's own writes and a guard that cries wolf gets disabled", - "line": 587 + "line": 596 }, { "id": "LIVE-CONFIG.GUARD.SEAM.severity", "klass": "LIVE-CONFIG", "value": "reports by default locally; CI wires GSD_STRICT_LIVE_CONFIG_GUARD=1 on Linux/macOS lanes (test.yml, all three test jobs) so a suite-produced leak FAILS those runs; Windows lanes stay report-only pending the documented pre-existing USERPROFILE sweep (~190 test sites sandbox HOME alone) — promote once that lands; skipped by GSD_SKIP_LIVE_CONFIG_GUARD=1", - "line": 590 + "line": 599 }, { "id": "LIVE-CONFIG.GUARD.SEAM.truncation", "klass": "LIVE-CONFIG", "value": "MAX_ENTRIES/MAX_DEPTH bound the walk; a bound hit sets truncated and diffLiveConfig emits kind:'unverified' — a truncated scan MUST NOT read as clean; boundary covered at {limit-1,limit,limit+1} via newestMtime's injected budget plus fast-check monotonicity, per RULESET.TESTS.boundary-coverage + RULESET.TESTS.property-based-testing", - "line": 589 + "line": 598 }, { "id": "META.RULE.brief-must-cite-doc", "klass": "META", "value": "agent prompts MUST quote the canonical doc line being applied; paraphrasing from predicate memory drifts and produces violations", - "line": 642 + "line": 651 }, { "id": "META.RULE.brief-no-paraphrase", "klass": "META", "value": "writing \"k040 — never leave changelog box unchecked\" caused 5 of 8 agents to edit CHANGELOG.md in violation of CONTRIBUTING.md L110", - "line": 643 + "line": 652 }, { "id": "META.RULE.canonical-source-precedence", "klass": "META", "value": "CONTRIBUTING.md > docs/adr/* > CONTEXT.md > agent memory", - "line": 640 + "line": 649 }, { "id": "META.RULE.read-contributing-first", "klass": "META", "value": "read CONTRIBUTING.md sections \"Pull Request Guidelines\" + \"CHANGELOG Entries\" before EVERY agent dispatch", - "line": 641 + "line": 650 }, { "id": "PLANNING.PATH.PARITY.project-scope", "klass": "PLANNING", "value": ".planning/ (never .planning/projects/); mirror planning-workspace.cjs planningDir()", - "line": 576 + "line": 585 }, { "id": "PLANNING.PATH.SEAM.helpers", "klass": "PLANNING", "value": "helpers.planningPaths delegates to workspacePlanningPaths + resolveWorkspaceContext; precedence explicit-ws > env-ws > env-project > root", - "line": 577 + "line": 586 }, { "id": "PLANNING.PATH.SEAM.init-handlers", "klass": "PLANNING", "value": "[initExecutePhase, initPlanPhase, initPhaseOp, initMilestoneOp] consume helpers.planningPaths().planning (no direct relPlanningPath join)", - "line": 578 + "line": 587 }, { "id": "PR.3267.POSTMORTEM.recovery", "klass": "PR", "value": "[issue#3270 created, label approved-enhancement applied, PR reopened, body includes \"Closes #3270\", label no-changelog applied]", - "line": 555 + "line": 564 }, { "id": "PR.3267.POSTMORTEM.root-cause", "klass": "PR", "value": "[missing issue link, missing changeset/no-changelog]", - "line": 554 + "line": 563 }, { "id": "PRED.k320.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L193-211", - "line": 646 + "line": 655 }, { "id": "PRED.k320.ci-enforcement", "klass": "PRED", "value": "scripts/changeset/lint.cjs", - "line": 652 + "line": 661 }, { "id": "PRED.k320.ci-paths-monitored", "klass": "PRED", "value": "bin/ gsd-core/ src/ agents/ commands/ hooks/ sdk/src/ sdk/prompts/", - "line": 653 + "line": 662 }, { "id": "PRED.k320.cure", "klass": "PRED", "value": "drop .changeset/--.md fragment ONLY", - "line": 648 + "line": 657 }, { "id": "PRED.k320.evidence", "klass": "PRED", "value": "PR #3302 merge-conflict against #3308 CHANGELOG.md row 2026-05-09", - "line": 655 + "line": 664 }, { "id": "PRED.k320.opt-out-label", "klass": "PRED", "value": "no-changelog", - "line": 651 + "line": 660 }, { "id": "PRED.k320.recovery", "klass": "PRED", "value": "open Removed-typed cleanup PR deleting only the redundant row", - "line": 654 + "line": 663 }, { "id": "PRED.k320.rule", "klass": "PRED", "value": "do not edit CHANGELOG.md in feature/fix/enhancement PRs", - "line": 647 + "line": 656 }, { "id": "PRED.k320.signal", "klass": "PRED", "value": "changelog-direct-edit-forbidden", - "line": 645 + "line": 654 }, { "id": "PRED.k320.tool", "klass": "PRED", "value": "npm run changeset -- --type --pr --body \"...\"", - "line": 649 + "line": 658 }, { "id": "PRED.k320.types", "klass": "PRED", "value": "Added|Changed|Deprecated|Removed|Fixed|Security", - "line": 650 + "line": 659 }, { "id": "PRED.k321.evidence", "klass": "PRED", "value": "PRs #3304/#3305 (2026-05-09): real Minor/Major findings in body, 0 threads", - "line": 661 + "line": 670 }, { "id": "PRED.k321.poll-shape", "klass": "PRED", "value": "parse pulls//reviews body AND graphql reviewThreads", - "line": 659 + "line": 668 }, { "id": "PRED.k321.resolution", "klass": "PRED", "value": "address in code; no GraphQL resolveReviewThread needed for body-only findings", - "line": 660 + "line": 669 }, { "id": "PRED.k321.shape", "klass": "PRED", "value": "CR posts \"[!CAUTION] outside the diff\" findings in review BODY, not in reviewThreads", - "line": 658 + "line": 667 }, { "id": "PRED.k321.signal", "klass": "PRED", "value": "cr-outside-diff-range-finding", - "line": 657 + "line": 666 }, { "id": "PRED.k322.cure-1", "klass": "PRED", "value": "2nd retrigger ~10min after first ack", - "line": 666 + "line": 675 }, { "id": "PRED.k322.cure-2", "klass": "PRED", "value": "if silent at 50min, treat as silent-pass with maintainer flag in merge-commit body", - "line": 667 + "line": 676 }, { "id": "PRED.k322.distinct-from", "klass": "PRED", "value": "k080", - "line": 664 + "line": 673 }, { "id": "PRED.k322.evidence", "klass": "PRED", "value": "PR #3306 (2026-05-09): 0 reviews after 50min + 2 retriggers", - "line": 669 + "line": 678 }, { "id": "PRED.k322.merge-gate-impact", "klass": "PRED", "value": "k070 real_coderabbit_review_present unsatisfied; requires maintainer judgment", - "line": 668 + "line": 677 }, { "id": "PRED.k322.shape", "klass": "PRED", "value": "ack posted, real review never lands within [5s, 410s] cooldown after burst of N PRs <15min", - "line": 665 + "line": 674 }, { "id": "PRED.k322.signal", "klass": "PRED", "value": "cr-sustained-throttle", - "line": 663 + "line": 672 }, { "id": "PRED.k323.cure-alt", "klass": "PRED", "value": "consolidate into single PR when 2+ issues share root cause", - "line": 674 + "line": 683 }, { "id": "PRED.k323.cure-pre-dispatch", "klass": "PRED", "value": "brief one agent canonical-owner; brief others to EXCLUDE shared site", - "line": 673 + "line": 682 }, { "id": "PRED.k323.evidence", "klass": "PRED", "value": "#3300 (#3297) overlapped #3306 (#3298) on add-backlog.md hunks 2026-05-09", - "line": 676 + "line": 685 }, { "id": "PRED.k323.recovery", "klass": "PRED", "value": "close smaller PR as \"subsumed by #N\" or rebase second to drop overlap hunk", - "line": 675 + "line": 684 }, { "id": "PRED.k323.shape", "klass": "PRED", "value": "2+ open issues touch same canonical bug site; each fix's sibling-audit produces overlapping diff", - "line": 672 + "line": 681 }, { "id": "PRED.k323.signal", "klass": "PRED", "value": "sibling-audit-cross-pr-overlap", - "line": 671 + "line": 680 }, { "id": "PRED.k324.cure", "klass": "PRED", "value": "verify via gh api on every agent-completion notification; never trust narrative", - "line": 680 + "line": 689 }, { "id": "PRED.k324.evidence", "klass": "PRED", "value": "2026-05-09 session: 5+ mid-monitor terminations across PRs #3232/#3271/#3251/#3255/#3262", - "line": 682 + "line": 691 }, { "id": "PRED.k324.k095-restatement", "klass": "PRED", "value": "k095 confirmed shape: agent reports \"waiting for monitor\" / \"tests still running\" then terminates", - "line": 679 + "line": 688 }, { "id": "PRED.k324.poll-shape", "klass": "PRED", "value": "gh pr view --json mergeStateStatus,statusCheckRollup + pulls//reviews + graphql reviewThreads + issues//comments tail", - "line": 681 + "line": 690 }, { "id": "PRED.k324.signal", "klass": "PRED", "value": "agent-terminates-mid-monitor", - "line": 678 + "line": 687 }, { "id": "PRED.k325.cleanup", "klass": "PRED", "value": "git worktree remove --force for aged agent worktrees", - "line": 687 + "line": 696 }, { "id": "PRED.k325.cure", "klass": "PRED", "value": "detached-HEAD: git checkout --detach $(git ls-remote origin ); modify; commit; git push --force-with-lease=: origin HEAD:refs/heads/", - "line": 686 + "line": 695 }, { "id": "PRED.k325.evidence", "klass": "PRED", "value": "2026-05-09 CHANGELOG.md strip on PRs #3300/#3302/#3304/#3305 required detached-HEAD", - "line": 688 + "line": 697 }, { "id": "PRED.k325.shape", "klass": "PRED", "value": "git checkout errors \"already used by worktree at \"", - "line": 685 + "line": 694 }, { "id": "PRED.k325.signal", "klass": "PRED", "value": "worktree-branch-lock-on-force-push", - "line": 684 + "line": 693 }, { "id": "PRED.k326.cure", "klass": "PRED", "value": "quote canonical doc verbatim in brief; mentally simulate \"if all N agents follow this brief literally, do they violate any rule?\"", - "line": 692 + "line": 701 }, { "id": "PRED.k326.evidence", "klass": "PRED", "value": "2026-05-09 brief \"k040 — update CHANGELOG.md\" → 5 of 8 agents violated CONTRIBUTING.md L110", - "line": 693 + "line": 702 }, { "id": "PRED.k326.shape", "klass": "PRED", "value": "N parallel agents amplify a single brief-vs-doc contradiction into N violations", - "line": 691 + "line": 700 }, { "id": "PRED.k326.signal", "klass": "PRED", "value": "brief-contradicts-canonical-doc", - "line": 690 + "line": 699 }, { "id": "PRED.k327.ack-shape", "klass": "PRED", "value": "body \"✅ Actions performed - Full review triggered\"", - "line": 696 + "line": 705 }, { "id": "PRED.k327.cooldown-normal", "klass": "PRED", "value": "[5s, 410s]", - "line": 699 + "line": 708 }, { "id": "PRED.k327.cooldown-throttled", "klass": "PRED", "value": "k322", - "line": 700 + "line": 709 }, { "id": "PRED.k327.distinguish-key", "klass": "PRED", "value": "len(pulls//reviews) — ack=0, real=≥1", - "line": 698 + "line": 707 }, { "id": "PRED.k327.real-review-shape", "klass": "PRED", "value": "body starts \"Actionable comments posted: N\" OR \"[!CAUTION] Some comments are outside the diff\"", - "line": 697 + "line": 706 }, { "id": "PRED.k327.signal", "klass": "PRED", "value": "cr-ack-vs-real-review", - "line": 695 + "line": 704 }, { "id": "PRED.k328.audit-list", "klass": "PRED", "value": "[heading-matches-class, closing-keyword-present, changeset-fragment-or-no-changelog-label]", - "line": 705 + "line": 714 }, { "id": "PRED.k328.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L48,L64,L81 (template links) + .github/PULL_REQUEST_TEMPLATE/{fix,enhancement,feature}.md L1 (heading text)", - "line": 703 + "line": 712 }, { "id": "PRED.k328.k100-restatement", "klass": "PRED", "value": "heading must match issue class: bug→## Fix PR, enhancement→## Enhancement PR, feature→## Feature PR", - "line": 704 + "line": 713 }, { "id": "PRED.k328.signal", "klass": "PRED", "value": "pr-template-typed-heading-required", - "line": 702 + "line": 711 }, { "id": "PRED.k329.body", "klass": "PRED", "value": "**** — . (#)", - "line": 711 + "line": 720 }, { "id": "PRED.k329.canonical-source", "klass": "PRED", "value": "CONTRIBUTING.md L196-202 + .changeset/README.md", - "line": 708 + "line": 717 }, { "id": "PRED.k329.filename", "klass": "PRED", "value": ".changeset/--.md", - "line": 709 + "line": 718 }, { "id": "PRED.k329.frontmatter", "klass": "PRED", "value": "---\\\\ntype: \\\\npr: \\\\n---", - "line": 710 + "line": 719 }, { "id": "PRED.k329.observed-clean", "klass": "PRED", "value": "#3299 sunny-ibex-wave, #3301 sturdy-rams-caper, #3306 3298-phase-dir-prefix-drift-workflows", - "line": 712 + "line": 721 }, { "id": "PRED.k329.signal", "klass": "PRED", "value": "changeset-fragment-canonical-shape", - "line": 707 + "line": 716 }, { "id": "PRED.k330.fallback", "klass": "PRED", "value": "append predicate-format findings directly to CONTEXT.md", - "line": 716 + "line": 725 }, { "id": "PRED.k330.shape", "klass": "PRED", "value": "mempalace MCP tools require explicit user call; AI cannot trigger", - "line": 715 + "line": 724 }, { "id": "PRED.k330.signal", "klass": "PRED", "value": "mempalace-diary-not-callable-by-ai", - "line": 714 + "line": 723 }, { "id": "PRED.k331.cure", "klass": "PRED", "value": "gh pr close with NO --comment flag", - "line": 721 + "line": 730 }, { "id": "PRED.k331.evidence", "klass": "PRED", "value": "2026-05-09 wave-3: violation on #3300 close, deleted within 30s", - "line": 723 + "line": 732 }, { "id": "PRED.k331.k101-restatement", "klass": "PRED", "value": "k101 includes close-time --comment flag; rationale belongs in subsuming PR's squash-merge body", - "line": 720 + "line": 729 }, { "id": "PRED.k331.recovery", "klass": "PRED", "value": "if violation lands, gh api -X DELETE repos///issues/comments/", - "line": 722 + "line": 731 }, { "id": "PRED.k331.shape", "klass": "PRED", "value": "instruction \"close with no comment (rationale)\" — parenthetical is rationale, NOT comment body", - "line": 719 + "line": 728 }, { "id": "PRED.k331.signal", "klass": "PRED", "value": "close-with-no-comment-is-literal", - "line": 718 + "line": 727 }, { "id": "PROBE.ci.surface", "klass": "PROBE", "value": "the contract (parse/validate, projection round-trip, fail-closed guards), NEVER the LLM judgment (ADR-550 D5)", - "line": 469 + "line": 478 }, { "id": "PROBE.core.seam", "klass": "PROBE", "value": "analyzeCoverage(items,resolutions?,validators) ingests ALREADY-proposed items; does NOT assume deterministic propose (ADR-550 D7b)", - "line": 462 + "line": 471 }, { "id": "PROBE.edge.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 464 + "line": 473 }, { "id": "PROBE.family", "klass": "PROBE", "value": "edge-probe(shape-axis)+prohibition-probe(must-NOT-axis)+ui-consideration-probe(UI-state-axis), shared probe-core, run as spec-phase/ui-phase soft gates (ADR-550 D7; #1867)", - "line": 460 + "line": 469 }, { "id": "PROBE.item.axes", "klass": "PROBE", "value": "status{resolved|dismissed|unresolved} x verification{|null} — orthogonal; the lifecycle enum carries no verification fact (ADR-550 D7a)", - "line": 463 + "line": 472 }, { "id": "PROBE.principle", "klass": "PROBE", "value": "verifier-reach-equals-spec-reach (a goal-backward verifier only checks assertions that exist; probes make omitted assertions exist before code) — ADR-857 verification-substrate boundary; docs/design/verifier-reach.md", - "line": 459 + "line": 468 }, { "id": "PROBE.prohib.verification", "klass": "PROBE", "value": "test|judgment", - "line": 465 + "line": 474 }, { "id": "PROBE.protocol", "klass": "PROBE", "value": "recall(adversarial over-generate)->precision(drop routine-engineering); dismissals require a non-empty reason", - "line": 461 + "line": 470 }, { "id": "PROBE.ui.axis", "klass": "PROBE", "value": "MIXED — closed compiled shape-rooted 8 (empty/loading/error/populated/partial/overflow/zero-one-many/long-text) via ui-consideration-probe adapter; open UX (real-time/a11y/i18n-RTL) prose-owned in references/domain-probes.md, NOT compiled (#1867)", - "line": 467 + "line": 476 }, { "id": "PROBE.ui.seam", "klass": "PROBE", "value": "ui-phase Step 9.5 post-verification: element-cue classify -> propose-then-confirm (partial-cue mitigation, Goodhart) -> autoResolve --auto floor (never dismiss; unclassified stays unresolved #1110) -> ## UI Considerations write-back -> plan-phase `## UI Considerations` lift rule (#1867)", - "line": 468 + "line": 477 }, { "id": "PROBE.ui.verification", "klass": "PROBE", "value": "explicit|backstop", - "line": 466 + "line": 475 }, { "id": "PROC.AGENT-DISPATCH.completion-verify", "klass": "PROC", "value": "run k324.poll-shape on every agent-completion notification", - "line": 727 + "line": 736 }, { "id": "PROC.AGENT-DISPATCH.parallel-overlap-audit", "klass": "PROC", "value": "before dispatching N sibling-audit fixers, compute file-set union and assign canonical owners", - "line": 726 + "line": 735 }, { "id": "PROC.AGENT-DISPATCH.preflight", "klass": "PROC", "value": "[read-CONTRIBUTING.md-fresh, read-relevant-ADRs, cite-specific-line-in-brief, require-closing-keyword, require-changeset-fragment, forbid-CHANGELOG.md-edit, require-isolation-worktree, forbid-self-PR-comment, mandate-trust-but-verify]", - "line": 725 + "line": 734 }, { "id": "PROC.MERGE-WAVE.changelog-strip-pattern", "klass": "PROC", "value": "detached-HEAD per k325 + git checkout main -- CHANGELOG.md + commit + force-with-lease", - "line": 731 + "line": 740 }, { "id": "PROC.MERGE-WAVE.merge-tool", "klass": "PROC", "value": "gh pr merge --squash --delete-branch", - "line": 732 + "line": 741 }, { "id": "PROC.MERGE-WAVE.merge-tool-warning", "klass": "PROC", "value": "delete-branch may fail with \"used by worktree at\" — harmless; remote branch still deleted", - "line": 733 + "line": 742 }, { "id": "PROC.MERGE-WAVE.ordering", "klass": "PROC", "value": "[wave1: isolated-files, wave2: CHANGELOG-only-overlap (better: strip per k320), wave3: same-file-overlap with explicit decision]", - "line": 729 + "line": 738 }, { "id": "PROC.MERGE-WAVE.preflight", "klass": "PROC", "value": "gh pr view --json files for every PR; identify overlap pairs; surface to maintainer", - "line": 730 + "line": 739 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.observed", "klass": "PROC", "value": "#3541 + #3542 dispatched simultaneously this session; PRs #3546 #3547 opened green; one syntax slip caught by AGENT-RETIRED-SLASH-SYNTAX-DRIFT and fixed before second PR opened", - "line": 1004 + "line": 1015 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.pattern", "klass": "PROC", "value": "bot triage brief → worktree per branch → parallel sub-agents do rubber-duck/RCA/TDD implementation only → top-level orchestrator owns commit + gsd-test + push + PR + changeset-pr-backfill", - "line": 1002 + "line": 1013 }, { "id": "PROC.PARALLEL-FIX-DISPATCH.rationale", "klass": "PROC", "value": "long-running test runs need cross-turn notifications (orchestrator-only); CONTRIBUTING.md gh-templates-first hook requires session-scoped Read calls sub-agents wouldn't otherwise make; sequencing test runs avoids GSD-TEST-CONCURRENT-OUTPUT-COLLISION", - "line": 1003 + "line": 1014 }, { "id": "PROC.TRIAGE.comment-shape", "klass": "PROC", "value": "lead with \"duplicate of #NNNN, fixed by PR #MMMM, in v1.X.Y\"; show current code snippet proving bug-surface gone; give @latest and @next upgrade commands; close", - "line": 1009 + "line": 1020 }, { "id": "PROC.TRIAGE.no-duplicate-label", "klass": "PROC", "value": "this repo has no duplicate label; framing lives in comment text + closing the issue", - "line": 1010 + "line": 1021 }, { "id": "PROC.TRIAGE.routing-incoming", "klass": "PROC", "value": "stale-bug-already-fixed to close as duplicate of originating issue + cite fix PR + first stable tag; release-publish-or-backport to ready-for-human; reporter-can-self-test to awaiting-retest", - "line": 1008 + "line": 1019 }, { "id": "PROHIB.canon-referral", "klass": "PROHIB", "value": "OWASP/GDPR/fairness-canon are REFERRED to /gsd:secure-phase+eslint, never minted as prohibitions (ADR-550 D6)", - "line": 471 + "line": 480 }, { "id": "PROHIB.descriptor.shape", "klass": "PROHIB", "value": "5 FLAT scalars (check_kind,check_target,check_rule,check_violation_fixture,check_clean_fixture) — NEVER a nested check:{} (parseMustHavesBlock is a flat parser, src/frontmatter.cts)", - "line": 476 + "line": 485 }, { "id": "PROHIB.enforce.adr", "klass": "PROHIB", "value": "docs/adr/1606 (verify-time enforcement seam) + docs/adr/550 (spec-phase contract)", - "line": 479 + "line": 488 }, { "id": "PROHIB.enforce.causation", "klass": "PROHIB", "value": "clean-fixture control proves the red is content-caused not env-var-set; MANDATORY for node-test (#1906 supersedes #1346 opt-in) — absent clean-fixture ⇒ node-test un-provable/fail-closed; lint-rule needs none (its subject IS the linted file)", - "line": 475 + "line": 484 }, { "id": "PROHIB.enforce.failfirst", "klass": "PROHIB", "value": "MACHINE-PROVEN against an author-supplied violation fixture (#1279); caller failFirst attestation DEMOTED to a non-authoritative hint (FF-08)", - "line": 474 + "line": 483 }, { "id": "PROHIB.enforce.green-rule", "klass": "PROHIB", "value": "passed iff provenFailFirst===true && run.passed===true (runProhibitionEnforcement); every miss/fail/un-provable HARD-GATES both modes via dispositionForProhibition's fail-closed default", - "line": 472 + "line": 481 }, { "id": "PROHIB.enforce.kinds", "klass": "PROHIB", "value": "node-test (non-vacuous red via isNonVacuousNodeTestRed; pass-side vacuity via isNonVacuousNodeTestPass) | lint-rule (eslint --format json filtered by ruleId)", - "line": 473 + "line": 482 }, { "id": "PROHIB.judgment-tier", "klass": "PROHIB", "value": "never-silent / never-hard-halt soft gate; autonomous emits \"unverified-prohibition — human review recommended\" (exogenous grading, ADR-550 D4)", - "line": 478 + "line": 487 }, { "id": "PROHIB.rail", "klass": "PROHIB", "value": "core verify rail, non-toggleable (ADR-857 verification-substrate boundary / decision #6); the verifier<->predicate contract is NOT an off-by-default capability", - "line": 477 + "line": 486 }, { "id": "PROHIB.recall", "klass": "PROHIB", "value": "LLM-prose; no compiled prohibition-probe recall engine (only the schema/projection layer is code, ADR-550 D7b)", - "line": 470 + "line": 479 }, { "id": "RELEASE-NOTES.ANTI-PATTERN", "klass": "RELEASE-NOTES", "value": "raw \"What's Changed\" PR list as final body for hotfix or feature release; \"Full Changelog only\" body for tagged release with >0 user-facing fixes", - "line": 622 + "line": 631 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.implementation-first", "klass": "RELEASE-NOTES", "value": "do not lead bullet with file path or function name; lead with symptom/user-visible behavior", - "line": 623 + "line": 632 }, { "id": "RELEASE-NOTES.ANTI-PATTERN.risk-commentary", "klass": "RELEASE-NOTES", "value": "do not include \"may break\", \"be careful\", \"test thoroughly\" - release notes state what changed, not hedges about what might go wrong", - "line": 624 + "line": 633 }, { "id": "RELEASE-NOTES.DEFAULT-STATE", "klass": "RELEASE-NOTES", "value": "auto-generated body is \"What's Changed\" PR list + Full Changelog link; treat as draft, not final", - "line": 598 + "line": 607 }, { "id": "RELEASE-NOTES.EXAMPLE.hotfix", "klass": "RELEASE-NOTES", "value": "v1.41.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.41.1) - 14 fixes grouped by 6 subgroups", - "line": 626 + "line": 635 }, { "id": "RELEASE-NOTES.EXAMPLE.minor-auto-acceptable", "klass": "RELEASE-NOTES", "value": "v1.41.0 - kept auto-generated body; many small fixes with clean conventional-commit titles", - "line": 628 + "line": 637 }, { "id": "RELEASE-NOTES.EXAMPLE.rc", "klass": "RELEASE-NOTES", "value": "v1.7.0-rc.1 (https://github.com/open-gsd/gsd-core/releases/tag/v1.7.0-rc.1) - intro + Added/Changed/Fixed/Documentation taxonomy", - "line": 627 + "line": 636 }, { "id": "RELEASE-NOTES.GATE.hotfix", "klass": "RELEASE-NOTES", "value": "manual edit required; auto-generated body for vX.Y.{Z>0} is \"Full Changelog only\" and must be replaced with structured body", - "line": 599 + "line": 608 }, { "id": "RELEASE-NOTES.GATE.minor", "klass": "RELEASE-NOTES", "value": "auto-generated body acceptable when PR titles are clean; promote to structured body when >20 PRs or contains feature+refactor+fix mix", - "line": 601 + "line": 610 }, { "id": "RELEASE-NOTES.GATE.rc", "klass": "RELEASE-NOTES", "value": "manual edit recommended; auto-generated PR list is acceptable for early RCs but final RC before vX.Y.0 should match standard", - "line": 600 + "line": 609 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.main-branch", "klass": "RELEASE-NOTES", "value": "next (RCs) + latest (stable); install via @next or @latest", - "line": 633 + "line": 642 }, { "id": "RELEASE-NOTES.RELEASE-STREAM.rule", "klass": "RELEASE-NOTES", "value": "streams do not mix; do not document @next in hotfix/stable notes", - "line": 634 + "line": 643 }, { "id": "RELEASE-NOTES.SCOPE", "klass": "RELEASE-NOTES", "value": "GitHub Releases body for tags vX.Y.Z, vX.Y.Z-rc.N; not CHANGELOG.md (changeset workflow owns that)", - "line": 597 + "line": 606 }, { "id": "RELEASE-NOTES.SOURCE.changesets", "klass": "RELEASE-NOTES", "value": ".changeset/*.md (frontmatter pr: + body bullets)", - "line": 613 + "line": 622 }, { "id": "RELEASE-NOTES.SOURCE.commits", "klass": "RELEASE-NOTES", "value": "git log .. --pretty=format:'%s%n%n%b' --no-merges", - "line": 612 + "line": 621 }, { "id": "RELEASE-NOTES.SOURCE.pr-bodies", "klass": "RELEASE-NOTES", "value": "gh pr view --json title,body for fixes lacking a changeset", - "line": 614 + "line": 623 }, { "id": "RELEASE-NOTES.SOURCE.precedence", "klass": "RELEASE-NOTES", "value": "changeset body > commit body > PR body > commit subject (prefer authored content over auto-generated)", - "line": 615 + "line": 624 }, { "id": "RELEASE-NOTES.STANDARD.bullet-shape", "klass": "RELEASE-NOTES", "value": "**Bold user-visible change** — explanation of what was broken or what's new, leading with symptom not implementation. Trailing (#NNN) PR ref.", - "line": 605 + "line": 614 }, { "id": "RELEASE-NOTES.STANDARD.footer.full-changelog", "klass": "RELEASE-NOTES", "value": "**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/...", - "line": 609 + "line": 618 }, { "id": "RELEASE-NOTES.STANDARD.footer.hotfix", "klass": "RELEASE-NOTES", "value": "Install/upgrade: \\`npx @opengsd/gsd-core@latest\\`", - "line": 607 + "line": 616 }, { "id": "RELEASE-NOTES.STANDARD.footer.rc", "klass": "RELEASE-NOTES", "value": "Install for testing: \\`npx @opengsd/gsd-core@next\\` (per branch->dist-tag policy)", - "line": 608 + "line": 617 }, { "id": "RELEASE-NOTES.STANDARD.heading-level", "klass": "RELEASE-NOTES", "value": "## for category, ### for subgroup (area), - for bullet", - "line": 604 + "line": 613 }, { "id": "RELEASE-NOTES.STANDARD.intro", "klass": "RELEASE-NOTES", "value": "optional one-paragraph framing for RC/feature releases; omit for pure-fix hotfixes", - "line": 610 + "line": 619 }, { "id": "RELEASE-NOTES.STANDARD.subgroups", "klass": "RELEASE-NOTES", "value": "phase-planning-state | workstream | query-dispatch-cli | code-review | install | capture | docs | architecture | security", - "line": 606 + "line": 615 }, { "id": "RELEASE-NOTES.STANDARD.taxonomy", "klass": "RELEASE-NOTES", "value": "Keep-a-Changelog 1.1.0: Added | Changed | Deprecated | Removed | Fixed | Security | Documentation", - "line": 603 + "line": 612 }, { "id": "RELEASE-NOTES.TEMPLATE.hotfix", "klass": "RELEASE-NOTES", "value": "## Fixed\\n\\n### \\n- **** — . (#)\\n\\n---\\n\\nInstall/upgrade: \\`npx @opengsd/gsd-core@latest\\`\\n\\n**Full Changelog**: ", - "line": 630 + "line": 639 }, { "id": "RELEASE-NOTES.TEMPLATE.rc", "klass": "RELEASE-NOTES", "value": "\\n\\n## Added\\n### \\n- **** — . (#)\\n\\n## Changed\\n### Architecture\\n- **** — . (#)\\n\\n## Fixed\\n### \\n- **** — . (#)\\n\\n## Documentation\\n- **** — . (#)\\n\\n---\\n\\nThis is a release candidate. Install for testing:\\n\\`\\`\\`bash\\nnpx @opengsd/gsd-core@next\\n\\`\\`\\`\\n\\n**Full Changelog**: ", - "line": 631 + "line": 640 }, { "id": "RELEASE-NOTES.WORKFLOW.edit", "klass": "RELEASE-NOTES", "value": "gh release edit --notes-file ", - "line": 617 + "line": 626 }, { "id": "RELEASE-NOTES.WORKFLOW.idempotency", "klass": "RELEASE-NOTES", "value": "gh release edit overwrites body wholesale; safe to re-run after refining", - "line": 620 + "line": 629 }, { "id": "RELEASE-NOTES.WORKFLOW.token", "klass": "RELEASE-NOTES", "value": "must use .envrc GITHUB_TOKEN per RULESET.GH.AUTH.DEFAULT (this doc); never ambient gh auth", - "line": 619 + "line": 628 }, { "id": "RELEASE-NOTES.WORKFLOW.view", "klass": "RELEASE-NOTES", "value": "gh release view --json body --jq .body", - "line": 618 + "line": 627 }, { "id": "RULESET.ADR-HEADER", "klass": "RULESET", "value": "every docs/adr/NNNN-*.md must open with - **Status:** Accepted|Proposed|Superseded (by [ADR-NNNN](file.md))|Legacy + - **Date:** YYYY-MM-DD immediately after title", - "line": 522 + "line": 531 }, { "id": "RULESET.AGENT_SIZE_BUDGET", "klass": "RULESET", "value": "agent-size-budget (#1074; sibling of WORKFLOW_SIZE_BUDGET; BYTES not lines per #717/#683, rebased from lines in PR 3/3) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4, same mechanism and same ack fragments (tests/emitted-drift-acks/, #2914; legacy tests/emitted-drift-ack.json still honored) as WORKFLOW_SIZE_BUDGET, scoped to agents/gsd-*.md) + loose tier hard caps (red lines, never raised on approach: XL<=57344 / LARGE<=49152 / DEFAULT<=24576); net-new agents are DEFAULT-tier (no separate new-file cap). Sizes are measured via the shared scripts/workflow-size.cjs measureMdFiles(dir,predicate) counter (tests/helpers/emitted-runtime.cjs's currentSizes() and the guard's own tier-cap checks both import it). A grown agent fails the differential guard — ack + justify, or extract LAZILY to gsd-core/references/. DISTINCT from DEFECT.AGENT-FILE-SIZE-CAP-BREACH (a separate 45K-CHAR extraction-evidence threshold on gsd-planner via planner-decomposition/reachability tests): that guard proves mode-sections were extracted; this one bounds total agent bytes. Two guards, two units (chars vs bytes), two purposes. The prior per-file baseline (tests/agent-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724", - "line": 511 + "line": 520 }, { "id": "RULESET.ALLOWED-TOOLS-FRONTMATTER", "klass": "RULESET", "value": "command's allowed-tools must cover every tool the workflow calls (including Write for file creation); thin-wrapper pattern makes this easy to miss", - "line": 518 + "line": 527 }, { "id": "RULESET.ARGUMENTS-SANITIZE", "klass": "RULESET", "value": "any workflow step constructing .planning/.../{SLUG}.md path from user input ($ARGUMENTS, parsed remainder) must sanitize inline ([a-z0-9-] only, reject ..//\\\\, max-length) — \"(already sanitized)\" must trace back to explicit guard; RESUME/fallback modes need own guards", - "line": 519 + "line": 528 }, { "id": "RULESET.AUDIT.search-source-not-generated", "klass": "RULESET", "value": "verify an invariant/validation EXISTS by searching the AUTHORED source (src/*.cts OR the scripts/gen-*.cjs generator), never the generated bin/lib/*.cjs (gitignored, ADR-457); gen-time checks live in gen-*.cjs not the .cts it consumes → search BOTH before declaring absent; read generated .cjs only for output drift. Repro: grep src/*.cts for VALID_CONVERTER_NAMES → false \"5e ConverterName unenforced\"; actually enforced in gen-capability-registry.cjs. cf RULESET.TESTS.no-source-grep", - "line": 507 + "line": 516 }, { "id": "RULESET.CAPABILITY.cutover-self-gating", "klass": "RULESET", "value": "a phase-6 per-feature cutover moves the host's phase-context detection + mode/flag logic INTO the skill (self-gating, per ADR-894); the loop hook is intentionally COARSE — \"invoke skill X at point Y when config Z\" — and carries no detection/mode. WORKED EXAMPLE: plan-phase.md §5.6 UI gate (frontend-detection via ui-safety-gate.cjs + --auto/manual branch + --skip-ui bypass) must move into gsd-ui-phase before its plan:pre hook can replace the inline call without behavior loss. Spike #1018 finding.", - "line": 304 + "line": 307 }, { "id": "RULESET.CAPABILITY.off-means-off", "klass": "RULESET", "value": "the host derives shared outputs from the ACTIVE hook set (via loop.render-hooks); a hook may ADD a labeled block or be COUNTED into a host-computed aggregate (e.g. a score denominator), but NEVER mutates host source — so a disabled capability yields the base output by construction, not by authoring discipline. Ratify in ADR-894; proven by spike #1018.", - "line": 302 + "line": 305 }, { "id": "RULESET.CAPABILITY.precedence-engine-single-owner", "klass": "RULESET", "value": "the config-key four-level precedence walk (loadConfig result → workstream config.json → root config.json → registry.configSchema default → absent) is owned solely by src/capability-activation.cts: raw-value primitive resolveConfigKey(dotKey, {config,cwd,registry}) and boolean wrapper _resolveActivationValue(dotKey,config,cwd,registry); loop-resolver.cts imports the engine (no duplicate); resolveConfigValues in loop-resolver.cts delegates to resolveConfigKey; resolveCapabilityRuntimeState does NOT return registry/config — callers import capability-registry.cjs and call loadConfig(cwd) directly.", - "line": 308 + "line": 311 }, { "id": "RULESET.CAPABILITY.step-additive-gate-blocks", "klass": "RULESET", "value": "a `step` hook is purely additive (invoke skill + produce artifacts, NEVER halts the host); host-blocking preconditions are `gate`s (blocking:true, onError:halt); runtime/mode context (auto/chain vs manual) self-gates IN THE SKILL, not via `when` (config-only). §5.6 = plan:pre step (ui-phase; skill self-gates on frontend+pipeline, auto-fires only in pipelines) + a NEW plan:pre gate (frontend-and-no-UI-SPEC → halt, when:workflow.ui_safety_gate); the loop.render-hooks dispatch template handles steps AND gates. Resolves #1022.", - "line": 306 + "line": 309 }, { "id": "RULESET.CODERABBIT.GUARD.COMPLETE", "klass": "RULESET", "value": "required_checks_green && coderabbit_check_pass && graphQL(reviewThreads.unresolved_count)==0", - "line": 544 + "line": 553 }, { "id": "RULESET.CODERABBIT.GUARD.GRAPHQL", "klass": "RULESET", "value": "reviewThreads(first:100){nodes{id isResolved comments{nodes{author body path line originalLine url}}}}; use unresolved threads as authoritative, not badge text alone", - "line": 545 + "line": 554 }, { "id": "RULESET.CODERABBIT.GUARD.OPEN_PRS", "klass": "RULESET", "value": "gh pr list --repo open-gsd/gsd-core --author @me --state open; repeat near end because open PR set can change mid-run", - "line": 543 + "line": 552 }, { "id": "RULESET.CODERABBIT.GUARD.RERUN", "klass": "RULESET", "value": "after every push wait for CodeRabbit completion, then re-query unresolved threads; CodeRabbit can add new findings after earlier threads were resolved", - "line": 546 + "line": 555 }, { "id": "RULESET.CODERABBIT.GUARD.RESOLVE", "klass": "RULESET", "value": "fix validated finding -> focused tests -> commit/push -> resolveReviewThread(threadId) -> wait CI/CodeRabbit -> final unresolved_count query", - "line": 547 + "line": 556 }, { "id": "RULESET.CODERABBIT.GUARD.SCOPE", "klass": "RULESET", "value": "if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete", - "line": 548 + "line": 557 }, { "id": "RULESET.CONTENT-PATH-NORMALIZATION", "klass": "RULESET", "value": "filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional; mechanically enforced by local/normalize-path-in-content (eslint, src/**/*.cts; #1733)", - "line": 871 + "line": 880 }, { "id": "RULESET.CONTRIB.CLASSIFY.enhancement", "klass": "RULESET", "value": "requires approved-enhancement before implementation", - "line": 537 + "line": 546 }, { "id": "RULESET.CONTRIB.CLASSIFY.feature", "klass": "RULESET", "value": "requires approved-feature before implementation", - "line": 538 + "line": 547 }, { "id": "RULESET.CONTRIB.CLASSIFY.fix", "klass": "RULESET", "value": "requires confirmed-bug before implementation (legacy 'confirmed' label is back-compat only for duplicate-sweep exemption, not a valid implementation gate)", - "line": 536 + "line": 545 }, { "id": "RULESET.CONTRIB.GATE.ORDER", "klass": "RULESET", "value": "issue-first -> approval-label -> code -> PR-link -> changeset/no-changelog", - "line": 535 + "line": 544 }, { "id": "RULESET.CR-THREAD-RESOLVE", "klass": "RULESET", "value": "after adding // allow-test-rule: to silence lint, resolve existing inline CR threads via graphql resolveReviewThread mutation before merge — open threads mislead future reviewers; pattern: gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:\"PRRT_...\"}) { thread { isResolved } } }'", - "line": 529 + "line": 538 }, { "id": "RULESET.EMITTED_ATTRIBUTION", "klass": "RULESET", "value": "the emitted-artifact family (ADR-2719, epic #2719) — POST-CUTOVER (#2724, Phase 4). Historically tests/fixtures/golden-install-parity/*.json (19 path→hash manifests) + tests/workflow-size-baseline.json + tests/agent-size-baseline.json were all committed, PURE FUNCTIONS of the source tree whose correct merge was ALWAYS \"recompute\" — 140 of 143 conflicted-file instances across the open PR queue were these files. #2724 DELETES all three, the golden test (tests/golden-install-parity.test.cjs), the generator (scripts/gen-golden-install-parity-zcode.cjs), `npm run gen:golden`, `UPDATE_GOLDEN`, the merge-driver bridge (scripts/git-merge-regen-driver.cjs, `npm run setup:merge-driver`, the .gitattributes merge=gsd-regen block), and scripts/update-size-baseline.cjs (`npm run size:baseline`). The differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the SOLE gate for emitted-artifact propagation AND size growth — no committed artifact, nothing to hand-merge, nothing to regenerate. `npm run regen:derived` still exists for what remains committed and derived: build, registry, ADR index, capability matrix, inventory manifest, manifest versions, and `tests/fixtures/install-tree/*.json` (now `npm run gen:install-tree`, folded into `regen:derived`). tests/fixtures/install-tree/*.json is DELIBERATELY EXCLUDED from the cutover (ADR-2719 §7): it conflicts on 0 of 7, its diffs are readable, and it preserves \"the installer stopped shipping X\" as a hard absolute failure — capturing it would convert that absolute into an attribution-free auto-resolve. The baseline the differential compares against is now published by `scripts/gen-emitted-baseline.cjs` on every push to `next` (cached, keyed on sha) and restored in PR lanes via `GSD_EMITTED_BASELINE`/`resolveBaseline()` (tests/helpers/emitted-baseline.cjs); a cache miss falls back to an in-job build via a throwaway `git worktree` (tests/helpers/emitted-runtime.cjs's `buildBaselineAtRef`). REMEDIATION IS PART OF THE GATE (#2778): the failure output names its own remedy, because a gate that states a requirement and withholds the means of satisfying it is a maintainer round-trip, not a gate — ADR-2719 §3's \"conspicuous declaration\" only works if the contributor can discover how to make it. Both failing branches name a NEW fragment to create under `tests/emitted-drift-acks/` (#2914; pick a name nobody else is using), say it may not exist yet (absence is the healthy steady state), print a minimal valid document, and repeat \"do NOT regenerate anything\" — post-#2724 there is nothing left to regenerate, and hunting for a deleted baseline is the predictable wrong guess. The two branches key on DIFFERENT spaces and each says which: the hash pass keys on the EMITTED PATH (always contains a `/`), the size ratchet keys on the BARE FILENAME (`currentSizes` writes `sizes[entry.name]` from readdirSync over `gsd-core/workflows/` + `agents/`). A stale-ack failure additionally says to delete the FILE when removing its last entry, since an empty-but-present ack parses fine yet signals nothing; post-#2789 it also offers CORRECTING the entry to name the ripple actually made, which is the other honest resolution and the one a contributor usually wants. NOT ack-able and deliberately given no ack text: the `NEW_FILE_CAP` branch, whose remedy is extraction. Text is sourced from one frozen `REMEDIATION` export in tests/helpers/emitted-diff.cjs whose example document is rendered from `ACK_VERSION` via `JSON.stringify`, so the taught schema cannot drift from the accepted one (a round-trip test feeds the printed document back through `parseAck`); the message teaches ONE canonical shape even though `parseAck` also accepts a bare-string reason and a missing `version` — liberal in what it accepts, conservative in what it sends. Note the ADR's Consequences originally called the #2724 migration \"terminal\"; #2778 corrected that — it is terminal only for a PR that grows no shipped file. #2914 replaced the single shared ack file with per-PR fragments under `tests/emitted-drift-acks/` — exactly the shape `.changeset/` already uses for the identical \"every PR rewrites one shared document\" conflict problem — so two PRs needing an ack can no longer collide with each other, and a fragment left on `next` after merge is inert rather than a shared cell; the legacy file is still read and unioned in for branches that predate the split, and a duplicate path key across two sources is a hard, loudly-reported error, never silent last-wins. `tests/emitted-drift-ack.json` (the LEGACY file specifically, NOT the fragment directory) must NEVER persist on `next` (#2914): every entry is scoped to the diff that introduced it, so once merged it is by definition already at the base — spent and inert regardless of shape — and a persistent copy makes that ONE file a shared merge-conflict cell across every open PR that also carries an ack, exactly the \"140 of 143\" cost this whole cutover exists to remove; a persisting FRAGMENT is harmless by construction and is deliberately not what this guard checks. This is enforced on `next` itself only, never as a PR-lane check: the `guard-no-ack-on-next` job in `.github/workflows/test.yml` (push-to-`next` trigger) runs `scripts/lint-emitted-drift-ack.cjs --guard-next` (`assertAbsentOnNext`), which fails on the LEGACY file's PRESENCE alone, valid or not — a PR-lane \"base ack must be absent\" check would red every open PR the instant a spent ack merged, which is the #2768 shape #2789 already ended. cf `RULESET.WORKFLOW_SIZE_BUDGET`, `RULESET.AGENT_SIZE_BUDGET`; see `### Emitted Artifact Provenance`", - "line": 512 + "line": 521 }, { "id": "RULESET.GH.AUTH.DEFAULT", "klass": "RULESET", "value": "source .envrc GITHUB_TOKEN before gh; exception=ambient allowed only when user explicitly says machine-only fallback", - "line": 542 + "line": 551 }, { "id": "RULESET.HARNESS.test-memory-guard", "klass": "RULESET", "value": "~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM", - "line": 962 + "line": 973 }, { "id": "RULESET.MANIFEST-CANONICAL-KEY", "klass": "RULESET", "value": "docs/INVENTORY-MANIFEST.json has a single top-level key: families; ALL EIGHT families.* arrays (agents/commands/workflows/references/cli_modules/hooks flat, plus workflow_modes/workflow_steps nested — #2996, epic #1671 Phase 6.5) are canonical, consumed by test suites — tests/inventory-manifest-sync.test.cjs reads all eight, edit-phase/enh-2380/enh-2430 tests read commands+workflows; the six flat families are keyed by BARE BASENAME while the two nested families are keyed by // path, deliberately, because two workflows may each own a same-named step file and a basename key would silently drop one under a JSON-equality comparison; recursion is bounded at exactly one named subdirectory, never a general walk; the family tables live ONCE in scripts/gen-inventory-manifest.cjs and are IMPORTED by the test (the test formerly redeclared them, a DEFECT.GENERATIVE-FIX divergence that let a new family be verified by nobody while still reporting green); the old generated date field and the stale top-level workflows key are both gone; regen via node scripts/gen-inventory-manifest.cjs --write, AFTER build:lib", - "line": 523 + "line": 532 }, { "id": "RULESET.PR-FLOW.docker-before-push", "klass": "RULESET", "value": "before ANY git push of any fix to any PR, run gsd-test (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — \"we don't set a timer we actively watch and record results in real time as possible\". SUPERSEDED 2026-07-17: 'confirm exit 0' is a false-green trap — piping/backgrounding can report exit 0 on a failed suite; gate on the verdict-line outcome:\"passed\" for the exact HEAD sha instead. See CLAUDE.md's gsd-test rule and the gsd-test-is-ref-based-commit-first predicate for the current, correct gating contract.", - "line": 964 + "line": 975 }, { "id": "RULESET.PR-FLOW.templates-mandatory", "klass": "RULESET", "value": "every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — \"the whole reason i have that github action is because you fucking blow through and ignore using the templates\"", - "line": 966 + "line": 977 }, { "id": "RULESET.PR-SCOPE.one-concern-per-pr", "klass": "RULESET", "value": "split unrelated changes into separate PRs; cherry-pick doc changes to dedicated docs/ branch immediately, then force-push original to remove the commit", - "line": 525 + "line": 534 }, { "id": "RULESET.SHARED-HELPERS-LINT-VS-TEST", "klass": "RULESET", "value": "when a lint script and test suite both implement same constant (CANONICAL_TOOLS) or parser (parseFrontmatter, executionContextRefs), extract to scripts/*-helpers.cjs required by both — silent divergence otherwise", - "line": 520 + "line": 529 }, { "id": "RULESET.TESTS.CODERABBIT_FIX", "klass": "RULESET", "value": "prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule", - "line": 549 + "line": 558 }, { "id": "RULESET.TESTS.boundary-coverage", "klass": "RULESET", "value": "tests MUST exercise inputs at and near the threshold/limit, not only trivial-fit and trivial-overflow; pick inputs where N ∈ {limit-1, limit, limit+1} and where pre-trim/pre-check accumulators ≈ effective limit; \"very small\" and \"very large\" inputs alone do not constitute edge-case coverage and routinely miss off-by-one + reservation-accounting bugs", - "line": 494 + "line": 503 }, { "id": "RULESET.TESTS.boundary-coverage.anti-pattern", "klass": "RULESET", "value": "test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)", - "line": 497 + "line": 506 }, { "id": "RULESET.TESTS.boundary-coverage.fixtures", "klass": "RULESET", "value": "for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)", - "line": 496 + "line": 505 }, { "id": "RULESET.TESTS.clock-seam", "klass": "RULESET", "value": "concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)", - "line": 501 + "line": 510 }, { "id": "RULESET.TESTS.coderabbit-fix-prefer", "klass": "RULESET", "value": "behavioral tests (call exported fn, capture JSON, assert typed fields) over source-grep", - "line": 492 + "line": 501 }, { "id": "RULESET.TESTS.delete-bad-tests", "klass": "RULESET", "value": "pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern", - "line": 504 + "line": 513 }, { "id": "RULESET.TESTS.diagnostics", "klass": "RULESET", "value": "after JSON.parse, assert output shape (Array.isArray(output.phases)) with raw-output-prefix diagnostics before .map() — prevents opaque TypeErrors when CLI output shape changes", - "line": 493 + "line": 502 }, { "id": "RULESET.TESTS.escape-regex", "klass": "RULESET", "value": "new RegExp(\"prefix${var}\") must escapeRegex(var); phase-id.cjs exports escapeRegex (core.cjs re-export spine retired in epic #1267); phase IDs like 5.1 contain . which is metacharacter", - "line": 489 + "line": 498 }, { "id": "RULESET.TESTS.eslint-harness", "klass": "RULESET", "value": "ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at eslint-rules/ (repo root, NOT scripts/eslint-rules/); replaces scripts/lint-*.cjs regex scanners (fully removed in #632); of the three test-rigor rules, local/no-source-grep and local/no-magic-sleep-in-tests are already promoted to error in tests/**/*.test.cjs scope (post-cleanup), local/no-elapsed-assertion remains at warn pending open epic #1885 (its dedicated ratchet issue #453 already merged without completing this promotion; follow-up #1888 was closed not-planned and folded into #1885)", - "line": 505 + "line": 514 }, { "id": "RULESET.TESTS.feedback-loop-convergence", "klass": "RULESET", "value": "when a feature's OUTPUT feeds back into its own INPUT (calibration, retry backoff, adaptive budgets, ratchets, any self-correcting signal), step-wise tests are NOT sufficient evidence of correctness: they assert `given X return Y` while the defect lives in the TRAJECTORY across iterations. Required: a closed-loop test that (a) drives the REAL end-to-end surface — not the pure core alone, since composition bugs live between surfaces — for N >= 2x the loop's window, (b) asserts convergence on the known-true value, (c) asserts the fixed point (an already-correct history must produce NO correction), and (d) asserts boundedness under an adversarial/oscillating history. Two defects shipped past a green ~26,800-test suite in epic #1952 for want of exactly this: calibration applied twice across two surfaces (factor^2, #2631) and calibration measured against its own corrected output so it oscillated to ~1.41 instead of converging on 2.0 (#2632). Every unit, boundary, property and round-trip test passed for both. HOW TO SPOT ONE (the detection tell, not a judgment call): the feature's own acceptance criterion carries a TEMPORAL QUANTIFIER — \"after N phases\", \"subsequent\", \"over time\", \"improves\", \"learns\", \"adapts\". That phrasing means the claim is about a TRAJECTORY, so a step-wise `given X return Y` test does not test the claim that was made. #1952's AC4 read \"After N phases, the error is computed and applied as a correction to SUBSEQUENT estimates\" — the tell was in plain sight and was still tested as a point. Survey of this repo (2026-07): estimation calibration is the ONLY true instance; size/mutation ratchets are exempt because they fail on both growth AND shrinkage (cannot self-satisfy), and retry ladders (node_repair_budget, plan_bounce_passes, provider_escalation) terminate rather than feed back. Test anchor: tests/estimate-loop-convergence.test.cjs", - "line": 495 + "line": 504 }, { "id": "RULESET.TESTS.guard-toplevel-readFileSync", "klass": "RULESET", "value": "module-level const src = readFileSync(...) throws before any test() registers — wrap in try/catch in test() or use lazy load", - "line": 491 + "line": 500 }, { "id": "RULESET.TESTS.mutation-score", "klass": "RULESET", "value": "Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification", - "line": 503 + "line": 512 }, { "id": "RULESET.TESTS.no-dead-regex-in-includes", "klass": "RULESET", "value": "src.includes(\"foo.*bar\") is always false — .* is regex metacharacter not wildcard; use new RegExp(...).test(src) or delete", - "line": 490 + "line": 499 }, { "id": "RULESET.TESTS.no-source-grep", "klass": "RULESET", "value": "local/no-source-grep ESLint AST rule (eslint-rules/no-source-grep.cjs) rejects readFileSync of a source .cjs/.js/.ts path bound to a var later hit with .includes()/.match()/.startsWith()/.endsWith()/.indexOf()/.search(); error in tests/**/*.test.cjs, warn in gsd-core/bin/**/*.cjs + scripts/**/*.cjs (ADR 452 retired the old regex script, removed for good in #632)", - "line": 485 + "line": 494 }, { "id": "RULESET.TESTS.no-source-grep.exemption", "klass": "RULESET", "value": "// allow-test-rule: with one-line justification; reserved for tests where the file content IS the product surface (STATE.md, config.toml, hooks.json, agent .md). Migration to typed-IR parser tracked in #2974.", - "line": 486 + "line": 495 }, { "id": "RULESET.TESTS.no-source-grep.tmp-file-traps", "klass": "RULESET", "value": "reading tmp files written by the SUT in tests still trips lint; round-trip through CLI (e.g. frontmatter get) instead of readFileSync+.includes()", - "line": 487 + "line": 496 }, { "id": "RULESET.TESTS.no-timing-assertion", "klass": "RULESET", "value": "do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule, currently warn (promotion to error tracked under open epic #1885, not #453 which already merged without completing it); canonical replacement: clock-seam pattern with node:test mock.timers", - "line": 500 + "line": 509 }, { "id": "RULESET.TESTS.property-based-testing", "klass": "RULESET", "value": "modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge", - "line": 502 + "line": 511 }, { "id": "RULESET.TRIAGE-EXISTING-WORK", "klass": "RULESET", "value": "before writing agent brief for confirmed bug, check (1) local branches git branch -a | grep , (2) untracked/modified files on that branch, (3) stash, (4) open PRs with matching head branch — recover existing work rather than re-implement", - "line": 527 + "line": 536 }, { "id": "RULESET.WORKFLOW.COVERAGE-METADATA", "klass": "RULESET", "value": "#1602 SUMMARY frontmatter `coverage:` block (list of {id,description,requirement?,verification:[{kind∈unit|integration|e2e|automated_ui|manual_procedural|other, ref, status∈pass|fail|unknown}],human_judgment:bool,rationale?}) is the per-deliverable RTM consumed DETERMINISTICALLY by verify-work extract_tests via `gsd-tools uat classify-coverage --summary ` (src/coverage.cts → bin/lib/coverage.cjs). AUTHORING: execute-plan create_summary populates it from task results; every deliverable MUST be classified; fail-safe default = human_judgment:true + rationale. CLASSIFY CONTRACT: auto-pass (skip human) ONLY when human_judgment===false (strict boolean) AND verification non-empty AND every status==='pass' AND zero validation errors — else PRESENT to human. mode:legacy (no block) ⇒ byte-identical prose `## Accomplishments` fall-through; `coverage: []` ⇒ mode:coverage, zero entries (single-confirmation). Frozen IR: MODE/PRESENT_REASON/ERROR_CODE enums locked by tests/coverage-metadata-parser.test.cjs. extractFrontmatter CANNOT parse it (scalars-only `-` items) → dedicated parser, sibling of parseMustHavesBlock. Asymmetry by design: false-negative=redundant prompt (status quo); false-positive=shipped bug UAT existed to catch", - "line": 516 + "line": 525 }, { "id": "RULESET.WORKFLOW_EXECUTE_END_TO_END", "klass": "RULESET", "value": "standard for single-workflow commands is \"Execute end-to-end.\" (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses \"execute the X workflow end-to-end.\" in routing bullets — convention verified live across ~20 commands/gsd/*.md files; no ADR currently documents this specific phrasing rule (ADR-0002 covers the adjacent but distinct command-contract/@-ref-resolution seam, not this convention)", - "line": 515 + "line": 524 }, { "id": "RULESET.WORKFLOW_EXECUTION_CONTEXT", "klass": "RULESET", "value": "@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/docs-update.test.cjs (folds former \\`bug-3135-capture-backlog-workflow\\`, consolidation epic #1969); INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; \"Invoked by\" attribution must move when a flag absorbs a micro-skill", - "line": 514 + "line": 523 }, { "id": "RULESET.WORKFLOW_FILE_NAMES", "klass": "RULESET", "value": "workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name", - "line": 513 + "line": 522 }, { "id": "RULESET.WORKFLOW_MARKDOWN.FENCES", "klass": "RULESET", "value": "preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)", - "line": 509 + "line": 518 }, { "id": "RULESET.WORKFLOW_SIZE_BUDGET", "klass": "RULESET", "value": "workflow size enforcement (#1074; BYTES not lines per #717; LF-normalized per #683) = differential attribution size ratchet (PRIMARY anti-creep since #2724/ADR-2719 §4: tests/emitted-attribution.test.cjs's real-tree test reports growth in any gsd-core/workflows/*.md with its exact byte delta vs `next`, no committed snapshot, requires an ack entry — a fragment under tests/emitted-drift-acks/, #2914; the legacy tests/emitted-drift-ack.json is still honored and unioned in) + loose tier hard caps (outer red lines, NEVER raised on approach: XL<=98304 / LARGE<=61440 / DEFAULT<=40960) + discuss-phase<32000; a file that grew fails the differential guard — add an ack entry naming the file and reason, justify the growth in the PR (or extract LAZILY-loaded content; eager @-imports don't reduce loaded context); crossing a hard cap means EXTRACT, not bump. The prior per-file baseline (tests/workflow-size-baseline.json, `npm run size:baseline`) is REMOVED by #2724. Its new-file cap (ADR-1610 Decision point 3, un-baselined files <=32768, the Codex anchor) is REVIVED inside the differential's size ratchet itself (`NEW_FILE_CAP` in tests/helpers/emitted-diff.cjs) rather than lost: \"not yet baselined\" is exactly \"present in sizeCurrent, absent from sizeBaseline\", a signal the ratchet already computes for its own reasons. NOT ack-able — same as the tier hard caps, the fix is extraction. Narrower than the original: this check cannot see XL/LARGE tiering (tests/workflow-size-budget.test.cjs's classification, invisible to the pure differential module), so a legitimately large NEW file must extract rather than tier in, one release earlier than an existing file would need to — a disclosed, deliberate simplification", - "line": 510 + "line": 519 }, { "id": "SESSION.2026-05-05", "klass": "SESSION", "value": "[PRED.k320..k331 introduced; DEFECT.SOURCE-GREP-IN-NEW-TESTS, DEFECT.CHANGESET-PR-FIELD-DRIFT, DEFECT.PHASE-DIR-PREFIX-DRIFT, DEFECT.PROMPT-INJECTION-SCAN-COLLISION; ADR-0002 thin-wrapper pattern findings folded into RULESET.WORKFLOW_*]", - "line": 917 + "line": 928 }, { "id": "SESSION.2026-05-05.sdk-bridge", "klass": "SESSION", "value": "PR #3158 SDK Runtime Bridge — observability isolation rule; strict-mode dispatchMode reporting invariant; transport decision ordering (guard before event emission); folded into Dispatch Policy Module glossary", - "line": 918 + "line": 929 }, { "id": "SESSION.2026-05-09", "klass": "SESSION", "value": "[8-PR triage wave, 7 merged + 1 subsumed; META.RULE.* introduced; WAVE.LESSON.* captured; k320/k322/k323/k326/k331 evidence; AI Ops Memory predicate format established]", - "line": 919 + "line": 930 }, { "id": "SESSION.2026-05-10", "klass": "SESSION", "value": "[ai-ops memory consolidation; release-notes standard taxonomy + templates; RELEASE-NOTES.* predicates introduced]", - "line": 920 + "line": 931 }, { "id": "SESSION.2026-05-13", "klass": "SESSION", "value": "[Shell Command Projection Module expansion (#3465-#3468); ADR-0009 superseded; new exports for subprocess dispatch and platform file I/O; phase-gated migration plan; PR #3464 three-gate invariant CI+CR+unresolved=0; PR #3470 stash-include-untracked rebase pattern]", - "line": 921 + "line": 932 }, { "id": "SESSION.2026-05-14", "klass": "SESSION", "value": "[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini [runtime removed #1928] cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]", - "line": 922 + "line": 933 }, { "id": "SESSION.2026-05-15", "klass": "SESSION", "value": "[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]", - "line": 923 + "line": 934 }, { "id": "SESSION.2026-05-15.parallel-fix-dispatch", "klass": "SESSION", "value": "[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]", - "line": 924 + "line": 935 }, { "id": "SESSION.2026-05-16", "klass": "SESSION", "value": "[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with \"Failed to read config.json:\"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]", - "line": 925 + "line": 936 }, { "id": "WAVE.LESSON.agent-narrative-unreliable", "klass": "WAVE", "value": "k095/k324 confirmed at scale: 5 of 8 agents terminated mid-monitor with stale claims requiring direct verification", - "line": 740 + "line": 749 }, { "id": "WAVE.LESSON.changelog-policy-violation-multiplier", "klass": "WAVE", "value": "brief contradicting CONTRIBUTING.md's changelog-fragment policy (\"CHANGELOG Entries — Drop a Fragment\" section) produced violations on 5 of 8 PRs (#3300, #3302, #3304, #3305, #3308); k326 + k320 capture", - "line": 737 + "line": 746 }, { "id": "WAVE.LESSON.cr-throttle-burst-correlation", "klass": "WAVE", "value": "8 PRs in <15min triggered k322 sustained-throttle on multiple PRs (#3306 worst case)", - "line": 738 + "line": 747 }, { "id": "WAVE.LESSON.k101-still-trips", "klass": "WAVE", "value": "even after CONTEXT.md k101 reinforcement, agent of record posted self-PR comment on close; k331 adds explicit close-time literal-instruction guard", - "line": 741 + "line": 750 }, { "id": "WAVE.LESSON.sibling-audit-overlap", "klass": "WAVE", "value": "k015-family parallel dispatch on #3297 + #3298 produced k323 add-backlog.md cross-PR overlap", - "line": 739 + "line": 748 }, { "id": "WORKSTREAM.INVARIANT.migrate-name", "klass": "WORKSTREAM", "value": "must normalize through canonical slug policy", - "line": 563 + "line": 572 }, { "id": "WORKSTREAM.INVARIANT.slug-contract", "klass": "WORKSTREAM", "value": "all .planning/workstreams/ must be addressable by set/get/status/complete", - "line": 564 + "line": 573 }, { "id": "WORKSTREAM.NAME.POLICY.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/workstream-name-policy.cjs owns toWorkstreamSlug + active-name/path-segment validation", - "line": 579 + "line": 588 }, { "id": "WORKSTREAM.POINTER.SEAM.cjs-module", "klass": "WORKSTREAM", "value": "gsd-core/bin/lib/active-workstream-store.cjs owns read/write self-heal for .planning/active-workstream", - "line": 580 + "line": 589 }, { "id": "WORKSTREAM.REGRESSION.test-anchor", "klass": "WORKSTREAM", "value": "tests/workstream.test.cjs::normalizes --migrate-name to a valid workstream slug", - "line": 565 + "line": 574 }, { "id": "WORKTREE.SEAM.caller-rule", "klass": "WORKTREE", "value": "verify.cjs must consume inspectWorktreeHealth for W017 classification; no ad-hoc porcelain parsing in callers", - "line": 573 + "line": 582 }, { "id": "WORKTREE.SEAM.current", "klass": "WORKTREE", "value": "Worktree Safety Policy Module", - "line": 557 + "line": 566 }, { "id": "WORKTREE.SEAM.decision-1", "klass": "WORKTREE", "value": "retain non-destructive default; destructive path only as explicit future opt-in scaffold", - "line": 561 + "line": 570 }, { "id": "WORKTREE.SEAM.default-prune-policy", "klass": "WORKTREE", "value": "metadata_prune_only (non-destructive)", - "line": 560 + "line": 569 }, { "id": "WORKTREE.SEAM.files", "klass": "WORKTREE", "value": "[gsd-core/bin/lib/worktree-safety.cjs]", - "line": 558 + "line": 567 }, { "id": "WORKTREE.SEAM.interface", "klass": "WORKTREE", "value": "[resolveWorktreeContext, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent]", - "line": 559 + "line": 568 }, { "id": "WORKTREE.SEAM.invariant", "klass": "WORKTREE", "value": "parser failure must degrade to metadata_prune_only and never escalate to destructive removal", - "line": 571 + "line": 580 }, { "id": "WORKTREE.SEAM.inventory-interface", "klass": "WORKTREE", "value": "[listLinkedWorktreePaths, inspectWorktreeHealth]", - "line": 572 + "line": 581 }, { "id": "WORKTREE.SEAM.inventory-snapshot", "klass": "WORKTREE", "value": "snapshotWorktreeInventory(repoRoot,{staleAfterMs,nowMs}) is canonical linked-worktree health snapshot for callers", - "line": 575 + "line": 584 }, { "id": "WORKTREE.SEAM.test-anchor-w017", "klass": "WORKTREE", "value": "tests/orphan-worktree-detection.test.cjs + tests/worktree-safety-policy.test.cjs", - "line": 574 + "line": 583 }, { "id": "WORKTREE.SEAM.test-anchors", "klass": "WORKTREE", "value": "[resolveWorktreeContext:has_local_planning|linked_worktree|not_git_repo|main_worktree, planWorktreePrune:git_list_failed|worktrees_present|no_worktrees|parser_throw_fallback, executeWorktreePrunePlan:missing_plan|skip_passthrough|unsupported_action|metadata_prune_only]", - "line": 570 + "line": 579 }, { "id": "WORKTREE.SEAM.test-policy", "klass": "WORKTREE", "value": "cover all decision branches in policy module before changing prune behavior", - "line": 569 + "line": 578 } ], "duplicates": [] From 35df0891afcd584e7a3906346f265e1089d72b39 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 8 Aug 2026 05:56:22 -0500 Subject: [PATCH 43/47] =?UTF-8?q?fix(#3156):=20sandbox=20HOME=20on=20raw?= =?UTF-8?q?=20installer=20spawns=20=E2=80=94=20the=20one=20leak=20no=20scr?= =?UTF-8?q?ub=20reaches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strict live-config guard this PR ships went red on CI: the suite creates $HOME/.gsd/defaults.json. Diagnosed rather than suppressed, because the guard is right — this is #2665's class arriving through the one door the scrub set is structurally unable to close. bin/install.js writeNonClaudeDefaults() (#2834) writes path.join(os.homedir(), '.gsd', 'defaults.json') for every non-Claude runtime. os.homedir() consults NO GSD variable, so: - no entry in CONFIG_LOCATION_ENV_KEYS can reach it, however the set is derived; and - blanking GSD_HOME does not reach it either -- a blank GSD_HOME falls back to exactly that homedir(). Only a sandboxed HOME contains it, and HOME is deliberately excluded from TEST_ENV_BASE because blanking it would break far more than it fixed. So the containment belongs per-spawn, which is the discipline the suite already applies by hand -- install-minimal-hooks.test.cjs:576 carries a comment naming this exact hazard for the --codex spawn, while the parameterized --${runtime} spawn 130 lines below it does not. Instance fixed, class open. Rather than add a fifth hand-synced env shape to a PR whose subject is that hand-synced copies drift, this adds ONE export -- installSpawnEnv() in tests/helpers.cjs -- and routes every raw installer spawn through it, including the shared tests/helpers/install-shared.cjs installerEnv(), which every install suite already consumes. Callers passing an explicit { HOME, USERPROFILE } are unaffected: overrides spread last. Census (measured, not reasoned): of the 119 test files that spawn bin/install.js, exactly four wrote into a sandboxed $HOME before this commit -- install, copilot-install, install-minimal-hooks, opencode-plugin-adapter -- and zero do after. Only install.test.cjs sits in CI's targeted lane, which is why ubuntu went red on one file while the macOS full lanes went red on four. Attribution: the leak reproduces unchanged at upstream/next itself, so the defect is base-owned and pre-existing; only the detector is new. The guard found a real leak on next within one run. No new failures: the surviving names under a sandboxed HOME (folded:enh-2380-sync-skills, getGlobalConfigDir (Copilot)) fail at base too, and base additionally fails folded:bug-3288-model-catalog-install-path, which this tree does not. --- tests/copilot-install.test.cjs | 30 ++++++++++++++++---- tests/helpers.cjs | 38 +++++++++++++++++++++++++- tests/helpers/install-shared.cjs | 10 ++++++- tests/install.test.cjs | 15 ++++++++-- tests/opencode-plugin-adapter.test.cjs | 3 ++ 5 files changed, 85 insertions(+), 11 deletions(-) diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index c66d1e56b..c60dc671d 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -1362,7 +1362,10 @@ const EXPECTED_AGENTS = listAgentFiles().length; const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); function runCopilotInstall(cwd) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; const r = runNode([INSTALL_PATH, '--copilot', '--local', '--no-sdk'], { cwd, @@ -1374,7 +1377,10 @@ function runCopilotInstall(cwd) { } function runCopilotUninstall(cwd) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; const r = runNode([INSTALL_PATH, '--copilot', '--local', '--uninstall', '--no-sdk'], { cwd, @@ -1673,7 +1679,10 @@ describe('E2E: Copilot uninstall verification', () => { // ─── E2E: Copilot global scope (#786) ────────────────────────────────────────── function runCopilotInstallGlobal(cwd, configDir) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; const r = runNode( [INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'], @@ -1684,7 +1693,10 @@ function runCopilotInstallGlobal(cwd, configDir) { } function runCopilotUninstallGlobal(cwd, configDir) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; const r = runNode( [INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'], @@ -1734,7 +1746,10 @@ describe('E2E: Copilot global install (#786)', () => { // ─── Claude uninstall: user file preservation (#1423) ───────────────────────── function runClaudeInstall(cwd) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], { cwd, @@ -1746,7 +1761,10 @@ function runClaudeInstall(cwd) { } function runClaudeUninstall(cwd) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; const r = runNode([INSTALL_PATH, '--claude', '--local', '--uninstall', '--no-sdk'], { cwd, diff --git a/tests/helpers.cjs b/tests/helpers.cjs index e17c57b59..7e224d74a 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -911,7 +911,43 @@ function clearSessionEnv() { for (const k of SESSION_ENV_KEYS) delete process.env[k]; } -module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv }; +/** + * #3156: env for a RAW installer spawn — one that bypasses runGsdTools and so + * never receives TEST_ENV_BASE on its own. + * + * Blanking config-LOCATION vars is necessary but NOT sufficient here. + * bin/install.js writes GSD's own user-owned store through os.homedir() + * DIRECTLY (writeNonClaudeDefaults -> /.gsd/defaults.json, #2834), and + * os.homedir() consults no GSD variable at all — so nothing in + * CONFIG_LOCATION_ENV_KEYS can reach it, and blanking GSD_HOME does not reach + * it either, because a blank GSD_HOME falls back to exactly that homedir(). + * Only a sandboxed HOME/USERPROFILE contains it. + * + * HOME stays deliberately OUT of TEST_ENV_BASE — blanking it would break far + * more than it fixed — so it is sandboxed per spawn instead, which is the + * discipline the suite already applies by hand elsewhere. USERPROFILE is set + * with it because os.homedir() reads that one on Windows. + * + * The sandbox home is per-process and removed on exit, so a caller gets + * containment without having to own a lifecycle. + */ +let installSpawnHomeDir = null; +function installSpawnHome() { + if (installSpawnHomeDir === null) { + installSpawnHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-install-home-')); + process.on('exit', () => { + try { fs.rmSync(installSpawnHomeDir, { recursive: true, force: true }); } catch { /* best effort */ } + }); + } + return installSpawnHomeDir; +} + +function installSpawnEnv(overrides = {}) { + const home = installSpawnHome(); + return { ...process.env, ...testEnvBase(), HOME: home, USERPROFILE: home, ...overrides }; +} + +module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome }; // Lazy, for the reason builtLib() is lazy: reading either of these is what // forces the built-lib require, so a test file that needs neither can still diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index b4c5ce580..153a449cb 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -520,7 +520,15 @@ function simulateHookCopy(hooksSrc, hooksDest) { /** Build a clean env for spawned installer processes. * Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */ function installerEnv(overrides = {}) { - const env = { ...process.env, ...overrides }; + // #3156: delegate to the ONE canonical raw-installer-spawn env rather than + // carrying a second shape of it. The installer writes GSD's own user store to + // /.gsd/defaults.json through os.homedir() DIRECTLY + // (bin/install.js writeNonClaudeDefaults, #2834), which reads no GSD variable, + // so no config-location scrub can reach it — only a sandboxed HOME can. Every + // caller that already passes an explicit { HOME, USERPROFILE } still wins: + // overrides spread last. + const { installSpawnEnv } = require('../helpers.cjs'); + const env = installSpawnEnv(overrides); delete env.GSD_TEST_MODE; return env; } diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 670ab886e..ae8e62133 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -5535,7 +5535,10 @@ function ensureHooksDist() { * GSD_TEST_MODE is cleared so the install() main block executes. */ function runInstall(cwd, args) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; // 120s, not 60s. A full install copies and converts the whole shipped // payload (117 workflows, 100 references, 34 agents, ~71 skills) and @@ -9597,7 +9600,10 @@ function ensureHooksDist() { * GSD_TEST_MODE is cleared so the install() main block executes. */ function runInstall(cwd, args) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; // 120s, not 60s. A full install copies and converts the whole shipped // payload (117 workflows, 100 references, 34 agents, ~71 skills) and @@ -10022,7 +10028,10 @@ const { * GSD_TEST_MODE must be cleared so the install() main block executes. */ function runClaudeLocalInstall(cwd) { - const env = { ...process.env }; + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + const { installSpawnEnv } = require('./helpers.cjs'); + const env = installSpawnEnv(); delete env.GSD_TEST_MODE; const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], { cwd, diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs index 6d4c11ecc..49b3ff23a 100644 --- a/tests/opencode-plugin-adapter.test.cjs +++ b/tests/opencode-plugin-adapter.test.cjs @@ -389,6 +389,9 @@ test('installer copies plugin as .js, records it in the manifest, and removes it const run = (args) => { const result = runNode([installer, '--opencode', '--global', '--config-dir', cfg, ...args], { timeoutMs: 120000, + // #3156: sandbox HOME — the installer writes /.gsd/defaults.json via + // os.homedir() directly, which no env scrub can reach. See installSpawnEnv. + env: require('./helpers.cjs').installSpawnEnv(), }); result.status = result.exitCode; return result; From af39f13be2e44b1b4fbd7bc58574fa9e44e3645e Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 8 Aug 2026 05:57:28 -0500 Subject: [PATCH 44/47] test(#3156): pin the ambient-HOME leak the scrub set cannot reach Two halves, and the second is the one that matters. The contract half asserts installSpawnEnv() and installerEnv() both replace the ambient HOME, keep USERPROFILE tracking it (os.homedir() reads that one on Windows), and still let an explicit override win. The behavioural half drives the REAL installer against a REAL ambient HOME: it points process.env.HOME at a canary, runs `install.js --cursor --local`, and asserts the canary gains no .gsd. No assertion about the scrub set can stand in for this, because writeNonClaudeDefaults() resolves through os.homedir(), which reads no GSD variable -- a set-membership test would pass with the bug fully live. Negative-controlled against the pre-fix tree rather than assumed: reverting installerEnv() to `{ ...process.env, ...overrides }` fails BOTH halves, the behavioural one reporting the actual artifact ("the installer wrote GSD's user store into the ambient HOME: defaults.json"). --- tests/helpers-process-isolation.test.cjs | 66 ++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index 7eceb1f72..083d09d23 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -342,3 +342,69 @@ describe('#2665 round 4: the skillsHome walk is reversion-sensitive', () => { void out; // exit 0 is the assertion; execFileSync throws on nonzero }); }); + +describe('#3156: a raw installer spawn cannot write into the ambient HOME', () => { + const fs = require('node:fs'); + const os = require('node:os'); + const { execFileSync } = require('node:child_process'); + const { installSpawnEnv } = require('./helpers.cjs'); + const { installerEnv } = require('./helpers/install-shared.cjs'); + const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); + + // Contract half — cheap, and it names the precedence the callers depend on. + test('the sandbox HOME replaces the ambient one, but an explicit override still wins', () => { + for (const build of [installSpawnEnv, installerEnv]) { + const env = build(); + assert.notStrictEqual(env.HOME, process.env.HOME, + 'a raw installer spawn must not inherit the ambient HOME'); + assert.strictEqual(env.USERPROFILE, env.HOME, + 'USERPROFILE must track HOME — os.homedir() reads it on Windows'); + assert.strictEqual(build({ HOME: '/explicit', USERPROFILE: '/explicit' }).HOME, '/explicit', + 'an explicit HOME override must still win (overrides spread last)'); + } + }); + + // Behavioural half — the one that actually fails pre-fix. + // + // bin/install.js writeNonClaudeDefaults() (#2834) writes + // /.gsd/defaults.json for every NON-Claude runtime, reading no + // GSD variable at all. So this is deliberately driven through the real + // installer against a real ambient HOME: no assertion about the scrub set can + // stand in for it, because no scrub set can reach os.homedir(). + // + // Negative control: revert installerEnv() to `{ ...process.env, ...overrides }` + // and the canary gains .gsd/defaults.json. + test('installing a non-Claude runtime leaves the ambient HOME untouched', () => { + const canaryHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-canary-home-')); + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3156-project-')); + const realHome = process.env.HOME; + const realUserProfile = process.env.USERPROFILE; + try { + // Make the AMBIENT home the canary — the vector is the parent process's + // own HOME, exactly as on a developer machine or a CI runner. + process.env.HOME = canaryHome; + process.env.USERPROFILE = canaryHome; + + execFileSync(process.execPath, [INSTALL_PATH, '--cursor', '--local', '--no-sdk'], { + cwd: projectDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env: installerEnv(), + timeout: 120_000, + }); + + assert.ok(!fs.existsSync(path.join(canaryHome, '.gsd')), + `the installer wrote GSD's user store into the ambient HOME: ${ + fs.existsSync(path.join(canaryHome, '.gsd')) + ? fs.readdirSync(path.join(canaryHome, '.gsd')).join(', ') + : '' + }`); + } finally { + if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; + if (realUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = realUserProfile; + fs.rmSync(canaryHome, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); From dbbbd7e89ddfa01dbf2e268a8325bfe0f3ce716f Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 8 Aug 2026 05:57:42 -0500 Subject: [PATCH 45/47] chore(#3156): re-point the changeset at the successor issue #2665 was closed by #3134 (the narrow three-variable scrub) while this PR was open, so the changeset's issue refs pointed at resolved work. Re-pointed to #3156, which is the class this PR actually closes. Fragment content is otherwise unchanged; changeset-lint returns ok_fragment_present. --- .changeset/quiet-moons-derive.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/quiet-moons-derive.md b/.changeset/quiet-moons-derive.md index 822cfb724..b0cae781c 100644 --- a/.changeset/quiet-moons-derive.md +++ b/.changeset/quiet-moons-derive.md @@ -2,6 +2,6 @@ type: Added pr: 2677 --- -**`runtime-homes` now exports its non-registry config-home descriptors** — `KIMI_HOOKS_TOML_DESCRIPTOR`, `NON_REGISTRY_CONFIG_HOME_DESCRIPTORS`, `GSD_LOCATION_ENV_KEYS`, and the `ConfigHomeDescriptor` type are public, so consumers that need the *set* of config-location env vars (rather than a single resolved path) can derive it instead of hand-maintaining a copy. `resolveKimiHooksTomlDir()` behaviour is unchanged; its descriptor is simply named rather than inline (#2665). +**`runtime-homes` now exports its non-registry config-home descriptors** — `KIMI_HOOKS_TOML_DESCRIPTOR`, `NON_REGISTRY_CONFIG_HOME_DESCRIPTORS`, `GSD_LOCATION_ENV_KEYS`, and the `ConfigHomeDescriptor` type are public, so consumers that need the *set* of config-location env vars (rather than a single resolved path) can derive it instead of hand-maintaining a copy. `resolveKimiHooksTomlDir()` behaviour is unchanged; its descriptor is simply named rather than inline (#3156). -**The test-instrumentation scripts no longer ship in the npm package** — `scripts/run-tests.cjs`, `scripts/live-config-guard.cjs`, `scripts/affected-tests-lib.cjs`, and `scripts/run-affected-tests.cjs` are now excluded from the tarball (they are one closed require chain of repo-only test tooling). `npm test` in an installed package was already inoperable (`tests/` has never shipped); a deep import of `scripts/run-tests.cjs` from the published package — an unsupported surface — will now be `MODULE_NOT_FOUND` (#2665). +**The test-instrumentation scripts no longer ship in the npm package** — `scripts/run-tests.cjs`, `scripts/live-config-guard.cjs`, `scripts/affected-tests-lib.cjs`, and `scripts/run-affected-tests.cjs` are now excluded from the tarball (they are one closed require chain of repo-only test tooling). `npm test` in an installed package was already inoperable (`tests/` has never shipped); a deep import of `scripts/run-tests.cjs` from the published package — an unsupported surface — will now be `MODULE_NOT_FOUND` (#3156). From 6ac4d2e6ab43ecfa51d921e6229351d8caebacf3 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 8 Aug 2026 05:58:35 -0500 Subject: [PATCH 46/47] fix(#3156): bound the cold-require probe the rebase turned into a violation Post-rebase validity finding, not a new defect. The base range added local/no-unbounded-spawn (#3143, 2afe17bb) and then DELETED its allowlist (#3148, 9faacc0c), so the cold-require probe this PR added in 4bc6b0a2 -- legal when written -- is now an error. Bounded at 30s: a cold require is sub-second, so that is ~30x headroom and still fails loudly rather than hanging a lane. Also routes this round's own teardown through cleanup() instead of raw fs.rmSync, per local/no-raw-rmsync-in-tests, which carries the Windows-EBUSY retry budget. eslint clean on the file. --- tests/helpers-process-isolation.test.cjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index 083d09d23..2f1db0ea7 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -24,7 +24,9 @@ describe('#2665: the built-lib require is deferred', () => { "const needle = path.join('gsd-core', 'bin', 'lib', 'capability-registry.cjs');", 'process.stdout.write(String(Object.keys(require.cache).some((m) => m.endsWith(needle))));', ].join('\n'); - const r = spawnSync(process.execPath, ['-e', src], { encoding: 'utf8' }); + // Bounded per local/no-unbounded-spawn (#3143): a cold require is sub-second, + // so 30s is ~30x headroom and still fails loudly instead of hanging a lane. + const r = spawnSync(process.execPath, ['-e', src], { encoding: 'utf8', timeout: 30_000 }); assert.strictEqual(r.status, 0, `probe failed: ${r.stderr}`); return r.stdout === 'true'; }; @@ -347,7 +349,7 @@ describe('#3156: a raw installer spawn cannot write into the ambient HOME', () = const fs = require('node:fs'); const os = require('node:os'); const { execFileSync } = require('node:child_process'); - const { installSpawnEnv } = require('./helpers.cjs'); + const { installSpawnEnv, cleanup } = require('./helpers.cjs'); const { installerEnv } = require('./helpers/install-shared.cjs'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); @@ -403,8 +405,8 @@ describe('#3156: a raw installer spawn cannot write into the ambient HOME', () = if (realHome === undefined) delete process.env.HOME; else process.env.HOME = realHome; if (realUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = realUserProfile; - fs.rmSync(canaryHome, { recursive: true, force: true }); - fs.rmSync(projectDir, { recursive: true, force: true }); + cleanup(canaryHome); + cleanup(projectDir); } }); }); From f3ce2dbab508a0daf92b12c968a22a7398f387d0 Mon Sep 17 00:00:00 2001 From: 0xdhx Date: Sat, 8 Aug 2026 06:18:09 -0500 Subject: [PATCH 47/47] docs(#3156): name the isolation this helper does NOT provide Found by the pre-push adversarial review of this round, and worth recording in the code rather than only in the PR thread. installSpawnHome() creates one sandbox home per test-FILE process, not one per spawn, so two installer spawns in the same file share .gsd state. The containment claim is unaffected -- nothing reaches the developer's real home -- and it is strictly better than the status quo it replaces, which shared the real home and every byte of its state. But "contained" and "isolated from each other" are different properties, and only the first is claimed. --- tests/helpers.cjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 7e224d74a..bcd850161 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -930,6 +930,14 @@ function clearSessionEnv() { * * The sandbox home is per-process and removed on exit, so a caller gets * containment without having to own a lifecycle. + * + * SCOPE, stated because it is a real residual rather than an oversight: this is + * one home per test-FILE process, not one per spawn. Two installer spawns in the + * same file therefore share `.gsd` state, so a prior non-Claude install can be + * observed by a later spawn. That is strictly better than the status quo it + * replaces -- which shared the developer's REAL home, and all of its state -- + * and it closes the leak this helper exists for; it does not claim isolation + * BETWEEN spawns. A test needing that passes its own { HOME, USERPROFILE }. */ let installSpawnHomeDir = null; function installSpawnHome() {