@opengsd/gsd-core 1.17.0 → 1.18.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 (176) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-advisor-researcher.compact.md +1 -1
  4. package/agents/gsd-advisor-researcher.md +1 -1
  5. package/agents/gsd-assumptions-analyzer.compact.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +1 -1
  7. package/agents/gsd-code-fixer.compact.md +1 -1
  8. package/agents/gsd-code-fixer.md +1 -1
  9. package/agents/gsd-code-reviewer.compact.md +1 -1
  10. package/agents/gsd-code-reviewer.md +1 -1
  11. package/agents/gsd-codebase-mapper.compact.md +1 -1
  12. package/agents/gsd-codebase-mapper.md +1 -1
  13. package/agents/gsd-debugger.md +1 -1
  14. package/agents/gsd-doc-verifier.compact.md +4 -4
  15. package/agents/gsd-doc-verifier.md +4 -3
  16. package/agents/gsd-doc-writer.compact.md +23 -5
  17. package/agents/gsd-doc-writer.md +19 -6
  18. package/agents/gsd-eval-auditor.compact.md +1 -1
  19. package/agents/gsd-eval-auditor.md +1 -1
  20. package/agents/gsd-executor.md +7 -3
  21. package/agents/gsd-integration-checker.compact.md +1 -1
  22. package/agents/gsd-integration-checker.md +1 -1
  23. package/agents/gsd-nyquist-auditor.compact.md +1 -1
  24. package/agents/gsd-nyquist-auditor.md +1 -1
  25. package/agents/gsd-phase-researcher.md +1 -1
  26. package/agents/gsd-plan-checker.md +2 -1
  27. package/agents/gsd-planner.md +5 -4
  28. package/agents/gsd-project-researcher.compact.md +1 -1
  29. package/agents/gsd-project-researcher.md +1 -1
  30. package/agents/gsd-research-synthesizer.compact.md +5 -5
  31. package/agents/gsd-research-synthesizer.md +5 -5
  32. package/agents/gsd-roadmapper.compact.md +1 -1
  33. package/agents/gsd-roadmapper.md +1 -1
  34. package/agents/gsd-security-auditor.compact.md +1 -1
  35. package/agents/gsd-security-auditor.md +1 -1
  36. package/agents/gsd-ui-auditor.compact.md +1 -1
  37. package/agents/gsd-ui-auditor.md +1 -1
  38. package/agents/gsd-ui-checker.compact.md +1 -1
  39. package/agents/gsd-ui-checker.md +1 -1
  40. package/agents/gsd-ui-researcher.compact.md +13 -1
  41. package/agents/gsd-ui-researcher.md +28 -1
  42. package/agents/gsd-verifier.md +1 -1
  43. package/bin/install.js +38 -2
  44. package/commands/gsd/code-review.md +8 -0
  45. package/commands/gsd/pause-work.md +1 -1
  46. package/gsd-core/bin/gsd-tools.cjs +18 -11
  47. package/gsd-core/bin/lib/capability-ledger.cjs +25 -0
  48. package/gsd-core/bin/lib/capability-lock.cjs +29 -7
  49. package/gsd-core/bin/lib/capability-registry.cjs +70 -64
  50. package/gsd-core/bin/lib/capability-source.cjs +3 -1
  51. package/gsd-core/bin/lib/capability-state.cjs +6 -3
  52. package/gsd-core/bin/lib/capability-validator.cjs +6 -4
  53. package/gsd-core/bin/lib/commands.cjs +5 -1
  54. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  55. package/gsd-core/bin/lib/decision-coverage-support.cjs +19 -0
  56. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +15 -0
  57. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +13 -0
  58. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +23 -3
  59. package/gsd-core/bin/lib/gate-predicate.cjs +8 -12
  60. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +60 -5
  61. package/gsd-core/bin/lib/init.cjs +11 -4
  62. package/gsd-core/bin/lib/install-effort-resolver.cjs +2 -2
  63. package/gsd-core/bin/lib/install-model-override-resolver.cjs +4 -3
  64. package/gsd-core/bin/lib/install-profiles.cjs +5 -3
  65. package/gsd-core/bin/lib/phase.cjs +120 -11
  66. package/gsd-core/bin/lib/report-parser.cjs +57 -1
  67. package/gsd-core/bin/lib/review-lane-invocation.cjs +40 -0
  68. package/gsd-core/bin/lib/review-reviewer-selection.cjs +20 -1
  69. package/gsd-core/bin/lib/roadmap.cjs +56 -5
  70. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +47 -2
  71. package/gsd-core/bin/lib/runtime-homes.cjs +18 -0
  72. package/gsd-core/bin/lib/shell-command-projection.cjs +2 -0
  73. package/gsd-core/bin/lib/smart-entry.cjs +24 -16
  74. package/gsd-core/bin/lib/state-document.cjs +37 -3
  75. package/gsd-core/bin/lib/state-transition.cjs +31 -10
  76. package/gsd-core/bin/lib/state.cjs +90 -60
  77. package/gsd-core/bin/lib/task-command-router.cjs +2 -3
  78. package/gsd-core/bin/lib/tdd-red-evidence.cjs +5 -0
  79. package/gsd-core/bin/lib/tdd-test-files.cjs +42 -0
  80. package/gsd-core/bin/lib/verify.cjs +36 -1
  81. package/gsd-core/references/agent-contracts.md +1 -1
  82. package/gsd-core/references/agent-skills-bootstrap.md +26 -7
  83. package/gsd-core/references/git-integration.md +3 -3
  84. package/gsd-core/references/planner-reversibility.md +18 -4
  85. package/gsd-core/references/planner-verify-command-grounding.md +18 -0
  86. package/gsd-core/references/tdd.md +4 -3
  87. package/gsd-core/references/verifier-phase-gates.md +8 -1
  88. package/gsd-core/workflows/add-backlog.md +10 -2
  89. package/gsd-core/workflows/add-phase.md +5 -1
  90. package/gsd-core/workflows/add-tests.md +5 -1
  91. package/gsd-core/workflows/add-todo.md +5 -1
  92. package/gsd-core/workflows/ai-integration-phase.md +5 -1
  93. package/gsd-core/workflows/audit-milestone.md +21 -9
  94. package/gsd-core/workflows/autonomous/templates/minimal-context.md +41 -0
  95. package/gsd-core/workflows/autonomous.md +52 -64
  96. package/gsd-core/workflows/check-todos.md +5 -1
  97. package/gsd-core/workflows/code-review-fix.md +5 -1
  98. package/gsd-core/workflows/code-review.md +5 -1
  99. package/gsd-core/workflows/complete-milestone/detail/elaboration.md +5 -1
  100. package/gsd-core/workflows/complete-milestone.md +25 -5
  101. package/gsd-core/workflows/debug.md +5 -1
  102. package/gsd-core/workflows/diagnose-issues.md +5 -1
  103. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -1
  104. package/gsd-core/workflows/discuss-phase-assumptions.md +11 -3
  105. package/gsd-core/workflows/discuss-phase.md +5 -1
  106. package/gsd-core/workflows/docs-update/detail/elaboration.md +7 -6
  107. package/gsd-core/workflows/docs-update.md +27 -5
  108. package/gsd-core/workflows/edit-phase.md +5 -1
  109. package/gsd-core/workflows/eval-review.md +11 -3
  110. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +5 -1
  111. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +12 -6
  112. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +19 -2
  113. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +18 -1
  114. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +5 -1
  115. package/gsd-core/workflows/execute-phase.md +37 -6
  116. package/gsd-core/workflows/execute-plan.md +8 -4
  117. package/gsd-core/workflows/extract-learnings.md +6 -2
  118. package/gsd-core/workflows/forensics.md +5 -1
  119. package/gsd-core/workflows/import.md +5 -1
  120. package/gsd-core/workflows/insert-phase.md +5 -1
  121. package/gsd-core/workflows/manager.md +10 -2
  122. package/gsd-core/workflows/map-codebase.md +5 -1
  123. package/gsd-core/workflows/milestone-summary.md +10 -2
  124. package/gsd-core/workflows/new-milestone.md +13 -5
  125. package/gsd-core/workflows/new-project.md +9 -5
  126. package/gsd-core/workflows/next.md +8 -4
  127. package/gsd-core/workflows/pause-work.md +3 -3
  128. package/gsd-core/workflows/plan-phase/steps/auto-advance-check.md +70 -0
  129. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +5 -1
  130. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +6 -2
  131. package/gsd-core/workflows/plan-phase.md +41 -73
  132. package/gsd-core/workflows/plan-review-convergence.md +17 -5
  133. package/gsd-core/workflows/plant-seed.md +8 -4
  134. package/gsd-core/workflows/progress.md +6 -2
  135. package/gsd-core/workflows/quick-batch.md +5 -1
  136. package/gsd-core/workflows/quick.md +22 -7
  137. package/gsd-core/workflows/remove-phase.md +5 -1
  138. package/gsd-core/workflows/resume-project.md +5 -1
  139. package/gsd-core/workflows/review.md +6 -2
  140. package/gsd-core/workflows/scan.md +5 -1
  141. package/gsd-core/workflows/secure-phase.md +6 -2
  142. package/gsd-core/workflows/settings.md +3 -3
  143. package/gsd-core/workflows/ship.md +13 -10
  144. package/gsd-core/workflows/thread.md +10 -2
  145. package/gsd-core/workflows/transition.md +5 -1
  146. package/gsd-core/workflows/ui-phase.md +5 -1
  147. package/gsd-core/workflows/ui-review.md +6 -2
  148. package/gsd-core/workflows/ultraplan-phase.md +5 -1
  149. package/gsd-core/workflows/update.md +5 -1
  150. package/gsd-core/workflows/validate-phase.md +6 -2
  151. package/gsd-core/workflows/verify-work/detail/elaboration.md +1 -1
  152. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -1
  153. package/gsd-core/workflows/verify-work.md +11 -3
  154. package/hooks/dist/gsd-agent-isolation-guard.js +3 -1
  155. package/hooks/dist/gsd-cursor-subagent-start.js +2 -1
  156. package/hooks/dist/gsd-secret-read-guard.js +72 -14
  157. package/hooks/dist/gsd-validate-commit.sh +27 -5
  158. package/hooks/gsd-agent-isolation-guard.js +3 -1
  159. package/hooks/gsd-cursor-subagent-start.js +2 -1
  160. package/hooks/gsd-secret-read-guard.js +72 -14
  161. package/hooks/gsd-validate-commit.sh +27 -5
  162. package/package.json +1 -1
  163. package/scripts/audit-workflow-script-paths.cjs +1 -1
  164. package/scripts/diagnose-validate-commit-in-tier.cjs +270 -0
  165. package/scripts/diagnose-validate-commit-phases.cjs +348 -0
  166. package/scripts/diagnose-windows-spawn-watcher.cjs +317 -0
  167. package/scripts/docs-guard-registry.cjs +2 -0
  168. package/scripts/lib/macos-conformance-tier.generated.cjs +9 -0
  169. package/scripts/lib/platform-conformance-tier.generated.cjs +16 -0
  170. package/scripts/lint-phase-arg-assignment.cjs +2 -2
  171. package/scripts/lint-workflow-shellcheck-baseline.json +16 -16
  172. package/scripts/release-tarball-smoke.cjs +1 -0
  173. package/scripts/xtrace-phase-timing.cjs +205 -0
  174. package/skills/gsd-code-review/SKILL.md +8 -0
  175. package/skills/gsd-pause-work/SKILL.md +1 -1
  176. package/vscode/package.json +1 -1
