Files
gstack/lib/plan-reality.ts
T
Garry Tanandcapy-ai[bot] 945ac301a9 v1.91.97.0 fix: closable unattended /autoplan runs on Capy (field wave B, items 6-13) (#3165)
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): scope keys on rendered surfaces, per-axis provenance, --no-dx and a read-only plan marker

UI scope reads the Implementation plan only and needs a rendered file path or a UI
term next to a visual noun; bare UI words stay weak evidence (sections load, no
Design phase). Scope records sources {ui,dx} (flag|marker|detected), --no-dx turns
DX off, a first-line <!-- gstack-autoplan-scope: ... --> marker is read and never
written, and the first next prints SCOPE: evidence plus a SCOPE_PIN hint. The
snapshot tool's scope output carries uiRequired from the same detector and
autoplan Step 2 uses it instead of a prose grep (field wave item 11).

Intentional pin change: test/review-checklists.test.ts Scope literal and the
source= line shape (source=ui:detected,dx:detected).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(plan-reality): a receipt per measured number, the rule text for a rule claim, dot-path receipts

Each clause stating a measured quantity (a unit, or a number after measured/costs/
takes/took) needs its own receipt; misses print unreceipted_numbers=<n> and one
NUMBER_UNRECEIPTED: <clause> line each. A line claiming a decision rule must quote
it or link its file:line (unquoted=<n>). Either miss maps to the existing verdict
(incomplete unattended, repair otherwise). RECEIPT_RE accepts dot paths and needs
a hex letter or a commit/backtick context for a sha. New pure checkPlanReality(planText)
runs the same checks over plan prose for gstack-autoplan submit (field wave item 12).
The unreceipted= text token is now pinned as well as the JSON.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): generated DRIVER.md, the compact entry for a parent driving gstack-autoplan

autoplan/DRIVER.md (from autoplan/DRIVER.md.tmpl) is what a parent agent on any host
reads instead of autoplan/SKILL.md: phase order and scope flags, the next/submit/
answer/revise/export loop with the plan-specified submit --record/--attest/
--unavailable, revise --incorporates/--withdraw and next --scaffold, the reviewer
RESULT FORMAT, the gate grammar including all as-is, the run files, the result codes
and the coordinator contract, every list rendered from code (117 lines). gen:skill-docs
emits it as a Claude asset, so normal, out-dir and dry-run inventories and the
freshness check cover it. gstack-autoplan --help and the first next print
DRIVER: <root>/autoplan/DRIVER.md, resolved through an install root's recorded
.source-path (new lib/source-root.ts) (field wave item 13).

Intentional pin change: test/gen-skill-docs-checks.test.ts asset inventory gains
autoplan/DRIVER.md.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(doctor): --min-revision, a launch Bun found off PATH, planning-only pins SKIP

gstack-doctor --check gains --min-revision <v>|unknown: below it the revision
row FAILs with REVISION_BELOW_MINIMUM naming the Capy wrapper or
bin/gstack-upgrade; unknown says so and never claims the minimum. The runtime
and pins rows name a supported Bun found in $BUN_INSTALL/bin (not on PATH)
with the export/GSTACK_LAUNCH fix (gstack_bun_launch_dir, one helper in
bin/gstack-bun-version.sh). --for a planning-only workflow (autoplan, the plan
reviews, office-hours) reports a repo test-pin mismatch as SKIP pins
(planning-only; ...). New result codes UPGRADE_REFUSED, UPGRADE_FETCH_FAILED,
REVISION_BELOW_MINIMUM, CAPY_WRAPPER_STALE, INSTALL_UNVERIFIED with
troubleshooting anchors; REVISION_UNMET's fix names bin/gstack-upgrade.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(install): install verdict and install admission at skill start

bin/gstack-install-verdict.sh (Bash) and lib/install-verdict.ts (TypeScript)
are the one reader of the install verdict gstack-capy-install writes to
<state root>/install-verdicts/<sha256(checkout realpath)>.json (checkout,
HEAD, workflow and required components, planning-only pins, trailer). The
source checkout and capy/support.env resolve through gstack_source_root /
sourceRoot, which follow an installed root's .source-path.

