From e5366a52b7ecd1acf26aca948cf3191007c1ec3a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 19:42:16 -0800 Subject: [PATCH] fix(autoplan): export --public drops owner replies and redacts quoted owner words (GST-1243) (#3967) * 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> * fix(autoplan): owner words in every deferral rendering, superseded replies, glued punctuation, short replies, echoed plan text (GST-1243 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): normalize markup, entities, escapes and format characters before matching owner words; each new run in an echoing reply is its own needle (GST-1243 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review-Finding: p1/outside-voice#1 reader=outside-voice Review-Finding: p1/outside-voice#2 reader=outside-voice Review-Finding: p1/outside-voice#3 reader=outside-voice Review-Finding: p1/outside-voice#4 reader=outside-voice Review-Finding: p1/outside-voice#5 reader=outside-voice Review-Finding: p1/outside-voice#6 reader=outside-voice Review-Finding: p1/adversarial-native#1 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Review-Finding: p2/red-team#1 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-1243 fragment names the review fixes 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> --- 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 | 13 + docs/troubleshooting.md | 24 ++ docs/unattended.md | 60 +++- lib/autoplan-owner-words.ts | 281 +++++++++++++++ lib/autoplan-public.ts | 62 +++- lib/result-codes-autoplan.ts | 6 + scripts/resolvers/autoplan-driver.ts | 2 +- test/autoplan-owner-words.test.ts | 176 +++++++++ test/autoplan-public-owner-words.test.ts | 334 ++++++++++++++++++ 13 files changed, 958 insertions(+), 16 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 5f99bf75f..2ad63e371 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 (every owner_reply the run recorded, carried words and a human's deferral reason dropped, quotes and gstack's renderings 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..bcdbd1eda 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 and renderings of them + redacted (OWNER_WORDS: lines), refused (ARTIFACT_OWNER_WORDS) when one 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 5652eab65..b5d840701 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -395,6 +395,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..476d3b2ae --- /dev/null +++ b/changelog.d/gst-1243-public-export-owner-words.md @@ -0,0 +1,13 @@ +**`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 +- **Whose words.** Every owner reply the private run ever recorded counts: decision rows, `run.json`'s gate and the journal (`attempts.jsonl` keeps a reply a later answer at the same gate replaced), the words a carried gate reused, and a reason a human gave for `close --deferred --by human`. Replies that parsed as gate grammar are the decision code. An agent's own words (`answer_words`) and an agent's deferral reason stay. +- **Dropped fields.** The public copy drops `owner_reply` everywhere and a carry record's `words`, and replaces a human deferral's `reason` with `[owner words redacted]`. The decision itself stays: `chosen`, the gate-grammar `reply`, `owner_mode` and `owner_reply_parsed`. +- **gstack's renderings.** A human's deferral reason is replaced where gstack writes it into fixed text (voice `detail`, consensus head, gate title, a blocker's `why`, the run log), and carried words in the `CARRIED:` line. Each rendering is matched with its delimiters on both sides, so a one-word reason never rewrites a longer one that starts with it. +- **Redacted quotes.** Every exported file, sealed `*.bound.md` reviewer results included, is scanned. Before matching, the text is normalized: JSON string escapes and HTML entities decoded (an accented letter such as `é` reads as its letter), format characters dropped by Unicode category (`\p{Cf}`, variation selectors, the combining grapheme joiner), HTML tags read as spaces (in the rendered view the whole tag, attributes included) and hyphen-newline wraps joined. It is read both as rendered (comments, footnote markers, reference definitions and link targets and titles dropped, link text kept) and as written, so markup can neither split a quote nor hide one. Replies are indexed in both views too, so a link target an owner gave is found. Quotes are redacted on the original bytes and again after recorded roots are rewritten, so a path in a reply neither hides a quote nor makes the export refuse. Contractions split ("can't" and "cannot" read "can not", "it's" "it is", "I'd" "I would", and -n't, -'re, -'ve, -'ll, -'d). A word is a run of letters and digits, and any other character separates words. A reply of 3 or more words is a quote wherever it appears whole; from 8 words, any 8 consecutive words of it are a quote too. Replies of 1 or 2 words ("yes", "do it") are not scanned for. A quote extends over neighboring words of the same reply, and the marker replaces the original characters from its first word to its last. +- **Echoes stay.** Words the owner repeated from text recorded before his reply that he didn't write (the plan at an earlier gate, that gate's items, reviewer results bound before it) stay; only the new words around them are redacted. Every run of 3 or more words a reply adds between echoes is matched on its own (and by any 8 of its words from 8), so those words are redacted even where they are quoted without the echo. +- **Refusal.** A quote or rendering inside plan.md code (fenced, or inline with any number of backticks), 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. Each changed file gets an `OWNER_WORDS: dropped= redacted=` line. + +### 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. Also: a quote in plan.md code refuses; validate catches a quote or rendering added later; a human's deferral reason leaves gate.json's `why` and every other rendering, an agent's stays; a one-word human reason leaves an agent's longer one alone; a reply replaced at the same gate is still redacted; a rendering never rewrites plan.md code and an exact one there refuses; echoed plan and finding text stays; a 4-word reply is redacted; a short quote split by a tag or a reference link is redacted and validates; the new words a reply added to an echoed plan line are redacted when quoted alone, and plan.md stays byte-identical. +- `test/autoplan-owner-words.test.ts`: the matching rule (1-2 word and grammar replies, whole phrases, the 8-word window, eleven kinds of glue, fifteen kinds of markup and invisible characters (tags, comments, reference links, footnotes, link titles, literal JSON escapes, zero-width and soft-hyphen entities, direction marks and isolates, the combining grapheme joiner, a variation selector), redaction offsets over markup and escapes, contractions, new words added to an echo, extension, echoes, kept code spans, bounded renderings) and a ratchet on every template that renders a deferral reason. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 96c58f3d6..12b13a060 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4800,6 +4800,30 @@ 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 the private run recorded (on a decision row, the gate or +the journal), the words a carried gate reused, or a reason a human gave for +deferring a phase, in prose or in one of gstack's renderings of that reason. +Nothing a person wrote leaves without their click. A reply of 3 or more words +is a quote wherever it appears whole, and from 8 words any 8 consecutive words +are one, compared across punctuation, line breaks, JSON escapes, entities, HTML and +Markdown markup, invisible format characters, contractions and case; replies +of 1 or 2 words are not scanned for, and words the owner echoed from earlier +text he didn't write are not his (the words a reply adds to an echo still are). See +[the public copy](unattended.md#the-public-copy). `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 or rendering 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 cc5e4ec46..5900a770f 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` (`-