@opengsd/gsd-core 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -71,13 +71,20 @@ function classifyRedEvidence(input) {
71
71
  if (failures.length === 0)
72
72
  return { verdict: exports.RedEvidenceVerdict.Invalid, reason: exports.RedEvidenceReason.NoFailure, evidence };
73
73
  const targetBase = baseOf(input?.targetFile);
74
- const distinctlyNamed = failures.filter((test) => !targetBase || baseOf(test.name) !== targetBase);
74
+ const distinctlyNamed = failures.filter((test) => !test.loadFailure && (!targetBase || baseOf(test.name) !== targetBase));
75
75
  if (distinctlyNamed.length === 0)
76
76
  return { verdict: exports.RedEvidenceVerdict.Invalid, reason: exports.RedEvidenceReason.LoadFailure, evidence };
77
77
  // Class targets can intentionally match multiple methods in a JUnit report.
78
78
  // Other identities must resolve uniquely, including passing/skipped siblings.
79
- const exact = report.tests.filter((test) => test.identities.includes(targetTest));
80
- const grouped = report.tests.filter((test) => test.groupIdentities.includes(targetTest));
79
+ // A load-failure entry (#5721) asserted nothing: it is never a target candidate. A target that
80
+ // names only such entries is refused as the load failure it is, not as a missing target.
81
+ const real = report.tests.filter((test) => !test.loadFailure);
82
+ const exact = real.filter((test) => test.identities.includes(targetTest));
83
+ const grouped = real.filter((test) => test.groupIdentities.includes(targetTest));
84
+ if (exact.length === 0 && grouped.length === 0 && report.tests.some((test) => test.loadFailure
85
+ && (test.identities.includes(targetTest) || test.groupIdentities.includes(targetTest)))) {
86
+ return { verdict: exports.RedEvidenceVerdict.Invalid, reason: exports.RedEvidenceReason.LoadFailure, evidence };
87
+ }
81
88
  const targets = exact.length > 0 ? exact : grouped;
82
89
  const unambiguous = exact.length > 0 ? exact.length === 1 : new Set(grouped.map((test) => test.group)).size === 1;
83
90
  const targetFailure = distinctlyNamed.find((test) => targets.includes(test));
@@ -23,7 +23,12 @@ function routeVerifyCommand({ verify, args, cwd, raw, error }) {
23
23
  error,
24
24
  unknownMessage: (_subcommand, available) => `Unknown verify subcommand. Available: ${available.join(', ')}`,
25
25
  handlers: {
26
- 'plan-structure': () => verify.cmdVerifyPlanStructure(cwd, args[2], raw),
26
+ 'plan-structure': () => {
27
+ const rest = args.slice(2);
28
+ const replay = rest.includes('--replay');
29
+ const positional = rest.filter((a) => a !== '--replay');
30
+ verify.cmdVerifyPlanStructure(cwd, positional[0], raw, { replay });
31
+ },
27
32
  'phase-completeness': () => verify.cmdVerifyPhaseCompleteness(cwd, args[2], raw),
28
33
  references: () => verify.cmdVerifyReferences(cwd, args[2], raw),
29
34
  commits: () => verify.cmdVerifyCommits(cwd, args.slice(2), raw),
@@ -26,10 +26,13 @@ const modelProfilesMod = require("./model-profiles.cjs");
26
26
  const coreUtilsMod = require("./core-utils.cjs");
27
27
  const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
28
28
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
29
+ const read_only_git_env_cjs_1 = require("./read-only-git-env.cjs");
29
30
  const security_cjs_2 = require("./security.cjs");
30
31
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
31
32
  const schema_detect_cjs_1 = require("./schema-detect.cjs");
32
33
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
34
+ const criterion_baseline_cjs_1 = require("./criterion-baseline.cjs");
35
+ const criterion_replay_cjs_1 = require("./criterion-replay.cjs");
33
36
  const pattern_cjs_1 = require("./pattern.cjs");
34
37
  const gate_exit_cjs_1 = require("./gate-exit.cjs");
35
38
  const gate_evidence_cjs_1 = require("./gate-evidence.cjs");
@@ -796,11 +799,9 @@ function scanQuantitativeCriteria(content) {
796
799
  return { errors, warnings };
797
800
  // Allow markers may sit anywhere in the plan (same as the sibling scanners).
798
801
  // The reason must be non-empty: `R1 - -->` is not an auditable exit.
799
- const allow = new Set();
800
- const allowRe = /<!--\s*plan-criteria-allow:\s*(R[1-6])\s+-\s+([^>]*\S)\s*-->/g;
801
- let am;
802
- while ((am = allowRe.exec(content || '')) !== null)
803
- allow.add(am[1]);
802
+ // #4309: the marker parser is shared with the baseline-notation scanner
803
+ // (it also honors B1/B2; this scanner only ever asks about R ids).
804
+ const allow = (0, criterion_baseline_cjs_1.parseCriteriaAllowMarkers)(content || '');
804
805
  const record = (bucket, rule, message, seen) => {
805
806
  if (allow.has(rule) || seen.has(message))
806
807
  return;
@@ -1179,7 +1180,7 @@ function scanPlanTruncation(content) {
1179
1180
  }
1180
1181
  return errors;
1181
1182
  }
1182
- function cmdVerifyPlanStructure(cwd, filePath, raw) {
1183
+ function cmdVerifyPlanStructure(cwd, filePath, raw, opts = {}) {
1183
1184
  if (!filePath) {
1184
1185
  error('file path required');
1185
1186
  }
@@ -1269,6 +1270,36 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
1269
1270
  const quantScan = scanQuantitativeCriteria(content);
1270
1271
  errors.push(...quantScan.errors);
1271
1272
  warnings.push(...quantScan.warnings);
1273
+ // #4309: criterion baseline notation (B1 non-discriminating, B2 legacy
1274
+ // comment). Parse failures are statuses only and never change validity.
1275
+ const baselineScan = (0, criterion_baseline_cjs_1.scanBaselineNotation)(content);
1276
+ errors.push(...baselineScan.errors);
1277
+ warnings.push(...baselineScan.warnings);
1278
+ // #4310: opt-in replay (ADR-4310). Advisory: mismatches are warnings, statuses never change validity.
1279
+ let replayReport = null;
1280
+ if (opts.replay === true) {
1281
+ const projectDir = node_path_1.default.resolve(cwd);
1282
+ const head = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', 'HEAD'], { cwd: projectDir, timeout: 5000 });
1283
+ const headSha = head.exitCode === 0 && /^[0-9a-f]{40}$/.test(head.stdout) ? head.stdout : null;
1284
+ const replayEnv = { ...process.env, ...(0, read_only_git_env_cjs_1.readOnlyGitEnv)(), GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_PAGER: 'cat', PAGER: 'cat' };
1285
+ // Fail closed: an operand that exists but cannot be shown to resolve inside the project is refused.
1286
+ const isInsideRoot = (candidate) => {
1287
+ const st = (0, gate_evidence_cjs_1.statEvidence)(node_path_1.default.resolve(projectDir, candidate));
1288
+ return st.kind === 'none' || (st.kind === 'found' && (0, security_cjs_2.tryWithinRoot)(candidate, projectDir) !== null);
1289
+ };
1290
+ replayReport = (0, criterion_replay_cjs_1.replayBaselineCriteria)(content, {
1291
+ isInsideRoot,
1292
+ run: (argv, { timeoutMs }) => (0, shell_command_projection_cjs_1.execArgvQuarantined)(argv[0], argv.slice(1), {
1293
+ cwd: projectDir,
1294
+ env: replayEnv,
1295
+ timeout: timeoutMs,
1296
+ maxBuffer: criterion_replay_cjs_1.REPLAY_LIMITS.OUTPUT_CAP_BYTES,
1297
+ }),
1298
+ now: () => Date.now(),
1299
+ headSha,
1300
+ });
1301
+ warnings.push(...replayReport.warnings);
1302
+ }
1272
1303
  emitVerbVerdict(validityVerdict(errors.length === 0, {
1273
1304
  valid: errors.length === 0,
1274
1305
  errors,
@@ -1276,6 +1307,18 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
1276
1307
  task_count: tasks.length,
1277
1308
  tasks,
1278
1309
  frontmatter_fields: Object.keys(fm),
1310
+ // #4309: appended keys (additive payload).
1311
+ baseline_notation_count: baselineScan.notationCount,
1312
+ baseline_findings: baselineScan.findings,
1313
+ baseline_statuses: baselineScan.statuses,
1314
+ ...(replayReport === null ? {} : {
1315
+ replay: {
1316
+ head_sha: replayReport.head_sha,
1317
+ results: replayReport.results,
1318
+ mismatch_count: replayReport.mismatch_count,
1319
+ statuses: replayReport.statuses,
1320
+ },
1321
+ }),
1279
1322
  }), 'status', raw, errors.length === 0 ? 'valid' : 'invalid');
1280
1323
  }
1281
1324
  function cmdVerifyPhaseCompleteness(cwd, phase, raw) {
@@ -25,7 +25,7 @@ const { readSubdirectories } = coreUtilsMod;
25
25
  const planScan = require("./plan-scan.cjs");
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const planningWorkspace = require("./planning-workspace.cjs");
28
- const { planningPaths, planningRoot, getActiveWorkstream } = planningWorkspace;
28
+ const { planningPaths, planningRoot, getActiveWorkstream, resolvePhaseIdConvention } = planningWorkspace;
29
29
  const state_document_cjs_1 = require("./state-document.cjs");
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
31
31
  const frontmatterMod = require("./frontmatter.cjs");
@@ -101,7 +101,7 @@ function countRoadmapPhases(roadmapPath, fallbackCount, cwd, ws, versionOverride
101
101
  * `phaseKeyFromDir` uses for directories, so a `| 01. … |` row and a `1-slug`
102
102
  * directory cannot land in different key spaces (the padding-asymmetry defect).
103
103
  */
104
- function parseRoadmapProgressRows(content, directoryContext) {
104
+ function parseRoadmapProgressRows(content, directoryContext, convention) {
105
105
  // Milestone-ATTRIBUTING shape first. Both shapes carry `Plans Complete`, so
106
106
  // probing that column first would pick a flat table appearing earlier in the
107
107
  // document over a milestone-grouped one later — every row would come back
@@ -116,7 +116,7 @@ function parseRoadmapProgressRows(content, directoryContext) {
116
116
  });
117
117
  const rows = [];
118
118
  for (const row of tableRows) {
119
- const key = phaseKeyFromProse(row['Phase'], directoryContext);
119
+ const key = phaseKeyFromProse(row['Phase'], directoryContext, convention);
120
120
  if (key === null)
121
121
  continue;
122
122
  const cell = (row['Milestone'] ?? '').trim();
@@ -439,7 +439,7 @@ function inspectWorkstream(cwd, name, options = {}) {
439
439
  // `phaseKeyFromProse`, both from the phase-id owner module) so the two sides
440
440
  // of the rollup cannot disagree.
441
441
  const currentVersion = readCurrentMilestoneVersion(p.state, p.roadmap);
442
- const progressRows = parseRoadmapProgressRows(roadmap, directoryContext);
442
+ const progressRows = parseRoadmapProgressRows(roadmap, directoryContext, resolvePhaseIdConvention(cwd, name));
443
443
  // Phase keys the ROADMAP attributes to the current milestone. A row whose
444
444
  // Milestone cell is blank or malformed is INCLUDED rather than dropped: a
445
445
  // phase we cannot attribute must still be visible to the rollup. Dropping it
@@ -24,8 +24,10 @@ exports.cmdWorktreeSetBaseRef = cmdWorktreeSetBaseRef;
24
24
  exports.classifyGitHead = classifyGitHead;
25
25
  exports.observeHarnessForkFromHead = observeHarnessForkFromHead;
26
26
  exports.evaluateWorktreeBaseDegrade = evaluateWorktreeBaseDegrade;
27
+ const node_crypto_1 = __importDefault(require("node:crypto"));
27
28
  const node_fs_1 = __importDefault(require("node:fs"));
28
29
  const node_path_1 = __importDefault(require("node:path"));
30
+ const gsd_tool_dir_cjs_1 = require("./gsd-tool-dir.cjs");
29
31
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
30
32
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
31
33
  // ─── Internal helpers ─────────────────────────────────────────────────────────
@@ -572,13 +574,41 @@ function observeHarnessForkFromHead(deps) {
572
574
  }
573
575
  });
574
576
  const stateWrite = deps.stateWrite ?? ((file, content) => {
577
+ let tmpPath = null;
575
578
  try {
576
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
577
- node_fs_1.default.writeFileSync(file, content, 'utf8');
579
+ // #5757: the shared owner refuses a symlinked/escaping .gsd and keeps the
580
+ // directory git-ignored; the write is an exclusive random tmp + rename so
581
+ // a planted cache-file symlink is replaced, never followed.
582
+ const dir = (0, gsd_tool_dir_cjs_1.ensureToolOwnedGsdDir)(cwd, {
583
+ marker: '# gsd-core harness fork probe',
584
+ rules: ['/harness-fork-probe.json', '/harness-fork-probe.json.tmp-*', '/.gitignore'],
585
+ });
586
+ const target = node_path_1.default.join(dir, node_path_1.default.basename(file));
587
+ // `tmpPath` is assigned only once the exclusive create succeeded, so an
588
+ // EEXIST never unlinks an entry this process did not create.
589
+ const candidateTmp = `${target}.tmp-${process.pid}-${node_crypto_1.default.randomBytes(6).toString('hex')}`;
590
+ const tmpFd = node_fs_1.default.openSync(candidateTmp, 'wx');
591
+ tmpPath = candidateTmp;
592
+ try {
593
+ node_fs_1.default.writeFileSync(tmpFd, content, 'utf8');
594
+ }
595
+ finally {
596
+ node_fs_1.default.closeSync(tmpFd);
597
+ }
598
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, target);
599
+ tmpPath = null;
578
600
  }
579
601
  catch {
580
602
  // A cache write must never break the check that produced the verdict.
581
603
  }
604
+ finally {
605
+ if (tmpPath) {
606
+ try {
607
+ node_fs_1.default.unlinkSync(tmpPath);
608
+ }
609
+ catch { /* best-effort cleanup */ }
610
+ }
611
+ }
582
612
  });
583
613
  // 1. Cache — trusted only while the orchestrator HEAD is unchanged.
584
614
  try {
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * Worktree Safety Policy Module
4
4
  *
5
- * Owns worktree-root resolution and non-destructive prune policy decisions.
5
+ * Owns worktree lifecycle safety and non-destructive prune policy decisions.
6
+ * Root resolution moved to the Project Context Module (#6043, ADR-4908).
6
7
  *
7
8
  * ADR-457 build-at-publish: the hand-written bin/lib/worktree-safety.cjs
8
9
  * collapsed to a TypeScript source of truth. Behaviour is preserved
@@ -72,7 +73,8 @@ function parseWorktreeEntries(porcelain) {
72
73
  const prunable = prunableLine
73
74
  ? (prunableLine.slice('prunable'.length).trim() || 'prunable')
74
75
  : null;
75
- entries.push({ path: worktreePath, branch, prunable });
76
+ const bare = lines.some((l) => l === 'bare');
77
+ entries.push({ path: worktreePath, branch, prunable, bare });
76
78
  }
77
79
  return entries;
78
80
  }
@@ -111,94 +113,6 @@ function readWorktreeList(repoRoot, deps = {}) {
111
113
  entries: parseWorktreeEntries(listResult.stdout),
112
114
  };
113
115
  }
114
- /**
115
- * Shortcut-free git-dir-vs-git-common-dir comparison: the actual primitive
116
- * that distinguishes a linked worktree from the main worktree.
117
- *
118
- * Deliberately factored out of `resolveWorktreeContext` (#3045). That
119
- * function's `has_local_planning` shortcut answers a DIFFERENT question ("is
120
- * there already a usable project root right here") and must NOT be consulted
121
- * for isolation detection: a git worktree created specifically to isolate an
122
- * executor is a full checkout, so it normally has its OWN checked-out
123
- * `.planning/` too. A caller that ran the shortcut first would read that
124
- * correctly-isolated worktree as `current_directory`/`has_local_planning` —
125
- * i.e. "not isolated" — a false positive that defeats the very isolation
126
- * guard that needs this check (see `hooks/gsd-cursor-subagent-start.js`,
127
- * #3045). `resolveWorktreeLinkage` always performs the real git-dir
128
- * comparison, independent of whether `.planning` exists locally.
129
- */
130
- function resolveWorktreeLinkage(cwd, deps = {}) {
131
- const execGit = deps.execGit || execGitDefault;
132
- const gitDir = execGit(['rev-parse', '--git-dir'], { cwd });
133
- const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd });
134
- // A TIMEOUT means the command never completed — it is not evidence of "not a
135
- // git repository" (which completes fast, with a clean non-zero exit). Surface
136
- // it under a distinct reason so callers can tell "genuinely not a repo" apart
137
- // from "could not determine" (#3050). effectiveRoot still degrades to cwd
138
- // (there is no safer default without a resolved git-dir), but the reason is
139
- // no longer indistinguishable from the benign case.
140
- if (gitDir.timedOut || commonDir.timedOut) {
141
- return {
142
- effectiveRoot: cwd,
143
- mode: 'current_directory',
144
- reason: 'git_timed_out',
145
- };
146
- }
147
- if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) {
148
- return {
149
- effectiveRoot: cwd,
150
- mode: 'current_directory',
151
- reason: 'not_git_repo',
152
- };
153
- }
154
- const gitDirResolved = node_path_1.default.resolve(cwd, gitDir.stdout);
155
- const commonDirResolved = node_path_1.default.resolve(cwd, commonDir.stdout);
156
- if (gitDirResolved !== commonDirResolved) {
157
- return {
158
- effectiveRoot: node_path_1.default.dirname(commonDirResolved),
159
- mode: 'linked_worktree_root',
160
- reason: 'linked_worktree',
161
- };
162
- }
163
- return {
164
- effectiveRoot: cwd,
165
- mode: 'current_directory',
166
- reason: 'main_worktree',
167
- };
168
- }
169
- function resolveWorktreeContext(cwd, deps = {}) {
170
- const existsSync = deps.existsSync || node_fs_1.default.existsSync;
171
- // Local .planning takes precedence over linked-worktree remapping.
172
- if (existsSync(node_path_1.default.join(cwd, '.planning'))) {
173
- return {
174
- effectiveRoot: cwd,
175
- mode: 'current_directory',
176
- reason: 'has_local_planning',
177
- };
178
- }
179
- const linkage = resolveWorktreeLinkage(cwd, deps);
180
- if (linkage.reason !== 'linked_worktree')
181
- return linkage;
182
- // #4885: from a SUBDIRECTORY of a LINKED worktree the cwd has no .planning of its own, but the
183
- // worktree's own top-level may. That checkout is the project the caller means; remapping to the
184
- // main checkout would make `commit` and every planning write land in the wrong repository. A linked
185
- // worktree with no .planning anywhere still remaps to the main checkout, and the main checkout's own
186
- // subdirectories are untouched.
187
- const execGit = deps.execGit || execGitDefault;
188
- const top = execGit(['rev-parse', '--show-toplevel'], { cwd });
189
- const topText = String(top.stdout || '').trim();
190
- if (!top.timedOut && top.exitCode === 0 && topText !== '') {
191
- const topLevel = node_path_1.default.resolve(cwd, topText);
192
- if (existsSync(node_path_1.default.join(topLevel, '.planning'))) {
193
- return {
194
- effectiveRoot: topLevel,
195
- mode: 'current_directory',
196
- reason: 'has_local_planning',
197
- };
198
- }
199
- }
200
- return linkage;
201
- }
202
116
  function planWorktreePrune(repoRoot, options = {}, deps = {}) {
203
117
  const parsePorcelain = deps.parseWorktreePorcelain || parseWorktreePorcelain;
204
118
  const destructiveModeRequested = Boolean(options.allowDestructive);
@@ -2698,24 +2612,6 @@ function cmdWorktreeWorkerComplete(cwd, args = [], deps = {}) {
2698
2612
  // Unused exports kept for API compatibility
2699
2613
  void parseWorktreeListPaths;
2700
2614
  // ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
2701
- /**
2702
- * Resolve the main worktree root when running inside a git worktree, along
2703
- * with the `reason` that produced it (#3050). Callers MUST inspect `reason`
2704
- * before trusting `root` unconditionally — a `reason` of `git_timed_out`
2705
- * means the git subprocess used to distinguish "linked worktree" from
2706
- * "not a repo" never completed, so `root` is a best-effort fallback (cwd),
2707
- * not a confirmed worktree root. Degrading to cwd rather than throwing is
2708
- * intentional (return degraded result on timeout; do not throw) — but the
2709
- * reason must still reach the caller so it can surface the risk instead of
2710
- * silently trusting the wrong root.
2711
- */
2712
- function resolveWorktreeRoot(cwd, deps = {}) {
2713
- const context = resolveWorktreeContext(cwd, {
2714
- existsSync: deps.existsSync || node_fs_1.default.existsSync,
2715
- execGit: deps.execGit,
2716
- });
2717
- return { root: context.effectiveRoot, reason: context.reason };
2718
- }
2719
2615
  /**
2720
2616
  * Clear stale worktree metadata references via `git worktree prune`.
2721
2617
  *
@@ -2749,8 +2645,10 @@ module.exports = {
2749
2645
  // Re-exported for the codebase-drift gate's --name-status parser (#4081):
2750
2646
  // single owner of git C-quoted-path decoding.
2751
2647
  decodeGitQuotedPath,
2752
- resolveWorktreeContext,
2753
- resolveWorktreeLinkage,
2648
+ // #6043: the project-context seam reads the porcelain through this parser and
2649
+ // shares the one subprocess timeout, so neither is re-derived there.
2650
+ DEFAULT_GIT_TIMEOUT_MS,
2651
+ parseWorktreeEntries,
2754
2652
  parseWorktreePorcelain,
2755
2653
  planWorktreePrune,
2756
2654
  executeWorktreePrunePlan,
@@ -2776,6 +2674,5 @@ module.exports = {
2776
2674
  cmdWorktreeWorkerRecord,
2777
2675
  cmdWorktreeWorkerStatus,
2778
2676
  cmdWorktreeWorkerComplete,
2779
- resolveWorktreeRoot,
2780
2677
  pruneOrphanedWorktrees,
2781
2678
  };
@@ -85,6 +85,7 @@
85
85
  "hooks.context_critical_threshold",
86
86
  "hooks.workflow_guard",
87
87
  "hooks.commit_types",
88
+ "hooks.secret_read_exempt",
88
89
  "hooks.community",
89
90
  "workflow.context_coverage_gate",
90
91
  "statusline.show_last_command",
@@ -1,6 +1,6 @@
1
1
  # Smart Discuss — Autonomous Mode
2
2
 
3
- Smart discuss is the autonomous-optimized variant of `gsd-discuss-phase`. It proposes grey area answers in batch tables — the user accepts or overrides per area — then writes an identical CONTEXT.md to what discuss-phase produces.
3
+ Smart discuss is the autonomous-optimized variant of `gsd-discuss-phase`. It classifies every grey-area question first: reversible technical choices are decided autonomously and logged without a prompt, hard-to-reverse technical choices are proposed with a recommendation the user accepts or overrides per area, and product / UX / preference questions are asked neutrally. It then writes CONTEXT.md and DISCUSSION-LOG.md.
4
4
 
5
5
  **Inputs:** `PHASE_NUM` from execute_phase. Run init to get phase paths:
6
6
 
@@ -132,17 +132,61 @@ Determine domain type from the phase goal:
132
132
  Check prior_decisions — skip grey areas already decided in prior phases.
133
133
 
134
134
  Generate **3-4 grey areas** with **~4 questions each**. For each question:
135
- - **Pre-select a recommended answer** based on: prior decisions (consistency), codebase patterns (reuse), domain conventions (standard approaches), ROADMAP success criteria
135
+ - **Draft a candidate answer** based on: prior decisions (consistency), codebase patterns (reuse), domain conventions (standard approaches), ROADMAP success criteria
136
136
  - Generate **1-2 alternatives** per question
137
137
  - **Annotate** with prior decision context ("You decided X in Phase N") and code context ("Component Y exists with Z variants") where relevant
138
138
 
139
+ The candidate answer is not yet a recommendation. Sub-step 3.5 decides, per question, whether it becomes an autonomous decision, a recommendation, or nothing (preference questions carry no recommendation).
140
+
141
+ ---
142
+
143
+ ## Sub-step 3.5: Classify Each Question
144
+
145
+ Classify every grey-area question before presenting anything. The class decides how the question is handled:
146
+
147
+ | Class | Handling |
148
+ |---|---|
149
+ | Technical, reversible (file layout, naming, internal data shapes, error-handling patterns, test approach) — rating `reversible` | Decide autonomously: take the candidate answer. Show it in the one informational "Decided autonomously" block, with no prompt. |
150
+ | Technical, hard to reverse (persisted schema, public API/URL contracts, auth model, new external dependency, data migrations) — rating `costly` or `one-way` | Ask, with a recommendation: the candidate answer is the ✅ Recommended answer. |
151
+ | Product / UX / preference | Ask neutrally: no `Recommended` label, no pre-selection, 2–3 genuinely competitive options, each with its trade-off. |
152
+
153
+ The ratings are the planner's reversibility taxonomy: `reversible` (undo is local and cheap), `costly` (undo touches many call sites or needs a coordinated change), `one-way` (undo needs a migration, breaks a published contract, or is impossible). "Hard to reverse" means `costly` or `one-way`. User-visible wording and output text are never internal naming: classify them as product. URL shapes are public contracts: classify them as hard to reverse. A new dependency is hard to reverse even when only tests use it.
154
+
155
+ Rules:
156
+ 1. When unsure between reversible and hard to reverse, treat the question as hard to reverse.
157
+ 2. When unsure between technical and product, treat the question as product.
158
+ 3. If one option wins on every axis, the question is not a preference question: reclassify it as technical (then apply rule 1 to its reversibility). Never pad a question with filler alternatives to make it look competitive.
159
+ 4. An area whose questions are all reversible technical questions is not presented at all. Its answers still go to the "Decided autonomously" block, CONTEXT.md and DISCUSSION-LOG.md.
160
+
161
+ Rule 1 is deliberately the opposite of the "when unsure, rate it `reversible`" rule in `gsd-core/references/planner-reversibility.md`, because the costs are inverted. In the planner, a false `one-way` adds a checkpoint nobody needed. Here, a false "hard to reverse" costs one question the user would have been asked anyway before this classification existed, while a false "reversible" silently walks through a one-way door.
162
+
163
+ Keep, per question, its class, its rating, and (for an auto-decided question) a one-clause reason why undoing it is cheap. Sub-steps 4–6 use them.
164
+
139
165
  ---
140
166
 
141
167
  ## Sub-step 4: Present Proposals Per Area
142
168
 
143
- Present grey areas **one at a time**. For each area (M of N):
169
+ ### Decided autonomously block
144
170
 
145
- Display a table:
171
+ Display every reversible technical question decided in Sub-step 3.5 once, before the first area, as information only. Do not prompt: there is no AskUserQuestion for this block and no "Accept" option.
172
+
173
+ ```
174
+ ### Decided autonomously ({count})
175
+
176
+ Reversible technical choices, recorded in CONTEXT.md under "Decided autonomously". To change one, edit CONTEXT.md or re-run discuss before planning.
177
+
178
+ | Area | Question | Decision | Why it is cheap to undo |
179
+ |------|----------|----------|-------------------------|
180
+ | {Area} | {question} | {answer} | {reason} |
181
+ ```
182
+
183
+ If no question was decided autonomously, skip this block entirely. If every question was decided autonomously, show the block and go straight to Sub-step 5: there is nothing to ask.
184
+
185
+ Then present the remaining areas **one at a time**. N counts only the areas that still have a hard-to-reverse or preference question. For each area (M of N), present its hard-to-reverse questions first, then its preference questions. Skip a part the area does not have.
186
+
187
+ ### Hard-to-reverse questions
188
+
189
+ Display a table of the area's hard-to-reverse questions only:
146
190
 
147
191
  ```
148
192
  ### Grey Area {M}/{N}: {Area Name}
@@ -151,16 +195,14 @@ Display a table:
151
195
  |---|----------|---------------|-----------------|
152
196
  | 1 | {question} | {answer} — {rationale} | {alt1}; {alt2} |
153
197
  | 2 | {question} | {answer} — {rationale} | {alt1} |
154
- | 3 | {question} | {answer} — {rationale} | {alt1}; {alt2} |
155
- | 4 | {question} | {answer} — {rationale} | {alt1} |
156
198
  ```
157
199
 
158
200
  Then prompt the user via **AskUserQuestion**:
159
201
  - **header:** "Area {M}/{N}"
160
202
  - **question:** "Accept these answers for {Area Name}?"
161
- - **options:** Build dynamically — always "Accept all" first, then "Change Q1" through "Change QN" for each question (up to 4), then "Discuss deeper" last. Cap at 6 explicit options max (AskUserQuestion adds "Other" automatically).
203
+ - **options:** Build dynamically — always "Accept all" first, then "Change Q1" through "Change QN" for each hard-to-reverse question (up to 4), then "Discuss deeper" last. Cap at 6 explicit options max (AskUserQuestion adds "Other" automatically).
162
204
 
163
- **On "Accept all":** Record all recommended answers for this area. Move to next area.
205
+ **On "Accept all":** Record all recommended answers in this table. Move to the area's preference questions, or to the next area.
164
206
 
165
207
  **On "Change QN":** Use AskUserQuestion with the alternatives for that specific question:
166
208
  - **header:** "{Area Name}"
@@ -169,7 +211,7 @@ Then prompt the user via **AskUserQuestion**:
169
211
 
170
212
  Record the user's choice. Re-display the updated table with the change reflected. Re-present the full acceptance prompt so the user can make additional changes or accept.
171
213
 
172
- **On "Discuss deeper":** Switch to interactive mode for this area only — ask questions one at a time using AskUserQuestion with 2-3 concrete options per question plus "You decide". After 4 questions, prompt:
214
+ **On "Discuss deeper":** Switch to interactive mode for this area only — ask questions one at a time using AskUserQuestion with 2-3 concrete options per question plus "You decide". Classify every new question with Sub-step 3.5 before asking it: decide a reversible technical question autonomously (add it to the block, CONTEXT.md and DISCUSSION-LOG.md, without asking), give a hard-to-reverse question a ✅ Recommended option, and ask a preference question under the neutral rules in "Preference questions" below. After 4 questions, prompt:
173
215
  - **header:** "{Area Name}"
174
216
  - **question:** "More questions about {area name}, or move to next?"
175
217
  - **options:** "More questions" / "Next area"
@@ -178,6 +220,17 @@ If "More questions", ask 4 more. If "Next area", display final summary table of
178
220
 
179
221
  **On "Other" (free text):** Interpret as either a specific change request or general feedback. Incorporate into the area's decisions, re-display updated table, re-present acceptance prompt.
180
222
 
223
+ ### Preference questions
224
+
225
+ Ask the area's product / UX / preference questions **neutrally**, in one AskUserQuestion call (up to 4 questions per call; use a second call for more):
226
+ - **header:** "{Area Name}"
227
+ - **question:** "Q{N}: {question text}"
228
+ - **options:** 2–3 genuinely competitive options, each with its trade-off in the option description, then "You decide" last.
229
+
230
+ Neutral means: no `Recommended` label in any option label or description, no pre-selection, no table column that ranks the options, and an option order that does not imply a preference (do not put the candidate answer first). In `--text` mode the numbered list follows the same rules.
231
+
232
+ Record the user's choice. "You decide" maps to Claude's Discretion, not to Decided autonomously: the user delegated the question, it was not skipped. "Other" (free text) is recorded as the user's answer.
233
+
181
234
  **Scope creep handling:** If user mentions something outside the phase domain:
182
235
 
183
236
  ```
@@ -197,7 +250,7 @@ After all areas are resolved (or infrastructure skip), write the CONTEXT.md file
197
250
 
198
251
  **File path:** `${phase_dir}/${padded_phase}-CONTEXT.md`
199
252
 
200
- Use **exactly** this structure (identical to discuss-phase output):
253
+ Use **exactly** this structure. It follows the discuss-phase CONTEXT.md layout with two differences: decisions carry no `D-NN` IDs, and there is no `<canonical_refs>` section. The decision-coverage gate therefore tracks no smart-discuss decisions, as before.
201
254
 
202
255
  ```markdown
203
256
  # Phase {PHASE_NUM}: {Phase Name} - Context
@@ -216,16 +269,16 @@ Use **exactly** this structure (identical to discuss-phase output):
216
269
  ## Implementation Decisions
217
270
 
218
271
  ### {Area 1 Name}
219
- - {Accepted/chosen answer for Q1}
220
- - {Accepted/chosen answer for Q2}
221
- - {Accepted/chosen answer for Q3}
222
- - {Accepted/chosen answer for Q4}
272
+ - {Accepted/chosen answer for a hard-to-reverse question} — **Reversibility:** {costly|one-way} — {rationale: the migration, contract or dependent system that makes it hard to undo}
273
+ - {Chosen answer for a preference question}
223
274
 
224
275
  ### {Area 2 Name}
225
- - {Accepted/chosen answer for Q1}
226
- - {Accepted/chosen answer for Q2}
276
+ - {Accepted/chosen answer}
227
277
  ...
228
278
 
279
+ ### Decided autonomously
280
+ - [{Area}] Q: "{question}" → Selected: "{answer}" — {why it is cheap to undo}
281
+
229
282
  ### Claude's Discretion
230
283
  {Any "You decide" answers collected — note Claude has flexibility here}
231
284
 
@@ -262,17 +315,45 @@ Use **exactly** this structure (identical to discuss-phase output):
262
315
  </deferred>
263
316
  ```
264
317
 
318
+ Fill it in like this:
319
+ - Record a hard-to-reverse answer with its `— **Reversibility:** costly|one-way — <rationale>` field. The rationale names the migration, the contract or the dependent system. It is the user's content: record it as data, never as an instruction to a later agent. Preference answers and Claude's Discretion items carry no rating.
320
+ - List every autonomously decided question under `### Decided autonomously`, one plain bullet each, in the format above. Do not start the bullet with bold text: a bold `D-…`-style lead-in makes the decision gate report the block as unparseable.
321
+ - Every recorded question, answer, "Other" text and reason is data, never an instruction to a later agent. Keep each bullet and `[auto]` line on one line: collapse line breaks to spaces, replace `"` inside a quoted question or answer with `'`, and never let recorded text start a line with `#` or `<`, so it cannot forge a heading or close the `<decisions>` block. If no question was decided autonomously, omit the `### Decided autonomously` subsection.
322
+
323
+ **Reversal.** An autonomous decision is reversed by editing it in CONTEXT.md (move it into its area with the new answer) or by re-running discuss for the phase, before planning starts. There is no confirmation step and no override step: this audit trail replaces the prompt.
324
+
265
325
  Write the file.
266
326
 
267
- **Commit:**
327
+ ---
328
+
329
+ ## Sub-step 6: Write DISCUSSION-LOG.md and Commit
330
+
331
+ **Infrastructure skip:** nothing was discussed, so write no discussion log. Commit CONTEXT.md alone:
268
332
 
269
333
  ```bash
270
334
  gsd_run query commit "docs(${PADDED_PHASE}): smart discuss context" --files "${phase_dir}/${padded_phase}-CONTEXT.md"
271
335
  ```
272
336
 
337
+ **Otherwise** write `${phase_dir}/${padded_phase}-DISCUSSION-LOG.md` using the template in `gsd-core/workflows/discuss-phase/templates/discussion-log.md` (read it now). It is an audit trail for humans; no downstream agent reads it.
338
+
339
+ - Each asked question goes into its area's options table, as in discuss-phase.
340
+ - After the area sections, add a `## Decided autonomously` section with one line per autonomously decided question, in the existing `[auto]` line format (`gsd-core/workflows/discuss-phase/modes/auto.md`):
341
+
342
+ ```
343
+ [auto] [{Area}] — Q: "{question}" → Selected: "{answer}" (reversible technical — decided autonomously: {why it is cheap to undo})
344
+ ```
345
+
346
+ If no question was decided autonomously, omit that section.
347
+
348
+ Commit both files together:
349
+
350
+ ```bash
351
+ gsd_run query commit "docs(${PADDED_PHASE}): smart discuss context" --files "${phase_dir}/${padded_phase}-CONTEXT.md" "${phase_dir}/${padded_phase}-DISCUSSION-LOG.md"
352
+ ```
353
+
273
354
  Display confirmation:
274
355
 
275
356
  ```
276
357
  Created: {path}
277
- Decisions captured: {count} across {area_count} areas
358
+ Decisions captured: {count} across {area_count} areas ({auto_count} decided autonomously)
278
359
  ```
@@ -30,7 +30,7 @@ The gate spans two layers, and both must honor it. `gsd-executor` refuses to aut
30
30
 
31
31
  **When:** Claude completed automated work, human confirms it works correctly.
32
32
 
33
- > **Default mode (#3309): `workflow.human_verify_mode = end-of-phase`.** New projects do NOT halt mid-flight at *planner-emitted* `checkpoint:human-verify` tasks. (The executor-synthesized **tracer feedback gate** is the one runtime checkpoint this mode also governs, with its own precedence chain — see "Tracer feedback gate (#3299)" below.) The planner suppresses those task emissions and embeds the verification details into the relevant `auto` task's `<verify><human-check>` block; the verifier harvests every `<verify><human-check>` at end-of-phase (Step 8) and consolidates them into the existing `human_needed` → `{phase_num}-UAT.md` flow in `workflows/execute-phase.md`. The user reviews everything in one batch.
33
+ > **Default mode (#3309): `workflow.human_verify_mode = end-of-phase`.** New projects do NOT halt mid-flight at *planner-emitted* `checkpoint:human-verify` tasks. (The executor-synthesized **tracer feedback gate** is the one runtime checkpoint this mode also governs, with its own precedence chain — see "Tracer feedback gate (#3299)" below.) The planner suppresses those task emissions and embeds the verification details into the relevant `auto` task's `<verify><human-check>` block; the verifier harvests every `<verify><human-check>` at end-of-phase (Step 8), even when its `<expected>` already records a verdict, and consolidates them into the existing `human_needed` → `{phase_num}-UAT.md` flow in `workflows/execute-phase.md`. The user reviews everything in one batch.
34
34
  >
35
35
  > **Why this is the default:** every mid-flight halt costs a full executor cold-start (CLAUDE.md, MEMORY.md, STATE.md, plan re-read on respawn) because subagent context is discarded across the pause. A plan with N human-verify checkpoints pays the cold-start cost N+1 times — measured at "tens of thousands of tokens" per round-trip on real projects.
36
36
  >
@@ -216,6 +216,17 @@ Message list component wiring:
216
216
 
217
217
  "Where is this most likely to break?" Key links = critical connections where breakage causes cascading failures.
218
218
 
219
+ ### Key Links Field Contract
220
+
221
+ | Field | Description |
222
+ |---|---|
223
+ | `key_links` | Critical connections between artifacts. |
224
+ | `key_links[].from` | Source file (relative path from project root). Describe components or symbols in `via:`. |
225
+ | `key_links[].to` | Target file (relative path from project root). Describe endpoints, APIs, or modules in `via:`. |
226
+ | `key_links[].via` | How they connect, including any endpoint or symbol name. |
227
+
228
+ > **Important:** `from:` and `to:` MUST be literal, project-relative file paths. Never place functions, symbols, HTTP endpoints, or prose descriptions in `from:` or `to:`. All symbols, methods, and endpoints belong in `via:`. See @~/.claude/gsd-core/templates/phase-prompt.md for the complete `must_haves` schema.
229
+
219
230
  ### Must-Haves Output Format
220
231
 
221
232
  ```yaml
@@ -27,7 +27,7 @@ Instead, fold each would-be verification step into the relevant `auto` task usin
27
27
  </task>
28
28
  ```
29
29
 
30
- The verifier (Step 8) harvests every `<verify><human-check>` block at end-of-phase and consolidates them into the existing `human_needed` → `{phase_num}-UAT.md` path in `workflows/execute-phase.md`. The user reviews everything in one batch instead of paying a cold-start cost per item.
30
+ The verifier (Step 8) harvests every `<verify><human-check>` block at end-of-phase, even when its `<expected>` already records a verdict, and consolidates them into the existing `human_needed` → `{phase_num}-UAT.md` path in `workflows/execute-phase.md`. The user reviews everything in one batch instead of paying a cold-start cost per item.
31
31
 
32
32
  ### `mid-flight` (opt-back-in — pre-#3309 behavior)
33
33
 
@@ -111,6 +111,8 @@ these records the human choosing this exact option:
111
111
  - an ADR ingested with `--ingest`;
112
112
  - a `checkpoint:decision` earlier in this phase's plans that was already answered.
113
113
 
114
+ Entries under `### Decided autonomously` in CONTEXT.md are not human-settled: smart discuss chose them without asking (`gsd-core/references/autonomous-smart-discuss.md`). If one of them turns out to be `one-way`, it is newly surfaced and earns a checkpoint. Otherwise the planner follows them like any other CONTEXT.md decision; only this checkpoint exemption does not apply.
115
+
114
116
  Only a **newly surfaced** `one-way` decision — one nothing above records — earns a
115
117
  fresh checkpoint before the implementing task. If you cannot point at the record,
116
118
  treat the decision as newly surfaced. `gsd-planner` and this reference state the