@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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|