From 07de60523c0db70544863c376532716d6c8e9f2b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 2 Aug 2026 21:22:43 -0400 Subject: [PATCH] fix(#2657): untrack the nine ADR-457 migration-gap compiled artifacts (#3011) * test(#2657): add failing-first regression for tracked bin/lib compiled artifacts Nine gsd-core/bin/lib/*.cjs artifacts are tracked in git despite having src/*.cts sources, violating ADR-457's build-at-publish contract. This regression test asserts the ADR-457 end state (untracked, gitignored, empty-set reported by the #2656 sync guard) and fails until the tracking is fixed. * fix(#2657): untrack the nine ADR-457 migration-gap compiled artifacts Nine gsd-core/bin/lib/*.cjs artifacts (api-coverage, assumption-delta, claude-orchestration, claude-orchestration-command-router, external-job, markdown-table, runtime-artifact-install-plan, state-transition, write-set) were tracked in git despite each having a matching src/*.cts source, letting the committed bytes drift silently from source (#2653 demonstrated this for api-coverage.cjs). Seven had no .gitignore entry at all; two (markdown-table.cjs, write-set.cjs) had a pattern added by #2248 but were never git rm --cached. Both gaps produce the same tracked-file symptom. Untracks all nine and adds the seven missing .gitignore entries next to their two siblings, reaching ADR-457's end state: bin/lib/*.cjs is a gitignored build artifact built via prepare/pretest/prepublishOnly, never checked-in source of truth. The #2656 artifact-sync guard is regime-agnostic by design and needed no code change; it now reports the empty-set end state. * test(#2657): consolidate repeated still-tracked/unmatched assertion shape Code-review finding (Standards axis, Duplicated Code): the three 'none of the nine should still be in bad state X' checks shared an identical filter-then-assert-empty shape. Extracted assertNoneStillBad() as a shared helper; behavior is unchanged. * fix(#2657): make the .gitignore-match assertion existence-independent The regression test's check-ignore assertion used --no-index, which locally exercises the pattern correctly but was reported failing on gsd-test's fresh shallow clone. Switched to plain 'git check-ignore -q' (no --no-index): verified via a real git worktree checkout at both origin/next (fails: all nine report not-ignored, since check-ignore correctly special-cases the still-tracked pre-fix state) and this branch's tip (passes: all nine report ignored). Plain check-ignore is also semantically stronger than --no-index here, since it honors the 'a tracked path is never reported ignored' rule that --no-index bypasses -- exactly the property under test for the two paths whose .gitignore pattern predates this fix (#2248) but were never untracked. Also reconciled the .gitignore comment: it previously read 'these seven' beside seven new lines with no indication of the other two (of nine total) that already had a pattern from #2248. Annotated both groups so the count is unambiguous at the point of the diff. * test(#2657): make every git invocation self-diagnosing b6f915bc0 failed in the runner with a shape that turned out not to be about .gitignore content or the merge: two of the five failures in this file were 'Command failed' / 'Got unwanted exception' -- git itself erroring, not answering. The old code used execFileSync + try/catch, which conflates 'git said no' with 'git could not run' -- both looked like the same negative result to the test, exactly the failure mode that produced 'unmatched: ' twice on two different assertions for two different reasons. Switched every git invocation to spawnSync (never throws) and made every assertion check the exit status explicitly before interpreting output: - git ls-files: must exit 0, or the assertion fails loud with cwd, exit status, and stderr instead of silently reading an error as 'nothing tracked'. - git check-ignore -q: only exit 0 (ignored) and exit 1 (not ignored) are legitimate answers per check-ignore(1); any other status is now a thrown infrastructure failure, never read as 'not ignored'. - trackedCompiledArtifacts(): a thrown error is now reported as what it is (its internal git call failed), not swallowed into a 'still tracked' verdict. - the sync-guard subprocess check now reports cwd/stderr/stdout on a non-zero exit instead of a bare doesNotThrow. This is a genuine, independent test defect (a test that reads a failed command's empty output as a meaningful answer can pass or fail for the wrong reason) as well as the mechanism for finally surfacing why b6f915bc0 failed in the runner: the next run's assertion messages will show the resolved cwd and git's actual stderr instead of an opaque 'unmatched: '. * fix(#2657): trust the repo root for git calls under dubious-ownership Root cause of the failing runner verdict, harvested from the diagnostics commit: every git invocation in the container exits 128 with 'fatal: detected dubious ownership in repository at /work' -- the checkout there is owned by a different uid than the process running the tests, and git refuses to operate at all. The old assertions read that hard failure as 'not ignored' / 'still tracked', producing the all-nine symptom seen on both b6f915bc0 (plain check-ignore) and 34052f836 (--no-index). Nothing was ever wrong with the untracking, the .gitignore content, or the merge -- confirmed by exhaustive local reproduction (git worktree, real shallow clone, the runner's exact clone+checkout+merge sequence from its own Go source) that could never surface the bug because this machine owns its own checkouts. Fixed at both git() call sites in this exact seam by passing '-c safe.directory=' per-invocation (never written to any config file, so trust is scoped to the single call): - tests/fix-2657-untrack-compiled-artifacts.test.cjs - scripts/lint-compiled-artifact-sync.cjs -- a SHIPPED script with the identical defect (its own git ls-files failed the same way in the same run), which would fail identically for any containerized CI lane whose checkout uid differs from the running user, not just this branch. Folded in under the no-defer rule rather than filed separately, since it sits in the exact tracked-compiled-artifact guard this issue is about. The status-code guards added in 31858818e stay in place -- they are what turned an unexplainable 'unmatched: ' into a one-line diagnosis, and they must keep any future infrastructure fault from silently reading as a substantive result. * chore(#2657): backfill changeset PR number to 3011 * chore(#2657): backfill changeset PR number to 3011 --------- Co-authored-by: sim --- .changeset/quick-tigers-parade.md | 5 + .changeset/silly-foxes-fly.md | 5 + .gitignore | 13 + gsd-core/bin/lib/api-coverage.cjs | 772 -------- gsd-core/bin/lib/assumption-delta.cjs | 231 --- .../claude-orchestration-command-router.cjs | 314 --- gsd-core/bin/lib/claude-orchestration.cjs | 582 ------ gsd-core/bin/lib/external-job.cjs | 287 --- gsd-core/bin/lib/markdown-table.cjs | 698 ------- .../bin/lib/runtime-artifact-install-plan.cjs | 128 -- gsd-core/bin/lib/state-transition.cjs | 1697 ----------------- gsd-core/bin/lib/write-set.cjs | 38 - scripts/lint-compiled-artifact-sync.cjs | 7 +- ...x-2657-untrack-compiled-artifacts.test.cjs | 185 ++ 14 files changed, 214 insertions(+), 4748 deletions(-) create mode 100644 .changeset/quick-tigers-parade.md create mode 100644 .changeset/silly-foxes-fly.md delete mode 100644 gsd-core/bin/lib/api-coverage.cjs delete mode 100644 gsd-core/bin/lib/assumption-delta.cjs delete mode 100644 gsd-core/bin/lib/claude-orchestration-command-router.cjs delete mode 100644 gsd-core/bin/lib/claude-orchestration.cjs delete mode 100644 gsd-core/bin/lib/external-job.cjs delete mode 100644 gsd-core/bin/lib/markdown-table.cjs delete mode 100644 gsd-core/bin/lib/runtime-artifact-install-plan.cjs delete mode 100644 gsd-core/bin/lib/state-transition.cjs delete mode 100644 gsd-core/bin/lib/write-set.cjs create mode 100644 tests/fix-2657-untrack-compiled-artifacts.test.cjs diff --git a/.changeset/quick-tigers-parade.md b/.changeset/quick-tigers-parade.md new file mode 100644 index 000000000..0472627fa --- /dev/null +++ b/.changeset/quick-tigers-parade.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3011 +--- +**Nine compiled `.cjs` runtime artifacts under `gsd-core/bin/lib/` are no longer tracked in git** — they are ADR-457 build outputs of `src/*.cts` sources and were missing from `.gitignore`, letting the committed bytes silently drift from source (as happened to `api-coverage.cjs` in #2653). They now build fresh from source like their ~160 already-gitignored siblings. (#2657) diff --git a/.changeset/silly-foxes-fly.md b/.changeset/silly-foxes-fly.md new file mode 100644 index 000000000..0c66add45 --- /dev/null +++ b/.changeset/silly-foxes-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3011 +--- +**`scripts/lint-compiled-artifact-sync.cjs` no longer fails on containerized checkouts owned by a different uid** — its internal `git` calls now scope `safe.directory` to the repo root per-invocation, so the guard runs instead of erroring with "detected dubious ownership" in any CI lane where the checkout owner differs from the running user. (#2657) diff --git a/.gitignore b/.gitignore index 7cfd46ade..a2c11747e 100644 --- a/.gitignore +++ b/.gitignore @@ -97,8 +97,21 @@ build/ /gsd-core/bin/lib/capability-consent.cjs /gsd-core/bin/lib/capability-lock.cjs /gsd-core/bin/lib/markdown-sectionizer.cjs +# #2657: these two (markdown-table.cjs, write-set.cjs) already had the +# pattern below since #2248, but stayed tracked because `git rm --cached` +# was never run — same untracking fix as the seven lines that follow. /gsd-core/bin/lib/markdown-table.cjs /gsd-core/bin/lib/write-set.cjs +# #2657: ADR-457 migration gap — these seven (of nine total; the two above +# are the other two) never got a .gitignore entry when their modules moved +# into src/*.cts, and were also never untracked. +/gsd-core/bin/lib/api-coverage.cjs +/gsd-core/bin/lib/assumption-delta.cjs +/gsd-core/bin/lib/claude-orchestration.cjs +/gsd-core/bin/lib/claude-orchestration-command-router.cjs +/gsd-core/bin/lib/external-job.cjs +/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +/gsd-core/bin/lib/state-transition.cjs /gsd-core/bin/lib/resolution.cjs /gsd-core/bin/lib/research-store.cjs /gsd-core/bin/lib/research-provider.cjs diff --git a/gsd-core/bin/lib/api-coverage.cjs b/gsd-core/bin/lib/api-coverage.cjs deleted file mode 100644 index 92fdc5b71..000000000 --- a/gsd-core/bin/lib/api-coverage.cjs +++ /dev/null @@ -1,772 +0,0 @@ -"use strict"; -/** - * API-Coverage detector + matrix validator (#1562). - * - * The enforcement half of "Full API Coverage by Default — Opt Out, Never Opt In." - * When a phase integrates an external API/service/SDK, the planner must produce a - * coverage matrix (COVERAGE.md) enumerating the API's capability surface; every - * non-integrated capability is an explicit, reasoned opt-out. The seal-time gate - * (capabilities/ai-integration, verify:pre) consumes this module to (a) detect - * whether a phase integrates an external API and (b) validate the produced matrix. - * - * Design notes (rubber-duck'd): - * - DETERMINISTIC + TYPED IR. Both the "does this phase integrate an external - * API?" decision and the "is this matrix complete?" decision are pure - * functions returning typed IR, not LLM judgments — so the low-false-positive - * guarantee (acceptance criterion #4) and the completeness guarantee - * (acceptance #2) are testable. Mirrors assumption-delta.cts (#1561). - * - COMPOUND SIGNAL for low false positives. A bare word like "api" appears in - * countless non-integration phases ("the public API of UserController"). The - * detector requires an INTEGRATION VERB and an EXTERNAL-API NOUN in the SAME - * CLAUSE (#2365 — same-line co-occurrence across unrelated clauses over-fired; - * the clause boundary, not a word-gap cap, is the relationship test), or an - * explicit " API/SDK" phrase naming a real service. Single weak - * tokens do not fire. This is the issue's "low false-positive trigger" made - * mechanical. - * - CODE AND PATHS ARE NOT PROSE. Fenced code blocks and inline code spans are - * stripped first (markdown-sectionizer seam), and path-shaped tokens - * (`src/app/api/...`, URLs) are masked, so a trigger term inside code or a - * first-party route path does not fire (#2365). - * - NO-INTEGRATION DECLARATION (#2365 acceptance #5). A COVERAGE.md consisting - * of `No external API integration: ` is a valid, reasoned way for a - * phase to state that no external surface exists — the alternative to - * fabricating a matrix row when the detector is overruled by a human. - * - THE DETECTOR IS A FALLBACK. The primary path is the plan:pre contribution - * prompting COVERAGE.md creation. The detector runs only when COVERAGE.md is - * ABSENT, to catch the "nobody decided" case (acceptance #1). Its precision - * therefore matters but is not the only line of defense. - * - MATRIX FORMAT. The matrix is a markdown table (human-editable, diff-friendly) - * with a header row `| capability | decision | reason |` and one row per - * capability. decision ∈ {INTEGRATE, OPT-OUT}. An OPT-OUT row MUST carry a - * non-empty reason. A fenced ```coverage JSON block is also accepted for - * machine-generated matrices. This dual shape is bijective (parse/render - * round-trip) and covered by a fast-check property test. - * - ADDITIVE-ONLY VOCABULARY (Hyrum's Law). Once shipped, the verb/noun sets - * are depended-upon interfaces; they only grow. Tunable via the `terms` - * parameter so teams can widen them without forking. - * - * Public API: - * detectApiIntegration(text, terms?) -> { detected, signals, terms } - * parseCoverageMatrix(text) -> { rows, errors, format } - * validateCoverageMatrix(text) -> { valid, errors, counts } - * renderCoverageMatrix(rows) -> string - * DEFAULT_API_COVERAGE_TERMS - * - * CLI: - * echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json] - * exit 0 = integration detected, 1 = none, 2 = startup error - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.DEFAULT_API_COVERAGE_TERMS = void 0; -exports.detectApiIntegration = detectApiIntegration; -exports.parseCoverageMatrix = parseCoverageMatrix; -exports.validateCoverageMatrix = validateCoverageMatrix; -exports.renderCoverageMatrix = renderCoverageMatrix; -const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs"); -/** - * Curated default trigger vocabulary. ADDITIVE-ONLY (Hyrum's Law). Tunable via - * the `terms` parameter. - * - * VERBS are deliberately conservative: common verbs like "add", "use", "call", - * "implement" are EXCLUDED because they appear in nearly every phase and would - * make the gate fire on prose that has nothing to do with an external API. The - * verbs kept all connote BRINGING IN an external surface. - * - * NOUNS name an external-API surface. Bare "client" is excluded — too ambiguous - * (client-side UI vs API client). "service" alone is excluded (internal - * services); a phase integrating an external service virtually always pairs it - * with "API"/"SDK"/"REST"/etc., which the compound verb+noun rule captures. - */ -exports.DEFAULT_API_COVERAGE_TERMS = { - verbs: [ - 'integrate', - 'integrates', - 'integrating', - 'integration', - 'wrap', - 'wraps', - 'wrapping', - 'connect', - 'connects', - 'connecting', - 'consume', - 'consumes', - 'consuming', - 'wire', - 'wires', - 'wiring', - 'onboard', - 'onboarding', - 'adopt', - 'adopts', - 'adopting', - ], - nouns: [ - 'api', - 'apis', - 'sdk', - 'sdks', - 'rest', - 'graphql', - 'grpc', - 'endpoint', - 'endpoints', - 'oauth', - 'oauth2', - 'webhook', - 'webhooks', - 'mcp', - ], -}; -/** Hardening caps for the tunable vocabulary (hostile `--terms` defense). */ -const MAX_TERMS_PER_KIND = 200; -const MAX_TERM_LEN = 32; -/** - * Field-length caps for matrix cell values. Cell content flows from a - * semi-trusted COVERAGE.md into the gate `message` that the orchestrator LLM - * reads, so it is bounded to keep the prompt-injection surface small and to - * document the format contract (short, single-line prose — not paragraphs). - */ -const CAPABILITY_MAX_LEN = 80; -const REASON_MAX_LEN = 200; -function normalizeTerms(list) { - if (!Array.isArray(list)) - return []; - const seen = new Set(); - const out = []; - for (const raw of list) { - if (typeof raw !== 'string') - continue; - const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN); - if (!t || !/[a-z0-9]/.test(t)) - continue; - if (seen.has(t)) - continue; - seen.add(t); - out.push(t); - if (out.length >= MAX_TERMS_PER_KIND) - break; - } - return out; -} -function resolveTerms(terms) { - const merge = (key) => { - const t = terms && terms[key]; - return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_API_COVERAGE_TERMS[key]]; - }; - return { verbs: merge('verbs'), nouns: merge('nouns') }; -} -function escapeRegex(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} -function makeSnippet(line, anchor) { - const cleaned = line.replace(/\s+/g, ' ').trim(); - if (cleaned.length <= 120) - return cleaned; - const idx = cleaned.toLowerCase().indexOf(anchor); - if (idx < 0) - return cleaned.slice(0, 120); - const start = Math.max(0, idx - 50); - const end = Math.min(cleaned.length, idx + anchor.length + 50); - const prefix = start > 0 ? '…' : ''; - const suffix = end < cleaned.length ? '…' : ''; - return `${prefix}${cleaned.slice(start, end)}${suffix}`; -} -/** ` API` / ` SDK` — a capitalized proper noun immediately - * followed by API/SDK. Strong signal on its own (no verb required). - * - * STOPWORDS guard against the false positive where an ordinary capitalized - * sentence starter ("The API …", "An SDK …", "Our REST …") matches the - * `[A-Z]\w+ API` shape. Those are common English, not a service name, so they - * are rejected before counting as a surface signal (acceptance #4 — low false - * positives). */ -// Service-name length is bounded ({1,40}) so a hostile "A-A-A-…-A-x" run cannot -// drive the greedy group into O(n^2) backtracking (#2365 review). Nearly all -// vendor names fit; a >41-char service token before API/SDK would be missed by -// this surface path (it would still fire via the compound verb+noun rule) — -// an accepted bound. -const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,40})\s+(API|SDK|REST|GraphQL)\b/; -const SERVICE_STOPWORDS = new Set([ - 'the', 'an', 'a', 'our', 'this', 'these', 'that', 'those', 'new', 'add', - 'use', 'your', 'my', 'no', 'some', 'any', 'all', 'each', 'every', 'both', - 'if', 'when', 'while', 'with', 'via', 'using', 'into', 'its', 'their', - 'we', 'you', 'they', 'it', -]); -/** #2365 — the detector is FAIL-CLOSED: it leans toward detecting, because a - * false positive is cheaply dismissed by a one-line COVERAGE.md "no external - * API integration" declaration, whereas a false NEGATIVE silently lets a real - * external-API phase past a BLOCKING gate. So the only prose the detector - * actively suppresses is the classes that are unambiguously NOT external - * integration: first-party route paths, verb/noun in unrelated clauses, and - * descriptive/protocol " API" prose with no named service. - * - * CLAUSE_BOUNDARY_RE: a verb and a noun form ONE compound action only inside - * one grammatical clause — sentence punctuation and table-cell walls (`|`) - * end a clause. `-` is deliberately absent (it would split hyphenated words). - * There is deliberately NO word-gap cap inside a clause: a cap cannot separate - * a genuine long integration clause (F4, 21 words) from a long internal-UI - * clause (18 words) — the clause boundary is the only sound signal, and the - * declaration handles the residual false positives. */ -const CLAUSE_BOUNDARY_RE = /[,;:.!?|()—–]/; -/** Same character class as CLAUSE_BOUNDARY_RE, as a set — for scanning a token's - * trailing punctuation without an unanchored `[…]+$` regex, whose backtracking - * is O(n^2) on a long punctuation run (#2365 review). */ -const CLAUSE_BOUNDARY_CHARS = new Set([',', ';', ':', '.', '!', '?', '|', '(', ')', '—', '–']); -/* DELIBERATELY NO cross-clause binding. Detection is same-clause only. Binding - * a verb in one clause to a noun in another ("Integrate Stripe, exposing its - * endpoints"; "Integrate Stripe; use its endpoints") requires knowing "Stripe" - * is a vendor and "its" refers to it — a vendor dictionary + coreference, which - * trek-e's brief rules out in principle. Every lexical cross-clause rule tried - * (word-gap cap, participle continuation) traded a false negative for a false - * positive across four review rounds. So a service named ONLY in a clause - * separate from its API noun, with no explicit ` API` surface, is a - * DOCUMENTED fail-open limitation — cheaply covered by the COVERAGE.md - * declaration and rare in real phase prose, which says "integrate the X API". */ -/** In the ` API|SDK` surface position, these capture words are NOT a - * named third-party service: locality/scope descriptors ("Internal API", - * "Public API") and bare protocol names ("REST API", "GraphQL API"). A real - * vendor name (Stripe, Shopify) is none of these, so rejecting them costs no - * true positives while killing the descriptive-prose false positives (#2365 - * acceptance #3, review F8). */ -const SURFACE_DESCRIPTOR_WORDS = new Set([ - 'internal', 'external', 'public', 'private', 'local', 'in-house', 'first-party', - 'generic', 'shared', 'common', 'legacy', 'rest', 'restful', 'graphql', 'grpc', - 'soap', 'rpc', 'http', 'https', 'json', 'xml', -]); -/** Locality qualifiers that, when they immediately precede a ` API`, - * mark it as first-party ("internal Payments API") — negative evidence for an - * EXTERNAL-API surface signal. Only unambiguously-internal words: "external" - * is deliberately absent (an external API IS external). */ -const INTERNAL_DESCRIPTORS = new Set(['internal', 'in-house', 'local', 'first-party', 'private']); -/** A capitalized compound modifier ("Resolver-only", "Read-only", "E-commerce" - * — lowercase letter right after the hyphen) is an adjective phrase, not a - * service name. Real hyphenated services capitalize the second segment - * ("T-Mobile"). */ -const COMPOUND_MODIFIER_RE = /^[A-Z][A-Za-z0-9]*-[a-z]/; -const URL_TOKEN_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\//i; -const LOCAL_URL_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\/(?:localhost|127(?:\.\d{1,3}){1,3}|0\.0\.0\.0|\[::1\])(?=[:/?#]|$)/i; -/** A scheme-less token that STARTS with a dotted hostname whose final label is - * alphabetic ("api.stripe.com/v1") — a bare external API host. A first-party - * route path ("src/app/api/…") has no dotted head, and an IP host ("127.1/…") - * has a numeric final label, so neither matches (#2365 review F2). */ -const DOMAIN_HEAD_RE = /^[([<"'`]*(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?=[:/?#]|$)/i; -/** Mask whitespace-delimited tokens with an interior `/` — file paths, framework - * routes (`src/app/api/...`), URLs. They are references, not integration prose - * (#2365 root cause 2: `/` counted as a word boundary, so first-party route - * paths matched the noun vocabulary). Two carve-outs keep genuine signals: - * - a slashed token whose segments are ALL noun-vocabulary words ("API/SDK", - * "REST/GraphQL") is prose shorthand, not a path — left unmasked; - * - a non-local URL is masked, but noun terms inside it are collected as - * compound-rule evidence (the old detector caught "connect to - * https://api.stripe.com" via the `api` segment; losing that would - * fail-open). */ -function scanLineTokens(line, nounRe, nounSet) { - const urlNouns = []; - let masked = ''; - const tokenRe = /\S+/g; - let last = 0; - let m; - while ((m = tokenRe.exec(line)) !== null) { - const rawTok = m[0]; - masked += line.slice(last, m.index); - last = m.index + rawTok.length; - // Peel trailing clause-boundary punctuation off the token and keep it - // LITERAL in `masked` — masking it away would erase a clause split and pair - // unrelated verb/noun across it (#2365 review F6: "…example.com, document…"). - // A backward char scan (not a `[…]+$` regex) keeps this linear. - let trailLen = 0; - while (trailLen < rawTok.length && CLAUSE_BOUNDARY_CHARS.has(rawTok[rawTok.length - 1 - trailLen])) { - trailLen++; - } - const trail = trailLen ? rawTok.slice(rawTok.length - trailLen) : ''; - const tok = trailLen ? rawTok.slice(0, rawTok.length - trailLen) : rawTok; - if (!/\S[\\/]\S/.test(tok)) { - masked += rawTok; - continue; - } - const segments = tok.split(/[\\/]/).map((s) => s.replace(/[^A-Za-z0-9]/g, '')); - if (segments.every((s) => s.length > 0 && (nounSet.has(s.toLowerCase()) || /^v\d+$/i.test(s))) && - segments.some((s) => nounSet.has(s.toLowerCase()))) { - masked += rawTok; // "API/SDK", "API/v2" — noun shorthand, not a path - continue; - } - // A scheme URL or a bare external hostname is an external dependency - // reference: mask it from prose but keep it as compound-rule evidence. A - // first-party route path has neither a scheme nor a dotted host, so it is - // masked WITHOUT contributing nouns (#2365 root cause 2). - // A non-local URL that NAMES an API vocabulary word ("api.stripe.com/v1") - // is external-dependency evidence, so its vocab nouns feed the compound - // rule. We deliberately do NOT treat every path-bearing URL as an endpoint: - // that fired on ordinary asset/link URLs ("…/theme.css", "…?next=/x") and - // recreated routine UI-phase false positives (#2365 review). A bare external - // host that names no vocabulary word ("graph.microsoft.com") and is not - // written as " API" is therefore a DOCUMENTED fail-open limitation. - const isSchemeUrl = URL_TOKEN_RE.test(tok) && !LOCAL_URL_RE.test(tok); - const isDomainUrl = !URL_TOKEN_RE.test(tok) && DOMAIN_HEAD_RE.test(tok); - if (nounRe && (isSchemeUrl || isDomainUrl)) { - for (const f of collectTermMatches(nounRe, tok)) { - urlNouns.push({ term: f.term, start: m.index, end: m.index + tok.length }); - } - } - masked += ' '.repeat(tok.length) + trail; - } - masked += line.slice(last); - return { masked, urlNouns }; -} -/** All term matches in a clause, with offsets. `re` must be global with the - * term in group 2 and a consumed leading boundary in group 1. */ -function collectTermMatches(re, clause) { - const out = []; - re.lastIndex = 0; - let m; - while ((m = re.exec(clause)) !== null) { - const start = m.index + (m[1] || '').length; - out.push({ term: (m[2] || '').toLowerCase(), start, end: start + (m[2] || '').length }); - if (m[0].length === 0) - re.lastIndex++; - } - return out; -} -/** Split a line into clause segments, keeping each segment's start offset so - * line-level spans (masked URL tokens) can be mapped into their clause. */ -function splitClauses(masked) { - const out = []; - let start = 0; - for (let i = 0; i <= masked.length; i++) { - if (i === masked.length || CLAUSE_BOUNDARY_RE.test(masked[i])) { - out.push({ text: masked.slice(start, i), start }); - start = i + 1; - } - } - return out; -} -/** - * Detect whether phase-scope prose describes integrating an external API/SDK. - * - * FAIL-CLOSED: it leans toward detecting, because a false positive is dismissed - * by a one-line COVERAGE.md declaration while a false negative silently slips a - * real external-API phase past a blocking gate. It fires when EITHER: - * (a) an integration VERB and an API NOUN share one CLAUSE ("integrate the - * Stripe API", "Connect … to api.stripe.com") — the clause boundary is the - * whole relationship test, so verb/noun in DIFFERENT clauses do not pair - * (#2365 acceptance #2). There is NO cross-clause binding: a service named - * only in a clause separate from its API noun is a documented limitation. - * (b) an explicit ` API|SDK|REST|GraphQL` surface names a service - * that is not a stopword, a locality/protocol descriptor, a compound - * modifier, or first-party-qualified ("Stripe API", "Spotify SDK"). - * - * Fenced code, inline code spans, and path-shaped tokens are excluded before - * matching. A package-shaped inline span (`@stripe/stripe-js`, `stripe-sdk`) - * and a URL that NAMES an API vocab word ("api.stripe.com/v1") still count as - * noun/dependency evidence; a bare host that names none does not. - * - * Non-string inputs degrade to `{ detected: false }` without throwing. - */ -function detectApiIntegration(text, terms) { - const effective = resolveTerms(terms); - if (typeof text !== 'string') { - return { detected: false, signals: [], terms: effective }; - } - const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text; - if (stripped.trim().length === 0) { - return { detected: false, signals: [], terms: effective }; - } - const signals = []; - const seen = new Set(); - const lines = stripped.split('\n'); - const hasCompoundTerms = effective.verbs.length > 0 && effective.nouns.length > 0; - // Trailing boundary is a LOOKAHEAD (not consumed) so back-to-back terms - // separated by one boundary char are both found. - const verbRe = hasCompoundTerms - ? new RegExp('(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)', 'gi') - : null; - const nounRe = hasCompoundTerms - ? new RegExp('(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)', 'gi') - : null; - const surfaceRe = new RegExp(SERVICE_SURFACE_API_RE.source, 'g'); - const nounSet = new Set(effective.nouns); - const emitPair = (vTerm, nTerm, snippetLine) => { - const key = `${vTerm}+${nTerm}`; - if (seen.has(key)) - return; - seen.add(key); - signals.push({ verb: vTerm, noun: nTerm, snippet: makeSnippet(snippetLine, nTerm) }); - }; - for (const rawLine of lines) { - // Inline code spans are code, not prose — mask them (length-preserving so - // offsets keep lining up), but keep package-shaped span content as noun - // evidence (#2365 review FN-4: `stripe-sdk` names a dependency). - const inlineSpans = (0, markdown_sectionizer_cjs_1.scanInlineCodeSpans)(rawLine); - let line = rawLine; - const spanNouns = []; - for (const s of inlineSpans) { - line = line.slice(0, s.start) + ' '.repeat(s.end - s.start) + line.slice(s.end); - const content = s.content.trim(); - if (content.length === 0 || /\s/.test(content)) - continue; - const segs = content.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); - if (segs.length < 2) - continue; // a bare `api` span is a code identifier - const hit = segs.find((seg) => nounSet.has(seg)); - if (hit) - spanNouns.push({ term: hit, start: s.start, end: s.end }); - } - // Path-shaped tokens (routes, file names, URLs) are references, not prose. - const { masked, urlNouns } = scanLineTokens(line, nounRe, nounSet); - const clauses = splitClauses(masked); - const extraNouns = urlNouns.concat(spanNouns); - // (a) compound verb+noun — SAME CLAUSE ONLY. There is no word-gap cap (a cap - // cannot tell a long genuine clause from a long internal one) and no - // cross-clause binding (see the note by CLAUSE_BOUNDARY_CHARS): the clause - // boundary is the whole relationship test. Nouns are NOT filtered on - // "internal" qualification here — "integrate the internal API" is a - // fail-closed positive; the declaration dismisses it if wrong. - if (verbRe && nounRe) { - for (const clause of clauses) { - const verbs = collectTermMatches(verbRe, clause.text); - if (verbs.length === 0) - continue; - const nouns = collectTermMatches(nounRe, clause.text); - const nounTerms = new Set(nouns.map((t) => t.term)); - for (const u of extraNouns) { - if (u.start >= clause.start && u.end <= clause.start + clause.text.length) { - nounTerms.add(u.term); - } - } - if (nounTerms.size === 0) - continue; - for (const vTerm of new Set(verbs.map((t) => t.term))) { - for (const nTerm of nounTerms) - emitPair(vTerm, nTerm, rawLine); - } - } - } - // (b) explicit API|SDK|REST|GraphQL surface — scan every candidate - // in every clause (a rejected first candidate must not shadow a later - // genuine service; #2365 review C-1). - for (const clause of clauses) { - surfaceRe.lastIndex = 0; - let m; - while ((m = surfaceRe.exec(clause.text)) !== null) { - const svc = m[1] || ''; - const svcLower = svc.toLowerCase(); - // Reject capitalized sentence starters ("The API"), locality/protocol - // descriptors ("Internal API", "REST API"), compound modifiers - // ("Resolver-only API"), and services qualified first-party - // ("internal Payments API"). A real vendor name is none of these. - if (SERVICE_STOPWORDS.has(svcLower)) - continue; - if (SURFACE_DESCRIPTOR_WORDS.has(svcLower)) - continue; - if (COMPOUND_MODIFIER_RE.test(svc)) - continue; - if (isInternallyQualified(masked, clause.start + m.index)) - continue; - const noun = (m[2] || '').toLowerCase(); - const key = `surface+${noun}`; - if (seen.has(key)) - continue; - seen.add(key); - signals.push({ verb: '(surface)', noun, snippet: makeSnippet(rawLine, svc) }); - } - } - } - return { detected: signals.length > 0, signals, terms: effective }; -} -/** True when the word IMMEDIATELY ADJACENT before `offset` is a locality - * descriptor ("internal Payments API") — first-party qualification is negative - * evidence for an EXTERNAL-API signal. Only plain spaces/tabs may separate the - * descriptor from the service: any intervening punctuation means the descriptor - * belongs to a prior clause/sentence and must NOT qualify ("The cache is - * private. Stripe API …" — `private` is a different sentence; #2365 review). - * Looks back through a BOUNDED window, not the whole prefix, to stay linear. */ -const QUALIFIER_LOOKBACK = 24; // longest descriptor ("first-party") + separators -function isInternallyQualified(masked, offset) { - const from = offset > QUALIFIER_LOOKBACK ? offset - QUALIFIER_LOOKBACK : 0; - const window = masked.slice(from, offset); - // Only whitespace and markdown emphasis/wrapper markers (`*_~\`) may separate - // the descriptor from the service, so "The **internal** Payments API" still - // qualifies — but NOT a clause/sentence boundary, so "…is private. Stripe API" - // does not (the descriptor is a different sentence; #2365 review). - const m = /([A-Za-z0-9'-]+)[\s*_~`]*$/.exec(window); - if (!m) - return false; - // A word truncated by the window start is not a descriptor match (its real - // start lies before the window) — fail toward detection. - if (from > 0 && m.index === 0 && /[A-Za-z0-9'-]/.test(masked[from - 1])) - return false; - return INTERNAL_DESCRIPTORS.has(m[1].toLowerCase()); -} -/** Matches a declaration line such as - * `No external API integration: ` (also `**bold**` and em-dash - * separators). The reason is REQUIRED — a bare declaration does not parse. - * Deliberately NOT matched: blockquoted lines (`> No external …` is quoted - * text, not a declaration) and anything inside fenced code or HTML comments - * (both stripped before the scan; #2365 review C-3). */ -const NO_INTEGRATION_DECLARATION_RE = /^\s*(?:\*\*)?no external api integration(?:\*\*)?\s*(?:[:—–-]|--)\s*(\S[^\n]*)$/im; -const HTML_COMMENT_RE = //g; -const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']); -/** - * Parse a coverage matrix from COVERAGE.md. Accepts two bijective formats: - * - * 1. Markdown table (canonical, human-editable): - * | capability | decision | reason | - * |---|---|---| - * | search | INTEGRATE | | - * | playlists | OPT-OUT | not needed yet | - * - * 2. Fenced ```coverage JSON block (machine-generated): - * ```coverage - * [ {"capability":"search","decision":"INTEGRATE","reason":""}, ... ] - * ``` - * - * Rows are trimmed; decisions upper-cased; missing reason → "". Returns - * `{ rows: [], errors: [], format: 'none' }` for empty/non-matrix input. - */ -function parseCoverageMatrix(text) { - const out = { rows: [], errors: [], format: 'none', declaration: null }; - if (typeof text !== 'string') - return out; - const src = text.replace(/\r\n/g, '\n'); - // #2365 acceptance #5: a "no external API integration" declaration. Scanned - // on fence-stripped, comment-stripped text so an example inside a code block - // or an HTML comment does not count. - const declMatch = NO_INTEGRATION_DECLARATION_RE.exec((0, markdown_sectionizer_cjs_1.stripFencedCode)(src).text.replace(HTML_COMMENT_RE, '')); - if (declMatch) { - out.declaration = { none: true, reason: (declMatch[1] || '').trim() }; - } - // (1) fenced ```coverage JSON block takes precedence if present. - // Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark). - const fenceBody = (0, markdown_sectionizer_cjs_1.extractFencedBlock)(src, 'coverage'); - if (fenceBody) { - out.format = 'json'; - let parsed; - try { - parsed = JSON.parse(fenceBody); - } - catch { - out.errors.push('fenced ```coverage block is not valid JSON'); - return out; - } - if (!Array.isArray(parsed)) { - out.errors.push('fenced ```coverage block must be a JSON array'); - return out; - } - for (let i = 0; i < parsed.length; i++) { - const row = rowFromJson(parsed[i]); - if ('error' in row) { - out.errors.push(`row[${i}]: ${row.error}`); - continue; - } - out.rows.push(row); - } - return out; - } - // (2) markdown table — collect rows from coverage matrix tables only (#2366). - // Track whether we are inside a recognized coverage matrix (after a header - // row, before a non-pipe line ends the table). This prevents summary tables - // elsewhere in the file from being parsed as data (#2366 bug 1) and allows - // multi-section matrices with repeated headers (#2366 bug 2). - const lines = src.split('\n'); - let inMatrix = false; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith('|')) { - inMatrix = false; - continue; - } - const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : trimmed.length).split('|'); - if (cells.length < 2) - continue; - const cleaned = cells.map((c) => c.trim()); - // skip separator rows (|---|---|); require ≥3 dashes so a literal "-" cell - // is not mistaken for a separator. - if (cleaned.every((c) => /^:?-{3,}:?$/.test(c))) - continue; - // Strip markdown emphasis (**, *, __, _, `) from the decision cell before - // comparison so **OPT-OUT** parses correctly (#2366 bug 3). - const decisionCell = (cleaned[1] || '').replace(/[*_`]/g, '').trim().toUpperCase(); - // header detection — recognized by 'capability' in column 0; allows multiple - // headers for multi-section matrices (#2366 bug 2). - if (cleaned[0].toLowerCase() === 'capability') { - inMatrix = true; - if (out.format === 'none') - out.format = 'table'; - continue; - } - // Only parse data rows from inside a recognized coverage matrix table. - // A pipe-table outside the matrix (e.g., a summary table) is ignored (#2366 bug 1). - if (!inMatrix) - continue; - if (!VALID_DECISIONS.has(decisionCell)) { - // A row that otherwise looks like data (≥3 cells, non-empty capability) - // but carries a malformed decision is a real error, not a row to skip - // silently — otherwise a single typo'd row collapses the matrix to - // "empty" and the user sees a confusing message. - if (cleaned.length >= 3 && cleaned[0]) { - out.errors.push(`row: decision "${decisionCell}" not in {INTEGRATE, OPT-OUT}`); - } - continue; - } - if (out.format === 'none') - out.format = 'table'; - // A coverage row has exactly 3 cells. Extra cells mean an unescaped pipe in - // a value silently corrupted the row — surface it rather than parse garbage. - if (cleaned.length > 3) { - out.errors.push(`row: ${cleaned.length} columns (expected 3 — unescaped pipe in a cell?)`); - } - out.rows.push({ - capability: cleaned[0] || '', - decision: decisionCell, - reason: (cleaned[2] ?? '').trim(), - }); - } - return out; -} -function rowFromJson(v) { - if (!v || typeof v !== 'object' || Array.isArray(v)) - return { error: 'not an object' }; - const o = v; - const capability = typeof o['capability'] === 'string' ? o['capability'].trim() : ''; - if (!capability) - return { error: 'missing/empty "capability"' }; - const dRaw = typeof o['decision'] === 'string' ? o['decision'].trim().toUpperCase() : ''; - if (!VALID_DECISIONS.has(dRaw)) { - return { error: `decision "${dRaw}" not in {INTEGRATE, OPT-OUT}` }; - } - const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : ''; - return { capability, decision: dRaw, reason }; -} -/** - * Validate a parsed matrix. A matrix is valid when: - * - it is non-empty (acceptance #1: "enumerating the API surface"), - * - every capability name is non-empty, - * - every decision is INTEGRATE or OPT-OUT (enforced by parser, re-checked - * here for defense-in-depth), - * - every OPT-OUT row carries a non-empty reason (acceptance #2). - * - * Un-enumerated remainder is not representable in the format — the gate blocks - * when an integration is detected and NO matrix exists. This validator catches - * a malformed/partial matrix that does exist. - */ -function validateCoverageMatrix(text) { - const parsed = parseCoverageMatrix(text); - const errors = [...parsed.errors]; - const rows = parsed.rows; - // #2365 acceptance #5: a reasoned no-integration declaration with no rows - // satisfies the gate. A declaration ALONGSIDE rows is contradictory — the - // file must say one thing. - if (parsed.declaration) { - if (rows.length > 0) { - errors.push('declares "no external API integration" but also contains coverage rows — remove the declaration or the rows'); - } - else { - if (parsed.declaration.reason.length > REASON_MAX_LEN) { - errors.push(`declaration reason exceeds ${REASON_MAX_LEN} chars`); - } - const valid = errors.length === 0; - return { - valid, - errors, - counts: { surface: 0, integrate: 0, optout: 0 }, - none_declared: valid, - }; - } - } - if (rows.length === 0) { - if (errors.length === 0) - errors.push('matrix is empty — no capabilities enumerated'); - return { valid: false, errors, counts: { surface: 0, integrate: 0, optout: 0 } }; - } - const seen = new Set(); - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - if (!row.capability) { - errors.push(`row[${i}]: empty capability name`); - } - else { - // Format contract + prompt-injection bound: cell values must be short, - // single-line, pipe-free prose (the matrix is a markdown table whose - // content flows into the gate message). Pipes/newlines would corrupt the - // table and let a COVERAGE.md inject unbounded text into the seal message. - if (/[|\n\r]/.test(row.capability)) { - errors.push(`row[${i}]: capability contains a pipe or newline (unsupported in a table cell)`); - } - if (row.capability.length > CAPABILITY_MAX_LEN) { - errors.push(`row[${i}]: capability exceeds ${CAPABILITY_MAX_LEN} chars`); - } - } - if (row.reason && /[|\n\r]/.test(row.reason)) { - errors.push(`row[${i}]: reason contains a pipe or newline (unsupported in a table cell)`); - } - if (row.reason.length > REASON_MAX_LEN) { - errors.push(`row[${i}]: reason exceeds ${REASON_MAX_LEN} chars`); - } - const key = row.capability.toLowerCase(); - if (key && seen.has(key)) - errors.push(`row[${i}]: duplicate capability`); - if (key) - seen.add(key); - if (!VALID_DECISIONS.has(row.decision)) { - errors.push(`row[${i}]: decision not in {INTEGRATE, OPT-OUT}`); - } - if (row.decision === 'OPT-OUT' && !row.reason) { - errors.push(`row[${i}]: OPT-OUT missing reason`); - } - } - const counts = { - surface: rows.length, - integrate: rows.filter((r) => r.decision === 'INTEGRATE').length, - optout: rows.filter((r) => r.decision === 'OPT-OUT').length, - }; - return { valid: errors.length === 0, errors, counts }; -} -/** Render rows back to the canonical markdown-table format (bijective with parse). */ -function renderCoverageMatrix(rows) { - const body = rows - .map((r) => `| ${r.capability} | ${r.decision} | ${r.reason} |`) - .join('\n'); - return `| capability | decision | reason |\n|---|---|---|\n${body}`; -} -// ── CLI entry point ────────────────────────────────────────────────────────── -// Reads phase-scope text from STDIN (not argv) to avoid OS ARG_MAX limits. -// Invoked by workflow bash as: echo "$SCOPE" | node .../api-coverage.cjs [--json] -// Exit 0 = integration detected, 1 = none, 2 = startup error. Mirrors -// assumption-delta.cjs / ui-safety-gate.cjs. -if (require.main === module) { - const argv = process.argv.slice(2); - const wantJson = argv.includes('--json'); - let termsOverride; - const verbsIdx = argv.indexOf('--verbs'); - const verbsVal = verbsIdx !== -1 ? argv[verbsIdx + 1] : undefined; - const nounsIdx = argv.indexOf('--nouns'); - const nounsVal = nounsIdx !== -1 ? argv[nounsIdx + 1] : undefined; - // A non-empty, non-flag value is an override. An EMPTY value ("") restores - // the curated defaults (does NOT silently zero the vocabulary). - const verbsOverride = typeof verbsVal === 'string' && verbsVal.length > 0 && !verbsVal.startsWith('-'); - const nounsOverride = typeof nounsVal === 'string' && nounsVal.length > 0 && !nounsVal.startsWith('-'); - if (verbsOverride || nounsOverride) { - termsOverride = {}; - if (verbsOverride) { - termsOverride.verbs = verbsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); - } - if (nounsOverride) { - termsOverride.nouns = nounsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); - } - } - const chunks = []; - process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => { - const input = chunks.join(''); - const result = detectApiIntegration(input, termsOverride); - if (wantJson) { - process.stdout.write(JSON.stringify(result) + '\n'); - } - process.exit(result.detected ? 0 : 1); - }); - process.stdin.on('error', (err) => { - process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`); - process.exit(2); - }); -} diff --git a/gsd-core/bin/lib/assumption-delta.cjs b/gsd-core/bin/lib/assumption-delta.cjs deleted file mode 100644 index 66f42b54d..000000000 --- a/gsd-core/bin/lib/assumption-delta.cjs +++ /dev/null @@ -1,231 +0,0 @@ -"use strict"; -/** - * Assumption-Delta detector (#1561). - * - * A rarely-firing, advisory architecture checkpoint. When a phase makes - * something PLURAL / OPTIONAL / CHOSEN that used to be SINGULAR / REQUIRED / - * DERIVED, the primary key / identity model may silently stop matching the - * generalized intent. This detector scans phase-scope prose for the linguistic - * signals of that transition so the plan:pre capability hook (see - * capabilities/assumption-delta/) can surface ONE identity-model question. - * - * Design notes (rubber-duck'd): - * - DETERMINISTIC + TYPED IR. The "does it fire?" decision is a pure function - * returning { detected, signals, terms }, not an LLM judgment — so the - * low-false-positive guarantee (acceptance criterion #2) is testable. - * - BARE "or" IS INTENTIONALLY EXCLUDED from the default pluralization cues. - * The issue lists "or" as a tell, but bare "or" is extremely common in - * English prose and would make the gate fire on nearly every phase - * description. Pluralization requires a stronger second-case cue - * (second / alternative / fallback / additional / ...). The vocabulary is - * tunable (config + the `terms` parameter) so teams can widen it. - * - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam) - * so a trigger term that appears only inside a code snippet does not fire. - * - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI whose - * exit codes mirror grep (0 = signal found, 1 = none, 2 = usage error). - * - * Public API: - * detectAssumptionDelta(text, terms?) -> { detected, signals, terms } - * DEFAULT_ASSUMPTION_DELTA_TERMS - * - * CLI: - * echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json] - * exit 0 = signal detected, 1 = none, 2 = startup error - * --json additionally prints the typed IR on stdout - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.DEFAULT_ASSUMPTION_DELTA_TERMS = void 0; -exports.detectAssumptionDelta = detectAssumptionDelta; -const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs"); -/** - * Curated default trigger vocabulary. Each kind lists cue terms that signal a - * core-assumption monopoly has been lost. ADDITIVE-ONLY (Hyrum's Law: once - * shipped, this set is a depended-upon interface). Tunable via the `terms` - * parameter or the capability's config slice. - */ -exports.DEFAULT_ASSUMPTION_DELTA_TERMS = { - // Primary trigger — a second X where there was one. - // Bare "or" excluded (prose-frequency false positives). - pluralization: [ - 'second', - 'alternative', - 'alternate', - 'fallback', - 'also', - 'additional', - 'another', - 'supplementary', - 'alongside', - 'multiple', - 'plural', - '2nd', - ], - // required / `only` -> optional - optional: ['optional', 'optionally'], - // derived -> chosen / constant -> parameter - chosen: [ - 'chosen', - 'choose', - 'selectable', - 'configurable', - 'parameterized', - 'parameterised', - 'parameterize', - 'parameterise', - 'custom', - ], -}; -/** Hardening caps for the tunable term vocabulary (Codex review finding). */ -const MAX_TERMS_PER_KIND = 200; -const MAX_TERM_LEN = 32; -/** - * Normalize a caller-provided term list: trim, lowercase, reject empties and - * punctuation-only terms (e.g. "-"), dedupe (preserve order), and cap the - * count/length so a huge or hostile `--terms` value cannot build a giant - * alternation regex or echo a massive payload. Defaults are already clean, so - * this is a no-op on them. - */ -function normalizeTerms(list) { - if (!Array.isArray(list)) - return []; - const seen = new Set(); - const out = []; - for (const raw of list) { - if (typeof raw !== 'string') - continue; - const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN); - // Require at least one alphanumeric char so punctuation-only terms like - // "-" cannot match prose punctuation as a "signal". - if (!t || !/[a-z0-9]/.test(t)) - continue; - if (seen.has(t)) - continue; - seen.add(t); - out.push(t); - if (out.length >= MAX_TERMS_PER_KIND) - break; - } - return out; -} -/** - * Resolve the effective term set: per-kind override. An explicitly-provided - * non-empty array for a kind REPLACES that kind's defaults (then normalized); - * an absent kind KEEPS its defaults. An explicitly-empty array disables that - * kind (override present, normalized to []). This lets a caller narrow one axis - * without re-declaring the others. - */ -function resolveTerms(terms) { - const merge = (key) => { - const t = terms && terms[key]; - return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_ASSUMPTION_DELTA_TERMS[key]]; - }; - return { - pluralization: merge('pluralization'), - optional: merge('optional'), - chosen: merge('chosen'), - }; -} -/** Trim + collapse + truncate a context window around a match for the snippet. */ -function makeSnippet(line, term) { - const cleaned = line.replace(/\s+/g, ' ').trim(); - if (cleaned.length <= 120) - return cleaned; - // Centre the window on the matched term when the line is long. - const idx = cleaned.toLowerCase().indexOf(term); - if (idx < 0) - return cleaned.slice(0, 120); - const start = Math.max(0, idx - 50); - const end = Math.min(cleaned.length, idx + term.length + 50); - const prefix = start > 0 ? '…' : ''; - const suffix = end < cleaned.length ? '…' : ''; - return `${prefix}${cleaned.slice(start, end)}${suffix}`; -} -/** - * Detect assumption-delta signals in phase-scope prose. - * - * @param text - Roadmap phase section / scope prose. Non-string inputs degrade - * to `{ detected: false }` without throwing. - * @param terms - Optional per-kind override (see resolveTerms). - * @returns typed IR: { detected, signals[], terms }. `terms` is the effective - * (merged) set actually used, so callers/tests can audit what fired. - */ -function detectAssumptionDelta(text, terms) { - if (typeof text !== 'string') { - return { detected: false, signals: [], terms: resolveTerms(terms) }; - } - const effective = resolveTerms(terms); - // Strip fenced code blocks so trigger terms inside code snippets do not fire. - // stripFencedCode is CommonMark-correct and CRLF-safe. - const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text; - if (stripped.trim().length === 0) { - return { detected: false, signals: [], terms: effective }; - } - const signals = []; - const kinds = ['pluralization', 'optional', 'chosen']; - for (const kind of kinds) { - const cueTerms = effective[kind]; - if (cueTerms.length === 0) - continue; - // Word-boundary anchored, case-insensitive — same shape as ui-safety-gate. - // (^|[^a-zA-Z0-9])(TERM)([^a-zA-Z0-9]|$) prevents interior-substring matches. - const escaped = cueTerms.map(escapeRegex).join('|'); - const pattern = new RegExp('(^|[^a-zA-Z0-9])(' + escaped + ')([^a-zA-Z0-9]|$)', 'gi'); - const seen = new Set(); - for (const line of stripped.split('\n')) { - pattern.lastIndex = 0; - for (const m of line.matchAll(pattern)) { - const raw = m[2]; - if (!raw) - continue; - const matched = raw.toLowerCase(); - const key = `${kind}:${matched}`; - if (seen.has(key)) - continue; - seen.add(key); - signals.push({ kind, term: matched, snippet: makeSnippet(line, matched) }); - } - } - } - return { detected: signals.length > 0, signals, terms: effective }; -} -function escapeRegex(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} -// ── CLI entry point ────────────────────────────────────────────────────────── -// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits. -// Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json] -// Exit 0 = signal detected, 1 = none, 2 = startup error. Mirrors ui-safety-gate. -if (require.main === module) { - const argv = process.argv.slice(2); - const wantJson = argv.includes('--json'); - // --terms : config-tunable vocabulary override. Replaces the - // pluralization cues (the primary trigger); optional/chosen keep defaults. - // An EMPTY value ("") or a flag-shaped value restores the curated defaults - // (does NOT disable pluralization). Terms are normalized (deduped, etc.) by - // detectAssumptionDelta's resolveTerms. - let termsOverride; - const termsIdx = argv.indexOf('--terms'); - const termsVal = termsIdx !== -1 ? argv[termsIdx + 1] : undefined; - if (typeof termsVal === 'string' && !termsVal.startsWith('-')) { - const list = termsVal - .split(',') - .map((t) => t.trim().toLowerCase()) - .filter((t) => t.length > 0); - termsOverride = list.length > 0 ? { pluralization: list } : undefined; - } - const chunks = []; - process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (chunk) => chunks.push(chunk)); - process.stdin.on('end', () => { - const input = chunks.join(''); - const result = detectAssumptionDelta(input, termsOverride); - if (wantJson) { - process.stdout.write(JSON.stringify(result) + '\n'); - } - process.exit(result.detected ? 0 : 1); - }); - process.stdin.on('error', (err) => { - process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`); - process.exit(2); - }); -} diff --git a/gsd-core/bin/lib/claude-orchestration-command-router.cjs b/gsd-core/bin/lib/claude-orchestration-command-router.cjs deleted file mode 100644 index da3655c6a..000000000 --- a/gsd-core/bin/lib/claude-orchestration-command-router.cjs +++ /dev/null @@ -1,314 +0,0 @@ -"use strict"; -/** - * Claude orchestration command router — CLI dispatcher for - * `gsd-tools claude-orchestration `. - * - * #1143 — thin CLI adapter over the pure `claude-orchestration.cjs` module. - * Lets execute-phase (or any orchestrator) invoke the Workflow-backend - * detection and the Workflow-script emitter through the standard capability - * command surface (ADR-959) instead of a bare `require()`. - * - * Router signature: { args, cwd, raw, error } — identical to the other host - * routers; discovered by dispatchCapabilityCommand via the registry's - * commandFamilies index. - * - * Subcommands: - * detect-backend [--runtime ] [--agent-sdk-version ] [--no-nested-dispatch] - * Resolves whether the Workflow backend should activate. Both flags are - * OPTIONAL (#2590): `--runtime` falls back to the canonical - * `GSD_RUNTIME > config.runtime > 'claude'` chain, and - * `--agent-sdk-version` to `GSD_AGENT_SDK_VERSION` then the installed - * @anthropic-ai/claude-agent-sdk version. Reads the - * `claude_orchestration.*` keys from .planning/config.json. Emits - * { available, backend, reason }. - * - * emit-workflow --waves --run-id [--phase-dir ] [--budget ] [--executor-model ] - * Reads a wave/plan manifest JSON file and emits the generated Workflow - * script + summary. The manifest shape matches emitWorkflowScript's input: - * { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }. - * `use_worktree` defaults to true; pass `false` for a plan the inline path - * (execute-phase.md step 2.5) would also keep out of worktree isolation - * (submodule-touching plans — #2772 / #2285 finding 1). - * - * resolve-wave-dispatch --waves --run-id [--runtime ] - * [--agent-sdk-version ] [--no-nested-dispatch] [--phase-dir ] - * [--budget ] [--executor-model ] - * #2285 — the single composed seam a PRE-wave dispatch-backend selector - * (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in - * ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }. - * Fail-closed identically to detect-backend/emit-workflow individually — - * any gate miss, or an emit failure on a malformed --waves manifest, - * resolves to 'inline' with no script. - */ -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -const node_fs_1 = __importDefault(require("node:fs")); -const node_path_1 = __importDefault(require("node:path")); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const io = require("./io.cjs"); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const core = require("./claude-orchestration.cjs"); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const configLoader = require("./config-loader.cjs"); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const runtimeSlash = require("./runtime-slash.cjs"); -// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module -const modelResolver = require("./model-resolver.cjs"); -const { output } = io; -const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core; -const CAPABLE_HOST = { dispatch: { nested: true, background: true } }; -function usage(error) { - error('Usage: gsd-tools claude-orchestration [...]\n' + - ' detect-backend [--runtime ] [--agent-sdk-version ] [--no-nested-dispatch]\n' + - ' emit-workflow --waves --run-id [--phase-dir ] [--budget ] [--executor-model ]\n' + - ' resolve-wave-dispatch --waves --run-id [--runtime ] [--agent-sdk-version ] [--no-nested-dispatch] [--phase-dir ] [--budget ] [--executor-model ]'); -} -function argValue(args, flag) { - const i = args.indexOf(flag); - return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined; -} -/** - * Resolve the `claude_orchestration.*` config slice from the project config - * (federated keys are merged by loadConfig as a nested object), flattened into - * the dotted-key shape `detectWorkflowBackend`/`resolveWaveDispatch` expect. A - * config read failure degrades to an empty slice — it must not break the core - * loop. Shared by `detect-backend` and `resolve-wave-dispatch`. - */ -function resolveFlatClaudeOrchestrationConfig(cwd) { - let claudeSlice = {}; - try { - const loaded = configLoader.loadConfig(cwd); - const slice = loaded['claude_orchestration']; - if (slice && typeof slice === 'object' && !Array.isArray(slice)) { - claudeSlice = slice; - } - } - catch { - claudeSlice = {}; - } - const flatConfig = {}; - for (const k of Object.keys(claudeSlice)) { - flatConfig['claude_orchestration.' + k] = claudeSlice[k]; - } - return flatConfig; -} -/** - * Resolve the installed Agent SDK version (#2590). - * - * The `execute:wave:pre` fragment claimed the orchestrator "has no scriptable - * way to introspect the live Agent SDK version" and told callers to omit the - * flag — so gate 5 returned `agent_sdk_version_unknown` on every automated run - * and the Workflow backend never activated, while `capability state` still - * reported it `active: true`. That claim is true for BASH, but this router runs - * in Node: the installed package's own package.json is authoritative and - * requires no flag at all. - * - * Resolution is side-effect-free and fails closed to undefined (gate 5 then - * declines, exactly as before) rather than guessing a version. - */ -const AGENT_SDK_PKG = node_path_1.default.join('@anthropic-ai', 'claude-agent-sdk', 'package.json'); -function resolveInstalledAgentSdkVersion(cwd) { - // Walk node_modules up the tree by hand rather than require.resolve: the SDK's - // `exports` map does not expose './package.json', so require.resolve throws - // ERR_PACKAGE_PATH_NOT_EXPORTED. Reading the file directly is exports-map - // independent and cannot execute package code. - for (const start of [cwd, __dirname]) { - let dir; - try { - dir = node_path_1.default.resolve(start); - } - catch { - continue; - } - for (;;) { - try { - const pkgPath = node_path_1.default.join(dir, 'node_modules', AGENT_SDK_PKG); - if (node_fs_1.default.existsSync(pkgPath)) { - const parsed = JSON.parse(node_fs_1.default.readFileSync(pkgPath, 'utf8')); - if (typeof parsed.version === 'string' && parsed.version.length > 0) - return parsed.version; - } - } - catch { /* unreadable/malformed — keep walking */ } - const parent = node_path_1.default.dirname(dir); - if (parent === dir) - break; - dir = parent; - } - } - return undefined; -} -/** - * Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the - * `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend` - * and `resolve-wave-dispatch` pass to the pure detection seam. - */ -function resolveDetectionArgs(args, cwd) { - // #2590: the old fallback chain was `--runtime > GSD_RUNTIME > 'unknown'`, - // diverging from the canonical `GSD_RUNTIME > config.runtime > 'claude'` used - // by runtime-slash.resolveRuntime — so ANY manual invocation without - // --runtime reported `runtime_not_claude` on a perfectly ordinary Claude - // project. Delegate to the canonical resolver instead of re-deriving it. - const runtimeId = argValue(args, '--runtime') || runtimeSlash.resolveRuntime(cwd || null); - // Explicit flag wins (lets a caller pin a version); then the environment; - // then the actually-installed SDK. - const agentSdkVersion = argValue(args, '--agent-sdk-version') - || process.env['GSD_AGENT_SDK_VERSION'] - || resolveInstalledAgentSdkVersion(cwd || process.cwd()); - const noNested = args.includes('--no-nested-dispatch'); - const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST; - return { runtimeId, hostIntegration, agentSdkVersion }; -} -/** - * #2686 — resolve the `gsd-executor` model this dispatch should carry. - * - * Defaults from the project config rather than requiring a flag. The Workflow - * backend previously emitted no model at all, so `model_overrides` / - * `model_policy` / `model_profile` were silently inert on that path while the - * inline path honored them. Reading the same source the inline path reads is - * what makes the two backends agree by construction: an orchestrator that never - * learns about a new flag would otherwise silently keep the old bug. - * - * `--executor-model` exists only to pin/override. Resolution is side-effect-free - * and fails closed to `undefined` (emission then omits the key, i.e. exactly the - * pre-#2686 output) rather than guessing a model. - */ -function resolveExecutorModel(args, cwd) { - const pinned = argValue(args, '--executor-model'); - if (pinned !== undefined) - return pinned; - try { - const resolved = modelResolver.resolveModelInternal(cwd, 'gsd-executor'); - return typeof resolved === 'string' ? resolved : undefined; - } - catch { - return undefined; - } -} -/** - * Read and parse a `--waves ` manifest file. - * - * #2285 finding 2: a real read/parse failure (`ok:false`) is DISTINCT from a - * manifest that parsed fine but has no top-level `waves` key (`ok:true, waves: - * undefined`) — collapsing both into the same sentinel made the missing-key - * case exit 0 with ZERO output (fail-silent), breaking the "exit 0 => parseable - * JSON verdict" contract callers rely on. Only the `ok:false` (read/parse threw) - * case calls `error(...)` and should short-circuit the caller; `ok:true` with a - * missing/malformed `waves` value must flow through to `emitWorkflowScript`'s - * own validation (matching how `{"waves": null}` already behaves) so the caller - * emits an explicit, non-empty verdict instead of silently doing nothing. - */ -function readWavesManifest(wavesPath, error) { - try { - const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8'); - const parsed = JSON.parse(content); - return { ok: true, waves: parsed['waves'] }; - } - catch (e) { - error('could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e))); - return { ok: false }; - } -} -/** - * Detect whether the Workflow backend should activate for the current/given - * runtime. Reads `claude_orchestration.*` from the project config; runtime and - * SDK version come from flags (the orchestrator already knows these) or env. - */ -function cmdDetectBackend(args, cwd, raw) { - const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd); - const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd); - const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion }); - output(result, raw); -} -/** - * Emit a Workflow script from a wave/plan manifest file. - */ -function cmdEmitWorkflow(args, cwd, raw, error) { - const wavesPath = argValue(args, '--waves'); - const runId = argValue(args, '--run-id'); - const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current'; - const budgetRaw = argValue(args, '--budget'); - if (!wavesPath) { - error('emit-workflow requires --waves '); - return; - } - if (!runId) { - error('emit-workflow requires --run-id '); - return; - } - const read = readWavesManifest(wavesPath, (msg) => error('emit-workflow: ' + msg)); - if (!read.ok) - return; // read/parse failure — error() already surfaced it loudly above - const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined; - const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined; - const result = emitWorkflowScript({ - phaseDir, - runId, - waves: read.waves, - budgetTokens: budget, - executorModel: resolveExecutorModel(args, cwd), - }); - if (!result.ok) { - error('emit-workflow: ' + result.reason); - return; - } - output({ script: result.script, summary: result.summary }, raw); -} -/** - * #2285 — the single composed seam a PRE-wave dispatch-backend selector - * (`execute:wave:pre`) uses: resolves `detect-backend` + `emit-workflow` in - * ONE call via `resolveWaveDispatch`. Emits - * `{ backend: 'inline'|'workflow', reason, script?, summary? }`. - */ -function cmdResolveWaveDispatch(args, cwd, raw, error) { - const wavesPath = argValue(args, '--waves'); - const runId = argValue(args, '--run-id'); - const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current'; - const budgetRaw = argValue(args, '--budget'); - if (!wavesPath) { - error('resolve-wave-dispatch requires --waves '); - return; - } - if (!runId) { - error('resolve-wave-dispatch requires --run-id '); - return; - } - const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg)); - if (!read.ok) - return; // read/parse failure — error() already surfaced it loudly above - const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd); - const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd); - const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined; - const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined; - const result = resolveWaveDispatch({ - runtimeId, - hostIntegration, - config: flatConfig, - agentSdkVersion, - phaseDir, - runId, - waves: read.waves, - budgetTokens: budget, - executorModel: resolveExecutorModel(args, cwd), - }); - output(result, raw); -} -function routeClaudeOrchestrationCommand(opts) { - const { args, cwd, raw, error } = opts; - // args[0] is the family ('claude-orchestration'); the subcommand is args[1]. - const subcommand = args[1]; - if (subcommand === 'detect-backend') { - cmdDetectBackend(args, cwd, raw); - } - else if (subcommand === 'emit-workflow') { - cmdEmitWorkflow(args, cwd, raw, error); - } - else if (subcommand === 'resolve-wave-dispatch') { - cmdResolveWaveDispatch(args, cwd, raw, error); - } - else { - usage(error); - } -} -module.exports = { routeClaudeOrchestrationCommand }; diff --git a/gsd-core/bin/lib/claude-orchestration.cjs b/gsd-core/bin/lib/claude-orchestration.cjs deleted file mode 100644 index 1b9f8b127..000000000 --- a/gsd-core/bin/lib/claude-orchestration.cjs +++ /dev/null @@ -1,582 +0,0 @@ -"use strict"; -/** - * Claude Orchestration Capability — Workflow-tool backend detection + emitter - * - * #1143 — adopts Claude Code's Workflow tool (the engine behind `/effort ultracode`) - * as an optional, runtime-gated parallel-execution backend for the GSD loop. - * - * This module is the pure, testable core of the capability. It owns two seams: - * - * detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion }) - * → { available: boolean, backend: 'workflow'|'inline', reason: string } - * Fail-closed: every miss degrades to `inline` (today's behaviour), so the - * core loop is byte-identical unless every gate opens. This is criteria 3 + 6. - * - * emitWorkflowScript({ phaseDir, waves, runId, budgetTokens? }) - * → { ok:true, script, summary } | { ok:false, reason } - * Maps GSD's wave/plan model 1:1 onto Workflow primitives: - * wave → sequential `parallel()` stage barriers, - * plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })` - * — UNLESS the plan's `use_worktree` is explicitly `false`, in which case - * `isolation` is omitted entirely for that plan (#2772 / #2285 finding 1: - * a submodule-touching plan must never be forced into worktree isolation - * the inline path (execute-phase.md step 2.5) would keep it out of), - * files_modified overlap → forces plans into separate sequential stages - * (the same overlap rule execute-phase already applies inline), - * resumeFromRunId → wired to the phase run id, - * budgetTokens → a shared token pool. - * The emitted script composes the SAME gsd-executor agent the inline path - * uses, with per-plan worktree isolation mirroring the inline path's own - * per-plan decision, so it produces the same artifacts/commits (criterion 2). - * It is a generated string consumed by the orchestrator; this module never - * invokes the Workflow tool itself. - * - * Design laws: - * - Gall's Law: ship a small working slice that composes existing primitives - * (gsd-executor + worktree isolation) rather than reinventing them. - * - Greenspun's Tenth Rule (cited in #1143): adopt the Workflow tool's - * barrier/pipeline/budget/resume semantics instead of hand-rolling them. - * - Postel's Law: liberal in input (missing fields → inline), conservative in - * output (workflow only when every gate opens). - * - Fail-closed: an unknown version, a missing descriptor, or a disabled - * toggle all resolve to `inline`, never to `workflow`. - * - * Zero external dependencies. Pure functions. Never throws on bad input. - */ -// ─── Constants ──────────────────────────────────────────────────────────────── -/** - * The Agent SDK version that introduced the Workflow tool (#1143 prior art). - * Used as the default floor when config does not override it. A runtime reporting - * an agentSdkVersion below this cannot host the Workflow backend. - */ -const WORKFLOW_TOOL_FLOOR_VERSION = '0.3.149'; -/** Closed enum for the `claude_orchestration.execution_backend` config key. */ -const BACKEND_VALUES = new Set(['auto', 'workflow', 'inline']); -/** Only this runtime can host the Workflow tool (Claude Code / Agent SDK). */ -const WORKFLOW_RUNTIME = 'claude'; -// ─── Semver helpers ─────────────────────────────────────────────────────────── -/** Official-ish strict SemVer 2.0.0 numeric triple (+ optional pre/build). */ -const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; -/** True for a syntactically valid semver string. */ -function isValidSemver(s) { - return typeof s === 'string' && SEMVER_RE.test(s); -} -/** - * Compare two semver strings. - * Returns -1/0/1 in the usual sense. Garbage in either position → -1 (fail-closed: - * an unparseable version is treated as "less than" any real floor, so detection - * never accidentally enables the preview backend on an unknown SDK). - * - * Pre-release/build metadata are ignored for the comparison — only the numeric - * major.minor.patch triple participates, matching how the Workflow-tool floor is - * specified (a plain "0.3.149"). - */ -function compareSemver(a, b) { - if (!isValidSemver(a) || !isValidSemver(b)) - return -1; - // Split numeric triple from pre-release/build metadata. - const parseTriple = (s) => { - const core = s.split('-')[0].split('+')[0].split('.'); - return [parseInt(core[0], 10), parseInt(core[1], 10), parseInt(core[2], 10)]; - }; - const hasPre = (s) => s.indexOf('-') !== -1; - const preIdentifiers = (s) => (s.split('-')[1] || '').split('+')[0].split('.').filter((x) => x.length > 0); - const am = parseTriple(a); - const bm = parseTriple(b); - for (let i = 0; i < 3; i++) { - if (am[i] < bm[i]) - return -1; - if (am[i] > bm[i]) - return 1; - } - // Numeric triple is equal. SemVer 2.0.0 §11 precedence: - // - a version WITH a pre-release tag is LOWER than the same triple WITHOUT one - // (keeps the floor fail-closed for pre-release builds of the GA floor); - // - two pre-releases of the same triple are ordered by their dot-separated - // identifiers (numeric < alphanumeric; numeric compared numerically, - // alphanumeric lexically; fewer identifiers < more). - const aPre = hasPre(a); - const bPre = hasPre(b); - if (aPre && !bPre) - return -1; - if (!aPre && bPre) - return 1; - if (aPre && bPre) { - const ai = preIdentifiers(a); - const bi = preIdentifiers(b); - const len = Math.min(ai.length, bi.length); - for (let i = 0; i < len; i++) { - const ax = ai[i]; - const bx = bi[i]; - const aNum = /^\d+$/.test(ax); - const bNum = /^\d+$/.test(bx); - if (aNum && bNum) { - const an = parseInt(ax, 10); - const bn = parseInt(bx, 10); - if (an < bn) - return -1; - if (an > bn) - return 1; - } - else if (aNum && !bNum) { - return -1; // numeric identifiers always lower than alphanumeric - } - else if (!aNum && bNum) { - return 1; - } - else { - if (ax < bx) - return -1; - if (ax > bx) - return 1; - } - } - if (ai.length < bi.length) - return -1; - if (ai.length > bi.length) - return 1; - } - return 0; -} -/** Inline result shorthand. */ -function inline(reason, available = false) { - return { available, backend: 'inline', reason }; -} -/** - * Resolve whether the Workflow-tool backend should activate. - * - * Gate ladder (all must pass for `workflow`; first miss wins, fail-closed): - * 1. capability enabled (claude_orchestration.enabled truthy) - * 2. runtime is Claude (the only runtime that exposes the Workflow tool) - * 3. execution_backend !== 'inline' - * 4. host descriptor signals nested+background dispatch (Workflow-tool capable) - * 5. agentSdkVersion is a known, valid semver - * 6. agentSdkVersion >= the configured floor (default WORKFLOW_TOOL_FLOOR_VERSION) - * 7. execution_backend === 'workflow' OR 'auto' (both reach here; 'inline' exited at 3) - * - * Never throws. Destructures defensively. - */ -function detectWorkflowBackend(input) { - if (input === null || input === undefined || typeof input !== 'object') { - return inline('capability_disabled'); - } - const cfg = (input.config !== null && input.config !== undefined && typeof input.config === 'object') - ? input.config - : {}; - // 1. capability must be opted in (default-off — ships disabled). - if (!cfg['claude_orchestration.enabled']) { - return inline('capability_disabled'); - } - // 2. only Claude can host the Workflow tool. - if (input.runtimeId !== WORKFLOW_RUNTIME) { - return inline('runtime_not_claude'); - } - // 3. explicit inline opt-out short-circuits. - let backendRaw = cfg['claude_orchestration.execution_backend']; - if (typeof backendRaw !== 'string' || !BACKEND_VALUES.has(backendRaw)) { - backendRaw = 'auto'; - } - if (backendRaw === 'inline') { - return inline('backend_inline'); - } - // 4. the host dispatch descriptor must be the nesting-capable Claude-Code shape - // (a proxy for Workflow-tool presence). This is Claude-specific and already - // gated at step 2; `background:true` alone is true on several non-Claude hosts, - // so the proxy is only meaningful after the runtime check above. Note: this is - // NOT the canonical `shouldFlattenDispatch` rule (which keys on - // `backgroundDispatch`); the Workflow backend works precisely because a single - // tool-call orchestrates internally, sidestepping the backgroundDispatch:false - // limitation. Missing/false/foreign descriptor → fail-closed. - const hi = input.hostIntegration; - if (hi === null || hi === undefined || typeof hi !== 'object' || Array.isArray(hi)) { - return inline('workflow_tool_unavailable'); - } - const dispatch = hi.dispatch; - if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) { - return inline('workflow_tool_unavailable'); - } - const nested = dispatch['nested']; - const background = dispatch['background']; - if (nested !== true || background !== true) { - return inline('workflow_tool_unavailable'); - } - // 5. an unknown agentSdkVersion cannot be trusted to meet the floor. - if (!isValidSemver(input.agentSdkVersion)) { - return inline('agent_sdk_version_unknown'); - } - // 6. version floor (config override > default constant). - const floorRaw = cfg['claude_orchestration.min_agent_sdk_version']; - const floor = typeof floorRaw === 'string' && isValidSemver(floorRaw) ? floorRaw : WORKFLOW_TOOL_FLOOR_VERSION; - if (compareSemver(input.agentSdkVersion, floor) < 0) { - return inline('agent_sdk_version_below_floor'); - } - // 7. auto/workflow both reach the workflow backend once every gate passes. - return { available: true, backend: 'workflow', reason: 'workflow_backend_active' }; -} -/** - * Partition a wave's plans into a near-minimal number of sequential stages (via - * greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but - * correct: no two plans sharing a file ever cohabit a stage) such that no two - * plans in the same stage share a modified file. Each plan goes into the earliest - * stage where it does not overlap any plan already there. - * - * A plan with an EMPTY files_modified set declares no files; it overlaps nothing - * and coalesces into stage 0 (same behavior as the inline path, which also cannot - * guard against undeclared concurrent writes — declare filesModified accurately). - * - * This is the same overlap rule execute-phase applies inline — the only difference - * is the execution vehicle (Workflow `parallel()` vs one-agent-per-message). - */ -function partitionStages(plans) { - const stages = []; - for (const plan of plans) { - const fileSet = new Set(plan.files_modified); - let placed = false; - for (const stage of stages) { - let overlap = false; - for (const f of fileSet) { - if (stage.files.has(f)) { - overlap = true; - break; - } - } - if (!overlap) { - stage.plans.push(plan); - for (const f of fileSet) - stage.files.add(f); - placed = true; - break; - } - } - if (!placed) { - stages.push({ plans: [plan], files: new Set(fileSet) }); - } - } - return stages.map((s) => s.plans.map((p) => p.id)); -} -/** - * Quote a free-text value for safe embedding as a JavaScript/Workflow double-quoted - * string literal. Uses JSON.stringify so every JS-relevant escape (backslash, quote, - * newline, tab, NUL, U+2028/U+2029, all control chars) is handled by the language - * itself — there is no hand-rolled escape table to drift. Returns the value already - * wrapped in its surrounding quotes. - */ -function quoteString(s) { - return JSON.stringify(s); -} -/** - * #2686 — the single decision of whether a resolved executor model is emittable, - * and what to emit. Shared by `agentOptions` (the emission) and the provenance - * comment (the claim about it) so the two can never disagree — a generated - * comment asserting something the generator does not actually do is the exact - * failure #2686 was filed for. - * - * Returns the model to emit, or `undefined` for "emit nothing": - * - non-string → malformed config; omit rather than throw, matching the - * defensive typeof guard `mapClaudeOverrideForRuntime` - * already carries in model-resolver for the same reason. - * - empty/whitespace → #2517: emitting `model: ""` 404s on runtimes without - * native tier aliases. Trimmed, so `" "` is also "none". - * - "inherit" → same rule; matched case-insensitively after trimming, - * since config is user-authored free text. - * - * NOTE it does NOT reject unscriptable characters — that is a hard input error, - * not a silent omission, and is rejected up front by `emitWorkflowScript` so the - * caller sees a reason instead of quietly losing their model routing. - */ -function emittableModel(executorModel) { - if (typeof executorModel !== 'string') - return undefined; - const trimmed = executorModel.trim(); - if (trimmed.length === 0) - return undefined; - if (trimmed.toLowerCase() === 'inherit') - return undefined; - return trimmed; -} -/** - * Render the `agent()` options object for a single plan — `isolation: "worktree"` - * ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285 - * finding 1). This is the single place that decides worktree isolation for the - * Workflow backend; it must never diverge from the inline path's per-plan gate. - */ -function agentOptions(p, executorModel) { - const parts = ['agentType: "gsd-executor"']; - if (p.use_worktree !== false) - parts.push('isolation: "worktree"'); - // #2686: carry the resolved executor model so this backend honors - // model_overrides / model_policy / model_profile exactly as the inline path. - const model = emittableModel(executorModel); - if (model !== undefined) - parts.push('model: ' + quoteString(model)); - return '{ ' + parts.join(', ') + ' }'; -} -/** - * True if `s` is a safe identifier/path token to interpolate into the generated - * script WITHOUT requiring a string-literal context — i.e. it contains no - * character that could terminate a comment line (`\n`/`\r`), break out of a - * string literal (`"` / `\`), or smuggle a NUL/control sequence. Used for - * `phaseDir`, `runId`, `wave.id`, and `plan.id`, which are identifiers/paths and - * must never legitimately contain such characters. Rejecting them at validation - * (rather than silently flattening) keeps the emitted script faithful to input. - */ -const UNSCRIPTABLE_CHAR_RE = /[\r\n"\\\x00-\x1f\x7f\u2028\u2029]/; -function isScriptableIdentifier(s) { - if (typeof s !== 'string' || s.length === 0) - return false; - return !UNSCRIPTABLE_CHAR_RE.test(s); -} -/** - * Emit a Workflow script mapping the phase's wave/plan model onto Workflow - * primitives. Pure and deterministic: identical input yields an identical string. - * - * Returns ok:false (never throws) on invalid input — empty waves, missing runId, - * a wave with no plans, etc. - */ -function emitWorkflowScript(input) { - if (input === null || input === undefined || typeof input !== 'object') { - return { ok: false, reason: 'invalid_input' }; - } - const { phaseDir, waves, runId, executorModel } = input; - // Identifiers/paths interpolated into the generated script must be free of any - // character that could terminate a comment, break out of a string literal, or - // smuggle control bytes — reject up front (security: #1143 review Finding 1). - if (!isScriptableIdentifier(phaseDir)) { - return { ok: false, reason: 'phaseDir must be a non-empty string without newlines/quotes/backslash/control chars' }; - } - if (!isScriptableIdentifier(runId)) { - return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' }; - } - // #2686 security: the resolved model is interpolated into BOTH an object - // literal (safe under quoteString) and a `//` provenance comment (NOT safe - // under quoteString — U+2028/U+2029 are LineTerminators that end a single-line - // comment in every engine, so a hostile model id would make the rest of the - // line live code). Reject the whole emission rather than silently dropping the - // model: an unscriptable id is malformed input, and `resolveWaveDispatch` maps - // an emit failure to the inline backend WITH a reason, so the user sees it. - // Only a STRING carrying such a character is rejected. A non-string is a - // malformed config rather than an injection attempt, and stays on the existing - // defensive path: `emittableModel` omits it and emission proceeds. - if (typeof executorModel === 'string' && UNSCRIPTABLE_CHAR_RE.test(executorModel)) { - return { ok: false, reason: 'executorModel must not contain newlines/quotes/backslash/control/line-separator chars' }; - } - if (!Array.isArray(waves) || waves.length === 0) { - return { ok: false, reason: 'waves must be a non-empty array' }; - } - // Wave ids must be unique ACROSS waves, not just plan ids within one (#2590). - // Each wave emits a `phase("Wave ")` call plus a matching meta.phases - // entry, and the Workflow tool matches phase titles by exact string — two - // waves sharing an id would collapse into one progress group and misattribute - // every agent in the second wave to the first. - const seenWaveIds = new Set(); - for (let i = 0; i < waves.length; i++) { - const w = waves[i]; - if (w === null || typeof w !== 'object' || typeof w.id !== 'string') { - return { ok: false, reason: 'waves[' + i + '] must be { id, plans: non-empty[] }' }; - } - if (!isScriptableIdentifier(w.id)) { - return { ok: false, reason: 'waves[' + i + '].id must not contain newlines/quotes/backslash/control chars' }; - } - if (seenWaveIds.has(w.id)) { - return { ok: false, reason: 'duplicate wave id "' + w.id + '" — wave ids must be unique (phase titles must map 1:1)' }; - } - seenWaveIds.add(w.id); - if (!Array.isArray(w.plans) || w.plans.length === 0) { - return { ok: false, reason: 'waves[' + i + '] must have a non-empty plans array' }; - } - const seenIds = new Set(); - for (let j = 0; j < w.plans.length; j++) { - const p = w.plans[j]; - if (p === null || typeof p !== 'object' || typeof p.id !== 'string' || typeof p.brief !== 'string' || !Array.isArray(p.files_modified)) { - return { ok: false, reason: 'waves[' + i + '].plans[' + j + '] must be { id, brief, files_modified[] }' }; - } - if (!isScriptableIdentifier(p.id)) { - return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' }; - } - if (p.use_worktree !== undefined && typeof p.use_worktree !== 'boolean') { - return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].use_worktree must be a boolean if present' }; - } - if (seenIds.has(p.id)) { - return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' }; - } - seenIds.add(p.id); - for (const f of p.files_modified) { - if (typeof f !== 'string' || f.length === 0) { - return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].files_modified entries must be non-empty strings' }; - } - } - } - } - const budgetTokens = (typeof input.budgetTokens === 'number' && Number.isFinite(input.budgetTokens) && input.budgetTokens > 0) - ? Math.floor(input.budgetTokens) - : null; - const lines = []; - // `export const meta = {…}` MUST be the first statement in the script — the - // Workflow tool rejects the whole script otherwise (#2590). Leading comments - // are not statements, but the meta block is emitted first regardless so the - // contract holds under the strictest reading of "first statement". - // - // meta.phases must be a PURE LITERAL (no variables, calls, spreads, or - // template interpolation), and its titles are matched EXACTLY against the - // phase() calls emitted below. - lines.push('export const meta = {'); - lines.push(' name: ' + quoteString('gsd-execute-' + runId) + ','); - lines.push(' description: ' + quoteString('GSD wave dispatch for ' + phaseDir) + ','); - lines.push(' phases: ['); - for (const w of waves) { - lines.push(' { title: ' + quoteString('Wave ' + w.id) + ', detail: ' - + quoteString(w.plans.length + ' plan(s)') + ' },'); - } - lines.push(' ],'); - lines.push('}'); - lines.push(''); - lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)'); - lines.push('// phase: ' + phaseDir); - lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.'); - lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)'); - lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)'); - lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).'); - // #2686 / ADR-1411: state which model was applied — or that none resolved — - // so an opted-in user can SEE the routing decision instead of having to read - // the emitted options. A fallback must be a visible value, never silent. - // - // SECURITY: this is a `//` comment, and U+2028/U+2029 are ECMAScript - // LineTerminators that END a single-line comment in every engine — the ES2019 - // change legalized them inside string LITERALS only, so `quoteString` alone is - // NOT sufficient here even though it is sufficient in the object literal - // above. An unscriptable model id would otherwise close the comment and make - // the remainder live top-level code. `emitWorkflowScript` rejects such ids - // before reaching this point (see the validation above), which is what makes - // interpolating here safe. - const provenanceModel = emittableModel(executorModel); - if (provenanceModel !== undefined) { - lines.push('// model: ' + quoteString(provenanceModel) + ' (resolved for gsd-executor, same source as the inline path)'); - } - else { - lines.push('// model: none applied — resolved to "inherit"/empty, so each agent inherits the'); - lines.push('// orchestrator model (#2517: emitting an empty model 404s on some runtimes).'); - } - lines.push('//'); - // resumeFromRunId is a Workflow TOOL INPUT parameter, not a script function — - // calling it threw "resumeFromRunId is not defined" (#2590). The run id is - // carried in summary.resumeRunId for the caller to pass as that input. - lines.push('// resume: pass ' + quoteString(runId) + ' as the Workflow tool\'s resumeFromRunId input'); - lines.push('// (it is a tool parameter, NOT a script function).'); - if (budgetTokens !== null) { - // `budget` is a read-only object ({ total, spent(), remaining() }) supplied - // by the caller's token directive — a script cannot SET it, and `budget(n)` - // threw "budget is not a function" (#2590). Recorded as intent only. - lines.push('// budget: ' + budgetTokens + ' output tokens intended for this run; `budget` is'); - lines.push('// read-only in a Workflow script — set it via the caller\'s token directive.'); - } - lines.push(''); - const stagesByWave = []; - let totalPlans = 0; - for (let wi = 0; wi < waves.length; wi++) { - const wave = waves[wi]; - const stages = partitionStages(wave.plans); - stagesByWave.push(stages); - totalPlans += wave.plans.length; - lines.push('// Wave ' + wave.id); - // Title must match this wave's meta.phases entry EXACTLY. - lines.push('phase(' + quoteString('Wave ' + wave.id) + ')'); - for (let si = 0; si < stages.length; si++) { - const stagePlanIds = stages[si]; - // Resolve back to plan objects for briefs (ids are unique within a wave — validated above). - const stagePlans = stagePlanIds.map((id) => wave.plans.find((p) => p.id === id)); - if (stages.length > 1) { - lines.push('// Stage ' + si + (si > 0 ? ' (sequential — files_modified overlap)' : '')); - } - // parallel() takes an ARRAY OF THUNKS — `parallel(agent(…), agent(…))` - // threw "parallel() expects an array of functions" (#2590). Passing - // agent() results directly would also start every agent eagerly, before - // parallel() could bound concurrency. - lines.push('await parallel(['); - for (const p of stagePlans) { - lines.push(' () => agent(' + quoteString(p.brief) + ', ' + agentOptions(p, executorModel) + '),'); - } - lines.push('])'); - } - if (wi < waves.length - 1) - lines.push(''); - } - lines.push('// Each agent writes SUMMARY.md on its worktree branch; commits land there'); - lines.push('// and are merged by the orchestrator exactly as in inline wave dispatch.'); - const script = lines.join('\n'); - return { - ok: true, - script, - summary: { - waves: waves.length, - plans: totalPlans, - stagesByWave, - resumeRunId: runId, - budgetTokens, - }, - }; -} -/** - * #2285 — single composed decision seam for a PRE-wave dispatch-backend selector - * (e.g. the `execute:wave:pre` claude-orchestration contribution). Composes - * `detectWorkflowBackend` (gate ladder) with `emitWorkflowScript` (wave→plan - * mapping) into ONE call so the orchestrator (and its CLI wrapper, - * `claude-orchestration resolve-wave-dispatch`) never has to re-implement the - * two-step "detect, then maybe emit" sequencing. - * - * Fail-closed at every layer, matching the two composed functions: - * - `detectWorkflowBackend` resolving anything other than `'workflow'` → - * `inline` immediately; `emitWorkflowScript` is never invoked (no wasted - * work, no risk of a bad emit masking a correct inline fallback). - * - `detectWorkflowBackend` resolves `'workflow'` but `emitWorkflowScript` - * fails (`ok:false` — e.g. a malformed wave manifest) → `inline`, carrying - * the emit failure reason so the caller can surface it. Never a partial or - * broken script. - * - * This is the designated non-CLI-router, non-test caller of - * `detectWorkflowBackend` and `emitWorkflowScript` — the standalone CLI - * subcommands (`detect-backend`, `emit-workflow`) remain for inspection/ - * debugging, but the orchestrator's real per-wave dispatch decision goes - * through this seam. - * - * Never throws on bad input. - */ -function resolveWaveDispatch(input) { - if (input === null || input === undefined || typeof input !== 'object') { - return { backend: 'inline', reason: 'invalid_input' }; - } - const detected = detectWorkflowBackend({ - runtimeId: input.runtimeId, - hostIntegration: input.hostIntegration, - config: input.config, - agentSdkVersion: input.agentSdkVersion, - }); - if (detected.backend !== 'workflow') { - return { backend: 'inline', reason: detected.reason }; - } - const emitted = emitWorkflowScript({ - phaseDir: input.phaseDir, - waves: input.waves, - runId: input.runId, - budgetTokens: input.budgetTokens, - executorModel: input.executorModel, - }); - if (!emitted.ok) { - return { backend: 'inline', reason: 'emit_failed: ' + emitted.reason }; - } - return { - backend: 'workflow', - reason: detected.reason, - script: emitted.script, - summary: emitted.summary, - }; -} -module.exports = { - detectWorkflowBackend, - emitWorkflowScript, - resolveWaveDispatch, - compareSemver, - isValidSemver, - WORKFLOW_TOOL_FLOOR_VERSION, - BACKEND_VALUES, - WORKFLOW_RUNTIME, -}; diff --git a/gsd-core/bin/lib/external-job.cjs b/gsd-core/bin/lib/external-job.cjs deleted file mode 100644 index 5901b25dd..000000000 --- a/gsd-core/bin/lib/external-job.cjs +++ /dev/null @@ -1,287 +0,0 @@ -"use strict"; -/** - * external-job.cts — scheduler-adapter producer module for the async - * external-job contract (#1164 / #1105). - * - * The CORE loop CONSUMES manifests at `.planning/async-jobs/.json` - * (#1165 — external_job_waiting half-state); this module is the Capability - * half that PRODUCES them. SLURM is the first backend; the design stays - * scheduler-pluggable via the `backend` field (planning-artifacts.md). - * - * Pure helpers (state map, build, validate, parsers) take no I/O; the writer - * takes injected `fs` and `clock` seams so tests drive it without touching disk - * or wall-clock time (CLAUDE.md clock-seam + injectable-deps conventions). - * - * Build: `src/*.cts` -> `gsd-core/bin/lib/*.cjs` (ADR-457 build-at-publish). - */ -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -const node_path_1 = __importDefault(require("node:path")); -const node_fs_1 = __importDefault(require("node:fs")); -// ─── Closed status enum (stability contract — Hyrum's Law) ──────────────────── -const MANIFEST_VERSION = '1.0'; -const MANIFEST_STATUS = [ - 'submitted', - 'running', - 'completed-unverified', - 'failed', - 'cancelled', - 'timeout', -]; -const NON_TERMINAL_STATUSES = ['submitted', 'running']; -const TERMINAL_FAILURE_STATUSES = ['failed', 'cancelled', 'timeout']; -// ─── SLURM state -> manifest status ─────────────────────────────────────────── -// -// Source: SLURM job state codes (squeue/sacct State column). Producers for -// other backends map their own states onto the closed enum above; this table -// is SLURM-specific and lives behind the `backend: 'slurm'` field. -const SLURM_STATE_MAP = { - PENDING: 'submitted', - CONFIGURING: 'submitted', - RUNNING: 'running', - COMPLETING: 'running', - COMPLETED: 'completed-unverified', - FAILED: 'failed', - CANCELLED: 'cancelled', - TIMEOUT: 'timeout', - OUT_OF_MEMORY: 'failed', - BOOT_FAIL: 'failed', - NODE_FAIL: 'failed', - PREEMPTED: 'failed', -}; -/** - * Map a raw SLURM state string to the closed, scheduler-agnostic manifest - * status. Case-insensitive; trims whitespace; strips a trailing by-part - * ("CANCELLED by 1001" -> "CANCELLED"). Returns `null` for any unknown - * state so the caller can decide whether to surface or fail — never guesses - * (CLAUDE.md anti-guessing). - */ -function mapSlurmState(raw) { - if (typeof raw !== 'string') - return null; - const key = raw.trim().toUpperCase(); - const head = key.split(/\s+/)[0]; - if (head && Object.prototype.hasOwnProperty.call(SLURM_STATE_MAP, head)) { - return SLURM_STATE_MAP[head]; - } - return null; -} -const REQUIRED_FIELDS = [ - 'plan_id', - 'phase', - 'job_id', - 'backend', - 'submit_command', - 'status', - 'expected_artifacts', - 'verification_command', - 'resume_command', -]; -function assertString(v, key) { - if (typeof v !== 'string' || v.length === 0) { - throw new Error(`buildManifest: field "${key}" must be a non-empty string`); - } -} -const STATUS_LIST = MANIFEST_STATUS; -/** - * Build a versioned, frozen manifest. Stamps `version` and `submitted_at` - * (via the injected clock seam) and normalises `terminal_details`: - * `null` unless the status is a terminal failure AND details were supplied. - * Throws on missing required fields or an out-of-enum status. - */ -function buildManifest(input, opts = {}) { - const inputRecord = input; - for (const key of REQUIRED_FIELDS) { - const v = inputRecord[key]; - if (key === 'expected_artifacts') { - if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'string')) { - throw new Error('buildManifest: field "expected_artifacts" must be a non-empty string[]'); - } - continue; - } - assertString(v, key); - } - if (!STATUS_LIST.includes(input.status)) { - throw new Error(`buildManifest: field "status" must be one of ${MANIFEST_STATUS.join(', ')}`); - } - const clock = opts.clock ?? { nowIso: () => new Date().toISOString() }; - const isTerminalFailure = TERMINAL_FAILURE_STATUSES.includes(input.status); - const terminal_details = isTerminalFailure && input.terminal_details ? input.terminal_details : null; - return Object.freeze({ - version: MANIFEST_VERSION, - job_id: input.job_id, - plan_id: input.plan_id, - phase: input.phase, - backend: input.backend, - submit_command: input.submit_command, - status: input.status, - expected_artifacts: [...input.expected_artifacts], - verification_command: input.verification_command, - resume_command: input.resume_command, - submitted_at: clock.nowIso(), - terminal_details, - }); -} -/** - * Producer-side schema validator — the mirror of the consumer trust boundary - * (planning-artifacts.md). Producers MUST emit a manifest this accepts; the - * core loop re-validates defensively on read. - */ -function validateManifest(value) { - if (!value || typeof value !== 'object') { - return { ok: false, errors: ['manifest must be an object'] }; - } - const m = value; - const errors = []; - if (m.version !== MANIFEST_VERSION) - errors.push(`version must be "${MANIFEST_VERSION}"`); - for (const f of ['job_id', 'plan_id', 'phase', 'backend', 'submit_command', 'verification_command', 'resume_command', 'submitted_at']) { - if (typeof m[f] !== 'string' || m[f].length === 0) { - errors.push(`field "${f}" must be a non-empty string`); - } - } - if (typeof m.status !== 'string' || !STATUS_LIST.includes(m.status)) { - errors.push(`status must be one of ${MANIFEST_STATUS.join(', ')}`); - } - if (!Array.isArray(m.expected_artifacts) || !m.expected_artifacts.every((x) => typeof x === 'string')) { - errors.push('expected_artifacts must be a string[]'); - } - if (m.terminal_details !== null && typeof m.terminal_details !== 'object') { - errors.push('terminal_details must be null or an object'); - } - return errors.length === 0 ? { ok: true } : { ok: false, errors }; -} -/** - * Parse `sbatch --parsable` output. Accepts either a bare job id - * (`"12345"`) or the `"12345;clustername"` form. Rejects prose like - * `"Submitted batch job 12345"` (that is the non-parsable default format). - */ -function parseSbatchParsable(stdout) { - if (typeof stdout !== 'string') - return { ok: false, kind: 'non_string', raw: String(stdout) }; - const trimmed = stdout.trim(); - if (!trimmed) - return { ok: false, kind: 'empty', raw: stdout }; - const head = trimmed.split(';')[0].split(/\s+/)[0]; - if (!/^\d+$/.test(head)) - return { ok: false, kind: 'not_numeric', raw: trimmed }; - return { ok: true, job_id: head }; -} -/** - * Parse a single `squeue` line of the form `" "`. Returns `null` - * for a header or any row that does not have at least two tokens. - */ -function parseSqueueLine(line) { - if (typeof line !== 'string') - return null; - const parts = line.trim().split(/\s+/); - if (parts.length < 2) - return null; - const [job_id, state] = parts; - if (!/^\d+$/.test(job_id)) - return null; - return { job_id, state }; -} -/** - * Parse a `sacct -P` row given as pre-split columns where index 0 is the job - * id and index 1 is the state. Returns `null` for malformed rows. - */ -function parseSacctRow(cols) { - if (cols.length < 2) - return null; - const job_id = cols[0]; - const state = cols[1]; - if (typeof job_id !== 'string' || typeof state !== 'string') - return null; - if (!/^\d+$/.test(job_id)) - return null; - return { job_id, state }; -} -/** - * Pure path projection: `.planning/async-jobs/.json`. - */ -function manifestPath(planningDir, jobId) { - return node_path_1.default.join(planningDir, 'async-jobs', `${jobId}.json`); -} -function _isNonTerminal(status) { - return typeof status === 'string' && NON_TERMINAL_STATUSES.includes(status); -} -/** - * Write a manifest to `.planning/async-jobs/.json`. - * - * Fail-closed rules (mirror of the consumer contract, planning-artifacts.md): - * - If any existing manifest in the dir shares `plan_id` but has a different - * `job_id` AND is non-terminal -> refuse (`duplicate_plan_id`); dispatching - * again would duplicate the external job. - * - If the target file exists but is not valid JSON -> refuse - * (`malformed_existing`); never silently clobber. - * - Same `job_id` for the same `plan_id` -> allowed (status progression). - * - A prior job for the same `plan_id` that is already terminal -> allowed - * (the duplicate guard only protects against re-dispatching live work). - */ -function writeManifest(manifest, planningDir, opts = {}) { - const fs = opts.fs ?? node_fs_1.default; - const dir = node_path_1.default.join(planningDir, 'async-jobs'); - const target = manifestPath(planningDir, manifest.job_id); - let names; - try { - fs.mkdirSync(dir, { recursive: true }); - names = fs.readdirSync(dir); - } - catch (e) { - return { ok: false, kind: 'io_error', message: e.message }; - } - for (const name of names) { - if (!name.endsWith('.json')) - continue; - const p = node_path_1.default.join(dir, name); - let raw; - try { - raw = String(fs.readFileSync(p)); - } - catch { - continue; - } - let existing; - try { - existing = JSON.parse(raw); - } - catch { - if (p === target) { - return { ok: false, kind: 'malformed_existing', message: `target manifest ${p} is not valid JSON` }; - } - continue; - } - const samePlan = existing.plan_id === manifest.plan_id; - const sameJob = existing.job_id === manifest.job_id; - if (samePlan && !sameJob && _isNonTerminal(existing.status)) { - return { - ok: false, - kind: 'duplicate_plan_id', - message: `plan_id "${manifest.plan_id}" already has non-terminal job "${String(existing.job_id)}" at ${p}; dispatching again would duplicate the external job`, - }; - } - } - try { - fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + '\n'); - } - catch (e) { - return { ok: false, kind: 'io_error', message: e.message }; - } - return { ok: true, path: target }; -} -module.exports = { - MANIFEST_VERSION, - MANIFEST_STATUS, - NON_TERMINAL_STATUSES, - TERMINAL_FAILURE_STATUSES, - mapSlurmState, - buildManifest, - validateManifest, - parseSbatchParsable, - parseSqueueLine, - parseSacctRow, - writeManifest, - manifestPath, -}; diff --git a/gsd-core/bin/lib/markdown-table.cjs b/gsd-core/bin/lib/markdown-table.cjs deleted file mode 100644 index cb9b54144..000000000 --- a/gsd-core/bin/lib/markdown-table.cjs +++ /dev/null @@ -1,698 +0,0 @@ -"use strict"; -/** - * Markdown Table Model — canonical GFM table parsing + schema registry seam - * (ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out, - * no I/O. Compiled by tsc to gsd-core/bin/lib/markdown-table.cjs. - * - * NOTE: the `Result` here is the ADR-2143 §5 parse-result shape {ok,value|reason}, - * now defined once in `./write-set.cjs` (the shared fail-loud + write-set seam) and - * re-exported here so existing importers of `Result` from this module keep working - * unchanged — deliberately distinct from command-routing-hub's dispatch `Result` - * {ok,data|kind}; the two never mix (different modules). - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.TABLE_SCHEMAS = void 0; -exports.matchTableSchema = matchTableSchema; -exports.splitTableRow = splitTableRow; -exports.isDelimiterRow = isDelimiterRow; -exports.parseMarkdownTable = parseMarkdownTable; -exports.updateTableCell = updateTableCell; -exports.deleteTableRow = deleteTableRow; -exports.insertTableRow = insertTableRow; -exports.findTableBySchema = findTableBySchema; -exports.findTableWithColumns = findTableWithColumns; -exports.escapeCell = escapeCell; -exports.appendQuickTaskRow = appendQuickTaskRow; -const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs"); -// ─── Schema registry ────────────────────────────────────────────────────────── -/** - * Canonical column-header shapes for every GFM table GSD parses or generates. - * Each entry in `TABLE_SCHEMAS[id]` is one accepted variant (exact column names, - * in order); `matchTableSchema` resolves a parsed header back to `{id, label}`. - * - * This registry is the single source of truth — a parity test - * (tests/markdown-table.test.cjs) asserts every variant's header appears - * verbatim in the template/workflow file that generates it, so the registry - * and the templates can never silently drift (ADR-2143 §3 Generative-Fix- - * Divergence guard). - */ -exports.TABLE_SCHEMAS = { - RoadmapProgress: [ - { label: 'flat', columns: ['Phase', 'Plans Complete', 'Status', 'Completed'] }, - { - label: 'milestone-grouped', - columns: ['Phase', 'Milestone', 'Plans Complete', 'Status', 'Completed'], - }, - ], - RequirementsTraceability: [ - { label: 'default', columns: ['Requirement', 'Phase', 'Status'] }, - ], - QuickTasks: [ - { label: 'no-status', columns: ['#', 'Description', 'Date', 'Commit', 'Directory'] }, - { - label: 'with-status', - columns: ['#', 'Description', 'Date', 'Commit', 'Status', 'Directory'], - }, - ], - Security: [ - { label: 'trust-boundaries', columns: ['Boundary', 'Description', 'Data Crossing'] }, - { - label: 'threat-register', - columns: [ - 'Threat ID', - 'Category', - 'Component', - 'Severity', - 'Disposition', - 'Mitigation', - 'Status', - ], - }, - { - label: 'accepted-risks', - columns: ['Risk ID', 'Threat Ref', 'Rationale', 'Accepted By', 'Date'], - }, - { - label: 'audit-trail', - columns: ['Audit Date', 'Threats Total', 'Closed', 'Open', 'Run By'], - }, - ], -}; -/** - * Resolve a parsed table's header columns to the canonical schema it matches - * (exact column names, same length, same order), else `null`. - */ -function matchTableSchema(columns) { - for (const [id, variants] of Object.entries(exports.TABLE_SCHEMAS)) { - for (const variant of variants) { - if (variant.columns.length === columns.length - && variant.columns.every((col, idx) => col === columns[idx])) { - return { id, label: variant.label }; - } - } - } - return null; -} -// ─── Parsing ────────────────────────────────────────────────────────────────── -/** - * Split one GFM table row line into trimmed cell strings. - * Strips one leading and one trailing `|`, splits on unescaped `|`, trims - * each cell, and unescapes `\\` back to `\` and `\|` back to `|` (the exact - * reverse of `escapeCell`'s `\`->`\\` then `|`->`\|` order below), so cell - * values round-trip exactly — including literal backslashes. - */ -function splitTableRow(line) { - let stripped = line.trim(); - if (stripped.startsWith('|')) - stripped = stripped.slice(1); - if (stripped.endsWith('|')) - stripped = stripped.slice(0, -1); - return stripped.split(/(? cell.trim().replace(/\\([\\|])/g, '$1')); -} -/** - * True when every delimiter cell matches GFM's `:?-{1,}:?` shape (spaces - * removed). Exported (alongside `splitTableRow`) so callers that need their - * own ragged-tolerant header/delimiter detection — e.g. state.cts's - * `cmdStateRecordMetric` row-append, which must recognize an existing table - * without requiring every DATA row to also parse cleanly (#2245 Blocker 2) — - * reuse the exact same header/delimiter-shape check `parseMarkdownTable` uses, - * instead of re-deriving it and risking divergence. - */ -function isDelimiterRow(cells) { - return cells.every((cell) => /^:?-{1,}:?$/.test(cell.replace(/\s+/g, ''))); -} -/** - * Parse the FIRST GFM pipe table found in `sectionText`. - * - * Defensive by design: never throws — every malformed shape (no table, - * missing/misaligned delimiter row, ragged data row) returns a typed - * `{ok:false, reason}` instead of silently coercing or dropping data - * (ADR-2143 §3 — ragged rows are errors, not silent). - * - * Scope note: GSD planning tables (STATE.md/ROADMAP.md/requirements.md/ - * SECURITY.md) are always fully-piped (leading + trailing `|` on every row) - * and non-indented — this parser targets THAT shape, not arbitrary - * CommonMark (which also allows non-piped rows and up to 3 leading spaces). - */ -function parseMarkdownTable(sectionText) { - if (typeof sectionText !== 'string' || sectionText.trim() === '') { - return { ok: false, reason: 'empty or non-string input' }; - } - const lines = sectionText.split(/\r?\n/); - let headerIdx = -1; - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].trim(); - if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) { - headerIdx = i; - break; - } - } - if (headerIdx === -1) { - return { ok: false, reason: 'no table found' }; - } - const columns = splitTableRow(lines[headerIdx]); - const delimiterLine = lines[headerIdx + 1]; - if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) { - return { ok: false, reason: 'missing delimiter row' }; - } - const delimiterCells = splitTableRow(delimiterLine); - if (!isDelimiterRow(delimiterCells)) { - return { ok: false, reason: 'missing delimiter row' }; - } - if (delimiterCells.length !== columns.length) { - return { ok: false, reason: 'delimiter/header column count mismatch' }; - } - const rows = []; - let rowNum = 0; - for (let i = headerIdx + 2; i < lines.length; i++) { - const trimmed = lines[i].trim(); - if (!trimmed.startsWith('|')) - break; - rowNum += 1; - const cells = splitTableRow(lines[i]); - if (cells.length !== columns.length) { - return { - ok: false, - reason: `row ${rowNum} has ${cells.length} cells, expected ${columns.length}`, - }; - } - const row = {}; - columns.forEach((col, idx) => { - row[col] = cells[idx]; - }); - rows.push(row); - } - return { ok: true, value: { columns, rows } }; -} -/** - * Split `text` into lines exactly like `.split(/\r?\n/)` (bare `\r` is NOT a - * line break, matching `parseMarkdownTable`), tracking each line's absolute - * start offset in `text` so cell ranges can be computed relative to the - * ORIGINAL string, not the trimmed/relative line. - */ -function splitLinesWithOffsets(text) { - const result = []; - let start = 0; - const re = /\r\n|\n/g; - let m; - while ((m = re.exec(text)) !== null) { - result.push({ line: text.slice(start, m.index), start }); - start = m.index + m[0].length; - } - result.push({ line: text.slice(start), start }); - return result; -} -/** - * Split one GFM table row LINE into raw cell ranges, absolute to the original - * `text` the line was sliced from (`lineStart` = that line's start offset). - * Mirrors `splitTableRow`'s trim + strip-leading/trailing-pipe + unescaped-pipe - * split EXACTLY, but returns character ranges instead of trimmed values, so a - * caller can splice a replacement into the original string byte-for-byte. - */ -function splitTableRowRanges(line, lineStart) { - const leftTrim = /^\s*/.exec(line)[0].length; - const rightTrim = /\s*$/.exec(line)[0].length; - let stripped = line.slice(leftTrim, line.length - rightTrim); - let strippedStart = lineStart + leftTrim; - if (stripped.startsWith('|')) { - stripped = stripped.slice(1); - strippedStart += 1; - } - if (stripped.endsWith('|')) { - stripped = stripped.slice(0, -1); - } - const cells = []; - const re = /(? `\` and `\|` -> `|`. */ -function unescapeCellText(raw) { - return raw.trim().replace(/\\([\\|])/g, '$1'); -} -/** - * Surgically edit ONE table cell while preserving the table's exact byte - * formatting (ADR-2143 §7). Locates the first GFM table's header + delimiter - * row in `tableText` (own header/delimiter detection — deliberately does NOT - * gate on `parseMarkdownTable(tableText).ok`), finds the first DATA row where - * `match(row, index)` is true, and replaces ONLY that row's `column` cell's - * raw inner text (the span between its two delimiting `|` characters) — every - * other byte of `tableText` (other cells, padding, alignment, EOL style) is - * left BYTE-IDENTICAL. This is deliberately NOT a parse-then-render: a - * render pass would reformat padding/alignment/dates that mutation sites - * (e.g. `status.padEnd(11)`) depend on staying pinned. - * - * Ragged-tolerant by design (#2245 review Fix 2): each data row's - * `{colName:cellText}` record is built ONLY from the columns physically - * present in THAT row — a short row simply omits its trailing column names; - * an over-long row's extra trailing cells are ignored — so `match` is called - * with whatever partial record a ragged row yields. A single sibling row - * whose cell count doesn't match the header must never silently no-op the - * whole write (the prior `parseMarkdownTable(tableText).ok` gate failed the - * ENTIRE table — including an otherwise-well-formed target row — the moment - * ANY other row in the same table was ragged). A row that matches on content - * but is too short to physically contain `column` has no cell to splice - * into, so it cannot be selected; the scan continues past it. - * - * `newValue` is spliced in VERBATIM as the new raw cell span — it is the - * caller's responsibility to supply the fully-formatted text (including any - * leading/trailing padding needed to reproduce the table's existing column - * alignment, and to escape a literal `|` or `\` the value might contain via - * the same convention `splitTableRow`/`escapeCell` use elsewhere in this - * module). When `newValue` is a function, it receives the CURRENT (trimmed, - * unescaped) cell value — the same value that appears in `match`'s `row` - * argument — and must return the full literal replacement text. Returning - * the current value unchanged is a supported no-op-probe pattern for callers - * that need to know whether (and to what current value) a row matched - * without necessarily writing a new value. - * - * Returns `{ok:false, reason}` only for a genuinely absent/malformed table - * (no header line, or no valid delimiter row immediately below it), an - * unknown `column`, or zero rows satisfying `match` while physically - * containing `column` — never for a ragged sibling row. - */ -function updateTableCell(tableText, match, column, newValue) { - const lines = splitLinesWithOffsets(tableText); - let headerIdx = -1; - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].line.trim(); - if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) { - headerIdx = i; - break; - } - } - if (headerIdx === -1) { - return { ok: false, reason: 'no table found' }; - } - const delimiterLine = lines[headerIdx + 1]?.line; - if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) { - return { ok: false, reason: 'missing delimiter row' }; - } - const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start); - const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end))); - const delimiterCells = splitTableRow(delimiterLine); - if (!isDelimiterRow(delimiterCells)) { - return { ok: false, reason: 'missing delimiter row' }; - } - if (delimiterCells.length !== columns.length) { - return { ok: false, reason: 'delimiter/header column count mismatch' }; - } - if (!columns.includes(column)) { - return { ok: false, reason: `unknown column: ${column}` }; - } - const targetColIdx = columns.indexOf(column); - let selectedRange; - let dataRowIndex = 0; - for (let i = headerIdx + 2; i < lines.length; i++) { - const trimmed = lines[i].line.trim(); - if (!trimmed.startsWith('|')) - break; - const cellRanges = splitTableRowRanges(lines[i].line, lines[i].start); - const record = {}; - const presentCount = Math.min(cellRanges.length, columns.length); - for (let c = 0; c < presentCount; c++) { - record[columns[c]] = unescapeCellText(tableText.slice(cellRanges[c].start, cellRanges[c].end)); - } - if (targetColIdx < cellRanges.length && match(record, dataRowIndex)) { - selectedRange = cellRanges[targetColIdx]; - break; - } - dataRowIndex += 1; - } - if (!selectedRange) { - return { ok: false, reason: 'no matching row' }; - } - const currentValue = unescapeCellText(tableText.slice(selectedRange.start, selectedRange.end)); - const replacement = typeof newValue === 'function' ? newValue(currentValue) : newValue; - // True no-op guard: a function `newValue` that returns `current` UNCHANGED - // (the documented no-op-probe pattern) must leave `tableText` genuinely - // byte-identical, padding included. `current` is already trimmed/unescaped, - // so naively splicing it back in would strip the raw cell's original - // leading/trailing padding — this returns the ORIGINAL text untouched - // instead whenever the callback's answer is "no change". - if (typeof newValue === 'function' && replacement === currentValue) { - return { ok: true, value: tableText }; - } - return { - ok: true, - value: tableText.slice(0, selectedRange.start) + replacement + tableText.slice(selectedRange.end), - }; -} -// ─── deleteTableRow (ADR-2143 §7 row-removal sibling of updateTableCell) ───── -/** - * Surgically delete ONE whole table row while preserving every other byte of - * `tableText` (ADR-2143 §7, row-removal sibling of `updateTableCell`). Locates - * the first GFM table's header + delimiter row in `tableText` using the exact - * same self-contained, ragged-tolerant scan `updateTableCell` uses (own - * header/delimiter detection — does NOT gate on `parseMarkdownTable(tableText).ok`), - * finds the FIRST data row where `match(row, index)` is true, and splices out - * that row's entire LINE — including its trailing newline (`\r\n` or `\n`, - * whichever terminates it) — from `tableText`. Every other byte (header, - * delimiter, other rows, surrounding prose before/after the table, EOL style) - * is left BYTE-IDENTICAL. - * - * Ragged-tolerant by design, mirroring `updateTableCell` (#2245 review Fix 2): - * each data row's `{colName:cellText}` record is built ONLY from the columns - * physically present in THAT row — a sibling row whose cell count doesn't - * match the header must never abort the whole scan; `match` is simply called - * with whatever partial record a ragged row yields. - * - * Returns `{ok:false, reason}` for a genuinely absent/malformed table (no - * header line, or no valid delimiter row immediately below it) or zero rows - * satisfying `match` — never for a ragged sibling row. - */ -function deleteTableRow(tableText, match) { - const lines = splitLinesWithOffsets(tableText); - let headerIdx = -1; - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].line.trim(); - if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) { - headerIdx = i; - break; - } - } - if (headerIdx === -1) { - return { ok: false, reason: 'no table found' }; - } - const delimiterLine = lines[headerIdx + 1]?.line; - if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) { - return { ok: false, reason: 'missing delimiter row' }; - } - const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start); - const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end))); - const delimiterCells = splitTableRow(delimiterLine); - if (!isDelimiterRow(delimiterCells)) { - return { ok: false, reason: 'missing delimiter row' }; - } - if (delimiterCells.length !== columns.length) { - return { ok: false, reason: 'delimiter/header column count mismatch' }; - } - let selectedLineIdx = -1; - let dataRowIndex = 0; - for (let i = headerIdx + 2; i < lines.length; i++) { - const trimmed = lines[i].line.trim(); - if (!trimmed.startsWith('|')) - break; - const cellRanges = splitTableRowRanges(lines[i].line, lines[i].start); - const record = {}; - const presentCount = Math.min(cellRanges.length, columns.length); - for (let c = 0; c < presentCount; c++) { - record[columns[c]] = unescapeCellText(tableText.slice(cellRanges[c].start, cellRanges[c].end)); - } - if (match(record, dataRowIndex)) { - selectedLineIdx = i; - break; - } - dataRowIndex += 1; - } - if (selectedLineIdx === -1) { - return { ok: false, reason: 'no matching row' }; - } - // Splice out the whole LINE including its trailing EOL: the next line's - // recorded `start` offset is already positioned right after whatever EOL - // (`\r\n` or `\n`) terminated the selected line (see `splitLinesWithOffsets` - // above) — when the selected row is the LAST line in `tableText` (no - // trailing EOL to preserve), fall back to the end of the string. - let rowStart = lines[selectedLineIdx].start; - let rowEnd; - if (selectedLineIdx + 1 < lines.length) { - rowEnd = lines[selectedLineIdx + 1].start; - } - else { - // The selected row is the LAST line and has no trailing EOL: deleting from - // its `start` to end-of-string would strand the EOL that terminated the - // PREVIOUS line as a dangling newline. Back `rowStart` up over that - // preceding `\n` (and its `\r`, if any) so the table ends cleanly after the - // new last row. - rowEnd = tableText.length; - if (rowStart > 0 && tableText[rowStart - 1] === '\n') { - rowStart -= 1; - if (rowStart > 0 && tableText[rowStart - 1] === '\r') - rowStart -= 1; - } - } - return { - ok: true, - value: tableText.slice(0, rowStart) + tableText.slice(rowEnd), - }; -} -// ─── insertTableRow (ADR-2143 §7 row-insertion sibling of updateTableCell) ─── -/** - * Insert ONE new row into a GFM table while preserving every other byte of - * `tableText` (ADR-2143 §7, row-insertion sibling of `updateTableCell` / - * `deleteTableRow`). Locates the first table's header + delimiter row using - * the exact same self-contained, ragged-tolerant scan the other two use (own - * header/delimiter detection — does NOT gate on `parseMarkdownTable(tableText).ok`), - * builds the new row's cells in the table's ACTUAL header order — each column - * name is passed through `valueFor(column)`; a column for which `valueFor` - * returns `undefined` gets `fallback` (default `'-'`) — and splices it in - * immediately after the table's LAST existing data row (or immediately after - * the delimiter row when the table has zero data rows). - * - * Name-addressed and header-order-agnostic by construction: unlike a - * hardcoded positional literal (`| ${a} | ${b} | - | - |`), this never - * silently no-ops or mis-maps a value onto the wrong column when the header - * is reordered or a superset of the columns `valueFor` knows about (#2245 - * audit sibling finding — the bug this helper replaces). - * - * EOL-preserving: the new row reuses whatever exact EOL bytes (`\r\n` or - * `\n`) already terminate the line it's inserted after, so a CRLF document - * stays CRLF and an LF document stays LF — never guessed or hardcoded. When - * the insertion point is at the very end of `tableText` with no following - * line (the table's last row has no trailing EOL of its own), the existing - * last row is terminated with the header/delimiter boundary's own EOL (so it - * gains a terminator, since it is no longer the last line) and the new row - * becomes the new EOL-less tail — mirroring `tableText`'s own convention of - * not forcing a trailing newline that wasn't already there. - * - * Escaping (F4 #2245 review): unlike `updateTableCell`, whose `newValue` is - * spliced in VERBATIM (caller-must-escape — see its doc comment above), every - * value returned by `valueFor` (and `fallback`) IS escaped internally here via - * `escapeCell` before being joined into the new row, exactly like - * `appendQuickTaskRow` below — a caller-supplied name containing a literal - * `|` or `\` cannot silently split the new row into extra columns. Callers do - * NOT need to pre-escape their values. - * - * Returns `{ok:false, reason}` only for a genuinely absent/malformed table - * (no header line, or no valid delimiter row immediately below it) — never - * for a ragged data row (mirrors `updateTableCell`/`deleteTableRow`). - */ -function insertTableRow(tableText, valueFor, fallback = '-') { - const lines = splitLinesWithOffsets(tableText); - let headerIdx = -1; - for (let i = 0; i < lines.length; i++) { - const trimmed = lines[i].line.trim(); - if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) { - headerIdx = i; - break; - } - } - if (headerIdx === -1) { - return { ok: false, reason: 'no table found' }; - } - const delimiterLine = lines[headerIdx + 1]?.line; - if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) { - return { ok: false, reason: 'missing delimiter row' }; - } - const delimiterCells = splitTableRow(delimiterLine); - if (!isDelimiterRow(delimiterCells)) { - return { ok: false, reason: 'missing delimiter row' }; - } - const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start); - const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end))); - // Header -> delimiter EOL, reused as the fallback terminator for the "insert - // point is at the absolute end of tableText" edge case below. - const headerToDelimiterEol = tableText.slice(lines[headerIdx].start + lines[headerIdx].line.length, lines[headerIdx + 1].start) || '\n'; - let lastLineIdx = headerIdx + 1; // delimiter row, when the table has zero data rows - for (let i = headerIdx + 2; i < lines.length; i++) { - if (!lines[i].line.trim().startsWith('|')) - break; - lastLineIdx = i; - } - const newRow = `| ${columns.map((col) => escapeCell(valueFor(col) ?? fallback)).join(' | ')} |`; - if (lastLineIdx + 1 < lines.length) { - // A following line exists — insert the new row, reusing the EXACT EOL - // that already terminates the current last table line, so every other - // byte (including everything after the table) stays untouched. - const insertAt = lines[lastLineIdx + 1].start; - const eol = tableText.slice(lines[lastLineIdx].start + lines[lastLineIdx].line.length, insertAt); - return { ok: true, value: tableText.slice(0, insertAt) + newRow + eol + tableText.slice(insertAt) }; - } - // The table's last row is also the last line of `tableText` (no trailing - // EOL). Terminate it now — it needs one, since it is no longer last — and - // append the new row as the new EOL-less tail. - return { ok: true, value: tableText + headerToDelimiterEol + newRow }; -} -/** - * Find the first table in `text` whose header matches `TABLE_SCHEMAS[schemaId]`, - * scanning the WHOLE document (not just a named section). Returns `null` when - * no table with that schema is found. - * - * Fixes the regression where callers first located a named heading (e.g. - * `## Progress`) via `collectSection` and only then parsed a table inside it — - * a schema-matching table that lives under a differently-named heading (or no - * heading at all), or that isn't the first table in the document, was - * invisible to that approach. Scanning the whole document by schema restores - * the old "find the progress table anywhere" behaviour while staying - * seam-based (ADR-2143). - */ -function findTableBySchema(text, schemaId) { - if (typeof text !== 'string') - return null; - const lines = text.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const t = lines[i].trim(); - if (!t.startsWith('|') || t.indexOf('|', 1) === -1) - continue; - const cols = splitTableRow(lines[i]); - const m = matchTableSchema(cols); - if (m && m.id === schemaId) { - const parsed = parseMarkdownTable(lines.slice(i).join('\n')); - if (parsed.ok) - return parsed.value; - } - } - return null; -} -/** - * Find the first GFM table in `text` whose header contains ALL of `required` - * column names (order-independent; extra/injected columns allowed). Returns - * the parsed `MarkdownTable`, or `null` when no table's header is a superset - * of `required`. - * - * Column-NAME/order/count-invariant counterpart to `findTableBySchema` (ADR-2143 - * §3 "addressed by NAME, never ordinal"): where `findTableBySchema` requires an - * EXACT canonical column set+order registered in `TABLE_SCHEMAS`, this scans - * for any header that names the required columns, in any order, tolerating - * extra/unrelated injected columns. Cells remain addressable by column NAME - * via the returned `MarkdownTable`. - */ -function findTableWithColumns(text, required) { - if (typeof text !== 'string') - return null; - const lines = text.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const t = lines[i].trim(); - if (!t.startsWith('|') || t.indexOf('|', 1) === -1) - continue; - const cols = splitTableRow(lines[i]); - if (required.every((rq) => cols.includes(rq))) { - const parsed = parseMarkdownTable(lines.slice(i).join('\n')); - if (parsed.ok) - return parsed.value; - } - } - return null; -} -// ─── Quick Tasks row append (#2133) ──────────────────────────────────────────── -/** - * Escape one dynamic cell value for insertion into a GFM pipe-table row. - * - * Escapes `\` -> `\\` FIRST, then `|` -> `\|` (in that order, so a literal - * backslash already in the value is never mistaken for part of an escape - * sequence introduced by this function — CodeQL js/incomplete-sanitization). - * `splitTableRow` reverses both in the opposite order (`\\` -> `\` then - * `\|` -> `|`, see line ~114 above), so escaping/unescaping round-trips - * exactly, including literal backslashes. Newlines are collapsed to a - * single space — a raw `|` or embedded newline in a cell value (e.g. a task - * `description`) would otherwise corrupt the table (extra column / a fake - * extra row) and get rejected by the now-fail-loud `parseMarkdownTable` as a - * ragged row. - * - * Exported (F3/#2245 review) so callers of `updateTableCell` that build a - * replacement value by transforming the CURRENT (already-unescaped) cell - * text — e.g. phase.cts's Progress-ordinal renumber, which decrements the - * leading digit of a `Phase` cell like `3. Parser | Lexer` and splices the - * rest of the cell text back verbatim — can re-escape that value before - * returning it from the `newValue` callback, honoring `updateTableCell`'s - * caller-must-re-escape contract (see its doc comment above) instead of - * spliceing a raw, unescaped `|` back into the table and silently splitting - * the cell. - */ -function escapeCell(value) { - return String(value) - .replace(/\r?\n+/g, ' ') - .replace(/\\/g, '\\\\') // escape the escape char FIRST (CodeQL js/incomplete-sanitization) - .replace(/\|/g, '\\|') - .trim(); -} -/** - * Append one row to STATE.md's "Quick Tasks Completed" table. - * - * Pure, schema-driven replacement for fast.md's inline `awk NF-2` column-count - * guess (#2133, ADR-2143 §3 schema registry / §7 fail-loud unrecognized-schema - * guard). Never touches disk, git, or the clock — callers (the `gsd-tools - * quick-tasks-append` subcommand) compute `date`/`commit` and pass them in. - * - * Fails loud (`{ok:false, reason}`, never a silent skip) when: - * - no "Quick Tasks Completed" heading exists in `stateContent` - * - the section's body doesn't parse as a GFM table (parseMarkdownTable failure) - * - the table's header doesn't match a known `TABLE_SCHEMAS.QuickTasks` variant - * (the old awk arithmetic silently skipped here instead — that silent-skip - * branch is the bug this replaces). - * - * The new row is inserted immediately after the LAST existing table row line - * (or immediately after the header/delimiter when the table has zero data - * rows), preserving any surrounding blank lines/trailing content in the section. - */ -function appendQuickTaskRow(stateContent, fields) { - const section = (0, markdown_sectionizer_cjs_1.collectSection)(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim())); - if (!section) { - return { ok: false, reason: 'no Quick Tasks Completed section' }; - } - const parsed = parseMarkdownTable(section.body); - if (!parsed.ok) { - return { ok: false, reason: `quick-tasks table: ${parsed.reason}` }; - } - const match = matchTableSchema(parsed.value.columns); - if (!match || match.id !== 'QuickTasks') { - return { - ok: false, - reason: `unrecognized Quick Tasks schema (columns: ${parsed.value.columns.join(' | ')})`, - }; - } - const variant = exports.TABLE_SCHEMAS.QuickTasks.find((v) => v.label === match.label); - const columns = variant ? variant.columns : parsed.value.columns; - const rowNumber = parsed.value.rows.length + 1; - const cellFor = (col) => { - switch (col) { - case '#': return escapeCell(String(rowNumber)); - case 'Description': return escapeCell(fields.description); - case 'Date': return escapeCell(fields.date); - case 'Commit': return escapeCell(fields.commit); - case 'Status': return escapeCell(fields.status ?? '—'); - case 'Directory': return escapeCell(fields.directory ?? '—'); - default: return '—'; - } - }; - const row = `| ${columns.map(cellFor).join(' | ')} |`; - // Detect the section's EOL BEFORE splitting on /\r?\n/ (which discards it) so - // the rejoin below preserves CRLF instead of downgrading a CRLF section to - // mixed EOL (the inserted `row` itself never contains a newline). - const eol = /\r\n/.test(section.body) ? '\r\n' : '\n'; - const lines = section.body.split(/\r?\n/); - let lastTableLineIdx = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].trim().startsWith('|')) - lastTableLineIdx = i; - } - // lastTableLineIdx is always >= 0 here — parseMarkdownTable already - // confirmed a header + delimiter row exist in this same `section.body`. - const newLines = [ - ...lines.slice(0, lastTableLineIdx + 1), - row, - ...lines.slice(lastTableLineIdx + 1), - ]; - const newBody = newLines.join(eol); - const content = (0, markdown_sectionizer_cjs_1.replaceSection)(stateContent, section, newBody); - return { ok: true, value: { content, row, variant: match.label } }; -} -// Consumers: require('../gsd-core/bin/lib/markdown-table.cjs') -// Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish). diff --git a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs deleted file mode 100644 index c0d9c09a4..000000000 --- a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +++ /dev/null @@ -1,128 +0,0 @@ -'use strict'; -/** - * Runtime Artifact Install Plan Module. - * - * Turns a pre-resolved runtime artifact layout into staged copy inputs. The - * installer adapter still owns pruning, copying, migrations, output, and final - * cleanup execution. - */ -// In .cts (CommonJS output) files, `require` is available as a global. -const _require = require; -const path = _require('node:path'); -/** - * Asserts that `destSubpath` resolves to a path inside `configDir`. - * - * Rejects any path that escapes the configDir root (e.g. "../../etc") and any - * path containing a NUL byte. This is a security gate for Phase B of - * ADR-1239: third-party descriptors must never be able to write outside the - * designated config home directory. - * - * @param configDir - The root config directory (e.g. ~/.claude). - * @param destSubpath - The relative path declared by the runtime descriptor. - * @returns The resolved absolute path under configDir. - * @throws {Error} if destSubpath escapes configDir or contains a NUL byte. - */ -function assertDestWithinConfigHome(configDir, destSubpath) { - if (destSubpath.includes('\0')) { - throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`); - } - const root = path.resolve(configDir); - const resolved = path.resolve(configDir, destSubpath); - if (resolved === root || !resolved.startsWith(root + path.sep)) { - throw new Error(`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`); - } - return resolved; -} -function errorMessage(err) { - if (err instanceof Error) - return err.message; - return String(err); -} -function addCleanupDir(cleanupDirs, stagedDir, rewrittenDir) { - const sourceDir = rewrittenDir ?? stagedDir; - if (sourceDir !== stagedDir) - cleanupDirs.push(sourceDir); - return sourceDir; -} -function createRuntimeArtifactInstallPlan(args) { - const { layout, resolvedProfile, homedir, platform, resolveAttribution, deps = {}, } = args; - const conversionExports = _require('./runtime-artifact-conversion.cjs'); - const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies; - const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies; - const cleanupDirs = []; - const items = []; - const scope = layout.scope ?? 'global'; - const rewriteOpts = { - runtime: layout.runtime, - configDir: layout.configDir, - scope, - homedir, - platform, - resolveAttribution, - }; - // ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply - // the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter - // → normalize). This mirrors the exact per-file order in the inline agent loop - // in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg - // to kind.stage() for agents kind entries with a converter (convertedAgentsKind). - // NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop. - const os = _require('node:os'); - const { posixNormalize } = _require('./shell-command-projection.cjs'); - const homedirFn = homedir ?? (() => os.homedir()); - const resolvedTarget = posixNormalize(path.resolve(layout.configDir)); - const homeDir = posixNormalize(homedirFn()); - const isGlobal = scope === 'global'; - const isOpencode = layout.runtime === 'opencode'; - const isWindowsHost = (platform ?? process.platform) === 'win32'; - const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir }); - const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined; - const agentCtx = { runtime: layout.runtime, pathPrefix, attribution }; - for (const kind of layout.kinds) { - let stagedDir; - try { - if (kind.kind === 'agents') { - // ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies - // the full inline-loop order: pathRewrites → attribution → converter → normalize. - // The cross-cutting is now PRE-converter (inside staging), not POST. - stagedDir = kind.stage(resolvedProfile, agentCtx); - } - else { - stagedDir = kind.stage(resolvedProfile); - } - } - catch (err) { - return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind }; - } - let sourceDir = stagedDir; - try { - if (kind.kind === 'commands') { - const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts); - sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir); - } - else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') { - const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts); - sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir); - } - // agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx. - // No POST-step needed. sourceDir stays as stagedDir. - } - catch (err) { - return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind }; - } - items.push({ - kind: kind.kind, - sourceDir, - destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath), - }); - } - return { ok: true, plan: { items, cleanupDirs } }; -} -function createRuntimeArtifactUninstallPlan(layout) { - return { - items: layout.kinds.map((kind) => ({ - kind: kind.kind, - destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath), - })), - }; -} -module.exports = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan }; diff --git a/gsd-core/bin/lib/state-transition.cjs b/gsd-core/bin/lib/state-transition.cjs deleted file mode 100644 index badc4e820..000000000 --- a/gsd-core/bin/lib/state-transition.cjs +++ /dev/null @@ -1,1697 +0,0 @@ -"use strict"; -/** - * STATE.md Transition Module — ADR-1769. - * - * Phase 1 substrate: field-classification table, section constants, the pure - * `transitionCore` dispatch, and the `beginPhase` intent (migrating - * `cmdStateBeginPhase` in state.cts onto this seam). - * - * Sibling/super-module of the STATE.md Document Module (state-document.cjs): - * consumes its `stateExtractField` / `stateReplaceField` primitives. Body - * section headings live as constants here (single writer after migration). - * - * Pure core + injected I/O (ADR-1769 §3): the exported `transitionCore` is a - * pure function `(content, intent, deps) → result`; adapters that own locks, - * file I/O, and the disk-scan wrap it. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.STATE_MD_SECTIONS = exports.FIELD_CLASSIFICATION = void 0; -exports.getFieldClassification = getFieldClassification; -exports.applyStatePreservation = applyStatePreservation; -exports.transitionCore = transitionCore; -exports.sliceCurrentPositionSection = sliceCurrentPositionSection; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const frontmatter = require("./frontmatter.cjs"); -const state_document_cjs_1 = require("./state-document.cjs"); -const state_document_cjs_2 = require("./state-document.cjs"); -const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs"); -const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs"); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const phaseIdMod = require("./phase-id.cjs"); -const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter; -const { escapeRegex } = phaseIdMod; -// Stop predicate for section-body slicing: a level-2+ heading ends the section. -const STOP_H2_PLUS = (lv) => lv >= 2; -/** - * Single source of truth for "which fields win when frontmatter and body - * disagree". Transitions declare which body fields they touch; the core - * consults the table to apply the preservation policy uniformly. - * - * Adding a new STATE.md field = one row here, not 9 transition edits. - * - * Field set verified against `buildStateFrontmatter` (state.cts:1474) — every - * frontmatter key emitted there has a row here. - * - * Frozen null-prototype object: prevents prototype-pollution lookups - * (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited - * function). Use `getFieldClassification()` for lookups. - */ -exports.FIELD_CLASSIFICATION = Object.freeze(Object.assign(Object.create(null), { - // Schema - gsd_state_version: { source: 'free', preservation: 'derive' }, - // Milestone (external — from ROADMAP.md) - milestone: { source: 'external', preservation: 'preserve-if-placeholder' }, - milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' }, - // Phase / plan position (body-derived) - current_phase: { source: 'body', preservation: 'preserve-when-unchanged' }, - current_phase_name: { source: 'curated', preservation: 'preserve-always' }, // #1743, #1695 - current_plan: { source: 'body', preservation: 'preserve-when-unchanged' }, - // Status / lifecycle (body-derived; #1230 delta heuristic applies) - status: { source: 'body', preservation: 'preserve-when-unchanged' }, - stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' }, - paused_at: { source: 'body', preservation: 'preserve-when-unchanged' }, - // Activity log - last_updated: { source: 'free', preservation: 'derive' }, // realClock.nowIso() - last_activity: { source: 'body', preservation: 'derive' }, // always refresh on transition - last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' }, - // Progress block (disk-derived, except the curated progress ratchet) - progress: { source: 'curated', preservation: 'preserve-always' }, // #3242, #1446 - 'progress.total_phases': { source: 'disk', preservation: 'derive' }, - 'progress.completed_phases': { source: 'disk', preservation: 'derive' }, - 'progress.total_plans': { source: 'disk', preservation: 'derive' }, - 'progress.completed_plans': { source: 'disk', preservation: 'derive' }, - 'progress.percent': { source: 'disk', preservation: 'derive' }, -})); -/** - * Own-property classification lookup. Returns `null` for unknown fields - * (including inherited prototype methods like `toString`/`valueOf`). - */ -function getFieldClassification(field) { - if (!Object.prototype.hasOwnProperty.call(exports.FIELD_CLASSIFICATION, field)) - return null; - return exports.FIELD_CLASSIFICATION[field]; -} -/** - * Pure, table-driven post-sync preservation. Mutates `postFm` in place to - * mirror the pre-consolidation inline block (which also mutated in place) and - * returns whether any field was restored. - */ -function applyStatePreservation(input) { - const { preFm, postFm, preFmSnapshot, resync } = input; - let mutated = false; - // Curated progress ratchet (#3242/#1446; closes the #1264 class by routing - // the policy through the table). Restored only when the table says preserve- - // always AND this transition is not re-deriving from disk (!resync). sync and - // the lifecycle transitions pass resync=true and recompute; patch/update and - // body-only writes pass resync=false and keep the curated counters. - const progressCls = getFieldClassification('progress'); - if (progressCls !== null && - progressCls.preservation === 'preserve-always' && - !resync && - preFm && - preFm['progress']) { - // #2440: when the caller opts in (deriveProgressKeys), total_plans and - // total_phases always take the derived (post-sync) value even under !resync. - // This is used by cmdStatePlannedPhase where total_plans must correct upward - // after plans are added. For body-only writes (state.update/patch without - // the flag), the wholesale restore preserves everything as before — the - // #3242 Bug A protection stays fully in force. - if (input.deriveProgressKeys && postFm['progress']) { - const curated = preFm['progress']; - const derived = (postFm['progress'] ?? {}); - const merged = { ...derived }; - if (curated) { - for (const [key, value] of Object.entries(curated)) { - if (key !== 'total_plans' && key !== 'total_phases') { - merged[key] = value; - } - } - } - postFm['progress'] = merged; - } - else { - postFm['progress'] = preFm['progress']; - } - mutated = true; - } - // status — #1230 body-delta heuristic. Table: preserve-when-unchanged. - const statusCls = getFieldClassification('status'); - if (statusCls !== null && - statusCls.preservation === 'preserve-when-unchanged' && - input.postBodyStatus === input.preBodyStatus && - typeof preFmSnapshot['status'] === 'string' && - preFmSnapshot['status'].length > 0 && - preFmSnapshot['status'] !== 'unknown' && - postFm['status'] !== preFmSnapshot['status']) { - postFm['status'] = preFmSnapshot['status']; - mutated = true; - } - // stopped_at — same #1230 body-delta heuristic. Table: preserve-when-unchanged. - const stoppedCls = getFieldClassification('stopped_at'); - if (stoppedCls !== null && - stoppedCls.preservation === 'preserve-when-unchanged' && - input.postBodyStoppedAt === input.preBodyStoppedAt && - typeof preFmSnapshot['stopped_at'] === 'string' && - preFmSnapshot['stopped_at'].length > 0 && - postFm['stopped_at'] !== preFmSnapshot['stopped_at']) { - postFm['stopped_at'] = preFmSnapshot['stopped_at']; - mutated = true; - } - // current_phase_name — curated (#1743/#1695). Table: preserve-always. - const phaseNameCls = getFieldClassification('current_phase_name'); - if (phaseNameCls !== null && - phaseNameCls.preservation === 'preserve-always' && - input.postBodyPhaseSource === input.preBodyPhaseSource && - typeof preFmSnapshot['current_phase_name'] === 'string' && - preFmSnapshot['current_phase_name'].length > 0 && - postFm['current_phase_name'] !== preFmSnapshot['current_phase_name']) { - postFm['current_phase_name'] = preFmSnapshot['current_phase_name']; - mutated = true; - } - return { postFm, mutated }; -} -// ---------------------------------------------------------------------------- -// Body section constants (ADR-1769 §6 — single writer after migration) -// ---------------------------------------------------------------------------- -/** - * Top-level STATE.md section headings (H2). Aligned byte-for-byte with the - * canonical template at `gsd-core/templates/state.md`. Sub-headings (H3) like - * `### Decisions` / `### Pending Todos` / `### Blockers/Concerns` live under - * `## Accumulated Context` and are not mutated by any Phase 1–7 transition; - * they will be added here if a future transition needs them. - * - * Verified against `gsd-core/templates/state.md` (codex Phase 1 review). - */ -exports.STATE_MD_SECTIONS = { - projectReference: '## Project Reference', - currentPosition: '## Current Position', - performanceMetrics: '## Performance Metrics', - accumulatedContext: '## Accumulated Context', - deferredItems: '## Deferred Items', - sessionContinuity: '## Session Continuity', -}; -// ---------------------------------------------------------------------------- -// transitionCore — pure dispatch (ADR-1769 §3) -// ---------------------------------------------------------------------------- -/** - * Pure transition core. `(content, intent, deps) → result`. - * - * Discriminated-union dispatch via plain `switch` (ADR-1769 §2.7 Kernighan's - * Law: debuggability over conciseness; the substrate sets the pattern). - * - * Phases 2–7 add cases for the remaining 9 intent kinds. A missing case is - * a compile-time error (the function would not return on that path). - */ -function transitionCore(content, intent, deps) { - switch (intent.kind) { - case 'beginPhase': - return beginPhaseCore(content, intent, deps); - case 'advancePlan': - return advancePlanCore(content, deps); - case 'completePhase': - return completePhaseCore(content, intent, deps); - case 'plannedPhase': - return plannedPhaseCore(content, intent, deps); - case 'milestoneSwitch': - return milestoneSwitchCore(content, intent, deps); - case 'milestoneComplete': - return milestoneCompleteCore(content, intent, deps); - case 'patch': - return patchCore(content, intent); - case 'update': - return updateCore(content, intent); - case 'prune': - return pruneCore(content, intent); - case 'sync': - return syncCore(content, intent, deps); - case 'rebuild': - return rebuildCore(content, intent, deps); - } -} -// ---------------------------------------------------------------------------- -// beginPhase — intent implementation (Phase 1) -// ---------------------------------------------------------------------------- -/** - * Apply a `beginPhase` transition to STATE.md content. - * - * Phase 1 scope (this file): the Status field update only. Subsequent - * behaviors land via RED-GREEN cycles per the ADR-1769 migration plan: - * - Current Phase, Current Phase Name, Current Plan, Total Plans - * - Current Position section mutation - * - Idempotency guard (#3127) - * - Resume vs first-time branching - * - #1255 / #1257 format-detection parity - * - * Adapters that acquire the STATE.md lock and call this core live in - * state.cts and consume the existing `readModifyWriteStateMd` post-sync - * machinery (preserves the #1230 delta heuristic without re-implementing it). - */ -function beginPhaseCore(content, intent, deps) { - const updated = []; - // #1255: body-field replacements operate on body only (frontmatter stripped), - // not on the full content. The YAML `status:` key matches `^Status:\s*` - // before the body pipe-table row if full content is passed. - const existingFm = extractFrontmatter(content, deps.sourcePath); - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b) => hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` - : b; - const today = deps.clock.localToday(); - // Consult the field-classification table for the frontmatter keys this - // transition touches (codex Phase 1 review: "table not consulted by - // transitionCore"). The table tracks FRONTMATTER keys (lowercase: `status`, - // `current_phase`, `last_activity`); body field names like `Status` / - // `Current Phase` are aliases and aren't enforced here — they're driven by - // the first-time/resume branching below, which encodes the same rules. - // Phase 2+ will dispatch preservation based on this lookup. - for (const fmKey of ['status', 'current_phase', 'current_plan', 'last_activity']) { - const cls = getFieldClassification(fmKey); - if (cls === null) { - throw new Error(`transitionCore beginPhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` + - `add a row per ADR-1769 §4 before touching it.`); - } - } - // Helper: try to replace a body field; push to `updated` on success. - // Body field names (Title Case: 'Status', 'Current Phase') are not in the - // table — they're body-side aliases of classified frontmatter keys. - const tryField = (name, value) => { - const replaced = (0, state_document_cjs_1.stateReplaceField)(body, name, value); - if (replaced !== null) { - body = replaced; - updated.push(name); - } - }; - // #3127 idempotency guard: if Status already contains "Executing Phase N" for - // the current phase number, this is a resume (e.g. --wave N continue). Skip - // the first-time-only fields so mid-flight state (Current Plan, Total Plans, - // Current Phase Name, Last Activity Description) is preserved. - // Extract from body (not full content) so the YAML `status:` key cannot - // shadow the body Status field (#1255). - const currentStatus = (0, state_document_cjs_1.stateExtractField)(body, 'Status') || ''; - const isAlreadyExecuting = new RegExp(`Executing Phase\\s+${escapeRegex(String(intent.phaseNumber))}\\b`, 'i').test(currentStatus); - // Status update (applies on both first-time and resume — Status is always refreshed). - tryField('Status', `Executing Phase ${intent.phaseNumber}`); - // Last Activity date — safe to refresh on resume (tracks when execute-phase ran). - tryField('Last Activity', today); - if (!isAlreadyExecuting) { - // First-time execution: set all progress fields. - tryField('Last Activity Description', `Phase ${intent.phaseNumber} execution started`); - tryField('Current Phase', String(intent.phaseNumber)); - if (intent.phaseName) { - tryField('Current Phase Name', intent.phaseName); - } - tryField('Current Plan', '1'); - if (intent.planCount) { - tryField('Total Plans in Phase', String(intent.planCount)); - } - // **Current focus:** body text line (#1104). - const focusLabel = intent.phaseName - ? `Phase ${intent.phaseNumber} — ${intent.phaseName}` - : `Phase ${intent.phaseNumber}`; - const focusPattern = /(\*\*Current focus:\*\*\s*).*/i; - if (focusPattern.test(body)) { - body = body.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`); - updated.push('Current focus'); - } - // ## Current Position section mutation (#1104, #1365). - // `locateCurrentPosition` (fence-aware, tokenizeHeadings-based) locates - // the section; mirrors state.cts:2261-2324 byte-for-behaviour. - body = mutateCurrentPositionFirstTime(body, intent, today, updated); - } - else { - // Resume path: only update Last activity timestamp in Current Position - // (do not touch Plan:, Phase:, Status:, stopped_at, progress.percent). - body = mutateCurrentPositionResume(body, intent, today, updated); - } - // #2736: surface the #3127 resume decision so the adapter can drop its - // intent-first current_phase_name override on a resume — the core just - // preserved the mid-flight name, and an override would drift frontmatter - // away from the preserved body value. - return { content: reassemble(body), updated, data: { resumed: isAlreadyExecuting } }; -} -/** - * Find the `## Current Position` section, return its `{start, end}` byte - * offsets in `body` (end is exclusive — first byte of the next section or - * body.length). Returns `null` when the section is absent. - * - * ADR-1372 T6: tokenizeHeadings-based locator (fence-aware). - */ -function locateCurrentPosition(body) { - const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body); - const idx = hs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text)); - if (idx === -1) - return null; - const h = hs[idx]; - const lines = body.split('\n'); - const hl = lines[h.line - 1]; - const start = h.offset + hl.length + 1; - let end = body.length; - for (let j = idx + 1; j < hs.length; j++) { - if (STOP_H2_PLUS(hs[j].level)) { - end = hs[j].offset - 1; - break; - } - } - return { start, end }; -} -/** - * Return the body text of the `## Current Position` section, or `null` when it - * is absent. Reuses the fence-aware `locateCurrentPosition` locator (ADR-1372). - * - * Exposed so callers that must read a position field (e.g. `cmdStatePrune`, - * #1776) can scope extraction to the canonical section instead of the whole - * document — where `stateExtractField`'s pipe-table fallback could otherwise - * latch onto an unrelated `| Phase | N |` row elsewhere in STATE.md. This - * scopes the *caller*; the shared extractor is left broad for every other use. - */ -function sliceCurrentPositionSection(body) { - const span = locateCurrentPosition(body); - return span === null ? null : body.slice(span.start, span.end); -} -/** - * First-time ## Current Position mutation: update Phase / Plan / Status / - * Last activity lines. Mirrors state.cts:2261-2324 byte-for-behaviour - * (inline regex first, pipe-table fallback via stateReplaceField — #1257). - * - * F2 (#2245 review, MAJOR): a prior revision of this function used - * `collectSection`/`replaceSection` here, whose default `levelBounded: true` - * only stops the section at the next heading of level <= the opener's own - * level (H1/H2 for a `##`-opened section) — an H3+ subsection nested under - * `## Current Position` was NOT a stop boundary and got folded into - * `sectionBody`, so the field regexes below (which run with the `m` flag, - * matching ANY line start in the body) could clobber a same-named line - * inside that subsection (the #2130/#2067/#2080 truncation/clobber class). - * Restored to the fence-aware `locateCurrentPosition` locator (which stops - * at ANY heading level >= 2, `STOP_H2_PLUS` — H2 through H6) + manual splice, - * exactly matching the `mutateCurrentPositionResume`/ - * `mutateCurrentPositionForAdvance` siblings below, both of which use - * `locateCurrentPosition` directly. - */ -function mutateCurrentPositionFirstTime(body, intent, today, updated) { - const span = locateCurrentPosition(body); - if (span === null) - return body; - let sectionBody = body.slice(span.start, span.end); - // Phase line — inline first, then pipe-table fallback (#1257). - const phaseLabel = `${intent.phaseNumber}${intent.phaseName ? ` (${intent.phaseName})` : ''} — EXECUTING`; - if (/^Phase:/m.test(sectionBody)) { - sectionBody = sectionBody.replace(/^Phase:.*$/m, `Phase: ${phaseLabel}`); - } - else { - const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Phase', phaseLabel); - if (replaced !== null) - sectionBody = replaced; - } - // Plan line. - const planValue = `1 of ${intent.planCount || '?'}`; - if (/^Plan:/m.test(sectionBody)) { - sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${planValue}`); - } - else { - const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', planValue); - if (replaced !== null) - sectionBody = replaced; - } - // Status line. - const statusValue = `Executing Phase ${intent.phaseNumber}`; - if (/^Status:/m.test(sectionBody)) { - sectionBody = sectionBody.replace(/^Status:.*$/m, `Status: ${statusValue}`); - } - else { - const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Status', statusValue); - if (replaced !== null) - sectionBody = replaced; - } - // Last activity line. The inline value carries date + narrative. - const activityValue = `${today} — Phase ${intent.phaseNumber} execution started`; - if (/^Last activity:/im.test(sectionBody)) { - sectionBody = sectionBody.replace(/^Last activity:.*$/im, `Last activity: ${activityValue}`); - } - else { - const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last Activity', activityValue) ?? - (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last activity', activityValue); - if (replaced !== null) - sectionBody = replaced; - } - updated.push('Current Position'); - return body.slice(0, span.start) + sectionBody + body.slice(span.end); -} -/** - * Resume ## Current Position mutation: only update Last activity line - * (preserves Plan/Phase/Status — #3127). Mirrors state.cts:2329-2363 - * byte-for-behaviour. - */ -function mutateCurrentPositionResume(body, intent, today, updated) { - const span = locateCurrentPosition(body); - if (span === null) - return body; - let sectionBody = body.slice(span.start, span.end); - const resumeActivity = `Last activity: ${today} — Phase ${intent.phaseNumber} execution resumed (wave continue)`; - if (/^Last activity:/im.test(sectionBody)) { - sectionBody = sectionBody.replace(/^Last activity:.*$/im, resumeActivity); - updated.push('Last activity (resume)'); - } - else { - // Pipe-table format fallback (#1255). - const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last Activity', resumeActivity) ?? - (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Last activity', resumeActivity); - if (replaced !== null) { - sectionBody = replaced; - updated.push('Last activity (resume)'); - } - } - return body.slice(0, span.start) + sectionBody + body.slice(span.end); -} -/** - * Update fields within the ## Current Position section for advancePlan. - * Mirrors `updateCurrentPositionFields` (state.cts:496) byte-for-behaviour: - * only replaces Status / Last Activity when the existing value is a known - * template default (Knuth invariant: preserve executor-authored values). - * Plan is always replaced (system-derived, never executor-authored). - * - * Cannot import `updateCurrentPositionFields` from state.cjs directly (circular - * dep: state.cjs → state-transition.cjs → state.cjs), so the mutation is - * inlined here using the same primitives. - */ -function mutateCurrentPositionForAdvance(content, fields, statusDefaults, lastActivityDefaults) { - const span = locateCurrentPosition(content); - if (span === null) - return content; - let sectionBody = content.slice(span.start, span.end); - let mutated = false; - if (fields.status) { - const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Status', statusDefaults, fields.status); - if (replaced !== null && replaced !== sectionBody) { - sectionBody = replaced; - mutated = true; - } - } - if (fields.lastActivity) { - const replaced = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Last Activity', lastActivityDefaults, fields.lastActivity) ?? - (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(sectionBody, 'Last activity', lastActivityDefaults, fields.lastActivity); - if (replaced !== null && replaced !== sectionBody) { - sectionBody = replaced; - mutated = true; - } - } - if (fields.plan) { - // Plan is always replaced — system-derived, not executor-authored. - if (/^Plan:/m.test(sectionBody)) { - sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`); - mutated = true; - } - else { - const replaced = (0, state_document_cjs_1.stateReplaceField)(sectionBody, 'Plan', fields.plan); - if (replaced !== null) { - sectionBody = replaced; - mutated = true; - } - } - } - if (!mutated) - return content; - return content.slice(0, span.start) + sectionBody + content.slice(span.end); -} -// ---------------------------------------------------------------------------- -// advancePlan — intent implementation (Phase 2) -// ---------------------------------------------------------------------------- -/** - * Apply an `advancePlan` transition to STATE.md content. - * - * Parses Current Plan / Total Plans (legacy separate fields or compound - * "Plan: X of Y" format), increments the plan number, updates body fields - * and the ## Current Position section. When currentPlan >= totalPlans, - * takes the phase-complete branch (sets Status to "Phase complete — ready - * for verification") instead of advancing. - * - * Uses `stateReplaceFieldIfTemplate` (template-default-aware) to preserve - * executor-authored field values (Knuth invariant from cmdStateAdvancePlan). - * - * Returns `data.advanced` / `data.currentPlan` / `data.totalPlans` for the - * adapter to construct CLI output. - */ -function advancePlanCore(content, deps) { - const today = deps.clock.localToday(); - // #1255: body-field replacements operate on body only (frontmatter stripped), - // not on the full content. The YAML `status:` key matches `^Status:\s*` - // before the body field if full content is passed (codex Phase 2 review: - // HIGH blocking finding — same pattern beginPhaseCore already handles). - const existingFm = extractFrontmatter(content, deps.sourcePath); - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b) => hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` - : b; - // Parse plan number — legacy first, then compound. - const legacyPlan = (0, state_document_cjs_1.stateExtractField)(content, 'Current Plan'); - const legacyTotal = (0, state_document_cjs_1.stateExtractField)(content, 'Total Plans in Phase'); - const planField = (0, state_document_cjs_1.stateExtractField)(content, 'Plan'); - let currentPlan; - let totalPlans; - let useCompoundFormat = false; - if (legacyPlan && legacyTotal) { - currentPlan = parseInt(legacyPlan, 10); - totalPlans = parseInt(legacyTotal, 10); - } - else if (planField) { - currentPlan = parseInt(planField, 10); - const ofMatch = planField.match(/of\s+(\d+)/); - totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN; - useCompoundFormat = true; - } - else { - currentPlan = NaN; - totalPlans = NaN; - } - if (isNaN(currentPlan) || isNaN(totalPlans)) { - return { content: reassemble(body), updated: [], data: { error: true } }; - } - const updated = []; - const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status']; - const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity']; - if (currentPlan >= totalPlans) { - // Phase-complete branch. - body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Phase complete — ready for verification') || body; - body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body; - body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body; - body = mutateCurrentPositionForAdvance(body, { - status: 'Phase complete — ready for verification', - lastActivity: today, - }, statusDefaults, lastActivityDefaults); - updated.push('Status', 'Last Activity', 'Current Position'); - return { - content: reassemble(body), - updated, - data: { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, - }; - } - // Normal advance branch. - const newPlan = currentPlan + 1; - let planDisplayValue; - if (useCompoundFormat) { - planDisplayValue = planField.replace(/^\d+/, String(newPlan)); - body = (0, state_document_cjs_1.stateReplaceField)(body, 'Plan', planDisplayValue) || body; - } - else { - planDisplayValue = `${newPlan} of ${totalPlans}`; - body = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Plan', String(newPlan)) || body; - } - body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute') || body; - body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today) || body; - body = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last activity', lastActivityDefaults, today) || body; - body = mutateCurrentPositionForAdvance(body, { - status: 'Ready to execute', - lastActivity: today, - plan: planDisplayValue, - }, statusDefaults, lastActivityDefaults); - updated.push('Current Plan', 'Status', 'Last Activity', 'Current Position'); - return { - content: reassemble(body), - updated, - data: { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, - }; -} -// ---------------------------------------------------------------------------- -// completePhase — intent implementation (Phase 3) -// ---------------------------------------------------------------------------- -/** - * Apply a `completePhase` transition to STATE.md content. - * - * Migrates the inline STATE.md transform that lived inside `cmdPhaseComplete` - * (phase.cts) onto the substrate. Owns the field-classification-governed body - * mutations: Current Phase (preserving the `of total` shape and phase name), - * Current Phase Name, Status (`All phases complete` on the last phase, else - * `Ready to plan` per ADR-2207), Current Plan (`Not started`), Last Activity + Description, - * and the Completed/Total Phases + Progress percent block (re-derived from the - * roadmap via the injected `roadmapProvider`). - * - * The adapter (`cmdPhaseComplete`) retains two concerns that are NOT pure field - * updates: `updatePerformanceMetricsSection` (a section table upsert) and - * `syncStateFrontmatter` (the disk-scan post-sync). It also retains the - * multi-file atomic transaction (`writePlanningFileSet`) that writes ROADMAP, - * REQUIREMENTS, and STATE together — `readModifyWriteStateMd` is not used here - * because STATE.md is committed atomically with the other two files. - * - * Behavior is byte-for-byte with the pre-migration `phase.cts:1671-1772` block - * (verified by characterization tests in tests/state-transition.test.cjs). - */ -function completePhaseCore(content, intent, deps) { - const updated = []; - const today = deps.clock.localToday(); - // Consult the field-classification table for the frontmatter keys this - // transition touches (same guard beginPhaseCore applies). A missing row is a - // substrate defect — fail loudly rather than silently re-encoding policy. - for (const fmKey of [ - 'current_phase', - 'current_phase_name', - 'status', - 'current_plan', - 'last_activity', - 'last_activity_desc', - 'progress', - ]) { - const cls = getFieldClassification(fmKey); - if (cls === null) { - throw new Error(`transitionCore completePhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` + - `add a row per ADR-1769 §4 before touching it.`); - } - } - // #1255: body-field replacements operate on body only (frontmatter stripped), - // so the YAML `status:` / `current_phase:` keys cannot shadow the body fields. - const existingFm = extractFrontmatter(content, deps.sourcePath); - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b) => hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` - : b; - // Current Phase — preserve the existing `of ` shape and the phase name - // in parens (mirrors phase.cts:1675-1697 byte-for-behaviour). - const phaseValue = intent.nextPhaseNum || intent.phaseNum; - const nextPhaseDisplayName = intent.nextPhaseName; - const existingPhaseField = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(body, 'Phase'); - let newPhaseValue = String(phaseValue); - if (existingPhaseField) { - const totalMatch = existingPhaseField.match(/of\s+(\d+)/); - const nameMatch = existingPhaseField.match(/\(([^)]+)\)/); - if (totalMatch) { - const total = totalMatch[1]; - const nameStr = nextPhaseDisplayName - ? ` (${nextPhaseDisplayName})` - : nameMatch - ? ` (${nameMatch[1]})` - : ''; - newPhaseValue = `${phaseValue} of ${total}${nameStr}`; - } - else if (nextPhaseDisplayName) { - newPhaseValue = `${phaseValue} — ${nextPhaseDisplayName}`; - } - } - const phaseAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Current Phase', 'Phase', newPhaseValue); - if (phaseAfter !== body) { - body = phaseAfter; - updated.push('Current Phase'); - } - // Current Phase Name — only written when a next-phase display name is known - // (#1743/#1695: classified curated/preserve-always, so an absent name does - // NOT clear an existing curated value). - if (nextPhaseDisplayName) { - const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Current Phase Name', nextPhaseDisplayName); - if (after) { - body = after; - updated.push('Current Phase Name'); - } - } - // Status — `All phases complete` on the final phase (ADR-2207), otherwise - // `Ready to plan`. Milestone termination (` milestone complete`) is - // owned solely by the milestone-close verb (milestoneCompleteCore). - const statusValue = intent.isLastPhase ? 'All phases complete' : 'Ready to plan'; - const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, statusValue); - if (statusAfter !== body) { - body = statusAfter; - updated.push('Status'); - } - // Current Plan — reset for the next phase. - const planAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Current Plan', 'Plan', 'Not started'); - if (planAfter !== body) { - body = planAfter; - updated.push('Current Plan'); - } - // Last Activity — prefer the prose `Last activity:` line (date + narrative) - // when present, else the bold `Last Activity:` date field. - const lastActivityDescription = `Phase ${intent.phaseNum} complete${intent.nextPhaseNum ? `, transitioned to Phase ${intent.nextPhaseNum}` : ''}`; - if (/^Last activity:/m.test(body)) { - const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Last activity', `${today} — ${lastActivityDescription}`); - if (after) { - body = after; - updated.push('Last Activity'); - } - } - else { - const after = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity', today); - if (after) { - body = after; - updated.push('Last Activity'); - } - } - const ladAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', lastActivityDescription); - if (ladAfter) { - body = ladAfter; - updated.push('Last Activity Description'); - } - // Progress block — re-derive completed/total phases from the roadmap when - // available (milestone-wide source of truth), then recompute the percent. - // Only runs when a Completed Phases field exists (the existing guard). - const completedRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Completed Phases'); - if (completedRaw !== null) { - let newCompleted = parseInt(completedRaw, 10); - let derivedTotalPhases = null; - const roadmapContent = deps.roadmapProvider ? deps.roadmapProvider() : null; - if (roadmapContent) { - const derived = (0, phase_lifecycle_cjs_1.deriveProgressFromRoadmap)(roadmapContent); - if (derived.completedPhases !== null) - newCompleted = derived.completedPhases; - if (derived.totalPhases !== null) - derivedTotalPhases = derived.totalPhases; - } - const completedAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Completed Phases', String(newCompleted)); - if (completedAfter) { - body = completedAfter; - updated.push('Completed Phases'); - } - const totalRaw = (0, state_document_cjs_1.stateExtractField)(body, 'Total Phases'); - const totalPhases = derivedTotalPhases || (totalRaw ? parseInt(totalRaw, 10) : null); - if (totalPhases && totalPhases > 0) { - const newPercent = (0, phase_lifecycle_cjs_1.clampPercent)(newCompleted, totalPhases); - const progAfter = (0, state_document_cjs_1.stateReplaceField)(body, 'Progress', `${newPercent}%`); - if (progAfter) { - body = progAfter; - updated.push('Progress'); - } - // Inline `percent:` token (frontmatter / progress sub-block). - body = body.replace(/(percent:\s*)\d+/, `$1${newPercent}`); - } - } - return { content: reassemble(body), updated }; -} -// ---------------------------------------------------------------------------- -// plannedPhase — intent implementation (Phase 4) -// ---------------------------------------------------------------------------- -/** - * Apply a `plannedPhase` transition to STATE.md content. - * - * Migrates `cmdStatePlannedPhase` (state.cts) onto the substrate. Updates the - * per-phase body fields after plan-phase runs: Status (template-aware — only - * replaces handler-generated values, preserving executor-authored ones), - * Total Plans in Phase, Last Activity (template-aware), Last Activity - * Description, and the ## Current Position section. The adapter wraps this in - * `readModifyWriteStateMd({ resync: false })` so the milestone-wide progress.* - * frontmatter is NOT re-derived from a half-planned disk snapshot (#500 RC1). - * - * Uses `mutateCurrentPositionForAdvance` (the inlined twin of state.cts's - * `updateCurrentPositionFields`) so the Knuth template-default invariant - * applies inside the Current Position section too. - */ -function plannedPhaseCore(content, intent, deps) { - const updated = []; - const today = deps.clock.localToday(); - for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) { - const cls = getFieldClassification(fmKey); - if (cls === null) { - throw new Error(`transitionCore plannedPhase: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` + - `add a row per ADR-1769 §4 before touching it.`); - } - } - // #1255: body-field replacements operate on body only. - const existingFm = extractFrontmatter(content, deps.sourcePath); - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b) => hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` - : b; - const statusDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Status']; - const lastActivityDefaults = state_document_cjs_2.KNOWN_TEMPLATE_DEFAULTS['Last Activity']; - // Status — template-aware (preserve executor-authored values). - const statusAfter = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Status', statusDefaults, 'Ready to execute'); - if (statusAfter !== null && statusAfter !== body) { - body = statusAfter; - updated.push('Status'); - } - // Total Plans in Phase — system-derived; always replaced when a count is given. - if (intent.planCount !== null && intent.planCount !== undefined) { - const result = (0, state_document_cjs_1.stateReplaceField)(body, 'Total Plans in Phase', String(intent.planCount)); - if (result) { - body = result; - updated.push('Total Plans in Phase'); - } - } - // Last Activity — template-aware. - const lastActivityAfter = (0, state_document_cjs_1.stateReplaceFieldIfTemplate)(body, 'Last Activity', lastActivityDefaults, today); - if (lastActivityAfter !== null && lastActivityAfter !== body) { - body = lastActivityAfter; - updated.push('Last Activity'); - } - // Last Activity Description. - const ladResult = (0, state_document_cjs_1.stateReplaceField)(body, 'Last Activity Description', `Phase ${intent.phaseNumber} planning complete — ${intent.planCount || '?'} plans ready`); - if (ladResult) { - body = ladResult; - updated.push('Last Activity Description'); - } - // ## Current Position section — Status + Last activity (template-aware). - const beforePos = body; - body = mutateCurrentPositionForAdvance(body, { - status: 'Ready to execute', - lastActivity: `${today} — Phase ${intent.phaseNumber} planning complete`, - }, statusDefaults, lastActivityDefaults); - if (body !== beforePos) - updated.push('Current Position'); - // #2400 Bug B: sync progress.total_plans to the frontmatter when a plan count - // is given. This writes the explicitly-provided count — it is NOT a re-derivation - // from disk (#500 RC1 is about deriving from a half-planned snapshot, not about - // refusing to write an explicitly-passed argument). - if (intent.planCount !== null && intent.planCount !== undefined && hasFrontmatter) { - const fmProgress = existingFm['progress'] || {}; - if (fmProgress['total_plans'] !== intent.planCount) { - fmProgress['total_plans'] = intent.planCount; - existingFm['progress'] = fmProgress; - updated.push('progress.total_plans'); - } - } - return { content: reassemble(body), updated }; -} -// ---------------------------------------------------------------------------- -// milestoneSwitch — intent implementation (Phase 4) -// ---------------------------------------------------------------------------- -/** - * Apply a `milestoneSwitch` transition to STATE.md content. - * - * Migrates `cmdStateMilestoneSwitch` (state.cts) onto the substrate. Resets - * STATE.md for a new milestone cycle: rewrites the frontmatter (milestone, - * milestone_name, status='planning', last_updated, last_activity, and the - * progress block zeroed) and rewrites the ## Current Position body to the - * "defining requirements" starting state. `gsd_state_version` is preserved. - * Body content OUTSIDE Current Position (e.g. Accumulated Context) is - * preserved. - * - * This is a destructive reset intent: it intentionally overwrites the curated - * `progress` / `current_phase_name` fields (classified preserve-always) because - * a new milestone starts from zero. That is the intent's contract, not a - * violation of the field-classification table — the table governs the steady- - * state RMW transitions; a milestone boundary is an explicit reset. - * - * The adapter wraps this in `acquireStateLock` + `platformWriteSync` (NOT - * `readModifyWriteStateMd`) because milestoneSwitch rebuilds frontmatter - * directly and must not run the steady-state `syncStateFrontmatter` post-sync. - */ -function milestoneSwitchCore(content, intent, deps) { - const today = deps.clock.localToday(); - const updated = [ - 'milestone', - 'milestone_name', - 'status', - 'last_updated', - 'last_activity', - 'progress', - 'Current Position', - ]; - const existingFm = extractFrontmatter(content, deps.sourcePath); - const body = stripFrontmatter(content); - const resolvedName = (intent.name && intent.name.trim()) || 'milestone'; - // ## Current Position reset body (mirrors state.cts:2371-2375). - const resetPositionBody = `\nPhase: Not started (defining requirements)\n` + - `Plan: —\n` + - `Status: Defining requirements\n` + - `Last activity: ${today} — Milestone ${intent.version} started\n\n`; - let newBody; - const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(body); - const posIdx = hs.findIndex((h) => h.level === 2 && /^current\s+position$/i.test(h.text)); - if (posIdx !== -1) { - const h = hs[posIdx]; - const lines = body.split('\n'); - const hl = lines[h.line - 1]; - const bodyStart = h.offset + hl.length + 1; - let bodyEnd = body.length; - for (let j = posIdx + 1; j < hs.length; j++) { - if (STOP_H2_PLUS(hs[j].level)) { - bodyEnd = hs[j].offset - 1; - break; - } - } - newBody = body.slice(0, bodyStart) + resetPositionBody + body.slice(bodyEnd); - } - else { - const preface = body.trim().length > 0 ? body : '# Project State\n'; - newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`; - } - // Rebuilt frontmatter — curated fields are intentionally reset (milestone - // boundary). gsd_state_version is preserved. - const fm = { - gsd_state_version: existingFm['gsd_state_version'] || '1.0', - milestone: intent.version, - milestone_name: resolvedName, - status: 'planning', - last_updated: deps.clock.nowIso(), - last_activity: today, - progress: { - total_phases: 0, - completed_phases: 0, - total_plans: 0, - completed_plans: 0, - percent: 0, - }, - }; - const yamlStr = reconstructFrontmatter(fm); - const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`; - return { content: assembled, updated }; -} -// ---------------------------------------------------------------------------- -// milestoneComplete — intent implementation (Phase 5) -// ---------------------------------------------------------------------------- -/** - * Replace a section's ENTIRE body with `newBody`, discarding whatever was - * there — the "wholesale reset" write pattern used by milestoneComplete's - * closure write (## Current Position / ## Operator Next Steps). Retires the - * fence-blind raw regex `(##\s*\s*\n)([\s\S]*?)(?=\n##|$)`, which a - * literal `##` inside a fenced code block in the section body could fool into - * stopping early (the #2130/#2067/#2080 truncation class) — heading location - * here goes through `tokenizeHeadings`, which is fence-aware. - * - * Byte-parity note: the retired regex's greedy `\s*` (before its mandatory - * `\n`) swallowed any blank line(s) immediately after the heading into the - * discarded match, and its non-greedy body match always left exactly ONE - * newline unconsumed before the next heading (or EOF), regardless of how many - * blank lines originally separated the section from what followed. Both - * edges are reproduced explicitly (rather than delegated to `collectSection`'s - * `trimEnd()`-based body, which trims a *different* amount and would drift - * the surrounding blank-line count) so `newBody`'s own leading/trailing - * formatting is exactly what appears in the output. - * - * Returns `null` when no heading matches `headingPredicate` (mirrors the - * retired regex's `pattern.test(body)` miss) — callers fall back to their own - * append-a-new-section path. - */ -function resetSectionVerbatim(content, headingPredicate, newBody) { - const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content); - const idx = headings.findIndex(headingPredicate); - if (idx === -1) - return null; - const target = headings[idx]; - const lines = content.split('\n'); - const headingLineEnd = target.offset + lines[target.line - 1].length + 1; - // Swallow blank line(s) immediately after the heading (mirrors the retired - // regex's greedy `\s*` folding them into the discarded match). - // - // F7 (#2245 review, nit): recognise a CRLF blank line (`\r\n`), not only a - // bare LF — a lone `content[bodyStart] === '\n'` check never advances past - // a `\r` byte, so on a CRLF STATE.md the blank line right after the - // heading fell into the DISCARDED [bodyStart, bodyEnd) span instead of the - // KEPT prefix, silently dropping one blank line (contradicting this - // function's own byte-parity docstring). - let bodyStart = headingLineEnd; - while (bodyStart < content.length) { - if (content[bodyStart] === '\n') { - bodyStart += 1; - continue; - } - if (content[bodyStart] === '\r' && content[bodyStart + 1] === '\n') { - bodyStart += 2; - continue; - } - break; - } - // Stop at the next heading of level >= 2 (mirrors the retired regex's - // literal `##` lookahead, which matches any ATX heading two-or-more levels - // deep); leave exactly one newline unconsumed before it, or run to EOF. - let bodyEnd = content.length; - for (let j = idx + 1; j < headings.length; j++) { - if (STOP_H2_PLUS(headings[j].level)) { - bodyEnd = headings[j].offset - 1; - break; - } - } - return content.slice(0, bodyStart) + newBody + content.slice(bodyEnd); -} -/** - * Apply a `milestoneComplete` transition to STATE.md content. - * - * Migrates the STATE.md write path inside `cmdMilestoneComplete` (milestone.cts) - * onto the substrate. Owns the closure write: Status (` milestone - * complete`), Last Activity, Last Activity Description, a ## Current Position - * reset to the "Awaiting next milestone" state, and a ## Operator Next Steps - * reset pointing at the next-milestone command. - * - * The adapter (`cmdMilestoneComplete`) retains `writeStateMd` (the writer that - * owns the lock + steady-state syncStateFrontmatter post-sync) and resolves the - * runtime-specific next-milestone slash command, injecting it via - * `intent.nextMilestoneCommand` so the core stays pure. - * - * Behavior is byte-for-byte with the pre-migration milestone.cts:314-353 block. - */ -function milestoneCompleteCore(content, intent, deps) { - const updated = []; - const today = deps.clock.localToday(); - const version = intent.version; - for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) { - const cls = getFieldClassification(fmKey); - if (cls === null) { - throw new Error(`transitionCore milestoneComplete: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` + - `add a row per ADR-1769 §4 before touching it.`); - } - } - // #1255: body-field replacements operate on body only. - const existingFm = extractFrontmatter(content, deps.sourcePath); - const hasFrontmatter = Object.keys(existingFm).length > 0; - let body = stripFrontmatter(content); - const reassemble = (b) => hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}` - : b; - // Status — ` milestone complete`. - const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`); - if (statusAfter !== body) { - body = statusAfter; - updated.push('Status'); - } - // Last Activity. - const lastActivityAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity', 'Last activity', today); - if (lastActivityAfter !== body) { - body = lastActivityAfter; - updated.push('Last Activity'); - } - // Last Activity Description. - const ladAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity Description', null, `${version} milestone completed and archived`); - if (ladAfter !== body) { - body = ladAfter; - updated.push('Last Activity Description'); - } - // ## Current Position reset — stop resume/progress flows pointing at closed - // execution instructions. - const closedPositionBody = `\nPhase: Milestone ${version} complete\n` + - `Plan: —\n` + - `Status: Awaiting next milestone\n` + - `Last activity: ${today} — Milestone ${version} completed and archived\n\n`; - const positionReset = resetSectionVerbatim(body, (h) => h.level === 2 && /^current\s+position$/i.test(h.text), closedPositionBody); - if (positionReset !== null) { - body = positionReset; - } - else { - body = `${body.trimEnd()}\n\n## Current Position\n${closedPositionBody}`; - } - updated.push('Current Position'); - // ## Operator Next Steps — normalize stale tails that can persist after close. - const operatorReset = resetSectionVerbatim(body, (h) => h.level === 2 && /^operator\s+next\s+steps$/i.test(h.text), `\n- Start the next milestone with ${intent.nextMilestoneCommand}\n\n`); - if (operatorReset !== null) { - body = operatorReset; - } - else { - body = `${body.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${intent.nextMilestoneCommand}\n`; - } - updated.push('Operator Next Steps'); - return { content: reassemble(body), updated }; -} -// ---------------------------------------------------------------------------- -// patch — intent implementation (Phase 6) -// ---------------------------------------------------------------------------- -/** - * Apply a `patch` transition to STATE.md content. - * - * Migrates `cmdStatePatch` (state.cts) onto the substrate. Applies each - * caller-supplied `{field: value}` pair via `stateReplaceField` over the full - * content (body + frontmatter — patch can target either), tracking which fields - * were updated vs. not found. - * - * The curated-field preservation that fixes #1743/#1695 is NOT in this core — - * it lives in `readModifyWriteStateMd`'s post-sync delta (table-driven via - * `getFieldClassification('current_phase_name').preservation === 'preserve-always'`). - * `patch` consulting the table "refuses to overwrite" curated fields implicitly: - * when the patch does not change a curated field's body source line, the - * existing frontmatter value wins over the sync re-derivation. The adapter - * still owns field-name validation (security) and the resync-progress decision. - * - * `data.updated` / `data.failed` mirror the pre-migration CLI output shape. - */ -function patchCore(content, intent) { - const updated = []; - const failed = []; - let result = content; - for (const [field, value] of Object.entries(intent.patches)) { - const replaced = (0, state_document_cjs_1.stateReplaceField)(result, field, value); - if (replaced !== null) { - result = replaced; - updated.push(field); - } - else { - failed.push(field); - } - } - return { content: result, updated, data: { updated, failed } }; -} -// ---------------------------------------------------------------------------- -// update — intent implementation (Phase 7) -// ---------------------------------------------------------------------------- -/** - * Apply an `update` transition to STATE.md content. - * - * Migrates `cmdStateUpdate` (state.cts) onto the substrate. A single-field - * body-only update (the field is replaced in the body; frontmatter is preserved - * as-is and re-synced by the adapter's `readModifyWriteStateMd` post-sync). - * Mirrors the pre-migration body-strip/reassemble contract. - */ -function updateCore(content, intent) { - const existingFm = extractFrontmatter(content); - const hasFrontmatter = Object.keys(existingFm).length > 0; - const body = stripFrontmatter(content); - const result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value); - if (result === null) { - return { content, updated: [], data: { updated: false } }; - } - const reassembled = hasFrontmatter - ? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}` - : result; - return { content: reassembled, updated: [intent.field], data: { updated: true } }; -} -// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends -// the section (mirrors state.cts STOP_H2_H3 — Decisions / Recently Completed / -// Blockers / Performance Metrics live at H2 or H3). -const STOP_H2_H3 = (lv) => lv === 2 || lv === 3; -/** - * Apply a `prune` transition to STATE.md content. - * - * Migrates the section-pruning half of `cmdStatePrune` (state.cts) onto the - * substrate. Pure `content → {content, archivedSections}` given a cutoff phase: - * archives Decisions / Recently Completed / resolved Blockers / Performance - * Metrics table rows whose phase number is <= cutoff. ADR-1372 T6 - * tokenizeHeadings + untrimmed-span splicing, byte-identical to the pre-migration - * `prunePass`. - * - * The adapter owns currentPhase derivation (with the #1760 `Phase` / `Current - * Phase` fallback), keepRecent/dryRun, and STATE-ARCHIVE.md writes. - */ -function pruneCore(content, intent) { - const cutoff = intent.cutoff; - const sections = []; - let c = content; - // Helper: locate a heading matching pred, extract untrimmed body [bs, se), - // apply transform, splice back. All prune sections stop at level 2 or 3. - const pruneSectionSpan = (pred, transform, sectionName) => { - const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(c); - const i = hs.findIndex((h) => pred(h.level, h.text)); - if (i === -1) - return; - const h = hs[i]; - const ls = c.split('\n'); - const hl = ls[h.line - 1]; - const bs = h.offset + hl.length + 1; - let se = c.length; - for (let j = i + 1; j < hs.length; j++) { - if (STOP_H2_H3(hs[j].level)) { - se = hs[j].offset - 1; - break; - } - } - const body = c.slice(bs, se); - const { keep, archive } = transform(body); - if (archive.length > 0) { - sections.push({ section: sectionName, count: archive.length, lines: archive }); - c = c.slice(0, bs) + keep.join('\n') + c.slice(se); - } - }; - pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), (body) => { - const keep = [], archive = []; - for (const line of body.split('\n')) { - const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i); - if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { - archive.push(line); - } - else { - keep.push(line); - } - } - return { keep, archive }; - }, 'Decisions'); - pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), (body) => { - const keep = [], archive = []; - for (const line of body.split('\n')) { - const phaseMatch = line.match(/Phase\s+(\d+)/i); - if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { - archive.push(line); - } - else { - keep.push(line); - } - } - return { keep, archive }; - }, 'Recently Completed'); - pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), (body) => { - const keep = [], archive = []; - for (const line of body.split('\n')) { - const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line); - const phaseMatch = line.match(/Phase\s+(\d+)/i); - if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { - archive.push(line); - } - else { - keep.push(line); - } - } - return { keep, archive }; - }, 'Blockers (resolved)'); - pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), (body) => { - const keep = [], archive = []; - for (const line of body.split('\n')) { - const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/); - if (tableRowMatch) { - const rowPhase = parseInt(tableRowMatch[1], 10); - if (rowPhase <= cutoff) { - archive.push(line); - } - else { - keep.push(line); - } - } - else { - keep.push(line); - } - } - return { keep, archive }; - }, 'Performance Metrics'); - const totalPruned = sections.reduce((sum, s) => sum + s.count, 0); - return { - content: c, - updated: totalPruned > 0 ? ['pruned'] : [], - data: { archivedSections: sections, totalPruned }, - }; -} -// ---------------------------------------------------------------------------- -// sync — intent implementation (Phase 7) -// ---------------------------------------------------------------------------- -/** - * Apply a `sync` transition to STATE.md content. - * - * Migrates the body-write half of `cmdStateSync` (state.cts) onto the substrate. - * Updates Total Plans in Phase, the Progress bar, and Last Activity from - * disk-derived numbers (injected via the intent). Returns the per-field change - * log via `data.changes` so the adapter can build the CLI output. - * - * #1761: when the current milestone cannot be bounded to a versioned phase set, - * the adapter passes `percent: null` and this core leaves Progress untouched - * (rather than silently writing fallback-derived wrong values). - */ -function syncCore(content, intent, deps) { - const today = deps.clock.localToday(); - const changes = []; - let modified = content; - const updated = []; - if (intent.totalPlansInPhase !== null) { - const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase'); - if (currentPlansField && parseInt(currentPlansField, 10) !== intent.totalPlansInPhase) { - changes.push(`Total Plans in Phase: ${currentPlansField} -> ${intent.totalPlansInPhase}`); - const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Total Plans in Phase', String(intent.totalPlansInPhase)); - if (result) { - modified = result; - updated.push('Total Plans in Phase'); - } - } - } - if (intent.percent !== null) { - const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress'); - if (currentProgress) { - const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10); - if (currentPercent !== intent.percent) { - const barWidth = 10; - const filled = Math.round((intent.percent / 100) * barWidth); - const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled); - const progressStr = `[${bar}] ${intent.percent}%`; - changes.push(`Progress: ${currentProgress} -> ${progressStr}`); - const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr); - if (result) { - modified = result; - updated.push('Progress'); - } - } - } - } - const lastActivityResult = (0, state_document_cjs_1.stateReplaceField)(modified, 'Last Activity', today); - if (lastActivityResult) { - const oldActivity = (0, state_document_cjs_1.stateExtractField)(modified, 'Last Activity'); - if (oldActivity !== today) { - changes.push(`Last Activity: ${oldActivity} -> ${today}`); - updated.push('Last Activity'); - } - modified = lastActivityResult; - } - return { content: modified, updated, data: { changes } }; -} -// ---------------------------------------------------------------------------- -// rebuild — intent implementation (ADR-1817, capstone 11th transition) -// ---------------------------------------------------------------------------- -// -// Implements the body-structure derivability contract (ADR-1817 §2–§6): -// - §2 re-derives derived sections (## Current Position prose, By Phase table -// inside ## Performance Metrics), preserves curated sections verbatim -// (## Accumulated Context, ## Deferred Items, ## Project Reference, ## -// Session Continuity's prose fields) and unknown sections. -// - §3 every mutation appends a structured entry to ## Rebuild Log -// (ADR-1411 provenance principle — never drop silently). -// - §4 idempotency: a no-mutation rebuild appends NO log entry, so two -// successive runs on a clean file are byte-identical. -// - §5 non-overlapping with sync (sync = 3 frontmatter fields, lightweight, -// auto-triggered; rebuild = body structure, heavier, manual). -// - §6 orthogonal to auto_prune_state (rebuild reconciles with current -// canonical sources; prune removes by retention policy). -// -// Section ordering is invariant: rebuild rewrites content IN PLACE; it does -// not reorder, insert (other than ## Rebuild Log when absent), or remove -// sections. -const REBUILD_LOG_SECTION = '## Rebuild Log'; -const REBUILD_LOG_TRUNCATION_LIMIT = 512; -/** - * Truncate a string for inclusion in a rebuild log entry. Per ADR-1817 §3 the - * `before` / `after` fields are bounded to REBUILD_LOG_TRUNCATION_LIMIT chars - * to prevent unbounded log growth when the drifted content is large. - */ -function truncateForLog(s) { - if (s.length <= REBUILD_LOG_TRUNCATION_LIMIT) - return s; - return s.slice(0, REBUILD_LOG_TRUNCATION_LIMIT - 3) + '...'; -} -/** - * Apply a `rebuild` transition to STATE.md content. Pure core per ADR-1769 §3 - * and ADR-1817 §1. Returns `{ content, updated, data }` where `data.mutated` - * is false when no drift was found (idempotency contract, ADR-1817 §4). - */ -function rebuildCore(content, _intent, deps) { - const timestamp = deps.clock.nowIso(); - const log = []; - let modified = content; - // §2 Decision: re-derive derived sections, preserve others. Order is - // oldest-section-first so log entries appear in body order. - // sourcePath threaded so `state rebuild --dry-run` names the file: that branch reads STATE.md - // directly rather than through readModifyWriteStateMd, so nothing upstream has named it yet. - modified = reconcileCurrentPosition(modified, timestamp, log, deps.sourcePath); - modified = reconcileByPhaseTable(modified, deps, timestamp, log); - modified = stripTemplatePlaceholders(modified, timestamp, log); - modified = deduplicateSessionArchive(modified, timestamp, log); - // §3 + §4: append the audit log ONLY when mutations occurred. The - // log-appends-only-on-mutation rule is what makes idempotency byte-identical - // (without it, the second invocation would always append a no-op entry). - if (log.length > 0) { - modified = appendRebuildLogSection(modified, log); - } - const updated = log.length > 0 ? ['rebuild'] : []; - return { - content: modified, - updated, - data: { - mutated: log.length > 0, - mutations: log.length, - log, - }, - }; -} -/** - * §2 — re-derive `## Current Position` prose fields from frontmatter. - * - * Drift class: `Phase:`, `Status:` etc. in body contradict frontmatter after - * a milestone switch or prune (epic #1817). The body prose is re-derivable - * because `buildStateFrontmatter` already derives the canonical values from - * disk; rebuild pushes those back into the body prose. - * - * Implementation: pull each canonical value from frontmatter and replace the - * body field via `stateReplaceField`. Skip silently when frontmatter lacks - * the key (Leaky-Abstractions guard — don't synthesize values the canonical - * source doesn't have). - */ -function reconcileCurrentPosition(content, timestamp, log, sourcePath) { - const fm = extractFrontmatter(content, sourcePath); - if (!fm || typeof fm !== 'object') - return content; - let modified = content; - // Phase prose: frontmatter `current_phase` overrides body `**Current Phase:**`. - // The body `Phase:` prose line (e.g. "Phase: 3 of 12 (Test Phase)") is owned - // by other transitions (beginPhase / completePhase) and reconstructed from - // total-phase counts; rebuild reconciles only the `**Current Phase:**` body - // field that frontmatter is the canonical source for. - const fmPhase = fm.current_phase; - if (typeof fmPhase === 'string' || typeof fmPhase === 'number') { - const canonicalPhase = String(fmPhase); - const existing = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase'); - if (existing !== null && existing !== canonicalPhase) { - const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase', canonicalPhase); - if (replaced !== null) { - modified = replaced; - log.push({ - timestamp, - kind: 'current-position-reconciled', - section: exports.STATE_MD_SECTIONS.currentPosition, - before: truncateForLog(existing), - after: truncateForLog(canonicalPhase), - reason: "frontmatter 'current_phase' is canonical; body 'Current Phase' was stale", - }); - } - } - } - // Phase name prose. - const fmPhaseName = fm.current_phase_name; - if (typeof fmPhaseName === 'string' || typeof fmPhaseName === 'number') { - const canonicalName = String(fmPhaseName); - const existing = (0, state_document_cjs_1.stateExtractField)(modified, 'Current Phase Name'); - if (existing !== null && existing !== canonicalName) { - const replaced = (0, state_document_cjs_1.stateReplaceField)(modified, 'Current Phase Name', canonicalName); - if (replaced !== null) { - modified = replaced; - log.push({ - timestamp, - kind: 'current-position-reconciled', - section: exports.STATE_MD_SECTIONS.currentPosition, - before: truncateForLog(existing), - after: truncateForLog(canonicalName), - reason: "frontmatter 'current_phase_name' is canonical; body 'Current Phase Name' was stale", - }); - } - } - } - return modified; -} -/** - * §2 — re-derive the `**By Phase:**` table inside `## Performance Metrics` - * from the injected `phaseInventoryProvider`. Drift class: orphaned rows for - * phases from a prior milestone, or zero-padded phase IDs that were renamed - * (epic #1817). - * - * Leaky-Abstractions guard (ADR-1817 §1): when `phaseInventoryProvider` is - * absent (no disk scan wired), this step is a no-op. The core stays pure and - * testable without disk I/O. - */ -function reconcileByPhaseTable(content, deps, timestamp, log) { - if (!deps.phaseInventoryProvider) - return content; - const inventory = deps.phaseInventoryProvider(); - if (!inventory || inventory.length === 0) - return content; - // The canonical table shape (from gsd-core/templates/state.md): - // | Phase | Plans | Total | Avg/Plan | - // |-------|-------|-------|----------| - // | - | - | - | - | - // rebuild renders one row per inventory record (Phase N: P plans). The - // Total/Avg columns are runtime-collected by other commands; rebuild does - // NOT re-derive them and resets them to '-' so future plan-completion - // repopulates. The canonical reconciliation target is the row SET. - const tableRows = inventory.map((r) => `| ${r.number} | ${r.planCount} | - | - |`); - const canonicalTable = [ - '| Phase | Plans | Total | Avg/Plan |', - '|-------|-------|-------|----------|', - ...tableRows, - ]; - // Line-based splice: find `**By Phase:**` line, then walk forward collecting - // the table block (header + separator + body rows), replace the block with - // the canonical table preceded by a single blank-line separator. - const lines = content.split('\n'); - const markerIdx = lines.findIndex((l) => l.trim() === '**By Phase:**'); - if (markerIdx === -1) - return content; // unknown shape — preserve verbatim - // Walk forward from markerIdx+1 to find the table block span. Skip leading - // blank lines; once we see the first table row, consume subsequent table - // rows; stop at the first non-table line after we've started. - let blockStart = -1; - let blockEnd = -1; - for (let i = markerIdx + 1; i < lines.length; i++) { - const trimmed = lines[i].trim(); - const isTable = trimmed.startsWith('|') && trimmed.endsWith('|'); - if (blockStart === -1) { - if (isTable) { - blockStart = i; - blockEnd = i + 1; - } - else if (trimmed === '') - continue; - else - break; // non-table, non-blank before any row — unknown shape - } - else { - if (isTable) - blockEnd = i + 1; - else - break; - } - } - if (blockStart === -1) - return content; // no table found - // Replace lines[blockStart..blockEnd) with canonicalTable. - const beforeBlock = lines.slice(0, markerIdx + 1); - const afterBlock = lines.slice(blockEnd); - // Splice: `**By Phase:**` + blank + canonicalTable rows + (whatever came after) - const newLines = [...beforeBlock, '', ...canonicalTable, ...afterBlock]; - const candidate = newLines.join('\n'); - if (candidate === content) - return content; - log.push({ - timestamp, - kind: 'by-phase-table-reconciled', - section: exports.STATE_MD_SECTIONS.performanceMetrics, - before: truncateForLog(lines.slice(blockStart, blockEnd).join('\n')), - after: truncateForLog(canonicalTable.join('\n')), - reason: 'phase dirs on disk are canonical; rows for missing phases dropped, missing phases added', - }); - return candidate; -} -/** - * §2 + epic-#1817 drift class — template-placeholder field values left in - * place when an AI agent wrote partial state. The canonical template uses - * `[X]`, `[Y]`, `[Phase name]`, `[date]`, `[N]`, etc. (see - * `gsd-core/templates/state.md`). Rebuild clears any `**Field:** [placeholder]` - * line where the value still matches the placeholder shape. - * - * "Clears" means: leaves the field in place with the literal text `(pending)`, - * signalling that rebuild recognized the placeholder but had no canonical - * source to substitute. This is honest — better than silently leaving `[X]` - * which looks like a value. - */ -const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/; -function stripTemplatePlaceholders(content, timestamp, log) { - // Scan body `**Field:** value` lines; when value matches the placeholder - // shape, replace with `(pending)`. We deliberately do NOT touch fields that - // other transitions actively maintain (syncCore's three, beginPhase's set, - // etc.) — only the template placeholder rows that nothing has touched. - const lines = content.split('\n'); - const replacements = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const m = line.match(/^\s*\*\*([^*]+):\*\*\s*(.*)$/); - if (!m) - continue; - const fieldName = m[1]; - const value = m[2]; - if (TEMPLATE_PLACEHOLDER_VALUE.test(value)) { - const cleared = `**${fieldName}:** (pending)`; - replacements.push({ lineIdx: i, before: line, after: cleared, fieldName }); - } - } - if (replacements.length === 0) - return content; - for (const r of replacements) { - lines[r.lineIdx] = r.after; - log.push({ - timestamp, - kind: 'placeholder-removed', - section: exports.STATE_MD_SECTIONS.currentPosition, - before: truncateForLog(r.before.trim()), - after: truncateForLog(r.after), - reason: `field ${JSON.stringify(r.fieldName)} still carried template placeholder ${JSON.stringify(r.before.match(/\*\*[^*]+:\*\*\s*(.*)$/)?.[1]?.trim() ?? '')}; no canonical source available — replaced with (pending)`, - }); - } - return lines.join('\n'); -} -/** - * §2 + epic-#1817 drift class — duplicate `## Session Continuity Archive` - * blocks from repeated `state record-session` calls on a corrupt file. The - * canonical template has one `## Session Continuity` section; archived blocks - * may accumulate as `### Session — ` H3 sub-sections under it. - * Rebuild keeps the most-recent N (default 3) and drops older duplicates, - * logging each drop. - * - * Conservative scope: only acts when the section has more than 3 H3 - * `### Session —` sub-headings; otherwise it's a no-op (preserve verbatim). - */ -const DEFAULT_MAX_SESSION_ARCHIVES = 3; -// `tokenizeHeadings` strips leading `#` markers — `h.text` for `### Session — X` -// is just `Session — X`. Match the bare heading text. -const SESSION_ARCHIVE_H3 = /^Session\s+—/; -function deduplicateSessionArchive(content, timestamp, log) { - const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content); - // Find `## Session Continuity` H2. - const sectionIdx = hs.findIndex((h) => h.level === 2 && h.text === 'Session Continuity'); - if (sectionIdx === -1) - return content; - // Find the section span: from this H2's offset to the next H2 (or EOF). - const sectionStart = hs[sectionIdx].offset; - let sectionEnd = content.length; - for (let i = sectionIdx + 1; i < hs.length; i++) { - if (hs[i].level === 2) { - sectionEnd = hs[i].offset; - break; - } - } - // Count `### Session — …` H3 sub-headings inside the section. - const archiveHeadings = hs.filter((h) => h.level === 3 && h.offset >= sectionStart && h.offset < sectionEnd && SESSION_ARCHIVE_H3.test(h.text)); - if (archiveHeadings.length <= DEFAULT_MAX_SESSION_ARCHIVES) - return content; - // Keep the most-recent N by offset (last N in document order; if timestamps - // in the H3 text are in chronological order — the template convention — - // last-N == most-recent-N). - const dropCount = archiveHeadings.length - DEFAULT_MAX_SESSION_ARCHIVES; - const toDrop = archiveHeadings.slice(0, dropCount); - // Compute the byte spans to drop: each archived H3 spans from its offset to - // the next H3 (or to sectionEnd). Drop with one preceding blank line so we - // don't leave a dangling separator. - let mutated = content; - // Process from the bottom up so offsets don't shift mid-edit. - for (let i = toDrop.length - 1; i >= 0; i--) { - const h = toDrop[i]; - let spanEnd = sectionEnd; - // Find next H3 at-or-after h.offset (within the section). - for (const candidate of hs) { - if (candidate.level === 3 && candidate.offset > h.offset && candidate.offset < sectionEnd) { - spanEnd = candidate.offset; - break; - } - } - const dropStart = h.offset; - const before = mutated.slice(0, dropStart); - const after = mutated.slice(spanEnd); - const droppedText = mutated.slice(dropStart, spanEnd); - mutated = before + after; - log.push({ - timestamp, - kind: 'session-archive-deduplicated', - section: exports.STATE_MD_SECTIONS.sessionContinuity, - before: truncateForLog(droppedText), - after: '', - reason: `archived session ${JSON.stringify(h.text)} exceeded the ${DEFAULT_MAX_SESSION_ARCHIVES}-most-recent retention; dropped`, - }); - } - return mutated; -} -/** - * §3 — append a structured audit entry to `## Rebuild Log`. Per ADR-1817 §3 - * the section is created if absent; existing entries are preserved verbatim - * (append-only). - * - * Format (yaml-ish, human-readable, machine-parseable): - * - * ## Rebuild Log - * - * - timestamp: 2026-06-29T19:30:00Z - * kind: placeholder-removed - * section: ## Current Position - * before: ... - * after: ... - * reason: ... - */ -function appendRebuildLogSection(content, entries) { - const lines = content.split('\n'); - // Render the new entry block. - const rendered = []; - for (const e of entries) { - rendered.push(`- timestamp: ${e.timestamp}`); - rendered.push(` kind: ${e.kind}`); - rendered.push(` section: ${e.section}`); - rendered.push(` before: ${e.before.replace(/\n/g, ' \\n ')}`); - rendered.push(` after: ${e.after.replace(/\n/g, ' \\n ')}`); - rendered.push(` reason: ${e.reason.replace(/\n/g, ' \\n ')}`); - } - // Locate an existing `## Rebuild Log` section. - const sectionHeaderIdx = lines.findIndex((l) => l.trim() === REBUILD_LOG_SECTION); - if (sectionHeaderIdx === -1) { - // Create the section at end-of-file, separated by a blank line. - const needsLeadingBlank = lines.length > 0 && lines[lines.length - 1].trim() !== ''; - const trailer = needsLeadingBlank ? ['', REBUILD_LOG_SECTION, '', ...rendered] : [REBUILD_LOG_SECTION, '', ...rendered]; - return [...lines, ...trailer].join('\n'); - } - // Append to the existing section. Find the end of the existing log entries - // (walk forward until the next H2 or EOF). Insert before that boundary. - let insertAt = sectionHeaderIdx + 1; - while (insertAt < lines.length) { - const l = lines[insertAt]; - if (/^##\s/.test(l)) - break; - insertAt++; - } - // Preserve a blank-line separator before the new entries if the prior line - // is non-blank and non-header. - const sep = []; - if (insertAt > 0 && lines[insertAt - 1].trim() !== '' && lines[insertAt - 1].trim() !== REBUILD_LOG_SECTION) { - sep.push(''); - } - const next = [...lines.slice(0, insertAt), ...sep, ...rendered, ...lines.slice(insertAt)]; - return next.join('\n'); -} diff --git a/gsd-core/bin/lib/write-set.cjs b/gsd-core/bin/lib/write-set.cjs deleted file mode 100644 index a62f261f2..000000000 --- a/gsd-core/bin/lib/write-set.cjs +++ /dev/null @@ -1,38 +0,0 @@ -"use strict"; -/** - * Write-Set — shared fail-loud parse `Result` and per-surface write-set - * contracts (ADR-2143, epic #2143). Pure, Node built-ins only, no I/O. - * Compiled by tsc to gsd-core/bin/lib/write-set.cjs. - * - * ADR-2143 §5 (fail-loud parsing, no null-swallow): seam parse operations - * and document-model accessors return a typed `Result` — never a bare - * `null` a caller can mistake for "empty but fine." This is the same - * `{ ok: true; value: T } | { ok: false; reason: string }` shape - * `markdown-table.cts` already defined for `parseMarkdownTable` / - * `appendQuickTaskRow`; this module is now the single source of truth for - * it and `markdown-table.cjs` re-exports the type so existing importers of - * `Result` from that module keep working unchanged. - * - * NOTE: deliberately distinct from command-routing-hub's dispatch `Result` - * (`{ok,data}|{ok:false,kind}`) — the two never mix (different modules, - * different shapes, different purposes). - * - * ADR-2143 §6 (write-set results for multi-surface commands, no - * OR-into-one-flag): a command that mutates more than one surface returns - * an explicit per-surface write-set — `{ surface, applied }` outcomes — and - * its top-level "did this fully succeed" signal is true only if EVERY - * surface in the set applied. ORing independent surfaces into a single - * boolean is the direct anti-pattern that let a checkbox-only partial - * write (#2140) report full success. - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.writeSetComplete = writeSetComplete; -/** - * True only if the write-set is non-empty AND every surface in it applied. - * An empty write-set is never "complete" — there is nothing to be complete - * about, so treating it as vacuously true would let a no-op masquerade as - * a full success (the same OR-into-one-flag class ADR-2143 §6 prohibits). - */ -function writeSetComplete(ws) { - return ws.length > 0 && ws.every((o) => o.applied); -} diff --git a/scripts/lint-compiled-artifact-sync.cjs b/scripts/lint-compiled-artifact-sync.cjs index 60d4fa7b6..1d6c875ee 100644 --- a/scripts/lint-compiled-artifact-sync.cjs +++ b/scripts/lint-compiled-artifact-sync.cjs @@ -43,7 +43,12 @@ const REASON = Object.freeze({ }); function git(args) { - return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' }); + // -c safe.directory=REPO_ROOT: containerized CI checkouts are frequently + // owned by a different uid than the one running the test process, and git + // refuses to operate at all on such a repo ("detected dubious ownership") + // unless explicitly trusted. Scoped per-invocation (not written to any + // config file) so this never widens trust beyond this one call. + return execFileSync('git', ['-c', `safe.directory=${REPO_ROOT}`, ...args], { cwd: REPO_ROOT, encoding: 'utf8' }); } /** diff --git a/tests/fix-2657-untrack-compiled-artifacts.test.cjs b/tests/fix-2657-untrack-compiled-artifacts.test.cjs new file mode 100644 index 000000000..c2f01444a --- /dev/null +++ b/tests/fix-2657-untrack-compiled-artifacts.test.cjs @@ -0,0 +1,185 @@ +'use strict'; + +/** + * Regression test for #2657. + * + * Nine compiled `.cjs` artifacts under gsd-core/bin/lib/ were tracked in git + * despite each having a matching src/*.cts source, violating ADR-457's + * build-at-publish contract ("bin/lib/*.cjs" must be a gitignored build + * artifact, never checked-in source of truth). A tracked compiled artifact + * can silently drift from its source without anyone noticing — #2653 + * demonstrated exactly this for api-coverage.cjs, which shipped four days + * behind its .cts with CI green throughout. + * + * This asserts the ADR-457 end state for all nine: none tracked, all + * gitignored, and the regime-agnostic sync guard (added in #2656, + * scripts/lint-compiled-artifact-sync.cjs) reports the empty tracked set. + * + * Two of the nine (markdown-table.cjs, write-set.cjs) already had a + * .gitignore pattern before this fix (added by #2248) but were never + * `git rm --cached`; the other seven had no .gitignore pattern at all. Both + * gaps produce the same `git ls-files` symptom, so both are covered by the + * same assertions here. + * + * ── Diagnostics discipline ──────────────────────────────────────────────── + * Every git invocation below uses `spawnSync` (never throws) and every + * assertion explicitly checks the exit status BEFORE interpreting output. + * A git command that errors (bad cwd, dubious-ownership refusal, missing + * binary, anything) must never be silently read as a legitimate "not + * ignored" / "still tracked" answer — that conflates "the property does not + * hold" with "I could not determine whether the property holds", which is a + * distinct defect from the bug this file guards against. On any failure, + * the assertion message includes the resolved cwd, exit status, and stderr, + * so a red run is self-diagnosing without a second round-trip. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { trackedCompiledArtifacts } = require('../scripts/lint-compiled-artifact-sync.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const LIB_DIR = 'gsd-core/bin/lib'; + +const NINE_ARTIFACTS = [ + 'api-coverage.cjs', + 'assumption-delta.cjs', + 'claude-orchestration-command-router.cjs', + 'claude-orchestration.cjs', + 'external-job.cjs', + 'markdown-table.cjs', + 'runtime-artifact-install-plan.cjs', + 'state-transition.cjs', + 'write-set.cjs', +].map((name) => `${LIB_DIR}/${name}`); + +/** + * Run a command via spawnSync (never throws) and return the raw result. + * Throws immediately, with full context, only on a genuine spawn failure + * (binary not found, etc.) — a condition no caller here can meaningfully + * interpret as a match/no-match answer. + */ +function run(cmd, args, opts) { + const result = spawnSync(cmd, args, { cwd: REPO_ROOT, encoding: 'utf8', ...opts }); + if (result.error) { + throw new Error( + `${cmd} ${args.join(' ')} failed to spawn (cwd=${REPO_ROOT}): ${result.error.message}`, + ); + } + return result; +} + +/** Render a failed command's full context for an assertion message. */ +function describeFailure(cmd, args, result) { + return ( + `${cmd} ${args.join(' ')} (cwd=${REPO_ROOT}) exited ${result.status}` + + (result.signal ? ` (signal ${result.signal})` : '') + + `\n stderr: ${(result.stderr || '(empty)').trim()}` + + `\n stdout: ${(result.stdout || '(empty)').trim()}` + ); +} + +function git(args) { + // -c safe.directory=REPO_ROOT: containerized CI checkouts are frequently + // owned by a different uid than the one running node --test, and git + // refuses to operate at all on such a repo ("detected dubious ownership") + // unless explicitly trusted. Scoped per-invocation (not written to any + // config file), matching the same fix applied to + // scripts/lint-compiled-artifact-sync.cjs's own git() helper, which has + // the identical defect (#2657 diagnostic run: `git ls-files` there failed + // with the same "dubious ownership" fatal in the runner). + return run('git', ['-c', `safe.directory=${REPO_ROOT}`, ...args]); +} + +/** `git ls-files `, asserting success before trusting the output. */ +function trackedLibFiles() { + const args = ['ls-files', LIB_DIR]; + const result = git(args); + assert.equal( + result.status, + 0, + `git ls-files must exit 0 before its output can be trusted as "nothing tracked":\n${describeFailure('git', args, result)}`, + ); + return new Set(result.stdout.split('\n').filter(Boolean)); +} + +/** + * `git check-ignore -q ` has exactly two legitimate outcomes: exit 0 + * (ignored) and exit 1 (not ignored) — check-ignore(1). Any other exit code + * or a signal is an infrastructure failure, not a "not ignored" answer, and + * must not be conflated with one. + */ +function isIgnored(artifactPath) { + const args = ['check-ignore', '-q', artifactPath]; + const result = git(args); + if (result.status === 0) return true; + if (result.status === 1) return false; + throw new Error( + `git check-ignore for ${artifactPath} returned neither a match (0) nor a legitimate ` + + `no-match (1) exit code — this is an infrastructure failure, not evidence the path ` + + `is unignored:\n${describeFailure('git', args, result)}`, + ); +} + +// Shared shape for both "none of the nine should still be in state X" checks +// below: derive the still-bad subset via `isBad`, then assert it's empty. +function assertNoneStillBad(isBad, failureLabel) { + const stillBad = NINE_ARTIFACTS.filter(isBad); + assert.deepEqual( + stillBad, + [], + `expected none of the nine ${failureLabel}; still: ${stillBad.join(', ') || '(none)'}`, + ); +} + +describe('fix-2657: compiled .cjs artifacts are gitignored, not tracked (ADR-457)', () => { + test('none of the nine ADR-457 migration-gap artifacts are tracked by git', () => { + const tracked = trackedLibFiles(); + assertNoneStillBad((p) => tracked.has(p), 'to be tracked'); + }); + + test('every one of the nine paths is ignored per git', () => { + // Deliberately WITHOUT --no-index: git-check-ignore(1) operates on the + // pathname alone and does not require the file to exist on disk (true + // both with and without --no-index — this repo's gsd-test runner checks + // out a fresh shallow clone per sha, where an untracked, gitignored path + // exists as a pattern match only, never as a file on disk). Plain + // check-ignore is preferred here over --no-index specifically because it + // also honors git's "a still-TRACKED path is never reported ignored" + // rule (check-ignore(1)) — which is exactly the property under test: a + // path that still matches a .gitignore pattern while ALSO remaining + // tracked (the pre-fix state for two of the nine, whose pattern + // predates this fix per #2248) must still read as "not ignored," the + // same as the seven with no pattern at all. --no-index would blur that + // distinction by reporting the two as ignored regardless of tracking. + assertNoneStillBad((p) => !isIgnored(p), 'to be reported not-ignored by git'); + }); + + test('trackedCompiledArtifacts() reports the ADR-457 empty-set end state for the nine', () => { + let pairs; + try { + pairs = trackedCompiledArtifacts(); + } catch (err) { + // trackedCompiledArtifacts() (scripts/lint-compiled-artifact-sync.cjs) + // wraps its OWN internal `git ls-files gsd-core/bin/lib` call, with cwd + // resolved from that script's own __dirname (should equal REPO_ROOT + // here regardless of caller). A throw means THAT invocation failed — + // not that any artifact is still tracked. Surface it, don't mask it. + assert.fail( + `trackedCompiledArtifacts() threw instead of returning a result — this indicates its ` + + `internal git invocation failed, not that any of the nine is still tracked:\n` + + `${err && err.stack ? err.stack : err}`, + ); + } + const stillPresentArtifacts = new Set(pairs.map((p) => p.artifact)); + assertNoneStillBad((p) => stillPresentArtifacts.has(p), 'to appear in trackedCompiledArtifacts()'); + }); + + test('lint-compiled-artifact-sync exits 0 with nothing left to check', () => { + const args = [path.join(REPO_ROOT, 'scripts', 'lint-compiled-artifact-sync.cjs')]; + const result = run(process.execPath, args); + assert.equal(result.status, 0, describeFailure(process.execPath, args, result)); + }); +});