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)); + }); +});