* fix: require fresh phase verification before transition * no-mistakes(review): Fix canonical verification closeout gates * no-mistakes(review): Fix verify-work frontmatter promotion command * no-mistakes(review): Fix stale verification gates * no-mistakes(review): Fix canonical verification routing gates * no-mistakes(review): Fix verification dependency and runtime routing gates * no-mistakes(review): Block stale verification bypasses * fix: handle large init manager outputs in verification workflows * chore: update changeset pr number * fix(verify-work): use fresh verification.status for stale gate The stale check after UAT used phase_completion.verification_status from session-start INIT while human_needed promotion already queried fresh verification.status. Align the stale gate with the canonical query so mid-session verification refresh is not ignored. * fix(init): skip roadmap-checked phases when selecting next_phase Roadmap-only phases without a disk directory were still promoted to next_phase when their checkbox was already checked. Exclude checkboxComplete phases so progress routing does not point at work the roadmap already marks done. * fix: gaps_found not overridden by stale, transition uses canonical verification - verification.cts: check gaps_found before stale so gap-closure routing is not masked by a newer summary mtime - phase.cts: remove redundant findStaleVerificationSummary — readVerificationStatus already handles stale detection - transition.md: replace raw grep on file content with verification.status query to avoid false-positive blocks from body text matching * ci: retrigger tests after rebase * fix(transition): replace gsd_run advisory check with awk frontmatter extraction The runtime launcher is not defined until the update_roadmap_and_state step bash block (~line 165). The early verify_completion block used gsd_run to query verification.status, which violated the runtime-launcher-parity test: 'preamble appears AFTER the first gsd_run reference'. Replace the gsd_run call with an awk-based frontmatter extractor that reads only the status: field between the two --- fences. This avoids both the preamble-ordering constraint and the original false-positive grep bug where body text like 'previous_status: gaps_found' would match a full-text regex. The phase.complete gate at update_roadmap_and_state is the canonical enforcement point; this early check is advisory only. Also update workflow-size-baseline.json for the updated transition.md size. Fixes: runtime-launcher-parity test (B) Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix: re-check verification under planning lock in phase complete Move readVerificationStatus into withPlanningLock so stale verification cannot slip through when a SUMMARY.md is written between the gate and the roadmap/state mutation. Return the blocked status from the lock callback and emit the error after release to avoid leaving .lock behind. * fix(transition): gate on canonical verification.status including stale Replace awk frontmatter read with verification.status query so transition blocks when summaries are newer than VERIFICATION.md, matching phase.complete and other workflows (autonomous, progress, verify-work). * Fix workflow verification gates for yolo transition and stale routing Require VERIFY_STATUS passed before yolo/interactive transition advance. Route stale verification recovery to verify-work, matching canonical projection. * fix(transition): use verification.status query for stale-aware advisory check The awk-based check read raw frontmatter status: passed, which misses the stale case where summaries are newer than the VERIFICATION.md file even though the frontmatter still says passed. The stale status is computed from file modification times, not stored in frontmatter. Move the preamble to the verify_completion bash block (the first block with a gsd_run call) so gsd_run query verification.status can be used for the advisory check. This gives the full readVerificationStatus logic including mtime-based staleness detection, matching the enforcement gate at phase.complete. Capture full JSON (VERIFY_JSON) so next_action can be included in the advisory output alongside the status. Also update workflow-size-baseline.json for the updated transition.md size. Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * ci: trigger test matrix for 525b946 Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(transition): restore awk frontmatter extraction for pre-shim verification check The gsd_run launcher shim is not defined until line ~163 of transition.md, so the verification debt check at line ~80 cannot use gsd_run. Restore the awk-based frontmatter extraction that correctly reads status without needing the runtime, and restore the shim at its proper location before phase.complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(#1522): clarify transition verification gate wording * fix(#1522): update transition workflow size baseline * fix(#1522): update workflow-size-baseline after rebase onto next Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> * fix(#1522): guard findStaleVerificationSummary FS calls + thread opts.fs seam (review) Address review blocker B1 on #1548: findStaleVerificationSummary ran fs.readdirSync and two fs.statSync calls unguarded between readVerificationStatus's try/catch sections, so a TOCTOU race (a SUMMARY listed by scanPhasePlans then removed before statSync) or any FS error threw uncaught into callers NOT under the planning lock (init.manager / init.progress / uat-predicate). Wrap the body in try/catch degrading to 'not stale', and thread the injectable opts.fs seam (add statSync to FsLike, pass fsImpl from the caller) for parity with readVerificationStatus's no-throw contract and testability. Also adds the Verification Module glossary entry to CONTEXT.md (review B3). --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
231 lines
8.7 KiB
TypeScript
231 lines
8.7 KiB
TypeScript
/**
|
|
* CLI I/O primitives — output(), error(), ERROR_REASON, JSON-error mode,
|
|
* and the temp-file helpers that output() depends on.
|
|
*
|
|
* Extracted from core.cts (ADR-857 rollout phase 1 / issue #859).
|
|
* The hand-written bodies are preserved byte-for-behaviour; only the module
|
|
* boundary moved. The core.cjs re-export spine was retired in epic #1267;
|
|
* callers import I/O primitives from io.cjs directly.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
|
|
|
// ─── Temp-file helpers (needed by output()) ──────────────────────────────────
|
|
|
|
/**
|
|
* Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
|
|
* Created on first use. Keeps GSD temp files isolated from the system
|
|
* temp directory so reap scans only GSD files (#1975).
|
|
*/
|
|
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
|
|
|
|
function ensureGsdTempDir(): void {
|
|
platformEnsureDir(GSD_TEMP_DIR);
|
|
}
|
|
|
|
interface ReapOptions {
|
|
maxAgeMs?: number;
|
|
dirsOnly?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
|
|
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
|
|
* @param prefix - filename prefix to match (e.g., 'gsd-')
|
|
* @param opts
|
|
* @param opts.maxAgeMs - max age in ms before removal (default: 5 min)
|
|
* @param opts.dirsOnly - if true, only remove directories (default: false)
|
|
*/
|
|
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {}): void {
|
|
try {
|
|
ensureGsdTempDir();
|
|
const now = Date.now();
|
|
const entries = fs.readdirSync(GSD_TEMP_DIR);
|
|
for (const entry of entries) {
|
|
if (!entry.startsWith(prefix)) continue;
|
|
const fullPath = path.join(GSD_TEMP_DIR, entry);
|
|
try {
|
|
const stat = fs.statSync(fullPath);
|
|
if (now - stat.mtimeMs > maxAgeMs) {
|
|
if (stat.isDirectory()) {
|
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
} else if (!dirsOnly) {
|
|
fs.unlinkSync(fullPath);
|
|
}
|
|
}
|
|
} catch {
|
|
// File may have been removed between readdir and stat — ignore
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-critical — don't let cleanup failures break output
|
|
}
|
|
}
|
|
|
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Transient write errnos. When stdout/stderr is a NON-BLOCKING pipe — as it is
|
|
* under the parallel `node --test` runner on Linux CI — a full pipe buffer makes
|
|
* `fs.writeSync` throw EAGAIN, and a signal can interrupt it with EINTR. Both
|
|
* clear on retry once the reader drains. This is the same transient class the
|
|
* STATE.md lock path already retries (ACQUIRE_LOCK_RETRY_ERRNOS, #3776); #1008.
|
|
*/
|
|
const WRITE_RETRY_ERRNOS = new Set(['EAGAIN', 'EINTR']);
|
|
|
|
// Bounded so a pathological never-draining fd cannot spin forever. Each retry
|
|
// yields the thread for ~1ms via Atomics.wait (the project's sync-sleep idiom —
|
|
// see clock.cts realClock.sleep), so the cap is ~1s of total back-pressure wait.
|
|
const WRITE_MAX_RETRIES = 1000;
|
|
const WRITE_RETRY_BACKOFF_MS = 1;
|
|
|
|
// Sleep buffer is lazily allocated on the FIRST back-pressure retry (rare — only
|
|
// when a non-blocking pipe is full) and then reused. Keeping it out of module
|
|
// load costs nothing on the overwhelmingly common no-retry path and avoids
|
|
// perturbing SharedArrayBuffer-allocation accounting in other modules (perf-316).
|
|
let _writeSleepBuf: Int32Array | null = null;
|
|
function backoffOnce(): void {
|
|
if (_writeSleepBuf === null) _writeSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
Atomics.wait(_writeSleepBuf, 0, 0, WRITE_RETRY_BACKOFF_MS);
|
|
}
|
|
|
|
/**
|
|
* Write the entire payload to `fd`, tolerating non-blocking-pipe back-pressure.
|
|
*
|
|
* `fs.writeSync` does NOT block on a non-blocking pipe: a full buffer throws
|
|
* EAGAIN, and a partially-drained buffer returns a SHORT count (fewer bytes than
|
|
* requested). The previous bare `fs.writeSync(fd, string)` call assumed it always
|
|
* blocked until the kernel accepted every byte — false under load, which both
|
|
* threw spurious errors and risked silently truncating output (#1008).
|
|
*
|
|
* This loops on short counts (advancing the offset) and retries EAGAIN/EINTR with
|
|
* a brief Atomics.wait backoff that yields the thread so the reader can drain.
|
|
* Non-transient errors (e.g. EPIPE) propagate unchanged.
|
|
*/
|
|
function writeAllSync(fd: number, data: string): void {
|
|
const buf = Buffer.from(data, 'utf8');
|
|
let offset = 0;
|
|
let retries = 0;
|
|
while (offset < buf.length) {
|
|
try {
|
|
offset += fs.writeSync(fd, buf, offset, buf.length - offset);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code ?? '';
|
|
if (WRITE_RETRY_ERRNOS.has(code) && retries < WRITE_MAX_RETRIES) {
|
|
retries += 1;
|
|
backoffOnce();
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function output(result: unknown, raw: boolean, rawValue?: unknown): void {
|
|
let data: string;
|
|
if (raw && rawValue !== undefined) {
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
data = String(rawValue);
|
|
} else {
|
|
const json = JSON.stringify(result, null, 2);
|
|
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
|
|
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
|
if (json.length > 50000) {
|
|
reapStaleTempFiles();
|
|
ensureGsdTempDir();
|
|
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
|
|
platformWriteSync(tmpPath, json);
|
|
data = '@file:' + tmpPath;
|
|
} else {
|
|
data = json;
|
|
}
|
|
}
|
|
// process.stdout.write() is async when stdout is a pipe — process.exit()
|
|
// can tear down the process before the reader consumes the buffer. writeAllSync
|
|
// pushes every byte synchronously (looping short counts, retrying EAGAIN/EINTR),
|
|
// and skipping process.exit() lets the event loop drain naturally.
|
|
writeAllSync(1, data);
|
|
}
|
|
|
|
/**
|
|
* Frozen enum of typed reason codes used by error() for structured errors.
|
|
* Each subcommand contributes its own codes; the enum exists so tests can
|
|
* assert against typed values instead of grepping stderr (#2974).
|
|
*
|
|
* Adding a new code:
|
|
* - Pick a snake_case lowercase value (the JSON wire form)
|
|
* - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
|
|
* - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
|
|
*/
|
|
const ERROR_REASON = Object.freeze({
|
|
// config-get / config-set
|
|
CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
|
|
CONFIG_NO_FILE: 'config_no_file',
|
|
CONFIG_PARSE_FAILED: 'config_parse_failed',
|
|
CONFIG_INVALID_KEY: 'config_invalid_key',
|
|
// SDK / gsd-tools dispatch
|
|
SDK_FAIL_FAST: 'sdk_fail_fast',
|
|
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
|
|
SDK_MISSING_ARG: 'sdk_missing_arg',
|
|
// workflow / phase
|
|
PHASE_NOT_FOUND: 'phase_not_found',
|
|
PHASE_VERIFICATION_INCOMPLETE: 'phase_verification_incomplete',
|
|
SUMMARY_NO_PLANNING: 'summary_no_planning',
|
|
// graphify
|
|
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
|
|
GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
|
|
// hooks
|
|
HOOKS_OPT_OUT: 'hooks_opt_out',
|
|
// security-scan
|
|
SECURITY_SCAN_FAILED: 'security_scan_failed',
|
|
// generic
|
|
USAGE: 'usage',
|
|
UNKNOWN: 'unknown',
|
|
});
|
|
|
|
type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON];
|
|
|
|
/**
|
|
* Process-level flag: when true, error() emits structured JSON to stderr
|
|
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
|
|
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
|
|
* assertions by passing that flag and parsing the JSON.
|
|
*
|
|
* Default off so existing callers and human operators keep their plain-text
|
|
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
|
|
*/
|
|
let _jsonErrorMode = false;
|
|
function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; }
|
|
function getJsonErrorMode(): boolean { return _jsonErrorMode; }
|
|
|
|
/**
|
|
* Emit an error and exit. When the second argument is provided it must be
|
|
* a value from ERROR_REASON; tests can assert on `result.reason`. When the
|
|
* process is in JSON-error mode, stderr receives `{ ok: false, reason,
|
|
* message }` so callers can parse it; otherwise stderr keeps the plain
|
|
* text form for human operators.
|
|
*/
|
|
function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN): never {
|
|
if (_jsonErrorMode) {
|
|
const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
|
|
writeAllSync(2, payload);
|
|
} else {
|
|
writeAllSync(2, 'Error: ' + message + '\n');
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
export = {
|
|
GSD_TEMP_DIR,
|
|
ensureGsdTempDir,
|
|
reapStaleTempFiles,
|
|
output,
|
|
ERROR_REASON,
|
|
setJsonErrorMode,
|
|
getJsonErrorMode,
|
|
error,
|
|
};
|