gstack-skill-start, after SESSION_KIND: an unattended session (any host)
refuses on a PATH Bun below the supported minimum; with GSTACK_CAPY=1 an
unattended or explicit spawned session also refuses without a passing
verdict for this checkout, HEAD and workflow (INSTALL_UNVERIFIED). A refusal
prints the INSTALL: row, an install-refused instruction block bound to its
SESSION_ID, GSTACK_RESULT ... status=refused run=- and exits 3. Ambient
spawned sessions warn; GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 admits and says so.
capy/support.env declares GSTACK_MIN_REVISION and GSTACK_CAPY_MIN_WRAPPER.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy-install): launch PATH, Bun reuse and CI pins, setup skip on HEAD, verdict, --check

The GSTACK_LAUNCH line carries PATH=<dir>:$PATH when the certified Bun is not
first on PATH, plus GSTACK_CAPY=1 and GSTACK_INSTALL_VERDICT, and
GSTACK_LAUNCH_PATH: <dir>; the doctor runs with that PATH. A supported Bun in
$BUN_INSTALL/bin is reused instead of downloaded again. Project pins are read
with bin/gstack-runtime-pins.ts (CI lanes included): when every exact pin is
gstack's verified version the archive is installed alongside a different PATH
Bun; any other exact pin is RUNTIME_PIN_CONFLICT. Both ./setup runs are
skipped when this HEAD was set up with the same hosts, model and browser flag
and the check passes (never after a fast-forward); a failed setup ends with
gstack: fail setup and is retried next run. --check certifies only;
GSTACK_CAPY_LOCK_HELD=1 accepts the bootstrapper's held lock; result anchors
come from the generated components table; REV_NOTE names bin/gstack-upgrade.

Intentional pin change: test/gstack-capy-install.test.ts --revision trailer
now names <root>/bin/gstack-upgrade (the old text named a command that never
existed).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* result codes: PLAN_SECTIONS_INVALID, PLAN_AMEND_PENDING, GSTACK_VERSION_CHANGED, INSTALL_VERDICT_FAILED, ARTIFACT_GATE_UNSETTLED

Troubleshooting anchors for each and the regenerated doctor components table (field wave lane B-core).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* artifact contract: changes, recommended_basis, carried decisions, attested count, settled-gate check

Findings and decisions carry the user challenge's `changes` value; decisions record recommended_basis, origin,
carried_from and chosen_text; validate refuses a complete run with an unsettled gate (ARTIFACT_GATE_UNSETTLED) and
prints attested=<n>. The gate reply grammar gains `all as-is` and resolves partial replies against prior answers
(field wave B2a/B2b).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* autoplan: run-state fields, plan-text hashing, version migration and admission modules

RunState gains gstack_version, plan_input/heading, scope_flags, plan_revision, amendments and plan_reality; voices
record receipt kind and reviewed hash. New pure modules: Implementation-plan review hash, scaffold and drift lines
(B1c); gstack_version stamp and legacy decision-id migration (B1a); install-verdict and Bun-minimum admission
with a local verdict shim until lane A's lib/install-verdict.ts lands.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

bin/gstack-autoplan-snapshot.ts exports its section scanner and review projection, and initializes only a plan with
no section headings by wrapping it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* autoplan: closable runs - submit --record/--attest/--unavailable, revise, amendment interlock, agreeing-UC defaults, admission

B1a: an older run directory prints GSTACK_VERSION_CHANGED once and migrates legacy decision ids; a newer one refuses.
B1b: submit binds hand-run records (fence, table, severity list, No findings.), attested binds need --attempt,
--attempt is optional with one open attempt, --unavailable/--failed close an outside voice native_only, and submit
caches the plan-reality report by Implementation-plan hash.
B1c: revise reviews a revised plan in the same run as one recoverable transaction (reopen on hash drift plus Eng,
add newly in-scope phases, .r<rev>/.v<k> archives, gate_rev+1 at the gate); PLAN_SECTIONS_INVALID and next --scaffold;
PLAN_CHANGED / PLAN_SOURCE_CHANGED drift hints.
B2a/B2b: user challenges carry changes; agreeing voices with the same changes on a confirmed finding recommend b with
both remedies shown (or the reconcile proposal); accepted changes print APPLY and become pending amendments that next
and export refuse past until revise --incorporates names them or --withdraw drops one; all as-is; partial replies,
DECIDED block, carry-forward and rerun demotion.
Mutating verbs pass admission (GSTACK_CAPY install verdict; unattended Bun minimum; override recorded as a deviation).
Intentional pin rewrite: test/autoplan-answer-invalidation.test.ts reopen tests now revise --incorporates before rerun.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(upgrade): bin/gstack-upgrade, the unattended fast-forward every fix line names

