feat(#2630): phase-estimation module, smart-zone config key, and cli verbs (#2661)

* feat(#2630): add phase-estimation module, smart-zone config key, and cli verbs

* fix(#2630): document smart_zone_tokens, refresh golden fixtures, fix null-proto property assertions

* fix(#2630): align smart_zone_tokens write/read validation and harden estimation tests

* chore(#2630): backfill changeset pr to 2661
This commit is contained in:
Tom Boucher
2026-07-26 01:42:47 -04:00
committed by GitHub
parent bf127b7d25
commit 46ba02acde
35 changed files with 1319 additions and 97 deletions

158
src/estimate-cli.cts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Estimate CLI — the I/O seam over the pure phase-estimation module.
*
* Epic #1952 Phase 1 (#2630). Design lock: docs/adr/2629-phase-effort-estimation-calibration.md.
*
* `phase-estimation.cts` is pure policy; everything that touches disk or config
* lives here. Two leaf verbs (a pair, so leaves rather than a family per
* ADR-2346's ">=3 subcommands" rule):
*
* gsd-tools query estimate-check --tokens <n>
* gsd-tools query estimate-calibration
*
* Both degrade rather than fail. A missing or corrupt
* `.planning/estimation-calibration.json` yields an inert calibration
* (factor 1, applied false) instead of breaking planning — the file is a disk
* trust boundary that steers planning output, so it is parsed defensively and
* never trusted structurally.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
import io = require('./io.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-estimation.cjs is an export= CommonJS module
import estimation = require('./phase-estimation.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
import configLoader = require('./config-loader.cjs');
const { output, error, ERROR_REASON } = io;
const { planningDir } = planningWorkspace;
const { CONFIG_DEFAULTS } = configLoader;
/** Filename of the persisted calibration document, written by extract-learnings (Phase 3). */
export const CALIBRATION_FILENAME = 'estimation-calibration.json';
function defaultBudget(): number {
const fromManifest = Number(CONFIG_DEFAULTS.smart_zone_tokens);
return Number.isSafeInteger(fromManifest) && fromManifest > 0 ? fromManifest : 100000;
}
/**
* Read the configured smart-zone budget, degrading to the manifest default.
*
* Reads config.json directly rather than through the flat loadConfig
* projection: a hand-edited config can hold any value, and this seam must
* validate rather than assume. An out-of-shape value falls back to the default
* instead of propagating NaN into the comparison.
*/
export function readSmartZoneBudget(cwd: string): number {
try {
const configPath = path.join(planningDir(cwd), 'config.json');
const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
if (parsed !== null && typeof parsed === 'object') {
const workflow = (parsed as Record<string, unknown>)['workflow'];
if (workflow !== null && typeof workflow === 'object') {
const value = (workflow as Record<string, unknown>)['smart_zone_tokens'];
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value;
}
}
} catch {
// Absent, unreadable, or malformed config — the default is the answer.
}
return defaultBudget();
}
/** Read and defensively parse the calibration history. Never throws. */
export function readCalibrationSamples(cwd: string): ReturnType<typeof estimation.parseCalibrationDocument> {
let raw: string;
try {
raw = fs.readFileSync(path.join(planningDir(cwd), CALIBRATION_FILENAME), 'utf-8');
} catch {
return [];
}
return estimation.parseCalibrationDocument(raw);
}
/**
* Parse `--tokens <n>`.
*
* Rejects a missing value, an empty/whitespace value, a value that is really
* the next flag, and anything that is not a positive integer. Uses an exact
* digit match rather than Number()/parseInt so that "1; touch x", "1e5",
* "0x10", and " 1 " are all refused — the value reaches us as argv, is never
* shell-interpolated, and must not be coerced into looking valid.
*/
export function parseTokensFlag(args: string[]): number {
const idx = args.indexOf('--tokens');
if (idx === -1) {
error('Usage: estimate-check --tokens <positive integer>', ERROR_REASON.USAGE);
}
const value = args[idx + 1];
if (value === undefined || value.startsWith('--') || !/^[0-9]+$/.test(value)) {
error(
`Invalid --tokens ${JSON.stringify(value ?? '')}. Must be a positive integer (token count).`,
ERROR_REASON.USAGE,
);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
error(
`Invalid --tokens ${JSON.stringify(value)}. Must be a positive integer (token count).`,
ERROR_REASON.USAGE,
);
}
return parsed;
}
/**
* `estimate-check --tokens <n>` — classify an estimate against the configured
* smart-zone budget, with the current calibration applied.
*
* The advisory contract (ADR-2629 Decision 5): this reports, it never blocks.
* Exit status is 0 whether or not the estimate is over budget; `over_budget`
* in the payload is the signal.
*/
export function cmdEstimateCheck(cwd: string, args: string[], raw: boolean): void {
const rawTokens = parseTokensFlag(args);
const budget = readSmartZoneBudget(cwd);
const calibration = estimation.computeCalibration(readCalibrationSamples(cwd));
const calibratedTokens = estimation.applyCalibration(rawTokens, calibration.factor);
const classification = estimation.classifyAgainstBudget(calibratedTokens, budget);
output({
raw_tokens: rawTokens,
calibrated_tokens: calibratedTokens,
budget,
over_budget: classification.overBudget,
budget_valid: classification.budgetValid,
ratio: Number(classification.ratio.toFixed(4)),
recommendation: classification.recommendation,
confidence: calibration.confidence,
calibration_applied: calibration.applied,
calibration_factor: calibration.factor,
sample_count: calibration.sampleCount,
}, raw);
}
/**
* `estimate-calibration` — report the current correction factor and the
* history behind it.
*/
export function cmdEstimateCalibration(cwd: string, _args: string[], raw: boolean): void {
const calibration = estimation.computeCalibration(readCalibrationSamples(cwd));
output({
factor: calibration.factor,
applied: calibration.applied,
sample_count: calibration.sampleCount,
confidence: calibration.confidence,
clamped: calibration.clamped,
min_samples: estimation.MIN_CALIBRATION_SAMPLES,
}, raw);
}