From fb663a2bdda264a123a0b919115390f4e49f443b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 7 Nov 2026 10:02:40 -0800 Subject: [PATCH] feat: the plan check works on public repos: a machine-local plan_record and a record-keyed Plan line (GST-1774) (#4133) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-approvals | 22 +++- bin/gstack-config | 19 ++- .../capy-gst-1774-plan-check-public.md | 4 + docs/captain.md | 7 +- docs/ship-policy.md | 2 +- docs/troubleshooting.md | 8 +- lib/plan-record.ts | 82 ++++++++++-- lib/result-codes-capy.ts | 2 +- lib/ship-policy.ts | 2 +- ship/sections/capy-pr.md | 3 +- ship/sections/capy-pr.md.tmpl | 3 +- test/plan-record.test.ts | 122 +++++++++++++++++- .../ship/capy-gst-1774-plan-check-public.json | 4 + 13 files changed, 247 insertions(+), 33 deletions(-) create mode 100644 changelog.d/capy-gst-1774-plan-check-public.md create mode 100644 test/size-ledger/ship/capy-gst-1774-plan-check-public.json diff --git a/bin/gstack-approvals b/bin/gstack-approvals index ac412c08b..be13d01f2 100755 --- a/bin/gstack-approvals +++ b/bin/gstack-approvals @@ -28,7 +28,7 @@ import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderMode, renderRecord, repoVisibility, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record'; import { GSTACK_ACTOR, openShipRun, RULED_NOTE_SCOPE, ruledNoteRow, ruledNotes, ruledNoteText } from '../lib/ruled-notes'; import { resultLine } from '../lib/result-codes'; -import { parsePlanLine, resolvePlan } from '../lib/plan-record'; +import { parsePlanLine, publicPlanLine, resolvePlan } from '../lib/plan-record'; import { TEST_LANE_EXCEPTION_SCOPE, testLaneExceptionRow } from '../lib/test-lane-exception'; const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md) @@ -51,8 +51,10 @@ const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gs render [--no-heading] [--full] the ## Approvals section for a PR body or report; a public repository, or one whose visibility gh cannot read, gets only "Approved: yes|no (record )", because a PR description keeps its edit history; --full prints the rows when gh cannot read the visibility and you know the repo is private; - --plan "" adds that line and its check, which gstack-ship-queue ready reads (lib/plan-record.ts) - plan-check --plan "Plan: approved: | exempt: | required" + --plan "" adds that line and its check, which gstack-ship-queue ready reads (lib/plan-record.ts); + redacted, an approval prints as "Plan: approved (record r-)" with no run or row id, and one that does not + resolve refuses with no output + plan-check --plan "Plan: approved: | approved (record r-) | exempt: | required" resolve a Plan line against every branch's approval record; PLAN: … and exit 0, or the refusal code and exit 3 list [--json] sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive) @@ -200,12 +202,18 @@ function main(argv: string[]): number { if (flags['--plan'] && !decl) { process.stderr.write(`gstack-approvals: render --plan needs "Plan: approved: | exempt: | required"; ${JSON.stringify(flags['--plan'])} is none of them\n`); return EXIT.usage; } const visibility = repoVisibility(process.cwd()); const mode = renderMode(visibility, bools.has('--full')); + const res = decl ? resolvePlan(decl, { cwd: process.cwd(), env: process.env }) : null; + if (decl && res && !res.ok && mode === 'redacted' && decl.kind === 'approved') { + return fail(cmd, { code: res.failure.code, message: `${res.failure.message}; on a public or unknown-visibility repository render prints no Plan line until it resolves; fix: ${res.failure.fix}` }); + } if (mode === 'redacted') process.stderr.write(`APPROVALS_RENDER: redacted (repository visibility ${visibility}${bools.has('--full') ? '; --full applies only to a private repository' : ''}); gstack-approvals list shows the full record\n`); process.stdout.write(renderRecord(read, loc, { heading: !bools.has('--no-heading'), mode })); - if (decl) { - const res = resolvePlan(decl, { cwd: process.cwd(), env: process.env }); - const short = res.ok ? `${res.record.state} ${res.record.ref ?? res.record.reason}${res.record.row_id && res.record.row_id !== res.record.ref ? ` row=${res.record.row_id}` : ''}` : `${res.failure.code} (gstack-approvals plan-check says why)`; - process.stdout.write(`${decl.line}\nPlan check: ${short}\n\n`); + if (decl && res) { + const line = mode === 'redacted' ? publicPlanLine(decl, res) : decl.line; + const short = !res.ok ? `${res.failure.code} (gstack-approvals plan-check says why)` + : mode === 'redacted' && res.record.state === 'approved' ? line.replace(/^Plan: /, '') + : `${res.record.state} ${res.record.ref ?? res.record.reason}${res.record.row_id && res.record.row_id !== res.record.ref ? ` row=${res.record.row_id}` : ''}`; + process.stdout.write(`${line}\nPlan check: ${short}\n\n`); } } if (read.state === 'unavailable') return fail(cmd, { code: 'APPROVAL_RECORD_UNAVAILABLE', message: `no record at ${loc.file} and no mirror` }); diff --git a/bin/gstack-config b/bin/gstack-config index d89934cf7..8eb26cf72 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -255,6 +255,12 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # off = no probe, no scan, no hint, no /impeccable # # handoff lines. An invalid value is REJECTED (existing # # value preserved) so a typo cannot silently disable it. +# plan_record: # off | warn | require. The plan check on this machine +# # (docs/ship-policy.md#plan-record) for a repo that +# # commits no .gstack/ship-policy.json; a tracked +# # plan_record of warn or require wins, and +# # GSTACK_PLAN_RECORD wins over this. Unset: warn in +# # a Capy session, else off. # gstack_contributor: false # true = file field reports when gstack misbehaves # skip_eng_review: false # true = skip eng review gate in /ship (not recommended) # @@ -351,6 +357,7 @@ lookup_default() { autoplan_gate) bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(dirname "$0")/gstack-skill-route" --default autoplan_gate ;; # on | off — the /autoplan stop gate; default: SWITCH_DEFAULT in lib/skill-route.ts (docs/skill-gates.md) redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection + plan_record) echo "" ;; # empty → warn in a Capy session, else off; a tracked plan_record of warn or require wins (lib/plan-record.ts) redact_prepush_hook) echo "false" ;; pair_agent) echo "off" ;; # remote tunnel consent — fail-closed until /pair-agent asks memorable_recall) echo "off" ;; # on | off — Memorable bridge gate, fail-closed until `gstack-memorable enable` @@ -408,7 +415,7 @@ lookup_default() { # VAR empty and the written default unreachable. # Deliberately *only* the unknown-key path: the keys above whose default is # intentionally empty (cross_project_learnings, salience_allowlist, - # user_slug_at_*, redact_repo_visibility) keep exit 0, because "" is their + # user_slug_at_*, redact_repo_visibility, plan_record) keep exit 0, because "" is their # real answer and their callers rely on it. *) return 1 ;; esac @@ -861,6 +868,12 @@ $_gstack_legacy_root" echo "Error: codex_reviews '$VALUE' not recognized. Valid values: enabled, disabled. Existing value left unchanged." >&2 exit 1 fi + # plan_record decides whether ship-queue ready and /ship finish refuse a PR with no approved plan: reject a + # typo instead of coercing it, so a misspelt require never silently turns the check off. + if [ "$KEY" = "plan_record" ] && [ "$VALUE" != "off" ] && [ "$VALUE" != "warn" ] && [ "$VALUE" != "require" ]; then + echo "Error: plan_record '$VALUE' not recognized. Valid values: off, warn, require. Existing value left unchanged." >&2 + exit 1 + fi # codex_effort feeds model_reasoning_effort on paid Codex calls: reject a # typo instead of coercing it (the probe refuses an invalid stored value too). if [ "$KEY" = "review_effort_tiers" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then @@ -1145,7 +1158,7 @@ $_gstack_legacy_root" ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \ - skill_routing skill_contracts ceo_review_gate autoplan_gate; do + skill_routing skill_contracts ceo_review_gate autoplan_gate plan_record; do gstack_config_select "$KEY" VALUE="$_gstack_cfg_value" SOURCE="default" @@ -1171,7 +1184,7 @@ $_gstack_legacy_root" ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook \ - skill_routing skill_contracts ceo_review_gate autoplan_gate; do + skill_routing skill_contracts ceo_review_gate autoplan_gate plan_record; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/changelog.d/capy-gst-1774-plan-check-public.md b/changelog.d/capy-gst-1774-plan-check-public.md new file mode 100644 index 000000000..952213d25 --- /dev/null +++ b/changelog.d/capy-gst-1774-plan-check-public.md @@ -0,0 +1,4 @@ +--- +bump: patch +--- +**The plan check works on public repos (GST-1774).** A repo that commits no `.gstack/` can turn the check on per machine with `gstack-config set plan_record warn|require|off`. The mode order is a tracked `plan_record` of warn or require, then `GSTACK_PLAN_RECORD`, then this key, then warn in a Capy session. A typo is rejected, and `PLAN:` names the file. With no tracked policy the key gates `gstack-ship-queue ready` and /ship finish, not land. On a public or unknown-visibility repo, `gstack-approvals render --plan` now writes `Plan: approved (record r-)` in place of the autoplan run id. That id is an opaque hash of the approval row, so the run id, the row id and the ask stay out of the PR body, its edit history and the READY note pushed to origin. `ready` resolves the id to the same row and applies the same rules as before. Render refuses with no output when an approved line does not resolve, instead of printing a misleading `Plan: required`. diff --git a/docs/captain.md b/docs/captain.md index c61794fd4..ea1baa6d9 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -279,7 +279,12 @@ The lane copies that line into the PR body (`gstack-approvals render --plan "
  • `. Crew sessions warn by default; `plan_record: require` or -`GSTACK_PLAN_RECORD=require` makes ready and land refuse. +`GSTACK_PLAN_RECORD=require` makes ready and land refuse. A repo that commits no +`.gstack/` sets the mode per machine (`gstack-config set plan_record warn|require`), which +gates ready and /ship finish only, since land needs the tracked policy; on Capy, where a fresh +machine starts without it, put `GSTACK_PLAN_RECORD` in the project env. On a public repo +render writes `Plan: approved (record r-)` in place of the brief's run id, and ready +resolves that id to the same approval row. The copy-paste brief is [`captain/templates/brief.md`](../captain/templates/brief.md): the plan and the ask as step 1, the build after the word as step 2, /ship to READY as step 3, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 5dcd401a3..8b9b50fdf 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -80,7 +80,7 @@ branch that changes the policy. | `bookkeeping` | glob list | `[]` | Root-anchored globs of bookkeeping tables a verify or ratchet step rewrites (size baselines, serial lists, limits), each ending in `.tsv`, `.csv` or `.txt` and never under `.gstack/` or `.github/` (`["scripts/*-baseline.tsv"]`). A matching file changed at most 20 lines after review binds, first touched or not, takes finish's [one scoped reader](review-policy.md#bounded-post-review-edits) instead of Steps 5–11.5; the reader still reads every changed row. Read from the base revision only. Needs `min_gstack_version`. | | `mechanical_retake` | `[{ path, template, fields, regen }]` | `[]` | A version or rules constant a branch may have to retake after a base merge, next to `bookkeeping`'s bounded kind: `path` is one literal file, `template` the whole line with one `{n}` (`const RULE_NUMBERS := {n}`), `fields` the `{ glob, field }` fixture fields that carry the same value, and `regen` the [`.gstack/generated.json`](pregate.md#generated) entry ids whose command regenerates its goldens. A post-review edit finish proves to be only that retake routes as `mechanical=`, with no reader and no review round; see [mechanical retakes](#mechanical-retake). At most 8 constants and 16 fields or ids each; no glob, `..`, `.gstack/` or `.github/` path. Read from the base revision only. Needs `min_gstack_version`. | | `test_code` | glob list | `[]` | Root-anchored globs of test code the built-in test names miss, never under `.gstack/` or `.github/`, and never matching a file there (`["tests/**/*.gd"]` for GDScript checks). A matching file changed after review routes as test code, one scoped reader of its test hunks, instead of behavior, beside the built-in globs; shared `helpers/` and `fixtures/` files stay behavior. See [test code and Godot UID sidecars](#test-code). Read from the base revision only. Needs `min_gstack_version`. | -| `plan_record` | `off` \| `warn` \| `require` | `off` | Whether `gstack-ship-queue ready` and `land` (direct, stamp and train land) and `/ship`'s `gstack-ship-release finish --plan ""` check the PR body's `Plan:` line (GST-1751). The line is the brief's (`Plan: approved: `, `Plan: exempt: ` or `Plan: required`), written under `## Approvals` by `gstack-approvals render --plan`. `ready` resolves it with lib/plan-record.ts against every branch's approval rows on the lane machine: a run id needs the newest gate revision's plan approval answered `approved`, in the owner's words, with no decision of that revision left unanswered; a row id needs an approved row that is not a ruling or grant, and a row from an autoplan run stands for that run's gate the same way; a broken or unreadable store refuses (`PLAN_STORE_UNAVAILABLE`) and never throws, and under `off` changes nothing; an exemption must be one docs/captain.md lists or `crew:`; a diff of changelog fragments only needs no line. `finish` resolves the brief's line the same way before its first stage. `ready` (train ready included) prints `PLAN:` and writes the resolved record into the READY note; `land` checks that the note carries the PR body's current line and never reads the store. `warn` prints and continues; `require` refuses (`PLAN_RECORD_MISSING`, `PLAN_UNRESOLVED`, `PLAN_NOT_APPROVED`, `PLAN_EXEMPT_UNKNOWN`, `PLAN_STORE_UNAVAILABLE`, `QUEUE_PLAN_MISMATCH`). A Capy session (`GSTACK_CAPY=1`) under `off` gets `warn`; `GSTACK_PLAN_RECORD=require` is a crew's opt-in. gstack never infers a feature from the diff, title or changelog. Read from the base revision only. Needs `min_gstack_version`. | +| `plan_record` | `off` \| `warn` \| `require` | `off` | Whether `gstack-ship-queue ready` and `land` (direct, stamp and train land) and `/ship`'s `gstack-ship-release finish --plan ""` check the PR body's `Plan:` line (GST-1751). The line is the brief's (`Plan: approved: `, `Plan: exempt: ` or `Plan: required`), written under `## Approvals` by `gstack-approvals render --plan`. `ready` resolves it with lib/plan-record.ts against every branch's approval rows on the lane machine: a run id needs the newest gate revision's plan approval answered `approved`, in the owner's words, with no decision of that revision left unanswered; a row id needs an approved row that is not a ruling or grant, and a row from an autoplan run stands for that run's gate the same way; a broken or unreadable store refuses (`PLAN_STORE_UNAVAILABLE`) and never throws, and under `off` changes nothing; an exemption must be one docs/captain.md lists or `crew:`; a diff of changelog fragments only needs no line. `finish` resolves the brief's line the same way before its first stage. `ready` (train ready included) prints `PLAN:` and writes the resolved record into the READY note; `land` checks that the note carries the PR body's current line and never reads the store. `warn` prints and continues; `require` refuses (`PLAN_RECORD_MISSING`, `PLAN_UNRESOLVED`, `PLAN_NOT_APPROVED`, `PLAN_EXEMPT_UNKNOWN`, `PLAN_STORE_UNAVAILABLE`, `QUEUE_PLAN_MISMATCH`). When this key is `off` or absent, the mode comes from `GSTACK_PLAN_RECORD` (a crew's opt-in), then `plan_record` in `/config.yaml` (`gstack-config set plan_record off|warn|require`: the untracked source for a repo that commits no `.gstack/`; a typo is rejected, and the `PLAN:` line names the file), then `warn` in a Capy session (`GSTACK_CAPY=1`), then `off`; a machine's key never loosens a tracked `warn` or `require`. A repo with no tracked policy gets the check at `ready` and `finish` only: ready writes no READY note there and `land` refuses before its plan step. Capy's state root does not survive a fresh machine, so a Capy crew that wants more than `warn` sets `GSTACK_PLAN_RECORD` in its project env. On a public or unknown-visibility repository `render --plan` writes `Plan: approved (record r-<12 hex>)`, an opaque id of the approval row (opaque, not secret), so no run id, row id or path reaches the PR body or the READY note pushed to origin; `ready` resolves it by hashing every row id it reads and then applies the row-id rules, and render refuses with no output when an approved line does not resolve. gstack never infers a feature from the diff, title or changelog. Read from the base revision only. Needs `min_gstack_version`. | | `ci_record_timeout_s` | integer seconds, at least 60 | `600` | How long `gstack-ship-release accept` lets one `gstack-evidence record-ci` run before it stops that lane. A value over 3600 is clamped to 3600. accept prints the bound it used as `CI_RECORD_TIMEOUT: s (default)`, `(origin/ ci_record_timeout_s)` or `(… is over the 3600s ceiling)`. Raise it when record-ci runs long, for example when it reruns a per_file remainder of CI-skipped files here (harness-evals #271 needed about 745 s). Read from the base revision only, so a branch can't raise its own bound. Needs `min_gstack_version`. | | `base_red` | `{ setup, select, timeout_sec }` or `null` | `null` | How `/ship` Step 5 proves a failure is the base branch's own before pausing on it; see [base_red](#base-red). Read from the base revision only. Needs `min_gstack_version`. | | `changelog_fragments` | `{ dir, required_checks? }` or `null` | `null` | PRs carry unversioned [CHANGELOG fragments](#changelog-fragments) in `dir` (one flat, repo-relative directory, no `..`) and the merge step ([`land`](#land)) assigns the version (under `merge: direct`, the [release PR](#release-stamp) does). `required_checks` (up to 50 distinct names) are the checks [`train land`](#stamped-trains-under-changelog_fragments-train-land) requires present and green on a stamp commit. `null` keeps `[Unreleased]` sections and per-PR stamps. Needs `min_gstack_version`. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c71cdf3b5..753e29c55 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2120,11 +2120,15 @@ under `## Approvals`. A PR whose only changes are changelog fragments needs no l **Meaning.** `Plan: approved: ` names no approval row on this machine. A run id must match a row source `autoplan:@gate_rev=` in any branch's file of the repository's approval store (the state root plus every Drive mirror); a row id must exist and be a plan -approval, not a `ruled_note`, `review_loop_grant` or `test_lane_exception` row. +approval, not a `ruled_note`, `review_loop_grant` or `test_lane_exception` row. A public +repo's `Plan: approved (record r-)` must hash from a row id this machine can read; when it +is the redacted Approvals block's own id (a hash of a whole record), the message says so. **Fix.** `gstack-approvals import --autoplan --by ""` after the owner's answer, or `gstack-approvals add` with their exact words for a plan approved outside /autoplan, then -cite the run id or the row id it printed. +cite the run id or the row id it printed. For a record id, paste the Plan line +`gstack-approvals render --plan` printed, and run ready where the plan was approved or after +`gstack-approvals sync`. ### `PLAN: ... (PLAN_NOT_APPROVED)` diff --git a/lib/plan-record.ts b/lib/plan-record.ts index 10a3b96db..169c593c6 100644 --- a/lib/plan-record.ts +++ b/lib/plan-record.ts @@ -5,6 +5,9 @@ * Plan: approved: an owner-approved plan: `a-<12 hex>` is an approval row id; a path is an autoplan * run directory (its run.json names the run); anything else is a run id matched * against row sources `autoplan:@gate_rev=` + * Plan: approved (record r-<12 hex>) + * the public-repo form render --plan writes (GST-1774): an opaque id of one approval row + * (planRecordId), so no run id or path reaches public PR text or the READY note * Plan: exempt: one of the exemptions docs/captain.md lists, or `crew:` for a crew's own * Plan: required the plan step has not finished * @@ -23,6 +26,7 @@ import * as path from 'node:path'; import { locate, type ApprovalRow, type Env } from './approval-record'; import type { ResultCodeName } from './result-codes'; import { isUnavailable, loadPolicyFromBase, type ShipPolicy } from './ship-policy'; +import { readConfigKeyWithRoot, resolveStateRoot } from './state-root'; export const PLAN_MODES = ['off', 'warn', 'require'] as const; export type PlanMode = typeof PLAN_MODES[number]; @@ -39,16 +43,40 @@ export type PlanResolution = { ok: true; record: PlanRecord; summary: string } | const NOT_PLAN_ROW = /\b(ruled_note|review_loop_grant|review_loop_exception|test_lane_exception)\b/; const ROW_ID = /^a-[0-9a-f]{12}$/; +const RECORD_ID = /^r-[0-9a-f]{12}$/; +const LOCAL_SOURCE = 'state-root config'; -/** The mode in force: the base policy's plan_record, else a crew opt-in (GSTACK_PLAN_RECORD), else warn in a Capy session, else off. */ +/** The opaque id a public Plan line names instead of a run or row id: one approval row, stable as later rows are added. */ +export const planRecordId = (rowId: string) => 'r-' + createHash('sha256').update(`plan-record\0${rowId}`).digest('hex').slice(0, 12); + +/** `plan_record` in `/config.yaml` (gstack-config set plan_record …), read only when the state root is absolute. */ +function localPlanMode(env: Env): { mode: PlanMode; source: string } | { ignored: string } | null { + if (!path.isAbsolute(resolveStateRoot(env))) return null; + const { value, root } = readConfigKeyWithRoot('plan_record', env); + if (value === null) return null; + const where = `${LOCAL_SOURCE} ${root}/config.yaml`; + return (PLAN_MODES as readonly string[]).includes(value) ? { mode: value as PlanMode, source: where } : { ignored: `${where} plan_record "${value.slice(0, 20)}" ignored (off, warn or require)` }; +} + +/** + * The mode in force: the base policy's plan_record when it is warn or require, else a crew opt-in (GSTACK_PLAN_RECORD), + * else the machine's own plan_record (the untracked source for a repo that commits no .gstack/), else warn in a Capy + * session, else off. A machine's key never loosens a tracked warn or require. + */ export function planMode(policyValue: PlanMode | undefined, env: Env): { mode: PlanMode; source: string } { if (policyValue && policyValue !== 'off') return { mode: policyValue, source: 'base policy plan_record' }; const opt = env.GSTACK_PLAN_RECORD; if (opt === 'warn' || opt === 'require') return { mode: opt, source: 'GSTACK_PLAN_RECORD' }; - if (env.GSTACK_CAPY === '1') return { mode: 'warn', source: 'Capy crew default' }; - return { mode: 'off', source: 'default' }; + const local = localPlanMode(env); + if (local && 'mode' in local) return local; + const ignored = local ? `; ${local.ignored}` : ''; + if (env.GSTACK_CAPY === '1') return { mode: 'warn', source: `Capy crew default${ignored}` }; + return { mode: 'off', source: `default${ignored}` }; } +/** Under off, the one PLAN: line ready, land and finish still print when the machine's key decided the mode or was ignored. */ +const offNote = (mode: { mode: PlanMode; source: string }) => (mode.source.includes(LOCAL_SOURCE) ? `PLAN: off [mode off from ${mode.source}]` : undefined); + /** The first `Plan:` line at the start of a line, outside fenced blocks; null when the body has none. */ export function parsePlanLine(body: string): PlanDecl | null { let fenced = false; @@ -59,7 +87,9 @@ export function parsePlanLine(body: string): PlanDecl | null { if (!m) continue; const line = `Plan: ${m[1]!.trim()}`; const rest = m[1]!.trim(); - if (!/^(approved|exempt|required)(?=[\s:]|$)/i.test(rest)) continue; + if (!/^(approved|exempt|required)(?=[\s:(]|$)/i.test(rest)) continue; + const record = /^approved\s*\(\s*record\s+(r-[0-9a-f]{12})\s*\)/i.exec(rest); + if (record) return { kind: 'approved', line, token: record[1]!.toLowerCase() }; const approved = /^approved\s*:?\s*(\S+)/i.exec(rest); if (approved) return { kind: 'approved', line, token: approved[1]!.replace(/[),.;]+$/, '') }; const exempt = /^exempt\s*:?\s*(.+)$/i.exec(rest); @@ -153,24 +183,46 @@ function resolveApproved(decl: Extract, o: { cwd if (!store) return { ok: false, failure: { code: 'PLAN_STORE_UNAVAILABLE', message: `${decl.line}: no approval store on this machine (the state root's approvals and every mirror are missing)`, fix: 'run this where the plan was approved, or gstack-approvals sync on the lane machine, then rerun' } }; let row: ApprovalRow | undefined; let ref = decl.token; - if (ROW_ID.test(decl.token)) { - row = store.rows.find(r => r.id === decl.token); + const byRecord = RECORD_ID.test(decl.token); + if (byRecord || ROW_ID.test(decl.token)) { + row = store.rows.find(r => (byRecord ? planRecordId(r.id) : r.id) === decl.token); + if (!row && byRecord) return { ok: false, failure: { code: 'PLAN_UNRESOLVED', message: `${decl.line}: no approval row behind record ${decl.token} in ${store.dirs.join(', ')}${blockRecordIds(store.rows).has(decl.token) ? `; ${decl.token} is the Approvals block's id for a whole record, not a Plan line's` : ''}`, fix: 'paste the Plan line gstack-approvals render --plan printed, run ready where the plan was approved, or gstack-approvals sync, then rerun' } }; if (!row) return { ok: false, failure: { code: 'PLAN_UNRESOLVED', message: `${decl.line}: no approval row ${decl.token} in ${store.dirs.join(', ')}`, fix: 'record the owner\'s words with gstack-approvals add (or import --autoplan), then cite the row id it prints' } }; - if (NOT_PLAN_ROW.test(row.ask) || NOT_PLAN_ROW.test(row.scope)) return { ok: false, failure: { code: 'PLAN_UNRESOLVED', message: `${decl.line}: row ${row.id} is a ruling or grant, not a plan approval`, fix: 'cite the row that recorded the owner\'s plan approval' } }; + if (NOT_PLAN_ROW.test(row.ask) || NOT_PLAN_ROW.test(row.scope)) return { ok: false, failure: { code: 'PLAN_UNRESOLVED', message: `${decl.line}: row ${byRecord ? `behind ${decl.token}` : row.id} is a ruling or grant, not a plan approval`, fix: 'cite the row that recorded the owner\'s plan approval' } }; } const run = row ? /^autoplan:(.+)@gate_rev=\d+$/.exec(row.source)?.[1] : runIdFor(decl.token, o.cwd); if (run) { const gate = gateState(store.rows, run); - if ('message' in gate) return { ok: false, failure: { code: gate.code, message: `${decl.line}: ${gate.message}`, fix: gate.code === 'PLAN_UNRESOLVED' ? `gstack-approvals import --autoplan --by "" after the owner's answer, then rerun` : 'finish the gate with the owner\'s answer and import it again' } }; + const where = byRecord ? `record ${decl.token}` : `autoplan run ${run}`; + if ('message' in gate) return { ok: false, failure: { code: gate.code, message: `${decl.line}: ${byRecord ? gate.message.replace(`autoplan run ${run}`, where) : gate.message}`, fix: gate.code === 'PLAN_UNRESOLVED' ? `gstack-approvals import --autoplan --by "" after the owner's answer, then rerun` : 'finish the gate with the owner\'s answer and import it again' } }; row = gate.row; - ref = run; + if (!byRecord) ref = run; } if (!row) return { ok: false, failure: { code: 'PLAN_UNRESOLVED', message: `${decl.line}: no approval row in ${store.dirs.join(', ')}`, fix: 'cite an approval row id or an autoplan run id' } }; - if (row.outcome !== 'approved') return { ok: false, failure: { code: 'PLAN_NOT_APPROVED', message: `${decl.line}: row ${row.id} (${row.source}) is ${row.outcome}`, fix: 'finish the gate with the owner\'s answer and import it again' } }; - if (agentWorded(row)) return { ok: false, failure: { code: 'PLAN_NOT_APPROVED', message: `${decl.line}: row ${row.id} was answered in an agent's own words, not the owner's (${row.by})`, fix: 'relay the gate to the owner through the Captain and answer it with their exact words (--owner-reply), then import again' } }; + const named = byRecord ? `the row behind ${decl.token}` : `row ${row.id}`; + if (row.outcome !== 'approved') return { ok: false, failure: { code: 'PLAN_NOT_APPROVED', message: `${decl.line}: ${named}${byRecord ? '' : ` (${row.source})`} is ${row.outcome}`, fix: 'finish the gate with the owner\'s answer and import it again' } }; + if (agentWorded(row)) return { ok: false, failure: { code: 'PLAN_NOT_APPROVED', message: `${decl.line}: ${named} was answered in an agent's own words, not the owner's${byRecord ? '' : ` (${row.by})`}`, fix: 'relay the gate to the owner through the Captain and answer it with their exact words (--owner-reply), then import again' } }; + if (byRecord) { + const rev = /@gate_rev=(\d+)$/.exec(row.source)?.[1]; + return { ok: true, record: { line: decl.line, state: 'approved', ref, row_sha256: rowSha(row), resolver: PLAN_RESOLVER_VERSION }, summary: `approved ${ref} (${rev ? `gate_rev ${rev}, ` : ''}${row.date})` }; + } return { ok: true, record: { line: decl.line, state: 'approved', ref, row_id: row.id, row_sha256: rowSha(row), resolver: PLAN_RESOLVER_VERSION }, summary: `approved ${ref} row=${row.id} (${row.ask.slice(0, 80)}; ${row.by.slice(0, 60)}, ${row.date})` }; } +/** The ids the redacted Approvals block prints for each branch's record (renderRecord), to name a pasted block id. */ +function blockRecordIds(rows: ApprovalRow[]): Set { + const byBranch = new Map(); + for (const r of rows) byBranch.set(r.branch, [...(byBranch.get(r.branch) ?? []), r.id]); + return new Set([...byBranch.values()].map(ids => 'r-' + createHash('sha256').update(ids.sort().join('\n')).digest('hex').slice(0, 12))); +} + +/** The Plan line render --plan prints on a public or unknown-visibility repo: a resolved approval by record id, any other line as is. */ +export function publicPlanLine(decl: PlanDecl, res: PlanResolution): string { + if (decl.kind !== 'approved' || !res.ok) return decl.line; + const ref = res.record.ref ?? ''; + return `Plan: approved (record ${RECORD_ID.test(ref) ? ref : planRecordId(res.record.row_id!)})`; +} + /** The READY line for a resolution under a mode. */ export function planLine(res: PlanResolution, mode: { mode: PlanMode; source: string }): string { if (res.ok) return `PLAN: ${res.summary}`; @@ -182,8 +234,10 @@ export function readyPlan(o: { cwd: string; env: Env; body: string; policyValue? const mode = planMode(o.policyValue, o.env); const decl = parsePlanLine(o.body); if (mode.mode === 'off') { - if (!decl) return {}; - try { const res = resolvePlan(decl, { cwd: o.cwd, env: o.env, changed: o.changed, fragmentsDir: o.fragmentsDir }); return res.ok ? { record: res.record } : {}; } catch { return {}; } + const note = offNote(mode); + const line = note ? { line: note } : {}; + if (!decl) return line; + try { const res = resolvePlan(decl, { cwd: o.cwd, env: o.env, changed: o.changed, fragmentsDir: o.fragmentsDir }); return res.ok ? { ...line, record: res.record } : line; } catch { return line; } } const res = resolvePlan(decl, { cwd: o.cwd, env: o.env, changed: o.changed, fragmentsDir: o.fragmentsDir }); const line = planLine(res, mode); @@ -194,7 +248,7 @@ export function readyPlan(o: { cwd: string; env: Env; body: string; policyValue? /** land's plan step: the READY note's record must exist and carry the PR body's current Plan line; land never needs the store. */ export function landPlan(o: { env: Env; policyValue?: PlanMode; record?: PlanRecord; body: string }): { line?: string; failure?: PlanFailure } { const mode = planMode(o.policyValue, o.env); - if (mode.mode === 'off') return {}; + if (mode.mode === 'off') { const note = offNote(mode); return note ? { line: note } : {}; } const current = parsePlanLine(o.body)?.line ?? null; const why = !o.record ? 'the READY note carries no plan record' : (current ?? 'Plan: exempt: changelog-only') !== o.record.line && !(current === null && o.record.reason === 'changelog-only') ? `the READY note recorded "${o.record.line}" but the PR body now says "${current ?? 'no Plan: line'}"` : null; diff --git a/lib/result-codes-capy.ts b/lib/result-codes-capy.ts index b0a38bac2..a7f3812a4 100644 --- a/lib/result-codes-capy.ts +++ b/lib/result-codes-capy.ts @@ -96,7 +96,7 @@ export const CAPY_RESULT_CODES = { }, PLAN_UNRESOLVED: { anchor: 'plan-unresolved', - summary: 'Plan: approved: names no approval row this machine can read, or a row that is a ruling or grant rather than a plan approval', + summary: 'Plan: approved: (or a public repo\'s approved (record r-)) names no approval row this machine can read, or a row that is a ruling or grant rather than a plan approval', fix: 'gstack-approvals import --autoplan --by "" after the owner answered (or gstack-approvals add with their words for a plan approved outside /autoplan), then cite the run id or row id and rerun', }, PLAN_NOT_APPROVED: { diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 1a5eae42e..abc4f1844 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -235,7 +235,7 @@ export const POLICY_EXPLANATIONS: Record = { mechanical_retake: MECHANICAL_EXPLANATION, test_code: 'Root-anchored globs of test code the built-in test names miss, never under .gstack/ or .github/, for example ["tests/**/*.gd"] for GDScript checks. /ship finish routes a matching file changed after review as test code (one scoped reader of its test hunks) instead of behavior, beside the built-in globs; shared helpers and fixtures stay behavior. Read from the base branch only. Empty (default) declares none.', ci_record_timeout_s: 'Seconds `gstack-ship-release accept` lets one `gstack-evidence record-ci` run before it stops that lane, an integer of at least 60 (default 600). A value over 3600 is clamped to 3600, and accept prints the bound it used (`CI_RECORD_TIMEOUT:`). Raise it when record-ci runs long, for example a per_file remainder of CI-skipped files. Read from the base branch only.', - plan_record: 'off (default) | warn | require. Whether `gstack-ship-queue ready` and `land` check the PR body\'s `Plan:` line (approved: | exempt: ) against the approval store (docs/ship-policy.md#plan-record). warn prints the PLAN: line and continues; require refuses a missing, unresolved or agent-approved plan. A Capy crew session with off gets warn; GSTACK_PLAN_RECORD=require is a crew opt-in. Read from the base branch only.', + plan_record: 'off (default) | warn | require. Whether `gstack-ship-queue ready` and `land` check the PR body\'s `Plan:` line (approved: | exempt: ) against the approval store (docs/ship-policy.md#plan-record). warn prints the PLAN: line and continues; require refuses a missing, unresolved or agent-approved plan. This key is read from the base branch only; with it off, GSTACK_PLAN_RECORD, then plan_record in /config.yaml (gstack-config set plan_record warn, for a repo that commits no .gstack/), then warn in a Capy session decide. On a public repo render --plan writes Plan: approved (record r-) instead of the run id.', merge: 'train (default) | direct. direct: the Captain lands each READY PR at its own green head with `gstack-ship-queue land --pr --pin ` (no train, no stamp commit; the PR leaves VERSION, the manifest version and the CHANGELOG at the base and the release stamps them), and train build refuses. train: merge trains as before (docs/ship-policy.md#land-direct).', min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).', }; diff --git a/ship/sections/capy-pr.md b/ship/sections/capy-pr.md index 0a587dfe7..89bf352cc 100644 --- a/ship/sections/capy-pr.md +++ b/ship/sections/capy-pr.md @@ -40,7 +40,8 @@ commit themes; do not repeat the captions. the one fact that makes that safe, with the evidence-ladder rung it reached (file:line read, walked failure path, script ran, repro). 7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render --plan ""`, verbatim. It escapes its cells, and the receipt never reads it as an approval citation. A public repo (or unknown - visibility) gets only `Approved: yes|no (record )`; never pass `--full` for a PR body. On + visibility) gets only `Approved: yes|no (record )` and `Plan: approved (record r-)`; never pass `--full` for a PR body. If render + refuses with a `PLAN_*` code, it printed nothing: run the fix it names and render again. On `APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand. 8. **READY report:** attach the key image's PNG to the report you send (Capy's message attachments) and give its uploaded URL, so the Captain can show it inline. diff --git a/ship/sections/capy-pr.md.tmpl b/ship/sections/capy-pr.md.tmpl index 36d92f732..502eb0291 100644 --- a/ship/sections/capy-pr.md.tmpl +++ b/ship/sections/capy-pr.md.tmpl @@ -38,7 +38,8 @@ commit themes; do not repeat the captions. the one fact that makes that safe, with the evidence-ladder rung it reached (file:line read, walked failure path, script ran, repro). 7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render --plan ""`, verbatim. It escapes its cells, and the receipt never reads it as an approval citation. A public repo (or unknown - visibility) gets only `Approved: yes|no (record )`; never pass `--full` for a PR body. On + visibility) gets only `Approved: yes|no (record )` and `Plan: approved (record r-)`; never pass `--full` for a PR body. If render + refuses with a `PLAN_*` code, it printed nothing: run the fix it names and render again. On `APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand. 8. **READY report:** attach the key image's PNG to the report you send (Capy's message attachments) and give its uploaded URL, so the Captain can show it inline. diff --git a/test/plan-record.test.ts b/test/plan-record.test.ts index 42efea087..f64eab03f 100644 --- a/test/plan-record.test.ts +++ b/test/plan-record.test.ts @@ -4,11 +4,12 @@ */ import { afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { buildRow, locate, type ApprovalRow } from '../lib/approval-record'; -import { exemptReason, finishPlan, landPlan, parsePlanLine, planMode, readyPlan, resolvePlan } from '../lib/plan-record'; +import { exemptReason, finishPlan, landPlan, parsePlanLine, planMode, planRecordId, publicPlanLine, readyPlan, resolvePlan } from '../lib/plan-record'; import { planBinding } from '../lib/ship-audit'; import { loadReady, persist, repoIdentity } from '../lib/ship-queue-train-record'; @@ -287,3 +288,122 @@ describe('a broken approval store never throws, and changes nothing with the key expect(req.failure?.code).toBe('PLAN_STORE_UNAVAILABLE'); }); }); + +describe('public repos: the record-keyed Plan line and the machine-local key (GST-1774)', () => { + const ghStub = (visibility: string) => { + const bin = tmp('plan-gh-'); + fs.writeFileSync(path.join(bin, 'gh'), `#!/bin/sh\necho ${visibility}\n`, { mode: 0o755 }); + return bin; + }; + const approvals = (f: { cwd: string; env: NodeJS.ProcessEnv }, visibility: string) => (args: string[]) => { + const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-approvals'), ...args], { cwd: f.cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...f.env, PATH: `${ghStub(visibility)}:${process.env.PATH}` } }); + return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; + }; + const setLocal = (env: NodeJS.ProcessEnv, value: string) => fs.writeFileSync(path.join(env.GSTACK_STATE_ROOT!, 'config.yaml'), `plan_record: ${value}\n`); + + test('parsePlanLine reads the record form; planRecordId is stable and opaque', () => { + expect(parsePlanLine('Plan: approved (record r-0123456789ab)')).toEqual({ kind: 'approved', line: 'Plan: approved (record r-0123456789ab)', token: 'r-0123456789ab' }); + expect(parsePlanLine('**Plan:** approved (record r-0123456789AB).')).toMatchObject({ token: 'r-0123456789ab' }); + expect(parsePlanLine('```\nPlan: approved (record r-0123456789ab)\n```')).toBeNull(); + expect(planRecordId('a-000000000000')).toMatch(/^r-[0-9a-f]{12}$/); + expect(planRecordId('a-000000000000')).toBe(planRecordId('a-000000000000')); + expect(planRecordId('a-000000000000')).not.toContain('000000000000'); + }); + + test('planMode: a tracked warn or require wins, then the env opt-in, then the machine key, then the Capy default', () => { + const state = tmp('plan-local-'); + const env = { GSTACK_STATE_ROOT: state } as NodeJS.ProcessEnv; + expect(planMode('off', env)).toEqual({ mode: 'off', source: 'default' }); + setLocal(env, 'require'); + expect(planMode('warn', env)).toEqual({ mode: 'warn', source: 'base policy plan_record' }); + expect(planMode('off', env)).toEqual({ mode: 'require', source: `state-root config ${state}/config.yaml` }); + expect(planMode(undefined, { ...env, GSTACK_PLAN_RECORD: 'warn' }).source).toBe('GSTACK_PLAN_RECORD'); + setLocal(env, 'off'); + expect(planMode('off', { ...env, GSTACK_CAPY: '1' }).mode).toBe('off'); + setLocal(env, 'requre'); + const typo = planMode('off', { ...env, GSTACK_CAPY: '1' }); + expect(typo.mode).toBe('warn'); + expect(typo.source).toContain('plan_record "requre" ignored'); + expect(planMode(undefined, { HOME: '' }).mode).toBe('off'); + }); + + test('a record id resolves to its row under the row-id rules, and never names the run, the row or the ask', () => { + const f = fixture({ main: [ + { ask: 'Approve the plan A (autoplan run ap-1)', source: 'autoplan:ap-1@gate_rev=1' }, + { ask: 'review_loop_grant abc for #1', scope: 'one fix-check' }, + { ask: 'Approve the plan C rev1', source: 'autoplan:ap-3@gate_rev=1' }, + { ask: 'Approve the plan C rev2', source: 'autoplan:ap-3@gate_rev=2', outcome: 'rejected', at: '2026-11-07T02:00:00Z' }, + ] }); + const id = planRecordId(f.ids['Approve the plan A (autoplan run ap-1)']); + const ok = resolvePlan(parsePlanLine(`Plan: approved (record ${id})`), f); + expect(ok.ok && ok.record).toEqual({ line: `Plan: approved (record ${id})`, state: 'approved', ref: id, row_sha256: expect.any(String), resolver: 1 }); + expect(ok.ok && ok.summary).toBe(`approved ${id} (gate_rev 1, 2026-11-07)`); + const grant = resolvePlan(parsePlanLine(`Plan: approved (record ${planRecordId(f.ids['review_loop_grant abc for #1'])})`), f); + expect(!grant.ok && grant.failure.code).toBe('PLAN_UNRESOLVED'); + const stale = resolvePlan(parsePlanLine(`Plan: approved (record ${planRecordId(f.ids['Approve the plan C rev1'])})`), f); + expect(!stale.ok && stale.failure.code).toBe('PLAN_NOT_APPROVED'); + expect(!stale.ok && stale.failure.message).not.toContain('ap-3'); + const unknown = resolvePlan(parsePlanLine('Plan: approved (record r-000000000000)'), f); + expect(!unknown.ok && unknown.failure.code).toBe('PLAN_UNRESOLVED'); + const block = 'r-' + createHash('sha256').update(Object.values(f.ids).sort().join('\n')).digest('hex').slice(0, 12); + const pasted = resolvePlan(parsePlanLine(`Plan: approved (record ${block})`), f); + expect(!pasted.ok && pasted.failure.message).toContain("the Approvals block's id"); + expect(publicPlanLine(parsePlanLine('Plan: approved: ap-1')!, resolvePlan(parsePlanLine('Plan: approved: ap-1'), f))).toBe(`Plan: approved (record ${id})`); + }); + + test('render on a public repo prints the record form, re-renders it unchanged, and refuses a line that does not resolve', () => { + const f = fixture({ 'capy-feature': [{ ask: 'Approve the plan A (autoplan run ap-1)', source: 'autoplan:ap-1@gate_rev=1' }] }); + const run = approvals(f, 'PUBLIC'); + const id = planRecordId(f.ids['Approve the plan A (autoplan run ap-1)']); + const body = run(['render', '--plan', 'Plan: approved: ap-1']).out; + expect(body).toContain(`Plan: approved (record ${id})\nPlan check: approved (record ${id})\n`); + for (const secret of ['ap-1', f.ids['Approve the plan A (autoplan run ap-1)']!, 'Approve the plan A']) expect(body).not.toContain(secret); + expect(parsePlanLine(body)).toMatchObject({ token: id }); + expect(run(['render', '--plan', `Plan: approved (record ${id})`]).out).toBe(body); + const check = run(['plan-check', '--plan', `approved (record ${id})`]); + expect(check.status).toBe(0); + expect(check.out).toBe(`PLAN: approved ${id} (gate_rev 1, 2026-11-07)\n`); + expect(run(['render', '--plan', 'Plan: exempt: bug fix (R65)']).out).toContain('Plan: exempt: bug fix (R65)\nPlan check: exempt bug fix\n'); + const missing = run(['render', '--plan', 'Plan: approved: ap-9']); + expect(missing.status).not.toBe(0); + expect(missing.out).toBe(''); + expect(missing.err).toContain('PLAN_UNRESOLVED'); + expect(missing.err).toContain('fix:'); + expect(approvals(f, 'PRIVATE')(['render', '--plan', 'Plan: approved: ap-1']).out).toContain('Plan: approved: ap-1\nPlan check: approved ap-1 row='); + }); + + test('ready, finish and land carry a record-form line with no run id, row id or ask', () => { + const f = fixture({ main: [{ ask: 'Approve the plan A (autoplan run ap-1)', source: 'autoplan:ap-1@gate_rev=1' }] }); + const rowId = f.ids['Approve the plan A (autoplan run ap-1)']!; + const line = `Plan: approved (record ${planRecordId(rowId)})`; + const ready = readyPlan({ cwd: f.cwd, env: f.env, body: line, policyValue: 'require', changed: ['lib/x.ts'] }); + const finish = finishPlan(f.cwd, 'main', line, { ...f.env, GSTACK_PLAN_RECORD: 'require' }); + for (const out of [ready.line!, finish.line!, JSON.stringify(ready.record)]) for (const secret of ['ap-1', rowId, 'Approve the plan']) expect(out).not.toContain(secret); + expect(ready.record?.row_id).toBeUndefined(); + expect(landPlan({ env: {}, policyValue: 'require', record: ready.record, body: line })).toEqual({ line: `PLAN: approved ${planRecordId(rowId)} (from the READY note)` }); + }); + + test('with no tracked policy the machine key drives ready and finish, and a deciding off still prints a line', () => { + const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] }); + setLocal(f.env, 'require'); + const ready = readyPlan({ cwd: f.cwd, env: f.env, body: '', changed: ['lib/x.ts'] }); + expect(ready.failure?.code).toBe('PLAN_RECORD_MISSING'); + expect(ready.line).toContain(`[mode require from state-root config ${f.env.GSTACK_STATE_ROOT}/config.yaml]`); + expect(finishPlan(f.cwd, 'main', undefined, f.env).failure?.code).toBe('PLAN_RECORD_MISSING'); + setLocal(f.env, 'off'); + const off = readyPlan({ cwd: f.cwd, env: { ...f.env, GSTACK_CAPY: '1' }, body: '', changed: ['lib/x.ts'] }); + expect(off).toEqual({ line: `PLAN: off [mode off from state-root config ${f.env.GSTACK_STATE_ROOT}/config.yaml]` }); + expect(landPlan({ env: { ...f.env, GSTACK_CAPY: '1' }, policyValue: 'off', body: '' }).line).toContain('PLAN: off'); + }); + + test('gstack-config takes off, warn or require for plan_record and rejects a typo', () => { + const state = tmp('plan-config-'); + const config = (...args: string[]) => spawnSync(path.join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: state, GSTACK_HOME: '' } }); + expect(config('get', 'plan_record')).toMatchObject({ status: 0, stdout: '' }); + expect(config('set', 'plan_record', 'warn').status).toBe(0); + const typo = config('set', 'plan_record', 'requre'); + expect(typo.status).toBe(1); + expect(typo.stderr).toContain('Existing value left unchanged'); + expect(config('get', 'plan_record').stdout.trim()).toBe('warn'); + }); +}); diff --git a/test/size-ledger/ship/capy-gst-1774-plan-check-public.json b/test/size-ledger/ship/capy-gst-1774-plan-check-public.json new file mode 100644 index 000000000..314ce5fc7 --- /dev/null +++ b/test/size-ledger/ship/capy-gst-1774-plan-check-public.json @@ -0,0 +1,4 @@ +{ + "bytes": 139, + "why": "GST-1774: capy-pr item 7 names the public Plan line form and render's PLAN_* refusal" +}