@@ -14,6 +14,7 @@ exports.isUnfilledFieldValue = isUnfilledFieldValue;
14
14
  exports.leadingCalendarDate = leadingCalendarDate;
15
15
  exports.isRealCalendarDate = isRealCalendarDate;
16
16
  exports.stateFieldContinuation = stateFieldContinuation;
17
+ exports.fieldContinuationEnd = fieldContinuationEnd;
17
18
  exports.stateExtractField = stateExtractField;
18
19
  exports.stateFieldValue = stateFieldValue;
19
20
  exports.stateCurrentPositionSlice = stateCurrentPositionSlice;
@@ -378,7 +379,16 @@ function stateFieldContinuation(content, fieldName) {
378
379
  if (!rest.startsWith('\n'))
379
380
  return null; // end of file: nothing follows
380
381
  const lines = rest.slice(1).split('\n').map((line) => line.replace(/\r$/, ''));
381
- const continuation = [];
382
+ const continuation = lines.slice(0, continuationLineCount(lines)).map((line) => line.trim());
383
+ return continuation.length ? continuation.join(' ') : null;
384
+ }
385
+ /**
386
+ * How many of the lines directly below a single-line field belong to its value: the scan
387
+ * `stateFieldContinuation` reports on, shared so a writer that replaces the value removes exactly
388
+ * the lines a validator would call its continuation (#4945). `lines` are CR-free.
389
+ */
390
+ function continuationLineCount(lines) {
391
+ let count = 0;
382
392
  for (let i = 0; i < lines.length; i++) {
383
393
  const line = lines[i];
384
394
  if (!line.trim())
@@ -391,9 +401,33 @@ function stateFieldContinuation(content, fieldName) {
391
401
  // title, so stop before consuming it rather than after.
392
402
  if (i + 1 < lines.length && SETEXT_UNDERLINE_RE.test(lines[i + 1]))
393
403
  break;
394
- continuation.push(line.trim());
404
+ count++;
395
405
  }
396
- return continuation.length ? continuation.join(' ') : null;
406
+ return count;
407
+ }
408
+ /**
409
+ * The index just past a single-line field's value AND the continuation lines that wrap it
410
+ * (#4945). `valueEnd` is where the field's own line ends (before its line terminator). Replacing
411
+ * `content.slice(valueEnd, result)` with nothing removes every continuation line and the
412
+ * terminator in front of each, so the terminator after the last one stays. Returns `valueEnd`
413
+ * when nothing continues the value.
414
+ */
415
+ function fieldContinuationEnd(content, valueEnd) {
416
+ const tail = content.slice(valueEnd);
417
+ const first = /^\r?\n/.exec(tail);
418
+ if (!first)
419
+ return valueEnd;
420
+ const rawLines = tail.slice(first[0].length).split('\n');
421
+ const count = continuationLineCount(rawLines.map((line) => line.replace(/\r$/, '')));
422
+ if (count === 0)
423
+ return valueEnd;
424
+ let removed = first[0].length + (count - 1);
425
+ for (let i = 0; i < count; i++)
426
+ removed += rawLines[i].length;
427
+ // The terminator after the last continuation line stays; on a CRLF document that includes its CR.
428
+ if (rawLines[count - 1].endsWith('\r'))
429
+ removed -= 1;
430
+ return valueEnd + removed;
397
431
  }
398
432
  function stateExtractField(content, fieldName) {
399
433
  const escaped = (0, pattern_cjs_1.escapeRegex)(fieldName);
@@ -922,6 +922,8 @@ function transitionCore(content, intent, deps) {
922
922
  */
923
923
  function beginPhaseCore(content, intent, deps) {
924
924
  const updated = [];
925
+ // #4945: a re-entry whose earlier plans already have summaries starts at the first plan still to run.
926
+ const startPlan = intent.startPlan && intent.startPlan > 0 ? Math.floor(intent.startPlan) : 1;
925
927
  // #1255: body-field replacements operate on body only (frontmatter stripped),
926
928
  // not on the full content. The YAML `status:` key matches `^Status:\s*`
927
929
  // before the body pipe-table row if full content is passed.
@@ -977,7 +979,7 @@ function beginPhaseCore(content, intent, deps) {
977
979
  if (intent.phaseName) {
978
980
  tryField('Current Phase Name', intent.phaseName);
979
981
  }
980
- tryField('Current Plan', '1');
982
+ tryField('Current Plan', String(startPlan));
981
983
  if (intent.planCount) {
982
984
  tryField('Total Plans in Phase', String(intent.planCount));
983
985
  }
@@ -996,14 +998,21 @@ function beginPhaseCore(content, intent, deps) {
996
998
  // end-of-line (inert here since `.` never crosses line terminators
997
999
  // without `/s`, which is not set).
998
1000
  const focusPattern = /^([ \t]*\*\*Current focus:\*\*[ \t]*)(.*)$/im;
999
- if (focusPattern.test(body)) {
1000
- body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
1001
+ const focusMatch = focusPattern.exec(body);
1002
+ if (focusMatch) {
1003
+ // #4945: a value an author wrapped onto further lines is replaced whole, so its tail is not
1004
+ // left behind under the new value. The lines that count are the ones `state validate` already
1005
+ // reports as a field's continuation (stops at a blank line, a heading, a list item, a table
1006
+ // row, a fence or a sibling field). An empty first line has no value to continue.
1007
+ const valueEnd = focusMatch.index + focusMatch[0].length;
1008
+ const end = focusMatch[2].trim() === '' ? valueEnd : (0, state_document_cjs_2.fieldContinuationEnd)(body, valueEnd);
1009
+ body = body.slice(0, focusMatch.index) + focusMatch[1] + focusLabel + body.slice(end);
1001
1010
  updated.push('Current focus');
1002
1011
  }
1003
1012
  // ## Current Position section mutation (#1104, #1365).
1004
1013
  // `locateCurrentPosition` (fence-aware, tokenizeHeadings-based) locates
1005
1014
  // the section; mirrors state.cts:2261-2324 byte-for-behaviour.
1006
- body = mutateCurrentPositionFirstTime(body, intent, today, updated);
1015
+ body = mutateCurrentPositionFirstTime(body, { ...intent, startPlan }, today, updated);
1007
1016
  }
1008
1017
  else {
1009
1018
  // Resume path: only update Last activity timestamp in Current Position
@@ -1107,7 +1116,7 @@ function mutateCurrentPositionFirstTime(body, intent, today, updated) {
1107
1116
  sectionBody = replaced;
1108
1117
  }
1109
1118
  // Plan line.
1110
- const planValue = `1 of ${intent.planCount || '?'}`;
1119
+ const planValue = `${intent.startPlan} of ${intent.planCount || '?'}`;
1111
1120
  if (/^Plan:/m.test(sectionBody)) {
1112
1121
  sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${planValue}`);
1113
1122
  }
@@ -1798,15 +1807,27 @@ function completePhaseCore(content, intent, deps) {
1798
1807
  // Only runs when a Completed Phases field exists (the existing guard).
1799
1808
  const completedRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Completed Phases');
1800
1809
  if (completedRaw !== null) {
1801
- let newCompleted = parseInt(completedRaw, 10);
1810
+ const recordedCompleted = parseInt(completedRaw, 10);
1811
+ let newCompleted = recordedCompleted;
1802
1812
  let derivedTotalPhases = null;
1803
1813
  const roadmapContent = deps.roadmapProvider ? deps.roadmapProvider() : null;
1804
1814
  if (roadmapContent) {
1805
1815
  const derived = (0, phase_lifecycle_cjs_1.deriveProgressFromRoadmap)(roadmapContent);
1806
- if (derived.completedPhases !== null)
1807
- newCompleted = derived.completedPhases;
1808
- if (derived.totalPhases !== null)
1809
- derivedTotalPhases = derived.totalPhases;
1816
+ // #4901: completed counters only ratchet up, like the frontmatter twin
1817
+ // (applyAuthoritativeProgressSubkeys, completedOnlyRaise). A derivation
1818
+ // that lands BELOW the recorded counter read a narrower scope than the
1819
+ // counter spans (e.g. the first `## Progress` section of a multi-milestone
1820
+ // roadmap), so its total is just as suspect: keep the recorded counter and
1821
+ // fall back to the recorded Total Phases for the percent below.
1822
+ const derivedLowered = derived.completedPhases !== null
1823
+ && Number.isFinite(recordedCompleted)
1824
+ && derived.completedPhases < recordedCompleted;
1825
+ if (!derivedLowered) {
1826
+ if (derived.completedPhases !== null)
1827
+ newCompleted = derived.completedPhases;
1828
+ if (derived.totalPhases !== null)
1829
+ derivedTotalPhases = derived.totalPhases;
1830
+ }
1810
1831
  }
1811
1832
  // #3057 B9: only mark 'Completed Phases' updated when the text actually
1812
1833
  // changed. `stateReplaceField` returns the full (re-)substituted content
@@ -14,11 +14,14 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const pattern_cjs_1 = require("./pattern.cjs");
15
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
16
  const ioMod = require("./io.cjs");
17
- const { output, error, declineNoOp, formatDiagnosticToken } = ioMod;
17
+ const { output, error, declineNoOp, formatDiagnosticToken, ERROR_REASON } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const cliExitModule = require("./cli-exit.cjs");
20
20
  const { ExitError } = cliExitModule;
21
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const capabilityLedgerMod = require("./capability-ledger.cjs");
23
+ const { readSmallRegularFile } = capabilityLedgerMod;
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
25
  const stateContract = require("./state-contract.cjs");
23
26
  const { publishStateContract } = stateContract;
24
27
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -316,19 +319,51 @@ function cmdStateGet(cwd, section, raw) {
316
319
  output({ error: `Section or field "${section}" not found` }, raw, '');
317
320
  }
318
321
  }
319
- function readTextArgOrFile(cwd, value, filePath, label) {
322
+ /**
323
+ * #4926: cap on a `--<flag>-file` text input. Twice the 128 KiB one argv string
324
+ * can carry on Linux, so the file form never takes less than the inline form,
325
+ * and the entry echoed back on stdout stays small.
326
+ */
327
+ const TEXT_FILE_MAX_BYTES = 256 * 1024;
328
+ /**
329
+ * Resolve a text argument that may instead be supplied as a file (`--<flag>`
330
+ * or `--<flag>-file`).
331
+ *
332
+ * #4926: the file is a read-once INPUT — its contents are copied into
333
+ * STATE.md and the path itself is never written, recorded, or resolved
334
+ * again — so it is not a project-write resource and is not confined to the
335
+ * project root. A scratch file from an agent's temp directory is accepted.
336
+ * A relative path still resolves against the project root.
337
+ *
338
+ * A file that cannot be read is a caller error and goes through the fault
339
+ * path — stderr, non-zero exit (USAGE), `--json-errors` envelope — rather than
340
+ * an `{ added: false }` payload at exit 0 that a shell caller gating on `$?`
341
+ * reads as success while nothing was written.
342
+ *
343
+ * An unconfined path is hostile input, so the read goes through
344
+ * `readSmallRegularFile`: a FIFO, a device (`/dev/zero`, a piped `/dev/stdin`)
345
+ * or a directory is refused without blocking, and a file over
346
+ * TEXT_FILE_MAX_BYTES is refused without being read.
347
+ */
348
+ function readTextArgOrFile(cwd, value, filePath, flag) {
320
349
  if (!filePath)
321
350
  return value;
322
- // Path traversal guard: ensure file resolves within project directory
323
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
324
- const { assertWithinRoot, PathAcceptance } = require('./security.cjs');
325
- const contained = assertWithinRoot(filePath, cwd, `${label} path`, PathAcceptance.AbsoluteInsideRoot);
351
+ const refuse = (why) => error(`--${flag}-file could not be read: ${formatDiagnosticToken(filePath)}${why ? ` (${why})` : ''}`, ERROR_REASON.USAGE);
352
+ let content;
326
353
  try {
327
- return node_fs_1.default.readFileSync(contained, 'utf-8').trimEnd();
328
- }
329
- catch {
330
- throw new Error(`${label} file not found: ${filePath}`);
354
+ content = readSmallRegularFile(node_path_1.default.resolve(cwd, filePath), TEXT_FILE_MAX_BYTES);
331
355
  }
356
+ catch (err) {
357
+ const code = err.code;
358
+ if (code === 'EFBIG')
359
+ return refuse(`larger than ${TEXT_FILE_MAX_BYTES} bytes`);
360
+ if (code === 'EISDIR' || code === 'ENXIO')
361
+ return refuse('not a regular file');
362
+ return refuse(code);
363
+ }
364
+ if (content === null)
365
+ return refuse('ENOENT');
366
+ return content.trimEnd();
332
367
  }
333
368
  function cmdStatePatch(cwd, patches, raw) {
334
369
  // Validate all field names before processing
@@ -1224,25 +1259,19 @@ function cmdStateUpdateProgress(cwd, raw) {
1224
1259
  }
1225
1260
  function cmdStateAddDecision(cwd, options, raw) {
1226
1261
  const statePath = planningPaths(cwd).state;
1227
- if (!node_fs_1.default.existsSync(statePath)) {
1228
- output({ error: 'STATE.md not found' }, raw, undefined);
1229
- return;
1230
- }
1262
+ // #4926: every refusal from this verb goes through the fault path (non-zero
1263
+ // exit), so a caller gating on `$?` never reads success while nothing was written.
1264
+ if (!node_fs_1.default.existsSync(statePath))
1265
+ return error('STATE.md not found', ERROR_REASON.USAGE);
1231
1266
  const { phase, summary, summary_file, rationale, rationale_file } = options;
1232
- let summaryText = undefined;
1233
- let rationaleText = '';
1234
- try {
1235
- summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
1236
- rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale') || '';
1237
- }
1238
- catch (err) {
1239
- output({ added: false, reason: err.message }, raw, 'false');
1240
- return;
1241
- }
1242
- if (!summaryText) {
1243
- output({ error: 'summary required' }, raw, undefined);
1244
- return;
1245
- }
1267
+ const summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
1268
+ // The rationale is optional: blank, inline or file, means "no rationale".
1269
+ const rationaleInput = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale') || '';
1270
+ const rationaleText = rationaleInput.trim() ? rationaleInput : '';
1271
+ // A blank inline value is refused the same as a blank file (the file form is
1272
+ // trimEnd()ed by readTextArgOrFile, the inline form is not).
1273
+ if (!summaryText || !summaryText.trim())
1274
+ return error('summary required — pass a non-blank --summary or --summary-file', ERROR_REASON.USAGE);
1246
1275
  // #3231/#3481: `--phase` omitted → resolve from the STATE.md being written, via
1247
1276
  // the canonical ladder `state prune` uses. A decision entry is a permanent
1248
1277
  // record, so a literal `[Phase ?]` written while `current_phase` sat three
@@ -1310,23 +1339,14 @@ function cmdStateAddDecision(cwd, options, raw) {
1310
1339
  }
1311
1340
  function cmdStateAddBlocker(cwd, text, raw) {
1312
1341
  const statePath = planningPaths(cwd).state;
1313
- if (!node_fs_1.default.existsSync(statePath)) {
1314
- output({ error: 'STATE.md not found' }, raw, undefined);
1315
- return;
1316
- }
1342
+ // #4926: every refusal from this verb goes through the fault path (non-zero
1343
+ // exit), so a caller gating on `$?` never reads success while nothing was written.
1344
+ if (!node_fs_1.default.existsSync(statePath))
1345
+ return error('STATE.md not found', ERROR_REASON.USAGE);
1317
1346
  const blockerOptions = typeof text === 'object' && text !== null ? text : { text: text };
1318
- let blockerText = undefined;
1319
- try {
1320
- blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
1321
- }
1322
- catch (err) {
1323
- output({ added: false, reason: err.message }, raw, 'false');
1324
- return;
1325
- }
1326
- if (!blockerText) {
1327
- output({ error: 'text required' }, raw, undefined);
1328
- return;
1329
- }
1347
+ const blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'text');
1348
+ if (!blockerText || !blockerText.trim())
1349
+ return error('text required — pass a non-blank --text or --text-file', ERROR_REASON.USAGE);
1330
1350
  const entry = `- ${blockerText}`;
1331
1351
  let _added = false;
1332
1352
  let created = false;
@@ -1379,25 +1399,16 @@ function cmdStateAddBlocker(cwd, text, raw) {
1379
1399
  }
1380
1400
  function cmdStateAddRoadmapEvolution(cwd, options, raw) {
1381
1401
  const statePath = planningPaths(cwd).state;
1382
- if (!node_fs_1.default.existsSync(statePath)) {
1383
- output({ error: 'STATE.md not found' }, raw, undefined);
1384
- return;
1385
- }
1402
+ // #4926: every refusal from this verb goes through the fault path (non-zero
1403
+ // exit), so a caller gating on `$?` never reads success while nothing was written.
1404
+ if (!node_fs_1.default.existsSync(statePath))
1405
+ return error('STATE.md not found', ERROR_REASON.USAGE);
1386
1406
  const { phase, action, after, note, note_file, urgent } = options;
1387
- let noteText = undefined;
1388
- try {
1389
- noteText = readTextArgOrFile(cwd, note, note_file, 'note');
1390
- }
1391
- catch (err) {
1392
- output({ added: false, reason: err.message }, raw, 'false');
1393
- return;
1394
- }
1407
+ const noteText = readTextArgOrFile(cwd, note, note_file, 'note');
1395
1408
  // Reject missing / empty / whitespace-only notes — an evolution entry with no
1396
1409
  // narrative is meaningless and would corrupt the section with a dangling bullet.
1397
- if (!noteText || !noteText.trim()) {
1398
- output({ error: 'note required' }, raw, undefined);
1399
- return;
1400
- }
1410
+ if (!noteText || !noteText.trim())
1411
+ return error('note required — pass a non-blank --note or --note-file', ERROR_REASON.USAGE);
1401
1412
  // Flatten line breaks so the entry is always a single Markdown bullet. The
1402
1413
  // dedupe + rendering contract is line-oriented; a multiline --note-file would
1403
1414
  // otherwise spill continuation lines outside the bullet and defeat dedupe.
@@ -5097,6 +5108,23 @@ function cmdStateJson(cwd, raw) {
5097
5108
  preferNewerLastActivity(existingFm, built);
5098
5109
  output(built, raw, JSON.stringify(built, null, 2));
5099
5110
  }
5111
+ /**
5112
+ * 1-based position of the first plan with no summary, when SOME of the phase's plans already have
5113
+ * one (a gap-closure re-entry: plans 1..17 are done and 18 and 19 are the gap plans, #4945).
5114
+ * `null` when no plan is summarized yet, when every plan is, or when the phase directory cannot be
5115
+ * found: the phase then starts at plan 1, exactly as before. Plans and summaries are paired by the
5116
+ * phase locator's own rule, so this agrees with `init` and `phase-plan-index`.
5117
+ */
5118
+ function firstPlanToRun(cwd, phaseNumber) {
5119
+ const located = phaseLocatorMod.findPhaseInternal(cwd, String(phaseNumber));
5120
+ if (!located || !located.found)
5121
+ return null;
5122
+ const { plans, incomplete_plans: incomplete } = located;
5123
+ if (incomplete.length === 0 || incomplete.length === plans.length)
5124
+ return null;
5125
+ const index = plans.indexOf(incomplete[0]);
5126
+ return index === -1 ? null : index + 1;
5127
+ }
5100
5128
  /**
5101
5129
  * Update STATE.md when a new phase begins execution.
5102
5130
  * Updates body text fields (Current focus, Status, Last Activity, Current Position)
@@ -5144,6 +5172,8 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
5144
5172
  phaseNumber: phaseNumber,
5145
5173
  phaseName: phaseName ?? null,
5146
5174
  planCount: planCount ?? null,
5175
+ // #4945: a gap-closure re-entry starts at the first plan still to run, not at plan 1.
5176
+ startPlan: firstPlanToRun(cwd, phaseNumber),
5147
5177
  };
5148
5178
  const deps = {
5149
5179
  clock: clock_cjs_1.realClock,
@@ -12,6 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const security_cjs_1 = require("./security.cjs");
15
+ const tdd_test_files_cjs_1 = require("./tdd-test-files.cjs");
15
16
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
17
  const ioMod = require("./io.cjs");
17
18
  const { output, error, ERROR_REASON } = ioMod;
@@ -37,9 +38,7 @@ function isBehaviorAddingTaskContent(content) {
37
38
  .filter(Boolean);
38
39
  hasSourceFiles = fileLines.some((file) => !/\.md$/i.test(file) &&
39
40
  !/\.json$/i.test(file) &&
40
- !/\.test\.[^.]+$/i.test(file) &&
41
- !/\.spec\.[^.]+$/i.test(file) &&
42
- !/(^|[\\/])tests?[\\/]/i.test(file) &&
41
+ !(0, tdd_test_files_cjs_1.isTestFilePath)(file) &&
43
42
  !/\.(yml|yaml|toml|ini|cfg|conf|properties)$/i.test(file) &&
44
43
  !/(^|[\\/])\.env(\..+)?$/i.test(file));
45
44
  }
@@ -10,6 +10,7 @@ exports.RedEvidenceReason = {
10
10
  TargetFailed: 'target_test_failed', Green: 'unexpected_green', Empty: 'zero_tests_discovered',
11
11
  NoFailure: 'nonzero_exit_without_test_failure', LoadFailure: 'fixture_or_load_failure',
12
12
  NoTarget: 'no_target_test_failure', Invalid: 'invalid_record', Unreadable: 'unreadable_record',
13
+ BuildFailure: 'build_failure',
13
14
  };
14
15
  /** Basename of a path-like string ('' for non-strings) — separators `/` and `\`. */
15
16
  function baseOf(p) {
@@ -59,6 +60,10 @@ function classifyRedEvidence(input) {
59
60
  };
60
61
  if (exitCode === 0)
61
62
  return { verdict: exports.RedEvidenceVerdict.Invalid, reason: exports.RedEvidenceReason.Green, evidence };
63
+ // A package that did not compile or load asserted nothing: refuse it as its own reason, ahead of
64
+ // the generic unusable-report refusal, because the fix is different (make the test compile).
65
+ if (report.buildFailed)
66
+ return { verdict: exports.RedEvidenceVerdict.Invalid, reason: exports.RedEvidenceReason.BuildFailure, evidence };
62
67
  if (!report.valid)
63
68
  return { verdict: exports.RedEvidenceVerdict.Invalid, reason: exports.RedEvidenceReason.Invalid, evidence };
64
69
  if (report.tests.length === 0)
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ /**
3
+ * The single definition of "a test file" for the TDD gate (#5106).
4
+ *
5
+ * Two places decide what a test file is: the behavior-adding check (`task-command-router.cts`) and
6
+ * the RED-commit pathspec in `workflows/execute-phase.md`. They were separate lists, so the pathspec
7
+ * learned the Go / Elixir / RSpec / pytest names in #4715 while the router never did, and a task
8
+ * listing only `placement_test.go` was classified behavior-adding — the gate then asked for a RED
9
+ * commit for the task that IS the RED commit.
10
+ *
11
+ * `TEST_FILE_PATHSPECS` is the list in git-pathspec spelling, which is what the workflow passes to
12
+ * `evaluation-scope --pathspec`; `isTestFilePath` evaluates the same list for a task's `<files>`.
13
+ * A test pins the workflow's pathspec flags to this array, so the two cannot drift apart again.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.TEST_FILE_PATHSPECS = void 0;
17
+ exports.isTestFilePath = isTestFilePath;
18
+ /** Git-pathspec spelling. A trailing `/` is a directory, anything else a basename glob. */
19
+ exports.TEST_FILE_PATHSPECS = [
20
+ '*.test.*', '*.spec.*', 'tests/', '__tests__/',
21
+ '*_test.go', 'test_*.py', '*_test.py', '*_test.exs', '*_spec.rb', '*_test.rb',
22
+ ];
23
+ /** Directory names a `<files>` entry may sit under. `test/` is the singular spelling many projects use. */
24
+ const TEST_DIRECTORY_NAMES = new Set([
25
+ ...exports.TEST_FILE_PATHSPECS.filter((spec) => spec.endsWith('/')).map((spec) => spec.slice(0, -1)),
26
+ 'test',
27
+ ]);
28
+ function escapeRegex(text) {
29
+ return text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
30
+ }
31
+ /** A basename glob where `*` is any run of non-separator characters. */
32
+ const FILE_GLOBS = exports.TEST_FILE_PATHSPECS
33
+ .filter((spec) => !spec.endsWith('/'))
34
+ .map((spec) => new RegExp(`^${spec.split('*').map(escapeRegex).join('[^/]*')}$`, 'i'));
35
+ /** True when `file` (either separator) is a test file by the shared convention list. */
36
+ function isTestFilePath(file) {
37
+ const segments = file.split(/[\\/]/).filter(Boolean);
38
+ const base = segments[segments.length - 1] ?? '';
39
+ if (FILE_GLOBS.some((glob) => glob.test(base)))
40
+ return true;
41
+ return segments.slice(0, -1).some((segment) => TEST_DIRECTORY_NAMES.has(segment.toLowerCase()));
42
+ }
@@ -1147,6 +1147,38 @@ function unreadableFileVerdict(evidence, displayPath) {
1147
1147
  : { error: 'File unreadable', path: displayPath, read_error: evidence.reason };
1148
1148
  return (0, gate_verdict_cjs_1.gateUnreadable)(false, payload);
1149
1149
  }
1150
+ /**
1151
+ * Issue #4927 — a PLAN.md can be cut off by a tool that still reported the write
1152
+ * as successful (runtimes cap tool-call output; a ~450-line plan stopped inside
1153
+ * an XML tag). The planner and the plan checker both run `verify plan-structure`
1154
+ * on every plan, so this is where a silent partial write is caught, independent
1155
+ * of what the write call returned.
1156
+ *
1157
+ * Three signals, each a strong sign of a partial file and none of them a shape a
1158
+ * finished plan carries:
1159
+ * - the incremental-write sentinel (`<!-- gsd:write-continue -->`) left behind
1160
+ * (the planner's large-file fallback never removed it);
1161
+ * - `<tasks>` opened and never closed;
1162
+ * - the file ends inside a tag (`...</ta`). Prose such as `a < b` does not
1163
+ * match: a tag needs a name character straight after the `<` or `</`.
1164
+ */
1165
+ function scanPlanTruncation(content) {
1166
+ const errors = [];
1167
+ const text = content || '';
1168
+ if (/<!--\s*gsd:write-continue\s*-->/.test(text)) {
1169
+ errors.push('Plan appears truncated: the incremental-write sentinel `<!-- gsd:write-continue -->` is still present — '
1170
+ + 'the last section was never written. Finish the write (or re-write the plan) before treating it as complete.');
1171
+ }
1172
+ if (/<tasks>/.test(text) && !/<\/tasks>/.test(text)) {
1173
+ errors.push('Plan appears truncated: `<tasks>` is opened but `</tasks>` never appears — the file was cut off before the task list ended.');
1174
+ }
1175
+ const trimmed = text.replace(/\s+$/, '');
1176
+ const lastLt = trimmed.lastIndexOf('<');
1177
+ if (lastLt !== -1 && trimmed.indexOf('>', lastLt) === -1 && /^<\/?[A-Za-z_!][^<>]{0,200}$/.test(trimmed.slice(lastLt))) {
1178
+ errors.push(`Plan appears truncated: the file ends inside a tag (\`${trimmed.slice(lastLt).slice(0, 40)}\`) — the write stopped mid-payload.`);
1179
+ }
1180
+ return errors;
1181
+ }
1150
1182
  function cmdVerifyPlanStructure(cwd, filePath, raw) {
1151
1183
  if (!filePath) {
1152
1184
  error('file path required');
@@ -1172,6 +1204,8 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
1172
1204
  const fm = extractFrontmatter(content, fullPath);
1173
1205
  const errors = [];
1174
1206
  const warnings = [];
1207
+ // #4927: a plan the write tool reported as written but that ends mid-payload.
1208
+ errors.push(...scanPlanTruncation(content));
1175
1209
  const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'];
1176
1210
  for (const field of required) {
1177
1211
  if (fm[field] === undefined)
@@ -1220,7 +1254,8 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
1220
1254
  continue;
1221
1255
  if (!decisionCheckpointOffsets.some((offset) => offset < at)) {
1222
1256
  warnings.push('Task rated <reversibility rating="one-way"> has no preceding checkpoint:decision — '
1223
- + 'a one-way door must be confirmed before the agent walks through it');
1257
+ + 'a one-way door must be confirmed before the agent walks through it (unless the decision is already settled '
1258
+ + 'in CONTEXT.md, an ingested ADR, or an answered checkpoint)');
1224
1259
  }
1225
1260
  }
1226
1261
  const echoScan = scanNegativeGrepCommentEcho(content);
@@ -33,7 +33,7 @@ This doc describes what IS, not what should be. Casing inconsistencies are docum
33
33
  | gsd-code-reviewer | Source-code review | No marker (writes REVIEW.md) | `gsd-core/workflows/code-review.md` reads REVIEW.md | artifact+query |
34
34
  | gsd-assumptions-analyzer | Assumption extraction | No marker (returns `## Assumptions` sections) | `gsd-core/workflows/discuss-phase-assumptions.md` reads the inline `## Assumptions` sections from the agent's return | structured-return |
35
35
  | gsd-doc-classifier | Planning-doc classification | No marker (writes `.planning/intel/classifications/*.json`) | `gsd-core/workflows/ingest-docs.md` reads the classification JSON | artifact+query |
36
- | gsd-doc-verifier | Doc validation | No marker (writes JSON to `.planning/tmp/`) | `.planning/tmp/verify-{doc_filename}.json` artifact, read by `gsd-core/workflows/docs-update.md` | artifact+query |
36
+ | gsd-doc-verifier | Doc validation | No marker (writes JSON to `.planning/tmp/`) | `{project_root}/.planning/tmp/verify-{doc_key}.json` artifact (the orchestrator-assigned `result_path`, keyed by the doc's full relative path), read by `gsd-core/workflows/docs-update.md` | artifact+query |
37
37
  | gsd-doc-writer | Doc generation | No marker (writes docs directly) | generated doc files, consumed by `gsd-core/workflows/docs-update.md` and `gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md` | artifact+query |
38
38
  | gsd-domain-researcher | Domain research | No marker (writes the AI-SPEC.md domain section via Edit) | `gsd-core/workflows/ai-integration-phase.md` reads the AI-SPEC.md section after the agent returns | artifact+query |
39
39
  | gsd-eval-auditor | Evaluation coverage audit | No marker (writes the REVIEW.md audit section) | `gsd-core/workflows/eval-review.md` reads REVIEW.md | artifact+query |
@@ -22,22 +22,41 @@ In your mandatory init step — right after `mandatory-initial-read.md` / the
22
22
  injection also runs (e.g. Claude Code). The guard is what keeps the two seams from
23
23
  doubling the block.
24
24
 
25
- 2. **Query your configured skills.** Use **your own agent type** — the `name:` value in
26
- your frontmatter (e.g. an agent whose frontmatter says `name: gsd-executor` queries
27
- `gsd-executor`). The query is read-only and idempotent — it exits 0 with an empty
28
- block when nothing is configured for your type:
25
+ 2. **Query your configured skills.** Use **your own agent type**: the one your agent
26
+ file states on its `agent_skills:` pointer line, as `(agent type: \`gsd-executor\`)`.
27
+ Do not read it from your frontmatter `name:` — Claude Code shows a subagent only the
28
+ body, the Kimi converter drops frontmatter, and Codex keeps it in a TOML key. Run the
29
+ query with `--skills-only`, in the **same bash call** as the launcher so `gsd_run` is
30
+ defined when it runs. Substitute your type for `<your-agent-type>`:
29
31
 
30
32
  ```bash
31
- _AGENT_SKILLS=$(gsd_run query agent-skills <YOUR-FRONTMATTER-NAME> 2>/dev/null || true)
33
+ # 1. Launcher: paste the bash block from gsd-run-resolver.md here unless gsd_run
34
+ # is already defined in this shell (@~/.claude/gsd-core/references/gsd-run-resolver.md).
35
+ # 2. Query:
36
+ _AGENT_SKILLS=$(gsd_run query agent-skills <your-agent-type> --skills-only)
32
37
  ```
33
38
 
34
- The runtime `gsd_run` resolver is the standard one from
35
- `_runtime-launcher.snippet.sh`; your own init already defines it.
39
+ Not every agent file defines `gsd_run`, so supply it as above rather than assuming
40
+ your init did. The query is read-only and idempotent. Its stderr and exit status stay
41
+ visible, so a missing launcher or a failed query is never mistaken for "nothing
42
+ configured".
36
43
 
37
44
  3. **Read every listed skill.** The block emits entries as `@<path>/SKILL.md`
38
45
  includes — `Read` each one before starting work. If the block is empty, there is
39
46
  nothing to do (zero overhead).
40
47
 
48
+ ## What the query returns, per runtime
49
+
50
+ | Call | Claude | Non-Claude (Codex, Kimi, OpenCode, Kilo, …) |
51
+ |---|---|---|
52
+ | `query agent-skills <type> --skills-only` (the self-load) | configured skills, or an empty block | configured skills, or an empty block |
53
+ | `query agent-skills <type>` (orchestrator-side `${AGENT_SKILLS_*}` injection) | configured skills, or an empty block | configured skills, or — when nothing is configured and the agent is installed — the agent's own **persona** (#2454, #4407) |
54
+
55
+ On a non-Claude runtime the plain query can answer with the agent's whole persona
56
+ (about 47 KB for the executor). `--skills-only` is what keeps the self-load from
57
+ reading your own persona back into your context as if it were a skills block. Never
58
+ run the query without it from inside an agent.
59
+
41
60
  ## What self-load does and does not cover
42
61
 
43
62
  | Skill form | Self-loads? | Notes |
@@ -144,10 +144,10 @@ gsd_run query commit "docs({phase}-{plan}): complete [plan-name] plan" --files .
144
144
  </format>
145
145
 
146
146
  <format name="handoff">
147
- ## Handoff (WIP)
147
+ ## Handoff (Pause)
148
148
 
149
149
  ```
150
- wip: [phase-name] paused at task [X]/[Y]
150
+ docs(pause): [phase-name] paused at task [X]/[Y]
151
151
 
152
152
  Current: [task name]
153
153
  [If blocked:] Blocked: [reason]
@@ -156,7 +156,7 @@ Current: [task name]
156
156
  What to commit:
157
157
 
158
158
  ```bash
159
- gsd_run query commit "wip: [phase-name] paused at task [X]/[Y]" --files .planning/
159
+ gsd_run query commit "docs(pause): [phase-name] paused at task [X]/[Y]" --files .planning/
160
160
  ```
161
161
 
162
162
  </format>
@@ -20,7 +20,7 @@ this turns out wrong three phases from now, what does undoing it cost?*
20
20
  |---|---|---|
21
21
  | `reversible` | Local and cheap — one file, one function, an implementation swapped behind a stable interface. | `reversible` decisions get no checkpoint and no flag; the task proceeds normally. |
22
22
  | `costly` | Undo touches many call sites or needs a coordinated change — a shared interface shape, a cross-module contract, a dependency major bump. | `costly` decisions are flagged in the plan so the reader sees the weight, but this does not block execution. |
23
- | `one-way` | Undo requires a data migration, breaks a published contract, or cannot be done at all — on-disk/wire format, public API shape, external-service lock-in, a schema other systems already read. | The planner inserts a `checkpoint:decision` **before** the dependent task, so the human confirms the door before the agent walks through it. |
23
+ | `one-way` | Undo requires a data migration, breaks a published contract, or cannot be done at all — on-disk/wire format, public API shape, external-service lock-in, a schema other systems already read. | The planner inserts a `checkpoint:decision` **before** the dependent task, so the human confirms the door before the agent walks through it — unless the human already settled that decision (see *Already settled*). |
24
24
 
25
25
  **When unsure, rate it `reversible`.** The value of this feature is
26
26
  *discrimination*. A planner that rates everything `one-way` produces checkpoint
@@ -101,6 +101,21 @@ the same terms as any other ingested text (ADR-1577,
101
101
  carrying something that belongs in `<context>`, and long free text is where
102
102
  smuggled structure hides.
103
103
 
104
+ ## Already settled
105
+
106
+ A `one-way` decision the human has **already settled** keeps its rating for the
107
+ record and gets **no** new `checkpoint:decision`. It is settled when any of
108
+ these records the human choosing this exact option:
109
+
110
+ - the phase `CONTEXT.md` `<decisions>` block (discuss-phase output);
111
+ - an ADR ingested with `--ingest`;
112
+ - a `checkpoint:decision` earlier in this phase's plans that was already answered.
113
+
114
+ Only a **newly surfaced** `one-way` decision — one nothing above records — earns a
115
+ fresh checkpoint before the implementing task. If you cannot point at the record,
116
+ treat the decision as newly surfaced. `gsd-planner` and this reference state the
117
+ same rule; if they ever diverge, this file wins.
118
+
104
119
  ## Anti-patterns
105
120
 
106
121
  - **Everything is `one-way`.** The most common failure. Re-read the undo cost:
@@ -111,9 +126,8 @@ the same terms as any other ingested text (ADR-1577,
111
126
  - **A rationale that restates the rating.** "This is irreversible because it
112
127
  cannot be undone" tells the reader nothing. Name the migration, the contract,
113
128
  or the dependent system.
114
- - **Gating a decision already made.** If the phase CONTEXT.md records the human
115
- choosing this exact option, the door is already walked through. Keep the
116
- rating for the record; do not insert a checkpoint to re-ask.
129
+ - **Gating a decision already made.** See *Already settled* below: a decision the
130
+ human has already settled does not earn a fresh checkpoint.
117
131
  - **Using the gate as a substitute for design.** The checkpoint buys deliberation
118
132
  on a door you must walk through. The better move, when available, is to *make
119
133
  the decision reversible* — put the format behind a writer seam, version the