--check fetches and prints current|behind <installed> -> <remote> (<n>
commits); the default fast-forwards to the remote default branch tip and
reinstalls (bin/gstack-capy-install on a Capy install, else ./setup
--refresh-registered), completing a pending reinstall on the next run. It
refuses (UPGRADE_REFUSED, exit 3, nothing changed) a dirty tracked byte, a
diverged commit, a pinned checkout, the incoming Bun floor and a held
.setup-lock; never stashes or resets; an unreachable remote is
UPGRADE_FETCH_FAILED. Fetches are receipted (fail-open). The unattended
skill-end UPGRADE_AVAILABLE line is followed by UPGRADE_COMMAND naming it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy): vendored Capy skill, frozen bootstrapper and full wrapper bin/gstack-capy-session

capy/skill/gstack/ is the Drive skill's source of truth. scripts/install.sh
is a frozen bootstrapper: it prints gstack-capy-skill: wrapper <n> (<path>),
clones or fetches under .gstack-capy-install.lock, reads the remote
capy/support.env (FETCH_HEAD), fast-forwards a clean unpinned checkout below
GSTACK_MIN_REVISION (refusing dirty, diverged or pinned checkouts with the
exact commands; GSTACK_CAPY_AUTO_UPGRADE=0 disables), and execs the
checkout's own bin/gstack-capy-session with GSTACK_CAPY_LOCK_HELD=1 so the
held lock is never taken twice. Offline it says the remote minimum is
unknown. A Drive copy older than GSTACK_CAPY_MIN_WRAPPER prints an owner
action (CAPY_WRAPPER_STALE). SKILL.md: check at session start, install
lazily before the first workflow, the GSTACK_LAUNCH line, the trailer rule,
the subagent first-report line, gstack-pregate sealed before any push, and
the autoplan/DRIVER.md pointer. The context-budget fixture gains the skill's
entry.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: Capy install layers, lazy install, snapshot bake, forward-only recovery, raising the minimum

docs/capy-install.md describes the three layers (Drive bootstrapper, full
wrapper, installer), the lazy install, the GSTACK_LAUNCH line, install
admission, baking gstack into a Capy snapshot, and that recovery after an
interrupted fast-forward completes or refuses the pending install stages
(checking out an older commit is not a rollback). docs/CHANGELOG_STYLE.md
(the release doc; there is no docs/RELEASING.md) says when
GSTACK_MIN_REVISION may be raised.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* refactor(capy): the stale-copy owner action moves from the frozen bootstrapper to bin/gstack-capy-session

