@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +186 -55
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +849 -2
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +5 -5
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +57 -5
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +128 -20
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +76 -19
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +57 -8
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- 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
|
+
}
|