From c535ddc46d337dd52656ff458b44ad482e58f94f Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 17:32:25 +0000 Subject: [PATCH] fix(autoplan): export --public drops owner replies and redacts quoted owner words, refusing what it can't redact (GST-1243) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- autoplan/DRIVER.md | 2 +- bin/gstack-artifact | 5 +- bin/gstack-autoplan | 8 +- bin/gstack-doctor-components.sh | 1 + .../gst-1243-public-export-owner-words.md | 10 ++ docs/troubleshooting.md | 20 +++ docs/unattended.md | 30 +++- lib/autoplan-owner-words.ts | 108 +++++++++++ lib/autoplan-public.ts | 60 ++++++- lib/result-codes-autoplan.ts | 6 + scripts/resolvers/autoplan-driver.ts | 2 +- test/autoplan-owner-words.test.ts | 80 +++++++++ test/autoplan-public-owner-words.test.ts | 167 ++++++++++++++++++ 13 files changed, 482 insertions(+), 17 deletions(-) create mode 100644 changelog.d/gst-1243-public-export-owner-words.md create mode 100644 lib/autoplan-owner-words.ts create mode 100644 test/autoplan-owner-words.test.ts create mode 100644 test/autoplan-public-owner-words.test.ts diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index a2aef2c50..fa41b0332 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -86,7 +86,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** - `gstack-autoplan export --out ` write the artifact set; then `gstack-artifact validate /run.json`. - `gstack-autoplan export --out --public ` - a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine. + a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner's own words (owner_reply, carried words and a human's deferral reason dropped, quotes of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine. - `gstack-autoplan status|resume --out ` read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). - `gstack-autoplan resume --out --from ` diff --git a/bin/gstack-artifact b/bin/gstack-artifact index 2a8f77af5..0161e1120 100755 --- a/bin/gstack-artifact +++ b/bin/gstack-artifact @@ -29,7 +29,8 @@ const HELP = `gstack-artifact — the unattended artifact contract (docs/unatten validate [--json] run-directory completion check validate --public [--private ] a public run copy (gstack-autoplan export --public): provenance digests; with --private (on the exporting machine) any recorded root fails - (ARTIFACT_MACHINE_PATH, file:line); home-path patterns WARN + (ARTIFACT_MACHINE_PATH, file:line), and so does a quote of the + owner's words (ARTIFACT_OWNER_WORDS); home-path patterns WARN validate --as [--run ] [--json] one artifact against its schema schema | --list JSON Schema for ${ARTIFACT_NAMES.join(', ')} ack --consumer [--json] set run.json.consumed_by (idempotent) and log the ack @@ -71,7 +72,7 @@ function main(argv: string[]): number { else { printErrors(result.errors); for (const w of result.warnings) process.stderr.write(w + '\n'); - process.stdout.write(result.valid ? `ARTIFACT_VALID: public run=${result.run} warnings=${result.warnings.length}${flag(rest, '--private') ? '' : ' (recorded roots unchecked: pass --private on the exporting machine)'}\n` : `ARTIFACT_INVALID: ${result.errors.length} error(s)\n`); + process.stdout.write(result.valid ? `ARTIFACT_VALID: public run=${result.run} warnings=${result.warnings.length}${flag(rest, '--private') ? '' : ' (recorded roots unchecked: pass --private on the exporting machine; it also checks for the owner\'s words)'}\n` : `ARTIFACT_INVALID: ${result.errors.length} error(s)\n`); } return result.valid ? EXIT.ok : EXIT.fail; } diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index c5f6d88eb..7e6454786 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -16,7 +16,7 @@ * gstack-autoplan resume --out [--from ] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN * gstack-autoplan status --out [--json] query: the manifest, no GSTACK_RESULT line * gstack-autoplan export --out [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes - * gstack-autoplan export --out --public a machine-path-free copy in ; the private run is only read + * gstack-autoplan export --out --public a machine-path-free copy in without the owner's own words; the private run is only read * gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran * gstack-autoplan close --out --phase

--deferred "" --by > close a phase accepted-with-findings * gstack-autoplan budget --out

[--spend-cap ] [--max-rounds ] --by > the owner's journaled cap change @@ -128,7 +128,9 @@ DRIVER: ${driverFile()} the owner's cap change, journaled; a run stopped incomplete (budget_exhausted) resumes with resume afterwards export --out --public write a machine-path-free copy (recorded roots rewritten, plan.md code kept) with provenance digests; the private - run is not touched; refused (ARTIFACT_MACHINE_PATH) when a recorded root would remain + run is not touched; refused (ARTIFACT_MACHINE_PATH) when a recorded root would remain. The owner's own words never + leave: owner_reply, carried words and a human's deferral reason are dropped, quotes of them redacted (OWNER_WORDS: + lines), refused (ARTIFACT_OWNER_WORDS) when a quote sits in plan.md code status --out [--json] export --out release --out --attempt contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) @@ -531,7 +533,7 @@ async function main(argv: string[]): Promise { return withRunLock(out, () => { const r = exportPublic(readState(out), path.resolve(flag(rest, '--public')!)); for (const w of r.warnings) process.stderr.write(w + '\n'); - emit(json, r.files.map(f => `EXPORTED_PUBLIC: ${f}`), { files: r.files, warnings: r.warnings }); + emit(json, [...r.files.map(f => `EXPORTED_PUBLIC: ${f}`), ...r.owner], { files: r.files, warnings: r.warnings, owner_words: r.owner }); return EXIT.ok; }); } diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 4ec7b9a61..3725587d1 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -388,6 +388,7 @@ gstack_result_anchor() { CEO_ONLY_AFTER_INIT) printf '%s' 'ceo-only-after-init' ;; CEO_ONLY_NO_GATE) printf '%s' 'ceo-only-no-gate' ;; SHADOW_BEFORE_ANSWER) printf '%s' 'shadow-before-answer' ;; + ARTIFACT_OWNER_WORDS) printf '%s' 'artifact-owner-words' ;; BROWSE_TAB_UNRECOVERABLE) printf '%s' 'browse-tab-unrecoverable' ;; BROWSE_SESSION_INVALID) printf '%s' 'browse-session-invalid' ;; BROWSE_SESSION_ROOT_ONLY) printf '%s' 'browse-session-root-only' ;; diff --git a/changelog.d/gst-1243-public-export-owner-words.md b/changelog.d/gst-1243-public-export-owner-words.md new file mode 100644 index 000000000..bb83b3dc5 --- /dev/null +++ b/changelog.d/gst-1243-public-export-owner-words.md @@ -0,0 +1,10 @@ +**`gstack-autoplan export --public` no longer carries the owner's own words (GST-1243).** GEVA's public bundle (GEVA-225, gstack 1.93.34.0) kept all 38 `owner_reply` fields in decisions.jsonl, and a sealed reviewer file (`ceo-native.a5.bound.md`) quoted an owner reply word for word. GEVA redacted both by hand. Garry's rule: nothing a person wrote leaves without their click. + +### Fixed +- **Dropped fields.** The public copy drops `owner_reply` from every decision row and from `run.json`'s gate, the `words` a carried gate reused (its `CARRIED:` line keeps `words=[owner words redacted]`), and the reason a human gave for `close --deferred --by human` (replaced by `[owner words redacted]` along with the gate titles and log lines that repeat it). The decision itself stays: `chosen`, the gate-grammar `reply`, `owner_mode` and `owner_reply_parsed`. An agent's own words (`answer_words`) and an agent's deferral reason stay too. +- **Redacted quotes.** Every exported file, sealed `*.bound.md` reviewer results included, is scanned for quotes of those words: any 8 consecutive words of a reply (the whole reply when it is shorter), compared without edge punctuation or case and across line breaks and `>` blockquote markers. Replies under 5 words or 20 characters ("yes", "do it") and replies that parsed as gate grammar are not scanned for, so common words are never redacted. Each quote becomes `[owner words redacted]`, prose without one keeps its bytes, and an `OWNER_WORDS: dropped= redacted=` line names each changed file. +- **Refusal.** A quote inside plan.md code, which export never rewrites, stops the export with `ARTIFACT_OWNER_WORDS`, file:line and exit 3, and nothing is written. `gstack-artifact validate --public --private ` runs the same check. The error never prints the words. + +### Tests +- `test/autoplan-public-owner-words.test.ts`: a GEVA-shaped bundle (3 `owner_reply` rows, a carried gate, one reviewer quoting a reply on one line and one reflowing a reply across blockquote lines, a control file with no owner words) exports with every owner field dropped and both quotes redacted, while the control and the round-1 reviewer files stay byte-identical; a quote in plan.md code refuses; validate catches a quote added later; a human's deferral reason leaves, an agent's stays. +- `test/autoplan-owner-words.test.ts`: the matching rule (short replies, grammar replies, the 8-word window, line breaks and curly quotes, kept code spans, gstack's verbatim copies). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 796299069..85eaa0ec6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4692,6 +4692,26 @@ a hit there needs a plan edit: change the path, `revise`, and export again. Generic home-directory paths that are not recorded roots are WARN lines, not errors. + +### `ARTIFACT_OWNER_WORDS` + +**Meaning.** A public copy of an autoplan run still quotes the owner's own +words: a gate reply an agent relayed (`owner_reply`), the words a carried gate +reused, or a reason a human gave for deferring a phase. Nothing a person wrote +leaves without their click. A quote is any 8 consecutive words of one of those +replies (the whole reply when it is shorter), compared across line breaks, +blockquote markers, quote marks and case; replies under 5 words are not +scanned for. `gstack-artifact validate --public --private +/run.json` reports each quote with file:line, and `gstack-autoplan export +--public` runs that check on a staging copy and writes nothing when it fails. +The error never prints the words themselves. + +**Fix.** Export with `gstack-autoplan export --out --public `, which +drops those fields and replaces each quote in prose and sealed reviewer files +with `[owner words redacted]`. Export never rewrites inline code or fenced +blocks in plan.md, so a quote there needs a plan edit: remove it, `revise`, and +export again. + ### `ARTIFACT_PUBLIC_TARGET_EXISTS` diff --git a/docs/unattended.md b/docs/unattended.md index 913dce009..0c65d562d 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -278,7 +278,7 @@ tree no review saw, so /ship runs again; | File | Schema | Contents | |------|--------|----------| | `run.json` | `run` v1 | The manifest: `run` id, `status`, `required_phases`, each phase's `native` and `outside` outcome (`status`, `model`, `provider`), input `snapshot` hashes, `counts`, every artifact's `path` (relative to the run directory) and `sha256`, `session_kind`, `gate_rev`, `deviations`, `consumed_by`. | -| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`-