The checkout-owned full wrapper reads GSTACK_CAPY_MIN_WRAPPER through
gstack_support_env_get(gstack_source_root) and compares it with the
bootstrapper revision it was handed, so the rule can change without
recopying the Drive skill. The bootstrapper keeps only what a stale checkout
cannot do: clone, fetch, read the remote minimum, fast-forward, exec.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.96.0: honest install and self-upgrade on Capy; supported minimum 1.91.96.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.97.0: closable unattended autoplan runs on Capy

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): revise naming a non-pending decision refuses with AMENDMENT_UNKNOWN, not PLAN_AMEND_PENDING

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: bound the new sync spawns in the install-verdict and wrapper tests (spawnSync tripwire)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan): pin CLI refusals to the result-code registry's fix text

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(gen-skill-docs): emit autoplan/DRIVER.md only when the autoplan template is in the tree (setup fixtures with a skill subset)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): run.lock is linked into place with its owner written; a fresh unreadable lock is held, not stolen

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci(paid-pr-profile): the vendored Capy skill is free-only; free-only files are not prompts needing paid coverage

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(harness): non-behavioral HARNESS_VERSION decision for the PR-profile free-only rule

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(doctor): lane A runtime and pins cases are platform-neutral (bash paths; a lane-free .bun-version pin)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(designs): field wave plan with amendments and the gate rev 2-3 review record (ARTIFACT_VALID, 3 gate revisions)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): follow a project's exact Bun pin above the minimum when gstack has its verified archive (adds Bun 1.4.3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: the installer follows a verified exact project Bun pin (capy-install, troubleshooting, changelog)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): the install reason names the project's pin and gstack's real minimum

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ci: run the main-targeted checks after retargeting #3165 to main

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan-admission): the PATH-Bun refusal case needs the POSIX Capy host stub; skip it on Windows

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-13 15:17:39 +00:00

