@opengsd/gsd-core 1.8.0 → 1.9.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 (174) 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 +31 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +186 -55
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +849 -2
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +5 -5
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +57 -5
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  49. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  50. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  53. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  56. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  57. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  58. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  59. package/gsd-core/bin/lib/state-document.cjs +164 -20
  60. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  61. package/gsd-core/bin/lib/state.cjs +141 -21
  62. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  63. package/gsd-core/bin/lib/uat.cjs +9 -7
  64. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  65. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  66. package/gsd-core/bin/lib/validate.cjs +32 -0
  67. package/gsd-core/bin/lib/verification.cjs +51 -14
  68. package/gsd-core/bin/lib/verify.cjs +128 -20
  69. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  72. package/gsd-core/bin/shared/model-catalog.json +5 -0
  73. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  74. package/gsd-core/references/context-budget.md +40 -0
  75. package/gsd-core/references/gate-prompts.md +6 -3
  76. package/gsd-core/references/model-profile-resolution.md +64 -13
  77. package/gsd-core/references/offer-next.md +88 -0
  78. package/gsd-core/references/planning-config.md +2 -1
  79. package/gsd-core/references/reviewer-instances.md +28 -21
  80. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  81. package/gsd-core/references/ui-consideration-probe.md +2 -2
  82. package/gsd-core/references/worktree-branch-check.md +4 -4
  83. package/gsd-core/templates/summary-minimal.md +4 -0
  84. package/gsd-core/templates/summary-standard.md +4 -0
  85. package/gsd-core/templates/summary.md +7 -0
  86. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  87. package/gsd-core/workflows/audit-fix.md +4 -0
  88. package/gsd-core/workflows/audit-milestone.md +8 -0
  89. package/gsd-core/workflows/autonomous.md +19 -15
  90. package/gsd-core/workflows/check-todos.md +2 -2
  91. package/gsd-core/workflows/code-review-fix.md +14 -6
  92. package/gsd-core/workflows/code-review.md +76 -19
  93. package/gsd-core/workflows/debug.md +10 -2
  94. package/gsd-core/workflows/diagnose-issues.md +4 -0
  95. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  96. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  97. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  98. package/gsd-core/workflows/discuss-phase.md +2 -2
  99. package/gsd-core/workflows/docs-update.md +8 -0
  100. package/gsd-core/workflows/eval-review.md +1 -1
  101. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  102. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  103. package/gsd-core/workflows/execute-phase.md +85 -115
  104. package/gsd-core/workflows/execute-plan.md +5 -4
  105. package/gsd-core/workflows/explore.md +4 -0
  106. package/gsd-core/workflows/extract-learnings.md +21 -0
  107. package/gsd-core/workflows/help/modes/full.md +3 -3
  108. package/gsd-core/workflows/import.md +4 -1
  109. package/gsd-core/workflows/ingest-docs.md +4 -0
  110. package/gsd-core/workflows/map-codebase.md +13 -6
  111. package/gsd-core/workflows/new-milestone.md +10 -2
  112. package/gsd-core/workflows/new-project.md +11 -4
  113. package/gsd-core/workflows/next.md +5 -2
  114. package/gsd-core/workflows/plan-phase.md +42 -46
  115. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  116. package/gsd-core/workflows/progress.md +1 -1
  117. package/gsd-core/workflows/quick.md +14 -3
  118. package/gsd-core/workflows/review.md +146 -575
  119. package/gsd-core/workflows/scan.md +9 -1
  120. package/gsd-core/workflows/secure-phase.md +10 -2
  121. package/gsd-core/workflows/ship.md +41 -11
  122. package/gsd-core/workflows/smart-entry.md +1 -1
  123. package/gsd-core/workflows/ui-phase.md +8 -1
  124. package/gsd-core/workflows/ui-review.md +8 -1
  125. package/gsd-core/workflows/update.md +104 -5
  126. package/gsd-core/workflows/validate-phase.md +10 -2
  127. package/gsd-core/workflows/verify-work.md +8 -1
  128. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  129. package/hooks/dist/gsd-cursor-stop.js +6 -2
  130. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  131. package/hooks/dist/gsd-graphify-update.sh +9 -0
  132. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  133. package/hooks/dist/gsd-prompt-guard.js +101 -2
  134. package/hooks/dist/gsd-read-guard.js +100 -2
  135. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  136. package/hooks/dist/gsd-statusline.js +9 -6
  137. package/hooks/dist/gsd-workflow-guard.js +110 -6
  138. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  139. package/hooks/dist/lib/cursor-workspace.js +74 -0
  140. package/hooks/gsd-cursor-session-start.js +6 -2
  141. package/hooks/gsd-cursor-stop.js +6 -2
  142. package/hooks/gsd-cursor-subagent-start.js +6 -2
  143. package/hooks/gsd-graphify-update.sh +9 -0
  144. package/hooks/gsd-phase-boundary.sh +14 -2
  145. package/hooks/gsd-prompt-guard.js +101 -2
  146. package/hooks/gsd-read-guard.js +100 -2
  147. package/hooks/gsd-read-injection-scanner.js +109 -2
  148. package/hooks/gsd-statusline.js +9 -6
  149. package/hooks/gsd-workflow-guard.js +110 -6
  150. package/hooks/gsd-worktree-path-guard.js +132 -8
  151. package/hooks/lib/cursor-workspace.js +74 -0
  152. package/package.json +7 -7
  153. package/pi/gsd.cjs +26 -1
  154. package/scripts/check-coverage-gate.cjs +51 -0
  155. package/scripts/check-glossary-refs.cjs +24 -0
  156. package/scripts/ci-test-scope.cjs +67 -17
  157. package/scripts/gen-adr-index.cjs +6 -4
  158. package/scripts/gen-capability-matrix.cjs +26 -2
  159. package/scripts/gen-capability-registry.cjs +132 -34
  160. package/scripts/gen-emitted-baseline.cjs +145 -0
  161. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  162. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  163. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  164. package/scripts/lint-resolution-provenance.cjs +9 -0
  165. package/scripts/mutation-matrix.cjs +4 -0
  166. package/scripts/prompt-injection-scan.sh +6 -0
  167. package/scripts/registry-schema.cjs +57 -8
  168. package/scripts/release-notes/conventional-title.cjs +19 -1
  169. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  170. package/scripts/workflow-size.cjs +16 -8
  171. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  172. package/vscode/package.json +1 -1
  173. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  174. package/scripts/update-size-baseline.cjs +0 -68
