@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,398 @@
1
+ "use strict";
2
+ /**
3
+ * Phase Estimation — estimate/actuals schema, smart-zone threshold policy, and
4
+ * estimate-vs-actual calibration.
5
+ *
6
+ * Epic #1952, Phase 1 (#2630). Design lock: docs/adr/2629-phase-effort-estimation-calibration.md.
7
+ *
8
+ * Pure functions only — no I/O, no config reads. Callers supply the budget and
9
+ * the raw calibration document; this module decides policy over them. The CLI
10
+ * seam (gsd-tools) owns reading `.planning/config.json` and
11
+ * `.planning/estimation-calibration.json`.
12
+ *
13
+ * Two properties this module exists to preserve, both from ADR-2629:
14
+ *
15
+ * 1. Every signal is EXOGENOUS. The correction routes on a measured
16
+ * actual/estimate ratio; `confidence` routes on a calibration sample
17
+ * count. Nothing routes on a model's self-assessment. This project
18
+ * measured self-rated confidence and found it weak
19
+ * (gsd-core/references/honest-verifier.md:25-29 — "on a true blind spot it
20
+ * stays confidently wrong"), which is why deriveConfidence() takes a
21
+ * sample count and there is no "how sure are you?" input anywhere here.
22
+ *
23
+ * 2. Estimate and actual share ONE measurement scale — estimateTokens() from
24
+ * prompt-budget. A ratio between two different measurement methods would
25
+ * measure the methods, not the miss. measureTokens() below is the single
26
+ * re-export so no consumer reaches for a second estimator.
27
+ *
28
+ * ADR-457 build-at-publish: source here, compiled to
29
+ * gsd-core/bin/lib/phase-estimation.cjs (gitignored).
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.CALIBRATION_SCHEMA_VERSION = exports.CALIBRATION_FACTOR_MAX = exports.CALIBRATION_FACTOR_MIN = exports.CONFIDENCE_HIGH_MIN_SAMPLES = exports.CONFIDENCE_MED_MIN_SAMPLES = exports.MIN_CALIBRATION_SAMPLES = exports.CONFIDENCE_VALUES = void 0;
33
+ exports.asRawTokens = asRawTokens;
34
+ exports.asCalibratedTokens = asCalibratedTokens;
35
+ exports.measureTokens = measureTokens;
36
+ exports.deriveConfidence = deriveConfidence;
37
+ exports.classifyAgainstBudget = classifyAgainstBudget;
38
+ exports.computeCalibration = computeCalibration;
39
+ exports.applyCalibration = applyCalibration;
40
+ exports.extractFrontmatterBlock = extractFrontmatterBlock;
41
+ exports.parseEstimate = parseEstimate;
42
+ exports.parseActuals = parseActuals;
43
+ exports.renderEstimate = renderEstimate;
44
+ exports.calibrationBasis = calibrationBasis;
45
+ exports.renderActuals = renderActuals;
46
+ exports.parseCalibrationDocument = parseCalibrationDocument;
47
+ exports.renderCalibrationDocument = renderCalibrationDocument;
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- prompt-budget.cjs is an export= CommonJS module
49
+ const promptBudget = require("./prompt-budget.cjs");
50
+ const { estimateTokens } = promptBudget;
51
+ /**
52
+ * Assert that a bare number is an UNCORRECTED projection.
53
+ *
54
+ * Call this only where a number crosses a trust boundary carrying a basis the
55
+ * type system cannot see — argv, disk frontmatter, a persisted document. The
56
+ * parameter type refuses a `CalibratedTokens`, so a corrected figure cannot be
57
+ * laundered back into the basis; without that the brand would be decorative and
58
+ * #2632 would be one keystroke away again.
59
+ */
60
+ function asRawTokens(tokens) {
61
+ return tokens;
62
+ }
63
+ /** Assert that a bare number already has the correction applied. Refuses a `RawTokens`. */
64
+ function asCalibratedTokens(tokens) {
65
+ return tokens;
66
+ }
67
+ exports.CONFIDENCE_VALUES = Object.freeze(['low', 'med', 'high']);
68
+ /** Below this many calibration samples, no correction is applied (ADR-2629 Decision 4). */
69
+ exports.MIN_CALIBRATION_SAMPLES = 3;
70
+ /** Sample-count thresholds for derived confidence (ADR-2629 Decision 1). */
71
+ exports.CONFIDENCE_MED_MIN_SAMPLES = 3;
72
+ exports.CONFIDENCE_HIGH_MIN_SAMPLES = 6;
73
+ /** Correction-factor clamp. Outside this range the estimator is wrong in kind, not degree. */
74
+ exports.CALIBRATION_FACTOR_MIN = 0.5;
75
+ exports.CALIBRATION_FACTOR_MAX = 3.0;
76
+ /** Schema version for the persisted calibration document. */
77
+ exports.CALIBRATION_SCHEMA_VERSION = 1;
78
+ /**
79
+ * A positive, finite, safe integer. Rejects NaN, Infinity, negatives, zero,
80
+ * non-integers, and anything past MAX_SAFE_INTEGER (where integer arithmetic
81
+ * silently stops being exact).
82
+ */
83
+ function isPositiveInt(value) {
84
+ return typeof value === 'number'
85
+ && Number.isSafeInteger(value)
86
+ && value > 0;
87
+ }
88
+ function isPositiveFinite(value) {
89
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
90
+ }
91
+ function isConfidence(value) {
92
+ return typeof value === 'string' && exports.CONFIDENCE_VALUES.includes(value);
93
+ }
94
+ /**
95
+ * A usable calibration sample: both sides present, positive, and finite.
96
+ * A zero or negative estimate would divide to Infinity or flip the ratio's
97
+ * sign, so those are dropped rather than coerced.
98
+ */
99
+ function isCalibrationSample(value) {
100
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
101
+ return false;
102
+ const record = value;
103
+ // The RawTokens brand on estimateTokens is asserted here, at the disk trust
104
+ // boundary — a persisted sample's basis is a fact about the writer, and the
105
+ // only writers are collectCalibrationSamples() (which reads it through
106
+ // calibrationBasis()) and this module's own renderCalibrationDocument().
107
+ return isPositiveFinite(record['estimateTokens']) && isPositiveFinite(record['actualTokens']);
108
+ }
109
+ /**
110
+ * Measure text on the canonical scale. The ONE estimator both the estimate and
111
+ * the actuals must use — see property 2 in the module header.
112
+ */
113
+ function measureTokens(text) {
114
+ return estimateTokens(text);
115
+ }
116
+ /**
117
+ * Derive confidence from how much measured history backs the estimate.
118
+ *
119
+ * Exogenous by construction: the input is a count, not a judgment. A non-integer
120
+ * or negative count degrades to 'low' rather than throwing — an unusable history
121
+ * is exactly the low-confidence case.
122
+ */
123
+ function deriveConfidence(sampleCount) {
124
+ if (typeof sampleCount !== 'number' || !Number.isFinite(sampleCount) || sampleCount < 0)
125
+ return 'low';
126
+ if (sampleCount >= exports.CONFIDENCE_HIGH_MIN_SAMPLES)
127
+ return 'high';
128
+ if (sampleCount >= exports.CONFIDENCE_MED_MIN_SAMPLES)
129
+ return 'med';
130
+ return 'low';
131
+ }
132
+ /**
133
+ * Classify an estimate against the smart-zone budget.
134
+ *
135
+ * Boundary contract (ADR-2629 Decision 3 + RULESET.TESTS.boundary-coverage.fixtures):
136
+ * budget-1 → under, budget → under, budget+1 → over. The comparison is strictly
137
+ * greater-than, so landing exactly on the budget is not a violation.
138
+ *
139
+ * An unusable budget (hand-edited config, missing key) never fabricates a
140
+ * violation: it reports budgetValid=false and overBudget=false, so a broken
141
+ * config cannot spam split recommendations.
142
+ */
143
+ function classifyAgainstBudget(estimate, budget) {
144
+ // Kept for untyped `.cjs` callers — see the note in applyCalibration. A
145
+ // hand-edited config reaches `budget` as anything at runtime regardless of
146
+ // what the TypeScript signature promises.
147
+ if (!isPositiveFinite(budget) || !isPositiveFinite(estimate)) {
148
+ return { overBudget: false, ratio: 0, recommendation: null, budgetValid: isPositiveFinite(budget) };
149
+ }
150
+ const ratio = estimate / budget;
151
+ if (estimate <= budget) {
152
+ return { overBudget: false, ratio, recommendation: null, budgetValid: true };
153
+ }
154
+ const slices = Math.ceil(ratio);
155
+ return {
156
+ overBudget: true,
157
+ ratio,
158
+ recommendation: `Estimated ${estimate} tokens exceeds the ${budget}-token smart-zone budget `
159
+ + `(${ratio.toFixed(2)}x). Consider splitting this phase into about ${slices} `
160
+ + `slices — a tracer plus ${slices - 1} expansion slice(s) — so each runs inside the budget.`,
161
+ budgetValid: true,
162
+ };
163
+ }
164
+ /** Median of a non-empty numeric array. Caller guarantees non-empty. */
165
+ function median(sorted) {
166
+ const mid = Math.floor(sorted.length / 2);
167
+ if (sorted.length % 2 === 1)
168
+ return sorted[mid];
169
+ return (sorted[mid - 1] + sorted[mid]) / 2;
170
+ }
171
+ /**
172
+ * Compute the correction factor from estimate/actual history.
173
+ *
174
+ * Median, not mean — one pathological phase (an aborted run, a mass rename)
175
+ * must not swing every later projection. Clamped, because a ratio outside
176
+ * [0.5, 3.0] means the estimator is wrong in kind and amplifying it would make
177
+ * the next estimate worse, not better.
178
+ *
179
+ * Samples missing either side, or carrying a non-positive/non-finite value, are
180
+ * dropped rather than coerced — a zero estimate would divide to Infinity.
181
+ */
182
+ function computeCalibration(samples) {
183
+ const candidates = Array.isArray(samples) ? samples : [];
184
+ const usable = candidates.filter(isCalibrationSample);
185
+ const sampleCount = usable.length;
186
+ const confidence = deriveConfidence(sampleCount);
187
+ if (sampleCount < exports.MIN_CALIBRATION_SAMPLES) {
188
+ return { factor: 1, sampleCount, applied: false, confidence, clamped: false };
189
+ }
190
+ const ratios = usable.map((s) => s.actualTokens / s.estimateTokens).sort((a, b) => a - b);
191
+ const raw = median(ratios);
192
+ const factor = Math.min(exports.CALIBRATION_FACTOR_MAX, Math.max(exports.CALIBRATION_FACTOR_MIN, raw));
193
+ return { factor, sampleCount, applied: true, confidence, clamped: factor !== raw };
194
+ }
195
+ /**
196
+ * Apply a correction factor to a raw estimate. Rounds to an integer because
197
+ * `estimate.tokens` is an integer field; floors at 1 so a heavy shrink factor
198
+ * can never produce a zero-token estimate.
199
+ */
200
+ function applyCalibration(rawTokens, factor) {
201
+ // These two guards look dead to the type-checker and are not: this module is
202
+ // compiled to `.cjs` and consumed by untyped callers (gsd-tools.cjs, the test
203
+ // suite), which reach it with NaN, null, 0 and worse. The brands are a
204
+ // compile-time contract for TypeScript callers; validation is what defends
205
+ // everyone else. Do not delete either one because the parameter is now typed.
206
+ if (!isPositiveFinite(rawTokens))
207
+ return asCalibratedTokens(0);
208
+ if (!isPositiveFinite(factor))
209
+ return asCalibratedTokens(Math.max(1, Math.round(rawTokens)));
210
+ // Bound the product: an inexact float past MAX_SAFE_INTEGER would masquerade
211
+ // as an integer token count. Unreachable through today's CLI (which is
212
+ // safe-integer bounded) but the function is exported and must not depend on
213
+ // its caller for that guarantee.
214
+ const scaled = Math.round(rawTokens * factor);
215
+ return asCalibratedTokens(Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, scaled)));
216
+ }
217
+ /**
218
+ * Extract a two-space-indented scalar block (`estimate:` / `actuals:`) out of a
219
+ * document's leading YAML frontmatter.
220
+ *
221
+ * Hand-rolled because gsd-core ships no external dependencies (CONTRIBUTING.md
222
+ * "No external dependencies in core") — js-yaml is a devDependency and is not
223
+ * available at runtime. Scope is deliberately narrow: the leading `---` block
224
+ * only, so a `estimate:` line inside a fenced code block in the body cannot be
225
+ * mistaken for frontmatter (the DEFECT.FRONTMATTER-SCALAR-BROAD-GREP class).
226
+ *
227
+ * Numeric-looking values are returned as numbers so parseEstimate/parseActuals
228
+ * see the types they validate; everything else stays a string.
229
+ */
230
+ function extractFrontmatterBlock(text, key) {
231
+ if (typeof text !== 'string')
232
+ return null;
233
+ // Anchor at byte 0 — CRLF-tolerant.
234
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?(?:\n|$)/.exec(text);
235
+ if (fm === null)
236
+ return null;
237
+ const lines = fm[1].split(/\r?\n/);
238
+ const startIdx = lines.findIndex((l) => l === `${key}:` || l.startsWith(`${key}:`));
239
+ if (startIdx === -1)
240
+ return null;
241
+ const out = Object.create(null);
242
+ for (let i = startIdx + 1; i < lines.length; i += 1) {
243
+ const line = lines[i];
244
+ if (!/^\s/.test(line))
245
+ break; // dedent ends the block
246
+ const m = /^\s+([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
247
+ if (m === null)
248
+ continue;
249
+ const rawValue = m[2].replace(/\s+#.*$/, '').trim();
250
+ if (rawValue === '')
251
+ continue;
252
+ const asNumber = Number(rawValue);
253
+ out[m[1]] = /^-?\d+(?:\.\d+)?$/.test(rawValue) && Number.isFinite(asNumber)
254
+ ? asNumber
255
+ : rawValue.replace(/^['"]|['"]$/g, '');
256
+ }
257
+ return Object.keys(out).length > 0 ? { ...out } : null;
258
+ }
259
+ /** Pull the `estimate:` mapping out of an already-parsed frontmatter object. */
260
+ function estimateBlockOf(input) {
261
+ if (input === null || typeof input !== 'object')
262
+ return null;
263
+ const record = input;
264
+ return Object.prototype.hasOwnProperty.call(record, 'estimate') ? record['estimate'] : record;
265
+ }
266
+ /**
267
+ * Parse an estimate block. Returns null for anything that is not a complete,
268
+ * well-typed estimate — a partial block is not a usable estimate, and silently
269
+ * defaulting a missing field would fabricate data the planner never produced.
270
+ *
271
+ * Accepts either the whole frontmatter object (`{estimate: {...}}`) or the
272
+ * estimate mapping itself, so callers need not unwrap.
273
+ */
274
+ function parseEstimate(input) {
275
+ const block = estimateBlockOf(input);
276
+ if (block === null || typeof block !== 'object' || Array.isArray(block))
277
+ return null;
278
+ const record = block;
279
+ const tokens = record['tokens'];
280
+ const tasks = record['tasks'];
281
+ const confidence = record['confidence'];
282
+ if (!isPositiveInt(tokens) || !isPositiveInt(tasks) || !isConfidence(confidence))
283
+ return null;
284
+ // The frontmatter trust boundary: `tokens` is calibrated-at-emission and
285
+ // `raw_tokens` is the uncorrected projection (ADR-2629 Decision 1/4), so this
286
+ // is where each figure's basis becomes a type rather than a field name.
287
+ const rawTokens = record['raw_tokens'];
288
+ return isPositiveInt(rawTokens)
289
+ ? { tokens: asCalibratedTokens(tokens), tasks, confidence, rawTokens: asRawTokens(rawTokens) }
290
+ : { tokens: asCalibratedTokens(tokens), tasks, confidence };
291
+ }
292
+ /** Pull the `actuals:` mapping out of an already-parsed frontmatter object. */
293
+ function actualsBlockOf(input) {
294
+ if (input === null || typeof input !== 'object')
295
+ return null;
296
+ const record = input;
297
+ return Object.prototype.hasOwnProperty.call(record, 'actuals') ? record['actuals'] : record;
298
+ }
299
+ /**
300
+ * Parse an actuals block. `commits` may be 0 — a phase can legitimately record
301
+ * zero commits — so it is validated as a non-negative integer while tokens and
302
+ * tasks stay strictly positive.
303
+ */
304
+ function parseActuals(input) {
305
+ const block = actualsBlockOf(input);
306
+ if (block === null || typeof block !== 'object' || Array.isArray(block))
307
+ return null;
308
+ const record = block;
309
+ const tokens = record['tokens'];
310
+ const tasks = record['tasks'];
311
+ const commits = record['commits'];
312
+ if (!isPositiveInt(tokens) || !isPositiveInt(tasks))
313
+ return null;
314
+ if (typeof commits !== 'number' || !Number.isSafeInteger(commits) || commits < 0)
315
+ return null;
316
+ return { tokens, tasks, commits };
317
+ }
318
+ /**
319
+ * Render an estimate as the YAML block that lands in PLAN.md frontmatter.
320
+ * Inverse of parseEstimate over the same value domain — the bijection the
321
+ * property test pins.
322
+ */
323
+ function renderEstimate(estimate) {
324
+ const lines = [
325
+ 'estimate:',
326
+ ` tokens: ${estimate.tokens}`,
327
+ ];
328
+ if (isPositiveInt(estimate.rawTokens))
329
+ lines.push(` raw_tokens: ${estimate.rawTokens}`);
330
+ lines.push(` tasks: ${estimate.tasks}`, ` confidence: ${estimate.confidence}`);
331
+ return lines.join('\n');
332
+ }
333
+ /**
334
+ * The figure calibration must measure against: the uncalibrated projection when
335
+ * the plan recorded one, else the stored value (pre-#2632 plans, where the two
336
+ * were the same because no factor had yet been applied).
337
+ */
338
+ function calibrationBasis(estimate) {
339
+ if (isPositiveInt(estimate.rawTokens))
340
+ return estimate.rawTokens;
341
+ // THE one legitimate crossover in this module, and the reason asRawTokens()
342
+ // refuses a CalibratedTokens rather than being permissive: on a plan written
343
+ // before #2632 no factor had been applied yet, so `tokens` IS the raw
344
+ // projection. Deliberately an explicit assertion so it stays a single
345
+ // auditable line instead of a hole in the brand.
346
+ return estimate.tokens;
347
+ }
348
+ /** Render an actuals block for SUMMARY.md frontmatter. Inverse of parseActuals. */
349
+ function renderActuals(actuals) {
350
+ return [
351
+ 'actuals:',
352
+ ` tokens: ${actuals.tokens}`,
353
+ ` tasks: ${actuals.tasks}`,
354
+ ` commits: ${actuals.commits}`,
355
+ ].join('\n');
356
+ }
357
+ /**
358
+ * Parse the persisted calibration document.
359
+ *
360
+ * This is a trust boundary: the file is on disk, may be hand-edited, and its
361
+ * contents steer planning output. Every failure mode degrades to an empty
362
+ * sample set rather than throwing or partially trusting — malformed JSON, a
363
+ * non-object root, a missing/!== current schema_version, a non-array samples
364
+ * field, or individual malformed samples.
365
+ *
366
+ * A schema_version we do not recognize is refused outright rather than
367
+ * best-effort read: a future writer may change the ratio's meaning, and
368
+ * misreading it would silently corrupt every subsequent estimate.
369
+ */
370
+ function parseCalibrationDocument(raw) {
371
+ if (typeof raw !== 'string' || raw.trim() === '')
372
+ return [];
373
+ let parsed;
374
+ try {
375
+ parsed = JSON.parse(raw);
376
+ }
377
+ catch {
378
+ return [];
379
+ }
380
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
381
+ return [];
382
+ const doc = parsed;
383
+ if (doc['schema_version'] !== exports.CALIBRATION_SCHEMA_VERSION)
384
+ return [];
385
+ if (!Array.isArray(doc['samples']))
386
+ return [];
387
+ // Rebuild each sample from its two known fields rather than passing the
388
+ // parsed object through — a hostile document cannot smuggle extra keys
389
+ // (or a __proto__ payload) into anything downstream.
390
+ return doc['samples']
391
+ .filter(isCalibrationSample)
392
+ .map((s) => ({ estimateTokens: s.estimateTokens, actualTokens: s.actualTokens }));
393
+ }
394
+ /** Serialize a calibration document. Inverse of parseCalibrationDocument. */
395
+ function renderCalibrationDocument(samples) {
396
+ const doc = { schema_version: exports.CALIBRATION_SCHEMA_VERSION, samples };
397
+ return `${JSON.stringify(doc, null, 2)}\n`;
398
+ }