mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* 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>
314 lines
21 KiB
TypeScript
314 lines
21 KiB
TypeScript
/**
|
|
* Plan D1: reality rows in plan reviews. Pins the row ids, the `REALITY:` line
|
|
* grammar, the checker's missing/unreceipted/n/a branches, the bin's exit codes
|
|
* and `PLAN_REALITY:` line, the unattended `incomplete` verdict, and that the
|
|
* rendered plan-review skills and autoplan phase files carry the contract.
|
|
* Each tier 1 row has a positive fixture from this wave's own review record
|
|
* (test/fixtures/multi-agent-wave/reality-rows.json, receipts verified against
|
|
* the record) and a decoy that must not count. Prose is not pinned.
|
|
*/
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { EXIT } from '../lib/headless-artifacts';
|
|
import { REALITY_ROWS, REALITY_SCOPES, TIER1_ROW_IDS, checkPlanReality, checkRealityRows, parseRealityLines, parseScopes, renderIncidentTable, renderRows, requiredRows } from '../lib/plan-reality';
|
|
import { extractHeading } from '../lib/autoplan-prompts';
|
|
import { RESULT_CODES } from '../lib/result-codes';
|
|
import { generatePlanRealityRows } from '../scripts/resolvers/plan-reality';
|
|
import type { TemplateContext } from '../scripts/resolvers/types';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-plan-reality');
|
|
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'multi-agent-wave', 'reality-rows.json'), 'utf8')) as {
|
|
rows: Record<string, { positive: string; source: { file: string; lines: [number, number]; contains: string }; decoy: string; decoy_kind: string; metric?: Record<string, string> }>;
|
|
};
|
|
const positives = Object.values(FIXTURE.rows).map(r => r.positive).join('\n');
|
|
|
|
function run(args: string[], input?: string, env: Record<string, string> = {}) {
|
|
const r = spawnSync('bun', [BIN, ...args], { cwd: ROOT, encoding: 'utf8', timeout: 30_000, input, env: { ...process.env, GSTACK_SESSION_KIND: '', ...env } });
|
|
return { code: r.status, out: r.stdout, err: r.stderr };
|
|
}
|
|
|
|
describe('reality row table', () => {
|
|
test('seven tier 1 rows, every row has a check and an incident, ids are unique and scopes known', () => {
|
|
expect(TIER1_ROW_IDS).toEqual(['premise-table', 'already-done', 'surface-check', 'binding-decisions', 'numbers', 'deferred-asks', 'pr-count']);
|
|
expect(REALITY_ROWS.filter(r => r.tier === 2).length).toBe(11);
|
|
expect(new Set(REALITY_ROWS.map(r => r.id)).size).toBe(REALITY_ROWS.length);
|
|
for (const r of REALITY_ROWS) {
|
|
expect(r.check.length).toBeGreaterThan(20);
|
|
expect(r.incident.length).toBeGreaterThan(20);
|
|
expect(REALITY_SCOPES).toContain(r.scope);
|
|
expect(r.tier === 1 ? 'always' : 'scoped').toBe(r.scope === 'always' ? 'always' : 'scoped');
|
|
}
|
|
});
|
|
test('required rows: tier 1 always, tier 2 only for a detected scope', () => {
|
|
expect(requiredRows([]).map(r => r.id)).toEqual([...TIER1_ROW_IDS]);
|
|
expect(requiredRows(['db']).map(r => r.id)).toContain('transaction-semantics');
|
|
expect(requiredRows(['db']).map(r => r.id)).not.toContain('recall');
|
|
expect(parseScopes('db, search,bogus')).toEqual({ scopes: ['db', 'search'], unknown: ['bogus'] });
|
|
});
|
|
});
|
|
|
|
describe('REALITY: line grammar', () => {
|
|
test('parses id, status, receipt (path:line, path:line-line, sha) and key=value metrics; tolerates list and table prefixes', () => {
|
|
const lines = parseRealityLines([
|
|
'REALITY: premise-table finding 27 checked unverified=3 docs/x/ceo-native.md:11-37',
|
|
'- REALITY: already-done pass 0 hits 91bbd9e',
|
|
'| REALITY: surface-check finding bun upgrade bin/gstack-capy-install:52 |',
|
|
'REALITY: numbers pass 7 PRs 48 min',
|
|
'REALITY: transaction-semantics n/a scope db absent',
|
|
'reality: not-a-row pass lower-case prefix is not the grammar',
|
|
].join('\n'));
|
|
expect(lines.map(l => [l.id, l.status, l.receipt])).toEqual([
|
|
['premise-table', 'finding', 'docs/x/ceo-native.md:11-37'],
|
|
['already-done', 'pass', '91bbd9e'],
|
|
['surface-check', 'finding', 'bin/gstack-capy-install:52'],
|
|
['numbers', 'pass', null],
|
|
['transaction-semantics', 'n/a', null],
|
|
]);
|
|
expect(lines[0]!.metrics.unverified).toBe('3');
|
|
});
|
|
});
|
|
|
|
describe('checkRealityRows', () => {
|
|
test('every tier 1 positive fixture from the review record passes and the premise metric is scored', () => {
|
|
const r = checkRealityRows(positives, { phase: 'ceo', sessionKind: 'unattended' });
|
|
expect(r.missing).toEqual([]);
|
|
expect(r.unreceipted).toEqual([]);
|
|
expect(r.verdict).toBe('complete');
|
|
expect(r.metrics).toEqual({ unverified: '3' });
|
|
expect(r.line).toBe('PLAN_REALITY: phase=ceo rows=7/7 missing=none unverified=3 verdict=complete');
|
|
});
|
|
test('each positive receipt points into the review record at the stated lines', () => {
|
|
for (const [id, row] of Object.entries(FIXTURE.rows)) {
|
|
const text = fs.readFileSync(path.join(ROOT, row.source.file), 'utf8').split('\n');
|
|
const [from, to] = row.source.lines;
|
|
const excerpt = text.slice(from - 1, to).join('\n');
|
|
expect(excerpt, `${id}: ${row.source.file}:${from}-${to}`).toContain(row.source.contains);
|
|
const parsed = parseRealityLines(row.positive)[0]!;
|
|
expect(parsed.id).toBe(id);
|
|
expect(parsed.receipt, `${id} carries a receipt`).not.toBeNull();
|
|
expect(row.positive).toContain(`${row.source.file}:${from}`);
|
|
}
|
|
});
|
|
test('each decoy fails for its own row while the other six positives stand (negative control per row)', () => {
|
|
for (const [id, row] of Object.entries(FIXTURE.rows)) {
|
|
const others = Object.entries(FIXTURE.rows).filter(([k]) => k !== id).map(([, r]) => r.positive);
|
|
const r = checkRealityRows([...others, row.decoy].join('\n'), { phase: 'eng' });
|
|
expect([...r.missing, ...r.unreceipted], `${id} decoy (${row.decoy_kind}) must not count`).toEqual([id]);
|
|
expect(r.verdict).toBe('repair');
|
|
}
|
|
});
|
|
test('a review that only talks about the rows in prose has every row missing', () => {
|
|
const prose = 'The premises were checked and already-done work was considered. Surface check done. Numbers estimated.';
|
|
const r = checkRealityRows(prose, { phase: 'ceo', sessionKind: 'unattended' });
|
|
expect(r.missing).toEqual([...TIER1_ROW_IDS]);
|
|
expect(r.verdict).toBe('incomplete');
|
|
expect(r.line).toContain('rows=0/7');
|
|
});
|
|
test('verdict word: unattended → incomplete, anything else → repair; complete needs no session kind', () => {
|
|
const partial = positives.split('\n').slice(0, 6).join('\n');
|
|
expect(checkRealityRows(partial, { phase: 'eng', sessionKind: 'unattended' }).verdict).toBe('incomplete');
|
|
expect(checkRealityRows(partial, { phase: 'eng', sessionKind: 'interactive' }).verdict).toBe('repair');
|
|
expect(checkRealityRows(partial, { phase: 'eng' }).verdict).toBe('repair');
|
|
expect(checkRealityRows(partial, { phase: 'eng' }).missing).toEqual(['pr-count']);
|
|
});
|
|
test('tier 2 rows: required only under their scope; n/a is accepted off-scope and a tier 1 n/a is missing', () => {
|
|
const withNa = `${positives}\nREALITY: transaction-semantics n/a no db change`;
|
|
expect(checkRealityRows(withNa, { phase: 'eng' }).missing).toEqual([]);
|
|
expect(checkRealityRows(withNa, { phase: 'eng', scopes: ['db'] }).missing).toEqual(['transaction-semantics']);
|
|
const dbRow = `${positives}\nREALITY: transaction-semantics pass idle, pooled and reserved traced lib/pool.ts:40-88`;
|
|
expect(checkRealityRows(dbRow, { phase: 'eng', scopes: ['db'] }).line).toContain('rows=8/8');
|
|
expect(checkRealityRows(positives.replace(/^REALITY: numbers finding/m, 'REALITY: numbers n/a'), { phase: 'eng' }).missing).toEqual(['numbers']);
|
|
});
|
|
test('a later real line outranks an earlier n/a for the same row; unknown row ids are reported, not counted', () => {
|
|
const r = checkRealityRows(`REALITY: already-done n/a\n${positives}\nREALITY: premises pass lookalike plan.md:1`, { phase: 'ceo' });
|
|
expect(r.missing).toEqual([]);
|
|
expect(r.unknownRows).toEqual(['premises']);
|
|
});
|
|
});
|
|
|
|
describe('gstack-plan-reality bin', () => {
|
|
test('--help prints the exit table and the line shape; no command is usage', () => {
|
|
const help = run(['--help']);
|
|
expect(help.code).toBe(EXIT.ok);
|
|
expect(help.out).toContain('Exit codes: 0 ok · 1 fail · 2 usage · 3 refused or needs a flag');
|
|
expect(help.out).toContain('REALITY: <row> pass|finding|n/a <summary> <file:line>');
|
|
expect(run([]).code).toBe(EXIT.usage);
|
|
expect(run(['rows']).code).toBe(EXIT.usage);
|
|
expect(run(['rows', '--phase', 'eng', '--scope', 'bogus']).code).toBe(EXIT.usage);
|
|
expect(run(['frob', '--phase', 'eng']).code).toBe(EXIT.usage);
|
|
});
|
|
test('rows prints the applicable rows; --scope adds tier 2; --json is JSON only', () => {
|
|
const plain = run(['rows', '--phase', 'eng']);
|
|
expect(plain.code).toBe(EXIT.ok);
|
|
for (const id of TIER1_ROW_IDS) expect(plain.out).toContain(`- ${id} (tier 1)`);
|
|
expect(plain.out).not.toContain('recall');
|
|
expect(run(['rows', '--phase', 'eng', '--scope', 'search']).out).toContain('- recall (tier 2, scope search)');
|
|
expect(run(['rows', '--phase', 'eng', '--deep']).out).toContain('Incident:');
|
|
const json = JSON.parse(run(['rows', '--phase', 'ceo', '--json']).out);
|
|
expect(json.rows.map((r: { id: string }) => r.id)).toEqual([...TIER1_ROW_IDS]);
|
|
});
|
|
test('check: complete exits 0; a miss exits 1 with REALITY_ROW_MISSING; unattended prints verdict=incomplete; stdin and --json', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-plan-reality-'));
|
|
const file = path.join(tmp, 'review.md');
|
|
fs.writeFileSync(file, `prose\n${positives}\n`);
|
|
const ok = run(['check', '--phase', 'ceo', file]);
|
|
expect(ok.code).toBe(EXIT.ok);
|
|
expect(ok.out.trim()).toBe('PLAN_REALITY: phase=ceo rows=7/7 missing=none unverified=3 verdict=complete');
|
|
|
|
const partial = positives.split('\n').slice(1).join('\n');
|
|
const miss = run(['check', '--phase', 'eng', '-'], partial, { GSTACK_SESSION_KIND: 'unattended' });
|
|
expect(miss.code).toBe(EXIT.fail);
|
|
expect(miss.out).toContain('missing=premise-table verdict=incomplete');
|
|
expect(miss.err).toContain('(REALITY_ROW_MISSING)');
|
|
expect(miss.err).toContain('fix:');
|
|
|
|
const flag = run(['check', '--phase', 'eng', '-', '--session-kind', 'unattended'], partial);
|
|
expect(flag.out).toContain('verdict=incomplete');
|
|
const interactive = run(['check', '--phase', 'eng', '-'], partial);
|
|
expect(interactive.out).toContain('verdict=repair');
|
|
|
|
const unreceipted = run(['check', '--phase', 'ceo', '-', '--json'], `${partial}\n${FIXTURE.rows['premise-table']!.decoy}`);
|
|
expect(unreceipted.code).toBe(EXIT.fail);
|
|
const parsed = JSON.parse(unreceipted.out);
|
|
expect(parsed.unreceipted).toEqual(['premise-table']);
|
|
expect(unreceipted.err).toContain('(REALITY_RECEIPT_MISSING)');
|
|
const unreceiptedText = run(['check', '--phase', 'ceo', '-'], `${partial}\n${FIXTURE.rows['premise-table']!.decoy}`);
|
|
expect(unreceiptedText.out).toMatch(/^PLAN_REALITY: phase=ceo .*\bunreceipted=premise-table\b.* verdict=repair$/m);
|
|
expect(run(['check', '--phase', 'ceo', path.join(tmp, 'absent.md')]).code).toBe(EXIT.fail);
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('rendered templates carry the contract', () => {
|
|
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
const ctx = (skillName: string) => ({ skillName, host: 'claude', paths: {} } as unknown as TemplateContext);
|
|
test('resolver variants name the bin, the tier 1 rows, the line shape and the unattended rule', () => {
|
|
const eng = generatePlanRealityRows(ctx('plan-eng-review'));
|
|
expect(eng).toContain('gstack-plan-reality rows --phase eng');
|
|
expect(eng).toContain('gstack-plan-reality check --phase eng');
|
|
for (const id of TIER1_ROW_IDS) expect(eng).toContain(id);
|
|
expect(eng).toContain('REALITY: <row> pass|finding|n/a <summary> <file:line>');
|
|
expect(generatePlanRealityRows(ctx('plan-ceo-review'))).toContain('--phase ceo');
|
|
expect(generatePlanRealityRows(ctx('plan-devex-review'))).toContain('--phase dx');
|
|
const deep = generatePlanRealityRows(ctx('plan-eng-review'), ['deep']);
|
|
for (const r of REALITY_ROWS) expect(deep).toContain(`| \`${r.id}\` | ${r.tier} | ${r.scope} |`);
|
|
expect(deep).toBe(deep.replace(/\n{3,}/g, '\n\n'));
|
|
expect(generatePlanRealityRows(ctx('autoplan'), ['autoplan', 'eng'])).toContain('check --phase eng');
|
|
expect(generatePlanRealityRows(ctx('autoplan'), ['pregate'])).toContain('`incomplete`');
|
|
expect(generatePlanRealityRows(ctx('plan-eng-review'), ['summary'])).toContain('PLAN_REALITY');
|
|
expect(renderIncidentTable(REALITY_ROWS).split('\n').length).toBe(REALITY_ROWS.length + 2);
|
|
expect(renderRows(requiredRows([]), { deep: false }).split('\n').length).toBe(TIER1_ROW_IDS.length);
|
|
});
|
|
test('plan-eng-review: Scope Challenge block, deep incident table and Completion summary line', () => {
|
|
const sections = read('plan-eng-review/sections/review-sections.md');
|
|
expect(sections).toContain('gstack-plan-reality rows --phase eng');
|
|
expect(sections).toContain('### Reality rows (why each exists)');
|
|
expect(sections).toContain('| `pr-count` | 1 | always |');
|
|
expect(sections).toContain('- Reality rows: ___/___ applicable rows emitted (`PLAN_REALITY` line), ___ unverified premises');
|
|
expect(read('plan-eng-review/sections/checklist.md')).toContain('| Reality rows (`REALITY:` lines, `PLAN_REALITY` check) | Reality rows (why each exists) | always |');
|
|
});
|
|
test('plan-ceo-review: 0A/0B block in the skeleton, Completion Summary row and checklist row', () => {
|
|
const skeleton = read('plan-ceo-review/SKILL.md');
|
|
const at = skeleton.indexOf('gstack-plan-reality rows --phase ceo');
|
|
expect(at).toBeGreaterThan(skeleton.indexOf('### 0B. Existing Code Leverage'));
|
|
expect(at).toBeLessThan(skeleton.indexOf('### 0C. Dream State Mapping'));
|
|
expect(read('plan-ceo-review/sections/review-sections.md')).toContain('| Reality rows | ___/___ rows (PLAN_REALITY), ___ unverified |');
|
|
expect(read('plan-ceo-review/sections/review-sections.md')).toContain('### Reality rows\n');
|
|
expect(read('plan-ceo-review/sections/checklist.md')).toContain('| Reality rows (`REALITY:` lines, `PLAN_REALITY` check) | Reality rows | always |');
|
|
});
|
|
test('autoplan: CEO and Eng phase files run the check; the Pre-Gate names incomplete for unattended misses', () => {
|
|
expect(read('autoplan/sections/ceo-phase.md')).toContain('gstack-plan-reality check --phase ceo');
|
|
expect(read('autoplan/sections/eng-phase.md')).toContain('gstack-plan-reality check --phase eng');
|
|
const skeleton = read('autoplan/SKILL.md');
|
|
const pregate = skeleton.slice(skeleton.indexOf('## Pre-Gate Verification'), skeleton.indexOf('## Phase 4: Final Approval Gate'));
|
|
expect(pregate).toContain('`REALITY:` lines');
|
|
expect(pregate).toContain('verdict=complete');
|
|
expect(pregate).toContain('`incomplete`');
|
|
});
|
|
test('the two result codes have troubleshooting anchors', () => {
|
|
const doc = read('docs/troubleshooting.md');
|
|
for (const code of ['REALITY_ROW_MISSING', 'REALITY_RECEIPT_MISSING'] as const) {
|
|
expect(doc).toContain(`<a id="${RESULT_CODES[code].anchor}"></a>`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('field wave item 12: a receipt per measured number, the rule text for a rule claim', () => {
|
|
const others = (id: string) => Object.entries(FIXTURE.rows).filter(([k]) => k !== id).map(([, r]) => r.positive).join('\n');
|
|
const FIVE = 'REALITY: numbers pass p50 120ms, p95 340ms, 13% faster, 28k tokens, cost $41 [measured: test/bench.ts:10]';
|
|
|
|
test('five measured numbers sharing one receipt leave four unreceipted, each printed with its clause', () => {
|
|
const r = checkRealityRows(`${others('numbers')}\n${FIVE}`, { phase: 'eng', sessionKind: 'unattended' });
|
|
expect(r.missing).toEqual([]);
|
|
expect(r.unreceipted).toEqual([]);
|
|
expect(r.unreceipted_numbers).toEqual(['p50 120ms', 'p95 340ms', '13% faster', '28k tokens']);
|
|
expect(r.numberLines).toEqual(['NUMBER_UNRECEIPTED: p50 120ms', 'NUMBER_UNRECEIPTED: p95 340ms', 'NUMBER_UNRECEIPTED: 13% faster', 'NUMBER_UNRECEIPTED: 28k tokens']);
|
|
expect(r.verdict).toBe('incomplete');
|
|
expect(r.line).toContain(' unreceipted_numbers=4 ');
|
|
expect(checkRealityRows(`${others('numbers')}\n${FIVE}`, { phase: 'eng' }).verdict).toBe('repair');
|
|
});
|
|
|
|
test('each clause with its own receipt (file:line, script path or a command in backticks) passes; unmeasured counts, versions and ids need none', () => {
|
|
const own = 'REALITY: numbers pass p95 340ms lib/x.ts:4, 13% faster `bun run bench --json`, 28k tokens scripts/count-tokens.ts, recall 0.82 vs 0.79 on gstack 1.91.95.0 per F2 and #3160 [estimated]';
|
|
const r = checkRealityRows(`${others('numbers')}\n${own}`, { phase: 'eng' });
|
|
expect(r.unreceipted_numbers).toEqual([]);
|
|
expect(r.verdict).toBe('complete');
|
|
});
|
|
|
|
test('a rule claim without the rule text is unquoted; the quoted rule or a file:line link to it passes', () => {
|
|
const bare = 'REALITY: binding-decisions pass the decision rule was written first 91bbd9e';
|
|
const r = checkRealityRows(`${others('binding-decisions')}\n${bare}`, { phase: 'eng', sessionKind: 'unattended' });
|
|
expect(r.unreceipted).toEqual([]);
|
|
expect(r.unquoted).toEqual([bare]);
|
|
expect(r.line).toContain(' unquoted=1 ');
|
|
expect(r.verdict).toBe('incomplete');
|
|
const quoted = 'REALITY: binding-decisions pass the decision rule was written first: "stop if recall drops below the base on any bucket" 91bbd9e';
|
|
expect(checkRealityRows(`${others('binding-decisions')}\n${quoted}`, { phase: 'eng' }).verdict).toBe('complete');
|
|
const linked = 'REALITY: binding-decisions pass the decision rule was written first, see docs/plan.md:4';
|
|
expect(checkRealityRows(`${others('binding-decisions')}\n${linked}`, { phase: 'eng' }).unquoted).toEqual([]);
|
|
});
|
|
|
|
test('receipts on dot paths count; a sha receipt needs a hex letter or a commit/backtick context', () => {
|
|
expect(parseRealityLines('REALITY: pr-count pass one PR .gstack/ship-policy.json:1')[0]!.receipt).toBe('.gstack/ship-policy.json:1');
|
|
expect(parseRealityLines('REALITY: pr-count pass ci lane .github/workflows/ci.yml:12-20')[0]!.receipt).toBe('.github/workflows/ci.yml:12-20');
|
|
expect(parseRealityLines('REALITY: numbers pass run took 20261012 cycles')[0]!.receipt).toBeNull();
|
|
expect(parseRealityLines('REALITY: already-done pass base clean at commit 1234567')[0]!.receipt).toBe('commit 1234567');
|
|
expect(parseRealityLines('REALITY: already-done pass base clean at `7654321` today')[0]!.receipt).toBe('`7654321`');
|
|
});
|
|
|
|
test('bin: NUMBER_UNRECEIPTED lines follow the PLAN_REALITY line and the verdict exits 1 with a fix', () => {
|
|
const r = run(['check', '--phase', 'eng', '-', '--session-kind', 'unattended'], `${others('numbers')}\n${FIVE}`);
|
|
expect(r.code).toBe(EXIT.fail);
|
|
expect(r.out.split('\n').filter(Boolean)).toEqual([
|
|
expect.stringMatching(/^PLAN_REALITY: phase=eng rows=7\/7 missing=none unreceipted_numbers=4 .*verdict=incomplete$/),
|
|
'NUMBER_UNRECEIPTED: p50 120ms', 'NUMBER_UNRECEIPTED: p95 340ms', 'NUMBER_UNRECEIPTED: 13% faster', 'NUMBER_UNRECEIPTED: 28k tokens',
|
|
]);
|
|
expect(r.err).toContain('(REALITY_RECEIPT_MISSING)');
|
|
expect(r.err).toContain('fix:');
|
|
});
|
|
|
|
test('checkPlanReality(planText): plan prose gets the same number and rule checks, pure and session-kind mapped', () => {
|
|
const plan = 'Cold install measured 32.5 s; the warm rerun takes 13 s (bin/gstack-capy-install:91).\n```\nlatency 900 ms in a fenced example\n```\nThe decision rule was written first.\nThe preregistered rule is below.\n> stop when recall drops below the base\n';
|
|
const r = checkPlanReality(plan, { sessionKind: 'unattended' });
|
|
expect(r.unreceipted_numbers).toEqual(['Cold install measured 32.5 s']);
|
|
expect(r.unquoted).toEqual(['The decision rule was written first.']);
|
|
expect(r.verdict).toBe('incomplete');
|
|
expect(r.line).toBe('PLAN_REALITY: scope=plan unreceipted_numbers=1 unquoted=1 verdict=incomplete');
|
|
expect(r.numberLines).toEqual(['NUMBER_UNRECEIPTED: Cold install measured 32.5 s']);
|
|
expect(checkPlanReality('Add a retry with a 30 s cap (lib/worker.ts:12).\n').line).toBe('PLAN_REALITY: scope=plan unreceipted_numbers=0 unquoted=0 verdict=complete');
|
|
expect(checkPlanReality(plan).verdict).toBe('repair');
|
|
});
|
|
|
|
test('false-positive bound: the field-wave plan itself raises only a small, pinned count', () => {
|
|
const text = fs.readFileSync(path.join(ROOT, 'docs', 'designs', 'GSTACK_FIELD_WAVE_2026_10_12.md'), 'utf8');
|
|
const r = checkPlanReality(extractHeading(text, 'Implementation plan')!);
|
|
expect(r.unquoted).toEqual([]);
|
|
expect(r.unreceipted_numbers.length).toBeGreaterThan(0);
|
|
expect(r.unreceipted_numbers.length).toBeLessThanOrEqual(6);
|
|
});
|
|
});
|