@@ -0,0 +1,336 @@
1
+ "use strict";
2
+ /**
3
+ * Estimate CLI — the I/O seam over the pure phase-estimation module.
4
+ *
5
+ * Epic #1952 Phase 1 (#2630). Design lock: docs/adr/2629-phase-effort-estimation-calibration.md.
6
+ *
7
+ * `phase-estimation.cts` is pure policy; everything that touches disk or config
8
+ * lives here. Two leaf verbs (a pair, so leaves rather than a family per
9
+ * ADR-2346's ">=3 subcommands" rule):
10
+ *
11
+ * gsd-tools query estimate-check --tokens <n>
12
+ * gsd-tools query estimate-calibration
13
+ *
14
+ * Both degrade rather than fail. A missing or corrupt
15
+ * `.planning/estimation-calibration.json` yields an inert calibration
16
+ * (factor 1, applied false) instead of breaking planning — the file is a disk
17
+ * trust boundary that steers planning output, so it is parsed defensively and
18
+ * never trusted structurally.
19
+ */
20
+ var __importDefault = (this && this.__importDefault) || function (mod) {
21
+ return (mod && mod.__esModule) ? mod : { "default": mod };
22
+ };
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.CALIBRATION_FILENAME = void 0;
25
+ exports.readSmartZoneBudget = readSmartZoneBudget;
26
+ exports.readCalibrationSamples = readCalibrationSamples;
27
+ exports.parseTokensFlag = parseTokensFlag;
28
+ exports.cmdEstimateCheck = cmdEstimateCheck;
29
+ exports.collectCalibrationSamples = collectCalibrationSamples;
30
+ exports.cmdEstimateCalibrate = cmdEstimateCalibrate;
31
+ exports.cmdEstimateCalibration = cmdEstimateCalibration;
32
+ const node_fs_1 = __importDefault(require("node:fs"));
33
+ const node_path_1 = __importDefault(require("node:path"));
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
35
+ const io = require("./io.cjs");
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-estimation.cjs is an export= CommonJS module
37
+ const estimation = require("./phase-estimation.cjs");
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
39
+ const planningWorkspace = require("./planning-workspace.cjs");
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
41
+ const configLoader = require("./config-loader.cjs");
42
+ const { output, error, ERROR_REASON } = io;
43
+ const { planningDir } = planningWorkspace;
44
+ const { CONFIG_DEFAULTS } = configLoader;
45
+ // WIN-1 parity (DEFECT.WINDOWS-FS-OPS): on Windows a concurrent reader, indexer,
46
+ // or AV scanner can transiently hold the rename target open. Retry the transient
47
+ // errnos with backoff, matching the writeLedger / writeConsentStore idiom.
48
+ const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
49
+ const RENAME_MAX_ATTEMPTS = 3;
50
+ const RENAME_RETRY_BACKOFF_MS = 50;
51
+ let _renameSleepBuf = null;
52
+ function renameBackoff() {
53
+ if (_renameSleepBuf === null)
54
+ _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
55
+ Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
56
+ }
57
+ /** Rename with a bounded retry on the transient Windows errnos. Rethrows anything else. */
58
+ function renameWithRetry(from, to) {
59
+ for (let attempt = 1;; attempt += 1) {
60
+ try {
61
+ node_fs_1.default.renameSync(from, to);
62
+ return;
63
+ }
64
+ catch (err) {
65
+ const code = err.code ?? '';
66
+ if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(code)) {
67
+ renameBackoff();
68
+ continue;
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+ }
74
+ /** Filename of the persisted calibration document, written by extract-learnings (Phase 3). */
75
+ exports.CALIBRATION_FILENAME = 'estimation-calibration.json';
76
+ function defaultBudget() {
77
+ const fromManifest = Number(CONFIG_DEFAULTS.smart_zone_tokens);
78
+ return Number.isSafeInteger(fromManifest) && fromManifest > 0 ? fromManifest : 100000;
79
+ }
80
+ /**
81
+ * Read the configured smart-zone budget, degrading to the manifest default.
82
+ *
83
+ * Reads config.json directly rather than through the flat loadConfig
84
+ * projection: a hand-edited config can hold any value, and this seam must
85
+ * validate rather than assume. An out-of-shape value falls back to the default
86
+ * instead of propagating NaN into the comparison.
87
+ */
88
+ function readSmartZoneBudget(cwd) {
89
+ try {
90
+ const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
91
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
92
+ if (parsed !== null && typeof parsed === 'object') {
93
+ const workflow = parsed['workflow'];
94
+ if (workflow !== null && typeof workflow === 'object') {
95
+ const value = workflow['smart_zone_tokens'];
96
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
97
+ return value;
98
+ }
99
+ }
100
+ }
101
+ catch {
102
+ // Absent, unreadable, or malformed config — the default is the answer.
103
+ }
104
+ return defaultBudget();
105
+ }
106
+ /** Read and defensively parse the calibration history. Never throws. */
107
+ function readCalibrationSamples(cwd) {
108
+ let raw;
109
+ try {
110
+ raw = node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), exports.CALIBRATION_FILENAME), 'utf-8');
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ return estimation.parseCalibrationDocument(raw);
116
+ }
117
+ /**
118
+ * Parse `--tokens <n>`.
119
+ *
120
+ * Rejects a missing value, an empty/whitespace value, a value that is really
121
+ * the next flag, and anything that is not a positive integer. Uses an exact
122
+ * digit match rather than Number()/parseInt so that "1; touch x", "1e5",
123
+ * "0x10", and " 1 " are all refused — the value reaches us as argv, is never
124
+ * shell-interpolated, and must not be coerced into looking valid.
125
+ *
126
+ * Returns a PLAIN number, deliberately (#2671). This function validates the
127
+ * magnitude of `--tokens`; it cannot know the figure's basis, because that is
128
+ * decided by a DIFFERENT flag (`--calibrated`). Branding here would force it to
129
+ * pick RawTokens or CalibratedTokens for every caller, and it would be wrong
130
+ * half the time — a type that lies is worse than no type. The basis assertion
131
+ * therefore belongs to the caller that reads both flags; today that is
132
+ * `cmdEstimateCheck`, which is this function's only caller. A future second
133
+ * caller must make the same assertion explicitly rather than inherit a guess.
134
+ */
135
+ function parseTokensFlag(args) {
136
+ const idx = args.indexOf('--tokens');
137
+ if (idx === -1) {
138
+ error('Usage: estimate-check --tokens <positive integer> [--calibrated]', ERROR_REASON.USAGE);
139
+ }
140
+ const value = args[idx + 1];
141
+ if (value === undefined || value.startsWith('--') || !/^[0-9]+$/.test(value)) {
142
+ error(`Invalid --tokens ${JSON.stringify(value ?? '')}. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
143
+ }
144
+ const parsed = Number(value);
145
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
146
+ error(`Invalid --tokens ${JSON.stringify(value)}. Must be a positive integer (token count).`, ERROR_REASON.USAGE);
147
+ }
148
+ return parsed;
149
+ }
150
+ /**
151
+ * `estimate-check --tokens <n>` — classify an estimate against the configured
152
+ * smart-zone budget, with the current calibration applied.
153
+ *
154
+ * The advisory contract (ADR-2629 Decision 5): this reports, it never blocks.
155
+ * Exit status is 0 whether or not the estimate is over budget; `over_budget`
156
+ * in the payload is the signal.
157
+ */
158
+ function cmdEstimateCheck(cwd, args, raw) {
159
+ const inputTokens = parseTokensFlag(args);
160
+ const preCalibrated = args.includes('--calibrated');
161
+ const budget = readSmartZoneBudget(cwd);
162
+ const calibration = estimation.computeCalibration(readCalibrationSamples(cwd));
163
+ // `--calibrated` says the caller already applied the factor. Without it we
164
+ // would apply the correction a SECOND time and compare factor^2 against the
165
+ // budget — with the [0.5, 3.0] clamp that is anywhere from 4x under to 9x
166
+ // over, and it is invisible until a project reaches 3 samples (below that
167
+ // factor === 1, and 1^2 === 1). A plan's recorded `estimate.tokens` is
168
+ // calibrated at emission time per ADR-2629 Decision 1, so the plan-checker
169
+ // MUST pass this flag.
170
+ //
171
+ // `--calibrated` is the whole basis question, and argv cannot answer it for
172
+ // the type system — so this is where the caller's claim is turned into a type
173
+ // (#2671). Past this expression the basis is carried by RawTokens /
174
+ // CalibratedTokens and the wrong composition stops compiling; the ambiguity
175
+ // is confined to these two lines instead of running the length of the seam.
176
+ const calibratedTokens = preCalibrated
177
+ ? estimation.asCalibratedTokens(inputTokens)
178
+ : estimation.applyCalibration(estimation.asRawTokens(inputTokens), calibration.factor);
179
+ const classification = estimation.classifyAgainstBudget(calibratedTokens, budget);
180
+ output({
181
+ raw_tokens: inputTokens,
182
+ calibrated_tokens: calibratedTokens,
183
+ pre_calibrated: preCalibrated,
184
+ budget,
185
+ over_budget: classification.overBudget,
186
+ budget_valid: classification.budgetValid,
187
+ ratio: Number(classification.ratio.toFixed(4)),
188
+ recommendation: classification.recommendation,
189
+ confidence: calibration.confidence,
190
+ calibration_applied: calibration.applied,
191
+ calibration_factor: calibration.factor,
192
+ sample_count: calibration.sampleCount,
193
+ }, raw);
194
+ }
195
+ /**
196
+ * Pair each completed phase's PLAN estimate with its SUMMARY actuals.
197
+ *
198
+ * A phase contributes a sample only when BOTH sides are present and well-formed.
199
+ * A plan with no `estimate` block, a summary with no `actuals`, or a malformed
200
+ * value is skipped rather than guessed — a fabricated sample would silently
201
+ * steer every future estimate.
202
+ */
203
+ function collectCalibrationSamples(cwd) {
204
+ const phasesRoot = node_path_1.default.join(planningDir(cwd), 'phases');
205
+ let phases;
206
+ try {
207
+ phases = node_fs_1.default.readdirSync(phasesRoot, { withFileTypes: true })
208
+ .filter((d) => d.isDirectory())
209
+ .map((d) => d.name)
210
+ .sort();
211
+ }
212
+ catch {
213
+ return [];
214
+ }
215
+ const readBlock = (file, key) => {
216
+ let text;
217
+ try {
218
+ text = node_fs_1.default.readFileSync(file, 'utf-8');
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ return estimation.extractFrontmatterBlock(text, key);
224
+ };
225
+ const samples = [];
226
+ for (const phase of phases) {
227
+ const dir = node_path_1.default.join(phasesRoot, phase);
228
+ let files;
229
+ try {
230
+ files = node_fs_1.default.readdirSync(dir).sort();
231
+ }
232
+ catch {
233
+ continue;
234
+ }
235
+ // Pair PER PLAN, keyed on the `<NN>-<PP>` stem, NOT per phase directory.
236
+ // A phase routinely holds several plans (docs/reference/planning-artifacts.md:
237
+ // "one file per plan"). Taking the first plan with an estimate and the first
238
+ // summary with actuals independently cross-pairs one plan's projection with
239
+ // another's cost — a fabricated sample — and discards every later plan.
240
+ const stems = new Map();
241
+ for (const f of files) {
242
+ const m = /^(.*?)-(PLAN|SUMMARY)\.md$/.exec(f);
243
+ if (m === null)
244
+ continue;
245
+ const stem = m[1];
246
+ const entry = stems.get(stem) ?? {};
247
+ if (m[2] === 'PLAN')
248
+ entry.plan = node_path_1.default.join(dir, f);
249
+ else
250
+ entry.summary = node_path_1.default.join(dir, f);
251
+ stems.set(stem, entry);
252
+ }
253
+ for (const stem of [...stems.keys()].sort()) {
254
+ const { plan, summary } = stems.get(stem);
255
+ if (plan === undefined || summary === undefined)
256
+ continue;
257
+ const estimate = estimation.parseEstimate(readBlock(plan, 'estimate'));
258
+ const actuals = estimation.parseActuals(readBlock(summary, 'actuals'));
259
+ if (estimate === null || actuals === null)
260
+ continue;
261
+ // Measure against the RAW projection — see PhaseEstimate.rawTokens for why
262
+ // measuring against the calibrated figure makes the loop self-defeating.
263
+ samples.push({
264
+ estimateTokens: estimation.calibrationBasis(estimate),
265
+ actualTokens: actuals.tokens,
266
+ });
267
+ }
268
+ }
269
+ return samples;
270
+ }
271
+ /**
272
+ * `estimate-calibrate` — rebuild the calibration document from completed phases.
273
+ *
274
+ * Rebuilds from scratch every run rather than appending, so it is idempotent and
275
+ * a corrupt prior document is replaced rather than merged. This is the verb that
276
+ * closes the loop (#1952 AC4): extract-learnings invokes it, and the planner's
277
+ * next estimate reads the result.
278
+ */
279
+ function cmdEstimateCalibrate(cwd, _args, raw) {
280
+ const samples = collectCalibrationSamples(cwd);
281
+ const calibration = estimation.computeCalibration(samples);
282
+ const target = node_path_1.default.join(planningDir(cwd), exports.CALIBRATION_FILENAME);
283
+ let written = true;
284
+ let writeError = null;
285
+ try {
286
+ // Write-then-rename: a direct writeFileSync can leave a truncated file if
287
+ // interrupted, and parseCalibrationDocument treats malformed JSON exactly
288
+ // like "no history yet" — so a torn write would silently erase the
289
+ // calibration instead of surfacing.
290
+ const tmp = `${target}.tmp-${String(process.pid)}`;
291
+ try {
292
+ node_fs_1.default.writeFileSync(tmp, estimation.renderCalibrationDocument(samples), 'utf-8');
293
+ renameWithRetry(tmp, target);
294
+ }
295
+ catch (err) {
296
+ // Never leave the temp behind for a later run to trip over.
297
+ try {
298
+ node_fs_1.default.rmSync(tmp, { force: true });
299
+ }
300
+ catch { /* best effort */ }
301
+ throw err;
302
+ }
303
+ }
304
+ catch (err) {
305
+ // Persisting is best-effort: a read-only .planning must not fail the phase.
306
+ // But report WHY — a genuine bug and a benign permission issue are otherwise
307
+ // indistinguishable to both the caller and the workflow.
308
+ written = false;
309
+ writeError = err instanceof Error ? (err.message || String(err)) : String(err);
310
+ }
311
+ output({
312
+ factor: calibration.factor,
313
+ applied: calibration.applied,
314
+ sample_count: calibration.sampleCount,
315
+ confidence: calibration.confidence,
316
+ clamped: calibration.clamped,
317
+ min_samples: estimation.MIN_CALIBRATION_SAMPLES,
318
+ written,
319
+ write_error: writeError,
320
+ }, raw);
321
+ }
322
+ /**
323
+ * `estimate-calibration` — report the current correction factor and the
324
+ * history behind it.
325
+ */
326
+ function cmdEstimateCalibration(cwd, _args, raw) {
327
+ const calibration = estimation.computeCalibration(readCalibrationSamples(cwd));
328
+ output({
329
+ factor: calibration.factor,
330
+ applied: calibration.applied,
331
+ sample_count: calibration.sampleCount,
332
+ confidence: calibration.confidence,
333
+ clamped: calibration.clamped,
334
+ min_samples: estimation.MIN_CALIBRATION_SAMPLES,
335
+ }, raw);
336
+ }
@@ -15,6 +15,10 @@ const node_path_1 = __importDefault(require("node:path"));
15
15
  const ioMod = require("./io.cjs");
16
16
  const { output, error } = ioMod;
17
17
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
18
+ const validate_cjs_1 = require("./validate.cjs");
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ const unusableInputMod = require("./unusable-input.cjs");
21
+ const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
18
22
  // ─── Parsing engine ───────────────────────────────────────────────────────────
19
23
  /**
20
24
  * Split a YAML inline array body on commas, respecting quoted strings.
@@ -52,18 +56,50 @@ function splitInlineArray(body) {
52
56
  items.push(trimmed);
53
57
  return items;
54
58
  }
55
- function extractFrontmatter(content) {
59
+ /**
60
+ * How many parsed keys an unterminated region must yield before it is reported as a
61
+ * truncated frontmatter rather than left alone as ordinary Markdown. See the rationale on
62
+ * `extractFrontmatter`; exported for tests so the boundary is asserted against the constant
63
+ * rather than a magic number duplicated in the suite.
64
+ */
65
+ const UNTERMINATED_KEY_THRESHOLD = 2;
66
+ /**
67
+ * Does every non-empty line of an unterminated region look like frontmatter?
68
+ *
69
+ * The key count alone cannot separate a truncated write from ordinary Markdown, because a
70
+ * thematic break above a short labelled preamble parses as keys too:
71
+ *
72
+ * ---
73
+ * Author: Jane Doe
74
+ * Reviewed-by: John Smith
75
+ *
76
+ * Ordinary prose, and no second `---` anywhere.
77
+ *
78
+ * Raising the threshold only moves that boundary — two labelled lines are as common in prose as
79
+ * one. What actually distinguishes the two is what follows: a write interrupted part-way through
80
+ * a frontmatter block ends mid-block, so *every* line in the region is still frontmatter-shaped,
81
+ * whereas a document merely opening with a rule goes on to prose. So the region must be
82
+ * uniformly frontmatter-shaped AND carry enough keys to be worth reporting; either test alone
83
+ * has a false-positive class the other closes.
84
+ */
85
+ function isFrontmatterShaped(region) {
86
+ const lines = region.split(/\r?\n/).filter((line) => line.trim() !== '');
87
+ if (lines.length === 0)
88
+ return false;
89
+ return lines.every((line) => (/^\s*[a-zA-Z0-9_-]+:/.test(line) // key: value
90
+ || /^\s*-\s+/.test(line) // - list item
91
+ || /^\s+\S/.test(line) // indented continuation of a nested value
92
+ ));
93
+ }
94
+ /**
95
+ * Parse one already-delimited YAML region into a Frontmatter object.
96
+ *
97
+ * Extracted from `extractFrontmatter` (#1882) so the truncation probe below and the real
98
+ * parse run the *same* parser. A second, simpler "does this look like YAML?" matcher would
99
+ * be a parallel surface that drifts — exactly the generative-fix-divergence class.
100
+ */
101
+ function parseYamlRegion(yaml) {
56
102
  const frontmatter = {};
57
- // Match frontmatter only at byte 0 — a `---` block later in the document
58
- // body (YAML examples, horizontal rules) must never be treated as frontmatter.
59
- const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
60
- if (headerEnd === -1)
61
- return frontmatter;
62
- const closingLineStart = content.indexOf('\n---', headerEnd);
63
- if (closingLineStart === -1)
64
- return frontmatter;
65
- const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
66
- const yaml = content.slice(headerEnd, yamlEnd);
67
103
  const lines = yaml.split(/\r?\n/);
68
104
  const stack = [{ obj: frontmatter, key: null, indent: -1 }];
69
105
  for (const line of lines) {
@@ -126,6 +162,60 @@ function extractFrontmatter(content) {
126
162
  }
127
163
  return frontmatter;
128
164
  }
165
+ /**
166
+ * Extract frontmatter from a document.
167
+ *
168
+ * Returns `{}` when the document has no frontmatter — and, unchanged since #1882, also
169
+ * returns `{}` when the frontmatter fence was opened and never closed. That return value is
170
+ * deliberately preserved: ADR-1411's amendment requires the fallback to stay, because
171
+ * changing it would break callers that treat "absent" and "unusable" identically. What #1882
172
+ * adds is that the second case is no longer *silent*.
173
+ *
174
+ * The discriminator is the reason this is not simply "opened but never closed". A Markdown
175
+ * document whose first line is a thematic break (`---`) takes that exact branch, so flagging
176
+ * on the missing fence alone reports corruption on perfectly good Markdown. Instead the
177
+ * unterminated region is run through this module's own parser and reported only when it
178
+ * yields **two or more** keys.
179
+ *
180
+ * Two, not one, and the extra key is doing real work. A single `key: value` line is genuinely
181
+ * ambiguous: `---` followed by `Note: this is a paragraph.` — or `Author:`, `TODO:`, `See:` —
182
+ * is ordinary technical writing, a thematic break above a labelled line, and it parses as
183
+ * exactly one key. There is no textual signal that separates it from a write interrupted
184
+ * after its first key, so the threshold is set where the ambiguity ends. The cost is a false
185
+ * negative on a file truncated after exactly one key; the benefit is silence on a very common
186
+ * Markdown shape. That direction is deliberate and matches the choice already made at zero
187
+ * keys: a false positive on valid Markdown is worse than a missed edge, because the
188
+ * diagnostic is unconditional and cannot be turned off. Every GSD artefact this guards
189
+ * (STATE.md, PLAN.md, ROADMAP.md, SUMMARY.md, agent/command docs) carries two or more
190
+ * frontmatter keys, so the realistic interruption window stays covered.
191
+ *
192
+ * @param content Raw document text.
193
+ * @param sourcePath Optional resolved path, used to name the file in the diagnostic and to
194
+ * key its deduplication. Optional because this function has 50-odd call sites and several
195
+ * hold only an in-memory string; those dedup on a content digest instead.
196
+ */
197
+ function extractFrontmatter(content, sourcePath) {
198
+ // Match frontmatter only at byte 0 — a `---` block later in the document
199
+ // body (YAML examples, horizontal rules) must never be treated as frontmatter.
200
+ const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
201
+ if (headerEnd === -1)
202
+ return {};
203
+ const closingLineStart = content.indexOf('\n---', headerEnd);
204
+ if (closingLineStart === -1) {
205
+ const region = content.slice(headerEnd);
206
+ const probe = parseYamlRegion(region);
207
+ if (Object.keys(probe).length >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) {
208
+ warnUnusableInput({
209
+ reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
210
+ source: sourcePath,
211
+ content,
212
+ });
213
+ }
214
+ return {};
215
+ }
216
+ const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
217
+ return parseYamlRegion(content.slice(headerEnd, yamlEnd));
218
+ }
129
219
  /**
130
220
  * Escape a string for emission inside a YAML double-quoted scalar (#1779).
131
221
  * Backslash must be escaped first so the backslashes added for embedded quotes
@@ -557,7 +647,9 @@ function cmdFrontmatterGet(cwd, filePath, field, raw) {
557
647
  output({ error: 'File not found', path: filePath }, raw, undefined);
558
648
  return;
559
649
  }
560
- const fm = extractFrontmatter(content);
650
+ // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated
651
+ // per file rather than per content digest (#1882, ADR-1411 wiring clause).
652
+ const fm = extractFrontmatter(content, fullPath);
561
653
  if (field) {
562
654
  const value = fm[field];
563
655
  if (value === undefined) {
@@ -584,7 +676,9 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
584
676
  return;
585
677
  }
586
678
  const content = node_fs_1.default.readFileSync(fullPath, 'utf-8');
587
- const fm = extractFrontmatter(content);
679
+ // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated
680
+ // per file rather than per content digest (#1882, ADR-1411 wiring clause).
681
+ const fm = extractFrontmatter(content, fullPath);
588
682
  let parsedValue;
589
683
  try {
590
684
  parsedValue = JSON.parse(value);
@@ -633,7 +727,9 @@ function cmdFrontmatterMerge(cwd, filePath, data, raw) {
633
727
  return;
634
728
  }
635
729
  const content = node_fs_1.default.readFileSync(fullPath, 'utf-8');
636
- const fm = extractFrontmatter(content);
730
+ // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated
731
+ // per file rather than per content digest (#1882, ADR-1411 wiring clause).
732
+ const fm = extractFrontmatter(content, fullPath);
637
733
  let mergeData;
638
734
  try {
639
735
  mergeData = JSON.parse(data);
@@ -651,6 +747,9 @@ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
651
747
  if (!filePath || !schemaName) {
652
748
  error('file and schema required');
653
749
  }
750
+ if (filePath.includes('\0')) {
751
+ error('file path contains null bytes');
752
+ }
654
753
  const schema = FRONTMATTER_SCHEMAS[schemaName];
655
754
  if (!schema) {
656
755
  error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`);
@@ -661,13 +760,24 @@ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
661
760
  output({ error: 'File not found', path: filePath }, raw, undefined);
662
761
  return;
663
762
  }
664
- const fm = extractFrontmatter(content);
763
+ // #2701: fail loud on NUL/binary corruption before schema checks. A structurally
764
+ // intact-but-NUL-corrupted file otherwise passes as valid:true and is then silently
765
+ // skipped by recursive/binary-skipping searchers, reading downstream as "absent."
766
+ const encErr = (0, validate_cjs_1.textEncodingError)(content, filePath);
767
+ if (encErr) {
768
+ output({ valid: false, errors: [encErr], schema: schemaName }, raw, 'invalid');
769
+ return;
770
+ }
771
+ // Pass the resolved path so a truncated file is named in the diagnostic and deduplicated
772
+ // per file rather than per content digest (#1882, ADR-1411 wiring clause).
773
+ const fm = extractFrontmatter(content, fullPath);
665
774
  const missing = schema.required.filter(f => fm[f] === undefined);
666
775
  const present = schema.required.filter(f => fm[f] !== undefined);
667
776
  output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
668
777
  }
669
778
  module.exports = {
670
779
  extractFrontmatter,
780
+ UNTERMINATED_KEY_THRESHOLD,
671
781
  // Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a
672
782
  // frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME
673
783
  // function as `extractFrontmatter` — a bare-object parse with no behavior change — exposed under