322 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* plan-reality — the reality rows every plan review must carry (multi-agent
* wave D1, release promise P5). One table owns the rows: the resolver
* scripts/resolvers/plan-reality.ts renders them into the plan-review
* templates, bin/gstack-plan-reality prints them at run time and checks a
* review for them, and the tests pin the row ids and the line grammar.
*
* A review emits one grep-able line per row:
*
* REALITY: <row-id> <pass|finding|n/a> <summary carrying a receipt>
*
* A receipt is `path:line`, `path:line-line` or a commit sha; a row without one
* is not a row. Tier 1 rows are required in every review. Tier 2 rows are
* required only when the review's detected scope (the manifest `scope` field
* from plan B4, passed to the checker as `--scope`) names theirs; otherwise
* they may be omitted or marked `n/a`. A tier 1 row marked `n/a` is missing.
*
* `checkRealityRows` is deterministic and returns the missing rows; the
* session kind decides what a miss means (unattended: the phase is
* `incomplete`; interactive: repair twice, then warn at the gate). The row
* table carries each row's incident, so the deep section can say why the row
* exists without a second table drifting.
*/
export const REALITY_SCOPES = ['always', 'db', 'perf', 'search', 'incident', 'dependency'] as const;
export type RealityScope = typeof REALITY_SCOPES[number];
export const REALITY_STATUSES = ['pass', 'finding', 'n/a'] as const;
export type RealityStatus = typeof REALITY_STATUSES[number];
export interface RealityRow {
id: string;
title: string;
tier: 1 | 2;
/** `always` for tier 1; the scope signal a tier 2 row needs. */
scope: RealityScope;
/** The operative check, one line, imperative. */
check: string;
/** The incident in the gbrain feedback that earned the row, one line. */
incident: string;
/** Optional `key=value` the line must carry (scored in the Completion Summary). */
metric?: string;
}
export const REALITY_ROWS: readonly RealityRow[] = [
{
id: 'premise-table', title: 'Premise table', tier: 1, scope: 'always',
check: '`premise | claim | file:line receipt | verdict` for every factual claim; no receipt means `unverified`, and `unverified=<n>` is scored in the Completion Summary.',
incident: 'A wave rested on "13 of 28 threads ran nothing" with no per-thread evidence; the first table found 1 FALSE and 3 UNVERIFIED claims in 27.',
metric: 'unverified',
},
{
id: 'already-done', title: 'Already done', tier: 1, scope: 'always',
check: '`git fetch origin <base>`, then `git log origin/<base> --since=<ask date> -- <named files>` and `--grep <symbols>`; report a hit before any task is written.',
incident: 'A fix was re-planned and rebuilt the day after main merged it; nobody read the base log.',
},
{
id: 'surface-check', title: 'Surface check', tier: 1, scope: 'always',
check: 'Every named CLI command, flag or HTTP route verified against the registry or `--help`; a miss is a finding.',
incident: 'The installer relied on `bun upgrade --version`; `bun upgrade --help` on the target listed only `--canary`.',
},
{
id: 'binding-decisions', title: 'Binding decisions', tier: 1, scope: 'always',
check: 'Read `gstack-decision-search --scope branch --recent 50` and the plan\'s `## Binding decisions`; they outrank project defaults and CLAUDE.md, and a reintroduced overruled default is a finding.',
incident: 'An owner decision taken mid-review was undone two phases later by the project default it had replaced.',
},
{
id: 'numbers', title: 'Numbers', tier: 1, scope: 'always',
check: 'Every number re-derived from raw data and tagged `[measured: <source>]` or `[estimated]`; a cited baseline is `[re-measure before gate]`.',
incident: 'An effort table with no basis was credited until a reviewer re-counted it; quoted parity ratios were off by two decimals.',
},
{
id: 'deferred-asks', title: 'Deferred asks', tier: 1, scope: 'always',
check: 'The linked issue\'s requests the plan leaves out, each with its reason, listed in the gate template.',
incident: 'An issue asked for six things; the plan shipped four and the owner found the other two after merge.',
},
{
id: 'pr-count', title: 'PR count', tier: 1, scope: 'always',
check: '`pr_count` from `.gstack/ship-policy.json` (default one PR per wave); a split shows the queue cost per extra PR and checks user-facing references across it.',
incident: 'A five-PR wave became seven in the Eng phase; the cost of each extra gate had never been stated.',
},
{
id: 'prior-art', title: 'Prior art', tier: 2, scope: 'dependency',
check: 'For a vendored or patched dependency or a known library behavior: search the upstream tracker and open PRs; record `adopt <link>` or `diverge because <reason>`.',
incident: 'A patched vendored hunk was dropped on upgrade because nobody had recorded why the fork diverged.',
},
{
id: 'transaction-semantics', title: 'Transaction semantics', tier: 2, scope: 'db',
check: 'For a driver, pool or protocol change: the surrounding transaction on idle, pooled and reserved connections; crash robot rerun after the change.',
incident: 'A pool change ended transactions on idle connections; the crash robot was not rerun.',
},
{
id: 'contract-assumptions', title: 'Contract assumptions', tier: 2, scope: 'dependency',
check: 'One row per default or flag assumed from another repo, rechecked against merged code before the next stage.',
incident: 'A sibling repo\'s default changed between planning and build; the plan still assumed the old flag.',
},
{
id: 'falsification-box', title: 'Falsification box', tier: 2, scope: 'incident',
check: 'Top hypothesis, the evidence that kills it, a time box, and the action in each branch.',
incident: 'An outage plan chased its first hypothesis for a day with nothing stated that would have killed it.',
},
{
id: 'stop-reasons', title: 'Stop reasons', tier: 2, scope: 'incident',
check: 'Every new stop reason, hold code and progress line names the test that triggers it.',
incident: 'A hold code shipped with no test that could produce it; it first fired in production.',
},
{
id: 'reporter-environment', title: 'Reporter environment', tier: 2, scope: 'incident',
check: 'A repro that does not match the reporter\'s environment is `class not confirmed`, never `ruled out`.',
incident: 'A bug was "ruled out" on a different runtime than the reporter\'s; the reporter still had it.',
},
{
id: 'units', title: 'Units', tier: 2, scope: 'perf',
check: 'A change to an input\'s units or accuracy lists every threshold that consumes it and shows the re-tune.',
incident: 'A timing input moved from ms to s; three downstream thresholds kept their numbers.',
},
{
id: 'perf-table', title: 'Perf table', tier: 2, scope: 'perf',
check: 'Machine, engine, data size, N, p50/p95, statistics state, base and branch interleaved, pathological and steady-state cases, `reproduced on main?`.',
incident: 'A speedup came from one warm-cache run with no base comparison; main was as fast.',
},
{
id: 'recall', title: 'Recall', tier: 2, scope: 'search',
check: 'A search or query-plan change reports recall against exact search across scope buckets.',
incident: 'A query-plan change sped up the hot path and dropped recall on the smallest bucket.',
},
{
id: 'dependency-fallback', title: 'Dependency fallback', tier: 2, scope: 'dependency',
check: 'What ships without each predecessor, and by when.',
incident: 'A stacked PR waited on a predecessor that never merged; nothing said what it could ship alone.',
},
{
id: 'obligation-tiers', title: 'Obligation tiers', tier: 2, scope: 'dependency',
check: 'Must-ship, next PR, deferred, under a stated size budget; the ledger status column carries the tier.',
incident: 'A PR outgrew review because every accepted finding was treated as must-ship.',
},
];
export const TIER1_ROW_IDS: readonly string[] = REALITY_ROWS.filter(r => r.tier === 1).map(r => r.id);
export function rowById(id: string): RealityRow | undefined {
return REALITY_ROWS.find(r => r.id === id);
}
/** Rows a review must carry for the given scopes: tier 1 always, tier 2 when its scope is detected. */
export function requiredRows(scopes: readonly string[]): RealityRow[] {
return REALITY_ROWS.filter(r => r.tier === 1 || scopes.includes(r.scope));
}
export function parseScopes(csv: string | undefined): { scopes: RealityScope[]; unknown: string[] } {
const scopes: RealityScope[] = [];
const unknown: string[] = [];
for (const raw of (csv ?? '').split(',').map(s => s.trim()).filter(Boolean)) {
if ((REALITY_SCOPES as readonly string[]).includes(raw)) scopes.push(raw as RealityScope);
else unknown.push(raw);
}
return { scopes, unknown };
}
/** `path:line` or `path:line-line` (the path has a letter, may start with a dot, no spaces), or a 7–40 hex sha with a hex letter, or digits-only after `commit ` or in backticks. */
export const RECEIPT_RE = /(?:(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+(?:-\d+)?)|(?<![0-9a-f])(?=[0-9]*[a-f])[0-9a-f]{7,40}(?![0-9a-f])|\bcommit [0-9]{7,40}\b|`[0-9]{7,40}`/;
export const REALITY_LINE_RE = /^\s*(?:[-*>|]\s*)?`?REALITY:\s+(?<id>[a-z][a-z0-9-]*)\s+(?<status>pass|finding|n\/a)\b(?<rest>.*)$/;
export interface RealityLine { id: string; status: RealityStatus; rest: string; receipt: string | null; line: number; metrics: Record<string, string> }
export function parseRealityLines(text: string): RealityLine[] {
const out: RealityLine[] = [];
text.split('\n').forEach((raw, i) => {
const m = REALITY_LINE_RE.exec(raw);
if (!m?.groups) return;
const rest = m.groups.rest!.replace(/`\s*$/, '').trim();
const receipt = RECEIPT_RE.exec(rest)?.[0] ?? null;
const metrics: Record<string, string> = {};
for (const kv of rest.matchAll(/\b([a-z_]+)=([^\s,;|]+)/g)) metrics[kv[1]!] = kv[2]!;
out.push({ id: m.groups.id!, status: m.groups.status as RealityStatus, rest, receipt, line: i + 1, metrics });
});
return out;
}
export type RealityVerdict = 'complete' | 'incomplete' | 'repair';
export interface RealityCheck {
phase: string;
required: string[];
present: string[];
/** Required rows with no line, or a tier 1 row marked n/a. */
missing: string[];
/** Required rows whose line carries no receipt. */
unreceipted: string[];
/** Clauses stating a measured number without a receipt of their own. */
unreceipted_numbers: string[];
/** Lines claiming a decision rule exists without quoting or linking its text. */
unquoted: string[];
/** One `NUMBER_UNRECEIPTED: <clause>` line per unreceipted number. */
numberLines: string[];
/** Lines naming a row id the table does not define. */
unknownRows: string[];
metrics: Record<string, string>;
verdict: RealityVerdict;
line: string;
}
/**
* A measured quantity: a number with a unit (s, ms, %, $, USD, tokens, ×, MB, GB) or a number shortly after
* measured/costs/takes/took. Receipts, versions and ids are removed before matching, so they never count.
*/
const MEASURED_RE = /\$\s?\d|\b\d[\d,]*(?:\.\d+)?\s?[kKM]?\s?(?:ms|s|%|USD|tokens?|MB|GB)(?![\w])|\b\d[\d,]*(?:\.\d+)?\s?[×x](?![\w])|\b(?:measured|costs?|takes|take|took)\b\W+(?:[A-Za-z]+\W+){0,3}?\$?\d/i;
const NOT_A_QUANTITY_RE = new RegExp(`${RECEIPT_RE.source}|\\bv?\\d+(?:\\.\\d+){2,}\\b|#\\d+|\\b[A-Za-z]+\\d+[a-z]?\\b`, 'g');
const EXEMPT_TAG_RE = /\[(?:estimated|re-measure before gate)\]/i;
const SCRIPT_RE = /(?:^|[\s(`"'])(?:\.?[\w-]+\/)*[\w.-]+\.(?:ts|js|mjs|cjs|sh|py|rb|go)\b|(?:^|[\s(`"'])(?:bin|scripts)\/[\w.-]+/;
const COMMAND_RE = /`[^`]*\s[^`]*`/;
const RULE_CLAIM_RE = /\b(?:decision rule|rules? (?:was |were )?written first|pre-?registered rule)\b/i;
const QUOTED_RULE_RE = /"[^"]{8,}"|\u201c[^\u201d]{8,}\u201d/;
const LINK_RE = /(?<![\w./-])\.?[A-Za-z~$][\w./~$@-]*:\d+/;
/** Split on `;`, `, ` and sentence ends, never inside brackets, parentheses or backticks. */
export function splitClauses(text: string): string[] {
const out: string[] = [];
let depth = 0, tick = false, start = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (c === '`') tick = !tick;
else if (!tick && '([{'.includes(c)) depth++;
else if (!tick && ')]}'.includes(c)) depth = Math.max(0, depth - 1);
else if (!tick && depth === 0 && (c === ';' || ((c === ',' || c === '.') && /\s/.test(text[i + 1] ?? '')))) {
out.push(text.slice(start, c === '.' ? i + 1 : i)); start = i + 1;
}
}
out.push(text.slice(start));
return out.map(c => c.trim()).filter(Boolean);
}
/** Clauses of one line that state a measured number and carry no receipt of their own. */
export function unreceiptedNumbers(line: string): string[] {
return splitClauses(line).filter(c => MEASURED_RE.test(c.replace(NOT_A_QUANTITY_RE, ' '))
&& !RECEIPT_RE.test(c) && !SCRIPT_RE.test(c) && !COMMAND_RE.test(c) && !EXEMPT_TAG_RE.test(c));
}
/** A rule claim needs the rule: a quoted span, a file:line link on the line, or a fenced or quoted block right after it. */
function ruleUnquoted(lines: string[], i: number): boolean {
const line = lines[i]!;
if (!RULE_CLAIM_RE.test(line) || QUOTED_RULE_RE.test(line) || LINK_RE.test(line)) return false;
const after = lines.slice(i + 1).find(l => l.trim());
return !after || !/^\s*(?:```|~~~|>)/.test(after);
}
const verdictFor = (ok: boolean, sessionKind?: string): RealityVerdict => (ok ? 'complete' : sessionKind === 'unattended' ? 'incomplete' : 'repair');
export interface CheckOptions { phase: string; scopes?: readonly string[]; sessionKind?: string }
/** Deterministic: which required rows the review carries. The verdict word depends on the session kind only. */
export function checkRealityRows(reviewText: string, o: CheckOptions): RealityCheck {
const required = requiredRows(o.scopes ?? []).map(r => r.id);
const lines = parseRealityLines(reviewText);
const byId = new Map<string, RealityLine>();
const unknownRows: string[] = [];
for (const l of lines) {
if (!rowById(l.id)) { if (!unknownRows.includes(l.id)) unknownRows.push(l.id); continue; }
if (!byId.has(l.id) || (byId.get(l.id)!.status === 'n/a' && l.status !== 'n/a')) byId.set(l.id, l);
}
const present: string[] = [], missing: string[] = [], unreceipted: string[] = [];
const metrics: Record<string, string> = {};
for (const id of required) {
const l = byId.get(id);
if (!l || l.status === 'n/a') { missing.push(id); continue; }
if (!l.receipt) { unreceipted.push(id); continue; }
present.push(id);
const metric = rowById(id)!.metric;
if (metric && l.metrics[metric] !== undefined) metrics[metric] = l.metrics[metric]!;
}
const rows = lines.filter(l => rowById(l.id) && l.status !== 'n/a');
const numbers = rows.flatMap(l => unreceiptedNumbers(l.rest));
const rawLines = reviewText.split('\n');
const unquoted = rows.filter(l => ruleUnquoted(rawLines, l.line - 1)).map(l => rawLines[l.line - 1]!.trim());
const verdict = verdictFor(missing.length === 0 && unreceipted.length === 0 && numbers.length === 0 && unquoted.length === 0, o.sessionKind);
const parts = [`PLAN_REALITY: phase=${o.phase}`, `rows=${present.length}/${required.length}`,
`missing=${missing.length ? missing.join(',') : 'none'}`];
if (unreceipted.length) parts.push(`unreceipted=${unreceipted.join(',')}`);
if (numbers.length) parts.push(`unreceipted_numbers=${numbers.length}`);
if (unquoted.length) parts.push(`unquoted=${unquoted.length}`);
for (const [k, v] of Object.entries(metrics)) parts.push(`${k}=${v}`);
parts.push(`verdict=${verdict}`);
return { phase: o.phase, required, present, missing, unreceipted, unreceipted_numbers: numbers, unquoted,
numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), unknownRows, metrics, verdict, line: parts.join(' ') };
}
/** Bumped whenever checkPlanReality's rules change, so a cached verdict keyed on the plan hash is recomputed. */
export const PLAN_REALITY_CHECKER = 'plan-reality/2';
export interface PlanRealityCheck { checker: string; unreceipted_numbers: string[]; unquoted: string[]; numberLines: string[]; verdict: RealityVerdict; line: string }
/**
* The same number and rule checks over plan prose (the Implementation plan the caller passes), outside fenced blocks.
* Pure: reads nothing, writes nothing. `gstack-autoplan submit` records it per plan hash; it never refuses a submit.
*/
export function checkPlanReality(planText: string, o: { sessionKind?: string } = {}): PlanRealityCheck {
const lines = planText.split('\n');
const numbers: string[] = [], unquoted: string[] = [];
let fence: string | null = null;
lines.forEach((line, i) => {
const delimiter = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1];
if (delimiter && (!fence || delimiter.startsWith(fence))) { fence = fence ? null : delimiter; return; }
if (fence || /^\s*<!--/.test(line)) return;
numbers.push(...unreceiptedNumbers(line));
if (ruleUnquoted(lines, i)) unquoted.push(line.trim());
});
const verdict = verdictFor(numbers.length === 0 && unquoted.length === 0, o.sessionKind);
return { checker: PLAN_REALITY_CHECKER, unreceipted_numbers: numbers, unquoted, numberLines: numbers.map(c => `NUMBER_UNRECEIPTED: ${c}`), verdict,
line: `PLAN_REALITY: scope=plan unreceipted_numbers=${numbers.length} unquoted=${unquoted.length} verdict=${verdict}` };
}
/** The example line every template quotes; one grammar, stated once. */
export const REALITY_LINE_SHAPE = 'REALITY: <row> pass|finding|n/a <summary> <file:line>';
/** The operative rows as the bin prints them (`deep` adds each row's incident). */
export function renderRows(rows: readonly RealityRow[], o: { deep: boolean }): string {
return rows.map(r => `- ${r.id} (tier ${r.tier}${r.scope === 'always' ? '' : `, scope ${r.scope}`}): ${r.check}${o.deep ? `\n Incident: ${r.incident}` : ''}`).join('\n');
}
/** The deep-section table: why each row exists. The check itself is what `rows` prints. */
export function renderIncidentTable(rows: readonly RealityRow[]): string {
const head = '| Row | Tier | Scope | Incident that earned it |\n|---|---|---|---|';
return [head, ...rows.map(r => `| \`${r.id}\` | ${r.tier} | ${r.scope} | ${r.incident.replace(/\|/g, '\\|')} |`)].join('\n');
}