--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` (`-` at gate revision 1, `-r-` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`, or `auto_kept` in a [CEO-only run](#ceo-only-runs)), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`, plus how the owner answered: `owner_mode` (`rec`, `rec_except`, `tokens`, `as_is` or `unparsed`, parsed from the owner's own words), `owner_named` (his words named this item), `rec_hash` (the recommendations answered against) and `answered_role` (`human`, or `agent` relaying his words). |
+| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`-` at gate revision 1, `-r-` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`, or `auto_kept` in a [CEO-only run](#ceo-only-runs)), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`, plus how the owner answered: `owner_mode` (`rec`, `rec_except`, `tokens`, `as_is` or `unparsed`, parsed from the owner's own words), `owner_named` (his words named this item), `rec_hash` (the recommendations answered against) and `answered_role` (`human`, or `agent` relaying his words); an agent-relayed answer adds `owner_reply`, his verbatim words, which never leave in a [public copy](#the-public-copy). |
| `shadow/` | private, not an artifact | [Shadow mode](#shadow-mode) only: `ceo-only.r.jsonl` (one hidden prediction per gate item, written before the answer), `r.prompt.md` and one `.reply.txt` per model call, the audit `salt`, and `r.pending` while a call is in flight. Never in `export --public`, the gate render or the owner brief. |
| `findings.jsonl` | `findings` v1 | One row per reviewer finding: `id`, `phase`, `voice` (`native`/`outside`), `model`, `severity`, `title`, `disposition`, `plan_items`, `native_counterpart` (string or null), `source`. Rows share the review-log row shape, so `gstack-review-log --findings` derives review status from this file. |
| `tasks.jsonl` | `tasks` v2 | Implementation tasks with `id`, `title`, `status`, `depends_on`, `findings` (ids in `findings.jsonl`), `blocked_by`, `acceptance`, optional `pr`/`item`/`tier`. |
@@ -292,6 +292,34 @@ only place the run id is declared. `gstack-artifact schema ` prints the
JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
`pregate` and `ship-receipt`; `schema --list` prints the names.
+### The public copy
+
+`gstack-autoplan export --out --public ` writes a copy to commit or
+share. It rewrites the machine paths the run recorded, and it never carries the
+owner's own words: nothing a person wrote leaves without their click (GST-1243).
+
+- **Dropped fields.** `owner_reply` on every decision row and on `run.json`'s
+ gate, the `words` a carried gate reused, and the reason a human typed for
+ `close --deferred --by human` (replaced by `[owner words redacted]`, along with
+ the titles and log lines that repeat it). The decision itself stays: `chosen`,
+ `reply` (the gate grammar), `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 reviewer results
+ (`*.bound.md`) included, is scanned for quotes of those words. Words are
+ compared without the punctuation at their edges (quote marks, `>` blockquote
+ markers), case-insensitive, with line breaks read as spaces. A quote is any 8
+ consecutive words of an owner reply, or the whole reply when it is shorter.
+ Replies under 5 words or 20 characters ("yes", "do it") and replies that parsed
+ as gate grammar are not scanned for: they are common words or the decision
+ code, and the reply field itself is already dropped. Each quote becomes
+ `[owner words redacted]`; prose with no quote keeps its bytes. One
+ `OWNER_WORDS: dropped= redacted=` line names each changed file.
+- **Refusal.** Export never rewrites code in plan.md, so a quote inside a code
+ span or fence there stops the export with
+ [`ARTIFACT_OWNER_WORDS`](troubleshooting.md#artifact-owner-words), file:line
+ and exit 3, and nothing is written. `gstack-artifact validate --public
+ --private /run.json` runs the same scan on an existing copy.
+
## How a parent consumes a run
1. **Decide whether the run finished.** For an `/autoplan` run directory,
diff --git a/lib/autoplan-owner-words.ts b/lib/autoplan-owner-words.ts
new file mode 100644
index 000000000..825af4105
--- /dev/null
+++ b/lib/autoplan-owner-words.ts
@@ -0,0 +1,108 @@
+/**
+ * autoplan-owner-words: the owner's own words a public copy of a run must not
+ * carry (GST-1243, GEVA-225). Garry: nothing a person wrote leaves without
+ * their click. `ownerWords` collects what the private run recorded in the
+ * owner's words: every relayed reply (`owner_reply` on decision rows and on
+ * run.json's gate), the words a carried gate reused (run.json `carry.words`),
+ * and a reason a human typed when deferring a phase (`close --deferred
+ * --by human`). The gate reply grammar (`reply`, `last_reply`) is
+ * the decision code, and `answer_words` are the answering agent's own words,
+ * so both stay.
+ *
+ * `dropOwnerFields` removes those fields from the export's JSON: `owner_reply`
+ * anywhere, `words` on a carry record, and a human deferral's `reason`
+ * (replaced by the marker, the field is required). `redactOwnerWords` handles
+ * text: first the places gstack copies those words verbatim (the CARRIED line,
+ * a deferral's titles and log lines), then quotes anywhere.
+ *
+ * The quote rule. Text is cut into words at whitespace (line breaks included);
+ * each word is compared without the punctuation and symbols at its edges
+ * (quote marks, `>` blockquote markers, list dashes), NFKC-normalized,
+ * lowercased, with curly apostrophes read as straight ones. Only an owner text
+ * of at least MIN_WORDS words and MIN_CHARS characters is scanned for: shorter
+ * replies ("yes", "do it", "rec except p2b") are common words or the gate
+ * grammar, so a scan would redact unrelated prose, and the reply field itself
+ * is already dropped. A reply that parsed as gate grammar (`owner_reply_parsed`)
+ * is the decision code the export keeps, so it is not scanned for either. A quote is a run of WINDOW consecutive words of an owner
+ * text (the whole text when it is shorter), wherever it sits and however it
+ * is wrapped; overlapping runs merge into one span, which the marker replaces.
+ */
+import type { RunState } from './autoplan-state';
+import type { DecisionRow } from './headless-artifacts';
+
+export const OWNER_WORDS_MARK = '[owner words redacted]';
+export const MIN_WORDS = 5;
+export const MIN_CHARS = 20;
+export const WINDOW = 8;
+
+export interface OwnerWords { placements: Array<[string, string]>; windows: Map> }
+export type Span = [number, number];
+
+interface Word { key: string; start: number; end: number }
+function words(text: string): Word[] {
+ const out: Word[] = [];
+ for (const m of text.matchAll(/\S+/g)) {
+ const lead = /^[\p{P}\p{S}]*/u.exec(m[0])![0].length;
+ const core = m[0].slice(lead).replace(/[\p{P}\p{S}]+$/u, '');
+ if (core) out.push({ key: core.normalize('NFKC').replace(/[\u2018\u2019\u02bc]/g, "'").toLowerCase(), start: m.index! + lead, end: m.index! + lead + core.length });
+ }
+ return out;
+}
+
+/** What the private run recorded in the owner's words, and the windows the quote scan looks for. */
+export function ownerWords(state: Pick, decisions: Array>): OwnerWords {
+ const deferred = Object.values(state.phases ?? {}).flatMap(p => (p?.deferred?.by === 'human' && p.deferred.reason ? [p.deferred.reason] : []));
+ const carried = state.carry?.words;
+ const texts = [...new Set([...decisions.map(d => d.owner_reply), (state.gate as { owner_reply?: unknown } | undefined)?.owner_reply, carried, ...deferred]
+ .filter((t): t is string => typeof t === 'string' && t.trim() !== ''))];
+ const placements: Array<[string, string]> = [
+ ...(carried ? [[`words=${JSON.stringify(carried)}`, `words=${OWNER_WORDS_MARK}`] as [string, string]] : []),
+ ...deferred.flatMap(r => [`deferred: ${r}`, `(${r}; by human)`, `(${r}): `, `, by human): ${r};`].map(s => [s, s.replace(r, () => OWNER_WORDS_MARK)] as [string, string])),
+ ];
+ const grammar = new Set(decisions.filter(d => d.owner_reply_parsed).map(d => d.owner_reply));
+ const windows = new Map>();
+ for (const t of texts.filter(t => !grammar.has(t))) {
+ const keys = words(t).map(w => w.key);
+ if (keys.length < MIN_WORDS || keys.join(' ').length < MIN_CHARS) continue;
+ const size = Math.min(keys.length, WINDOW);
+ const set = windows.get(size) ?? windows.set(size, new Set()).get(size)!;
+ for (let i = 0; i + size <= keys.length; i++) set.add(keys.slice(i, i + size).join(' '));
+ }
+ return { placements, windows };
+}
+
+/** Every quote of an owner text in `text`, as merged [start, end) spans. */
+export function ownerSpans(text: string, owner: OwnerWords): Span[] {
+ const ws = words(text);
+ const hits: Span[] = [];
+ for (const [size, set] of owner.windows) {
+ for (let i = 0; i + size <= ws.length; i++) if (set.has(ws.slice(i, i + size).map(w => w.key).join(' '))) hits.push([ws[i]!.start, ws[i + size - 1]!.end]);
+ }
+ hits.sort((a, b) => a[0] - b[0]);
+ return hits.reduce((out, s) => { const last = out[out.length - 1]; if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]); else out.push([...s]); return out; }, []);
+}
+
+/** Replace the verbatim placements, then every quote outside `keep` (code export never rewrites), with the marker. */
+export function redactOwnerWords(text: string, owner: OwnerWords, keep: Span[] = []): { text: string; redacted: number } {
+ let redacted = 0;
+ let out = text;
+ for (const [from, to] of owner.placements) { const parts = out.split(from); redacted += parts.length - 1; out = parts.join(to); }
+ const spans = ownerSpans(out, owner).filter(([s, e]) => !keep.some(([ks, ke]) => s < ke && e > ks));
+ for (const [s, e] of spans.reverse()) out = out.slice(0, s) + OWNER_WORDS_MARK + out.slice(e);
+ return { text: out, redacted: redacted + spans.length };
+}
+
+/** Drop the owner-word fields from a JSON value: `owner_reply`, a carry record's `words`, a human deferral's `reason`. */
+export function dropOwnerFields(value: unknown): { value: unknown; dropped: number } {
+ let dropped = 0;
+ const walk = (v: unknown): unknown => {
+ if (Array.isArray(v)) return v.map(walk);
+ if (!v || typeof v !== 'object') return v;
+ const o: Record = { ...(v as Record) };
+ if ('owner_reply' in o) { delete o.owner_reply; dropped++; }
+ if ('verdict' in o && typeof o.words === 'string') { delete o.words; dropped++; }
+ if (o.by === 'human' && typeof o.reason === 'string' && 'cause' in o) { o.reason = OWNER_WORDS_MARK; dropped++; }
+ return Object.fromEntries(Object.entries(o).map(([k, x]) => [k, walk(x)]));
+ };
+ return { value: walk(value), dropped };
+}
diff --git a/lib/autoplan-public.ts b/lib/autoplan-public.ts
index 2e1c94404..d77ecfa32 100644
--- a/lib/autoplan-public.ts
+++ b/lib/autoplan-public.ts
@@ -21,7 +21,8 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { repoRoot } from './autoplan-plan-text';
import { checkpointRoot } from './autoplan-checkpoint';
-import { FILES, RunError, type RunState } from './autoplan-state';
+import { dropOwnerFields, ownerSpans, ownerWords, redactOwnerWords, type OwnerWords, type Span } from './autoplan-owner-words';
+import { FILES, RunError, readDecisions, type RunState } from './autoplan-state';
import { sha256File, validateRun, type ValidationError } from './headless-artifacts';
export const LOCAL_NOTES = '';
@@ -68,6 +69,19 @@ function fencedLines(text: string): boolean[] {
});
}
+/** plan.md's code as [start, end) spans: fenced lines and inline code spans, which export never rewrites. */
+function codeSpans(text: string): Span[] {
+ const fenced = fencedLines(text);
+ const spans: Span[] = [];
+ let at = 0;
+ text.split('\n').forEach((line, i) => {
+ if (fenced[i]) spans.push([at, at + line.length]);
+ else for (const m of line.matchAll(/`[^`]*`/g)) spans.push([at + m.index!, at + m.index! + m[0].length]);
+ at += line.length + 1;
+ });
+ return spans;
+}
+
/** plan.md: rewrite prose only; fenced blocks and inline code spans keep their bytes. */
function rewriteProse(text: string, roots: RecordedRoot[]): string {
const fenced = fencedLines(text);
@@ -82,8 +96,8 @@ function rewriteJson(value: unknown, roots: RecordedRoot[]): unknown {
return value;
}
-/** Every recorded-root hit (error) and generic home-path hit (warning) in the files under `dir`, with file:line. */
-function scan(dir: string, roots: RecordedRoot[]): { errors: ValidationError[]; warnings: string[] } {
+/** Every recorded-root and owner-words hit (errors) and generic home-path hit (warning) in the files under `dir`, with file:line. */
+function scan(dir: string, roots: RecordedRoot[], owner?: OwnerWords): { errors: ValidationError[]; warnings: string[] } {
const errors: ValidationError[] = [], warnings: string[] = [];
const walk = (rel: string): string[] => fs.readdirSync(path.join(dir, rel), { withFileTypes: true }).flatMap(e => (e.isDirectory() ? walk(path.join(rel, e.name)) : e.isFile() ? [path.join(rel, e.name)] : []));
for (const rel of walk('')) {
@@ -95,6 +109,13 @@ function scan(dir: string, roots: RecordedRoot[]): { errors: ValidationError[];
if (hit) errors.push({ code: 'ARTIFACT_MACHINE_PATH', path: `${rel}:${i + 1}`, message: `the recorded ${hit.label} root ${hit.root} appears in the public copy${code ? ' (inside code, which export never rewrites: edit the plan)' : ''}` });
else if (GENERIC_HOME.test(line)) warnings.push(`WARN: ${rel}:${i + 1} has a home-directory path (${GENERIC_HOME.exec(line)![0]}); check it is not a machine path`);
});
+ if (!owner) continue;
+ const code = rel === 'plan.md' ? codeSpans(text) : [];
+ const plain = /\.jsonl?$/.test(rel) ? text.replace(/\\[nrt"\\/]/g, ' ') : text;
+ for (const [s, e] of ownerSpans(plain, owner)) {
+ const inCode = code.some(([cs, ce]) => s < ce && e > cs);
+ errors.push({ code: 'ARTIFACT_OWNER_WORDS', path: `${rel}:${text.slice(0, s).split('\n').length}`, message: `owner words the private run recorded appear in the public copy${inCode ? ' (inside code, which export never rewrites: edit the plan)' : ''}` });
+ }
}
return { errors, warnings };
}
@@ -115,23 +136,25 @@ export function validatePublic(dir: string, privateRunJson?: string, env: NodeJS
else if (sha256File(file) !== d.public_sha256) errors.push({ code: 'ARTIFACT_STALE', path: name, message: `sha256 differs from provenance public_sha256 ${d.public_sha256.slice(0, 12)}` });
}
let roots: RecordedRoot[] = [];
+ let owner: OwnerWords | undefined;
if (privateRunJson) {
const priv = JSON.parse(fs.readFileSync(privateRunJson, 'utf8')) as RunState;
priv.out = path.dirname(path.resolve(privateRunJson));
roots = recordedRoots(priv, env);
+ owner = ownerWords(priv, readDecisions(priv.out));
if (prov && prov.private_run !== priv.run) errors.push({ code: 'ARTIFACT_UNBOUND_ID', path: 'run.json.provenance.private_run', message: `${prov.private_run} is not the private run ${priv.run}` });
for (const [name, d] of Object.entries(prov?.files ?? {})) {
const listed = name === FILES.run ? sha256File(privateRunJson) : priv.artifacts?.[name]?.sha256;
if (listed !== d.private_sha256) errors.push({ code: 'ARTIFACT_STALE', path: name, message: `provenance private_sha256 ${d.private_sha256.slice(0, 12)} is not the private run's ${(listed ?? 'missing').slice(0, 12)}` });
}
}
- const s = scan(dir, roots);
+ const s = scan(dir, roots, owner);
errors.push(...s.errors);
return { valid: errors.length === 0, errors, warnings: s.warnings, run: manifest?.run };
}
-/** Write the public copy of `state` to `dir` without touching the private run; refuses on any recorded root left behind. */
-export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEnv = process.env): { files: string[]; warnings: string[] } {
+/** Write the public copy of `state` to `dir` without touching the private run; refuses on any recorded root or owner words left behind. */
+export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEnv = process.env): { files: string[]; warnings: string[]; owner: string[] } {
const target = path.resolve(dir);
const out = path.resolve(state.out);
if (target === out || target.startsWith(out + path.sep) || out.startsWith(target + path.sep)) throw new RunError('ARTIFACT_PATH_ESCAPE', `--public ${target} overlaps the run directory ${out}`, 3);
@@ -141,6 +164,23 @@ export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEn
if (previous !== state.run) throw new RunError('ARTIFACT_PUBLIC_TARGET_EXISTS', `${target} is not empty and holds no earlier public copy of ${state.run}`, 3);
}
const roots = recordedRoots(state, env);
+ const owner = ownerWords(state, readDecisions(out));
+ const report: string[] = [];
+ /** The public bytes of one file: recorded roots rewritten, owner-word fields dropped, quotes redacted (plan.md code untouched). */
+ const publicText = (name: string, file: string, text: string): string => {
+ let dropped = 0, redacted = 0;
+ const redact = (t: string, keep?: Span[]) => { const r = redactOwnerWords(t, owner, keep); redacted += r.redacted; return r.text; };
+ const strings = (x: unknown): unknown => (typeof x === 'string' ? redact(x) : Array.isArray(x) ? x.map(strings)
+ : x && typeof x === 'object' ? Object.fromEntries(Object.entries(x).map(([k, y]) => [k, strings(y)])) : x);
+ const json = (v: unknown) => { const d = dropOwnerFields(rewriteJson(v, roots)); dropped += d.dropped; return strings(d.value); };
+ let body: string;
+ if (file.endsWith('.json')) body = JSON.stringify(json(JSON.parse(text)), null, 2) + '\n';
+ else if (file.endsWith('.jsonl')) body = text.split('\n').map(line => (line ? JSON.stringify(json(JSON.parse(line))) : line)).join('\n');
+ else if (name === 'plan.md') { const prose = rewriteProse(text, roots); body = redact(prose, codeSpans(prose)); }
+ else body = redact(rewriteRoots(text, roots));
+ if (dropped || redacted) report.push(`OWNER_WORDS: ${file} dropped=${dropped} redacted=${redacted}`);
+ return body;
+ };
const privateRun = path.join(out, FILES.run);
const staging = `${target}.public-${process.pid}`;
fs.rmSync(staging, { recursive: true, force: true });
@@ -153,10 +193,10 @@ export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEn
if (!fs.existsSync(src) || sha256File(src) !== entry.sha256) throw new RunError('ARTIFACT_STALE', `${entry.path} does not match run.json; run gstack-autoplan export --out ${out} first`, 3);
const text = fs.readFileSync(src, 'utf8');
fs.mkdirSync(path.dirname(dest), { recursive: true });
- fs.writeFileSync(dest, name === 'plan.md' ? rewriteProse(text, roots) : entry.path.endsWith('.json') ? JSON.stringify(rewriteJson(JSON.parse(text), roots), null, 2) + '\n' : rewriteRoots(text, roots));
+ fs.writeFileSync(dest, publicText(name, entry.path, text));
files[name] = { private_sha256: entry.sha256, public_sha256: sha256File(dest) };
}
- const manifest = rewriteJson(JSON.parse(fs.readFileSync(privateRun, 'utf8')), roots) as RunState & { provenance?: Provenance };
+ const manifest = JSON.parse(publicText(FILES.run, FILES.run, fs.readFileSync(privateRun, 'utf8'))) as RunState & { provenance?: Provenance };
for (const [name, d] of Object.entries(files)) manifest.artifacts[name] = { path: state.artifacts[name]!.path, sha256: d.public_sha256 };
manifest.provenance = { private_run: state.run, private_manifest_sha256: sha256File(privateRun), exported_at: new Date().toISOString(), roots: roots.map(r => r.label), files };
fs.writeFileSync(path.join(staging, FILES.run), JSON.stringify(manifest, null, 2) + '\n');
@@ -165,10 +205,12 @@ export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEn
if (escapes.length) throw new RunError('ARTIFACT_PATH_ESCAPE', escapes.map(e => `${e.path}: ${e.message}`).join('; '), 3);
const leaks = check.errors.filter(e => e.code === 'ARTIFACT_MACHINE_PATH');
if (leaks.length) throw new RunError('ARTIFACT_MACHINE_PATH', leaks.slice(0, 5).map(e => `${e.path}: ${e.message}`).join('; ') + (leaks.length > 5 ? ` (+${leaks.length - 5} more)` : ''), 3);
+ const words = check.errors.filter(e => e.code === 'ARTIFACT_OWNER_WORDS');
+ if (words.length) throw new RunError('ARTIFACT_OWNER_WORDS', words.slice(0, 5).map(e => `${e.path}: ${e.message}`).join('; ') + (words.length > 5 ? ` (+${words.length - 5} more)` : ''), 3);
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.renameSync(staging, target);
- return { files: [...Object.values(state.artifacts).map(e => path.join(target, e.path)), path.join(target, FILES.run)], warnings: check.warnings };
+ return { files: [...Object.values(state.artifacts).map(e => path.join(target, e.path)), path.join(target, FILES.run)], warnings: check.warnings, owner: report };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
diff --git a/lib/result-codes-autoplan.ts b/lib/result-codes-autoplan.ts
index ab48270c0..3d97d2880 100644
--- a/lib/result-codes-autoplan.ts
+++ b/lib/result-codes-autoplan.ts
@@ -29,4 +29,10 @@ export const AUTOPLAN_RESULT_CODES = {
summary: 'gstack-autoplan shadow skipped a gate revision whose plan approval is unanswered or answered c (interrogate): the export pairs predictions with answers, so it waits for the owner',
fix: 'answer the gate (gstack-autoplan answer --gate-rev ...), then run gstack-autoplan shadow again; answered revisions of the same run still export',
},
+ // GST-1243: the public copy of an autoplan run never carries the owner's own words.
+ ARTIFACT_OWNER_WORDS: {
+ anchor: 'artifact-owner-words',
+ summary: 'a public copy of an autoplan run still quotes the owner\'s own words: a relayed gate reply, carried words or a human\'s deferral reason the private run recorded',
+ fix: 'export with `gstack-autoplan export --out --public `, which drops those fields and redacts quotes of them in prose; a quote inside a code span or fence in the plan is never rewritten, so edit the plan, revise, and export again',
+ },
} as const satisfies Record;
diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts
index 488cc93c0..a4c1ab8e8 100644
--- a/scripts/resolvers/autoplan-driver.ts
+++ b/scripts/resolvers/autoplan-driver.ts
@@ -31,7 +31,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
{ cmd: 'gstack-autoplan close --out --phase --deferred "" --by human|agent:', does: 'close a phase accepted-with-findings: its open findings go to tasks.jsonl, and Critical, urgent or user-challenge findings become gate items; also the recovery from `STOP: reason=budget_exhausted`' },
{ cmd: 'gstack-autoplan budget --out [--spend-cap ] [--max-rounds ] --by human|agent:', does: 'the owner\'s journaled cap raise; after a budget stop, `resume` continues the run' },
{ cmd: 'gstack-autoplan export --out ', does: 'write the artifact set; then `gstack-artifact validate /run.json`' },
- { cmd: 'gstack-autoplan export --out --public ', does: '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' },
+ { cmd: 'gstack-autoplan export --out --public ', does: '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' },
{ cmd: 'gstack-autoplan status|resume --out ', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
{ cmd: 'gstack-autoplan resume --out --from ', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from ` lists them' },
];
diff --git a/test/autoplan-owner-words.test.ts b/test/autoplan-owner-words.test.ts
new file mode 100644
index 000000000..5c4754708
--- /dev/null
+++ b/test/autoplan-owner-words.test.ts
@@ -0,0 +1,80 @@
+/**
+ * GST-1243: the owner-words rule behind `gstack-autoplan export --public` (lib/autoplan-owner-words.ts). Short replies
+ * and gate grammar are never scanned for; a quote is WINDOW consecutive words of an owner text (all of it when shorter),
+ * matched across line breaks, blockquote markers, case and curly quotes; code export keeps is never rewritten.
+ */
+import { describe, expect, test } from 'bun:test';
+import { MIN_WORDS, OWNER_WORDS_MARK as MARK, WINDOW, dropOwnerFields, ownerSpans, ownerWords, redactOwnerWords } from '../lib/autoplan-owner-words';
+
+const LONG = 'Keep the backoff cap at thirty seconds and log every retry attempt so we can see it.';
+const owners = (...replies: Array) =>
+ ownerWords({ phases: {} } as never, replies.map(r => (typeof r === 'string' ? { owner_reply: r } : r)));
+const quoted = (text: string, ...replies: string[]) => ownerSpans(text, owners(...replies)).map(([s, e]) => text.slice(s, e));
+
+describe('the quote rule', () => {
+ test(`replies under ${MIN_WORDS} words or 20 characters are never scanned for`, () => {
+ const prose = 'Do it. Yes. Garry: do it. Ship it now please, the owner said.';
+ expect(quoted(prose, 'do it', 'yes', 'Garry: do it', 'Ship it now please')).toEqual([]);
+ expect(quoted('a b c d e f', 'a b c d e')).toEqual([]);
+ });
+
+ test('a reply that parsed as gate grammar is the decision code, not scanned for', () => {
+ const o = owners({ owner_reply: 'rec except p2b p3a c4b', owner_reply_parsed: true });
+ expect(ownerSpans('reply: rec except p2b p3a c4b', o)).toEqual([]);
+ expect(quoted('reply: rec except p2b p3a c4b', 'rec except p2b p3a c4b')).toEqual(['rec except p2b p3a c4b']);
+ });
+
+ test(`a reply shorter than ${WINDOW} words matches only whole`, () => {
+ expect(quoted('They said: please drop the Redis cache layer, entirely.', 'Drop the Redis cache layer entirely')).toEqual(['drop the Redis cache layer, entirely']);
+ expect(quoted('They said: please drop the Redis cache layer.', 'Drop the Redis cache layer entirely')).toEqual([]);
+ });
+
+ test(`any ${WINDOW} consecutive words of a longer reply are a quote; ${WINDOW - 1} are not`, () => {
+ expect(quoted('Per the owner, log every retry attempt so we can see it. Fine.', LONG)).toEqual(['log every retry attempt so we can see it']);
+ expect(quoted('We will log every retry attempt so we can tell.', LONG)).toEqual([]);
+ expect(quoted(`A: ${LONG} B: ${LONG}`, LONG)).toHaveLength(2);
+ });
+
+ test('line breaks, blockquote markers, indentation, case and curly quotes do not hide a quote', () => {
+ const text = '> \u201cDon\u2019t ship the backoff\n> without the flag, EVER\u201d\n';
+ expect(quoted(text, "don't ship the backoff without the flag, ever")).toEqual(['Don\u2019t ship the backoff\n> without the flag, EVER']);
+ });
+});
+
+describe('redactOwnerWords', () => {
+ test('replaces each quote with the marker, keeps the punctuation around it, and leaves other prose byte for byte', () => {
+ const o = owners(LONG);
+ expect(redactOwnerWords(`The owner: "${LONG}" Noted.`, o)).toEqual({ text: `The owner: "${MARK}." Noted.`, redacted: 1 });
+ const control = 'Keep the backoff cap. Log every retry. Thirty seconds is fine.';
+ expect(redactOwnerWords(control, o)).toEqual({ text: control, redacted: 0 });
+ });
+
+ test('a quote overlapping a kept span (code) is left for the validator to refuse', () => {
+ const text = `\`${LONG}\``;
+ expect(redactOwnerWords(text, owners(LONG), [[0, text.length]])).toEqual({ text, redacted: 0 });
+ });
+
+ test("gstack's verbatim copies of carried words and a human's deferral reason are replaced whatever their length", () => {
+ const o = ownerWords({ phases: { ceo: { deferred: { reason: 'no time', by: 'human' } } }, carry: { words: 'Garry: do it' } } as never, []);
+ expect(redactOwnerWords('CARRIED: gate_rev=2 <- gate_rev=1 words="Garry: do it" by=agent:x', o).text).toBe(`CARRIED: gate_rev=2 <- gate_rev=1 words=${MARK} by=agent:x`);
+ expect(redactOwnerWords('Deferred ceo at round 1 (owner, by human): no time; 0 open', o).text).toBe(`Deferred ceo at round 1 (owner, by human): ${MARK}; 0 open`);
+ expect(redactOwnerWords('ceo closed accepted-with-findings at round 1 (no time): 0 open', o).text).toBe(`ceo closed accepted-with-findings at round 1 (${MARK}): 0 open`);
+ expect(redactOwnerWords('round 1 (no time; by human); open findings', o).text).toBe(`round 1 (${MARK}; by human); open findings`);
+ expect(redactOwnerWords('deferred: no time', o).text).toBe(`deferred: ${MARK}`);
+ expect(redactOwnerWords('we had no time to spare', o).text).toBe('we had no time to spare');
+ });
+});
+
+describe('dropOwnerFields', () => {
+ test("drops owner_reply and a carry record's words, marks a human's deferral reason, keeps an agent's", () => {
+ const run = {
+ gate: { reply: 'p1b', owner_reply: LONG, owner_reply_parsed: false }, carry: { verdict: 'carried', words: LONG, lines: [] },
+ phases: { ceo: { deferred: { reason: 'no time', by: 'human', cause: 'owner' } }, eng: { deferred: { reason: 'budget', by: 'agent:x', cause: 'owner' } } },
+ };
+ expect(dropOwnerFields(run)).toEqual({ dropped: 3, value: {
+ gate: { reply: 'p1b', owner_reply_parsed: false }, carry: { verdict: 'carried', lines: [] },
+ phases: { ceo: { deferred: { reason: MARK, by: 'human', cause: 'owner' } }, eng: { deferred: { reason: 'budget', by: 'agent:x', cause: 'owner' } } },
+ } });
+ expect(run.gate.owner_reply).toBe(LONG);
+ });
+});
diff --git a/test/autoplan-public-owner-words.test.ts b/test/autoplan-public-owner-words.test.ts
new file mode 100644
index 000000000..5c47b50d2
--- /dev/null
+++ b/test/autoplan-public-owner-words.test.ts
@@ -0,0 +1,167 @@
+/**
+ * GST-1243 (GEVA-225, gstack 1.93.34.0): `gstack-autoplan export --public` kept the owner's own words. decisions.jsonl
+ * kept every `owner_reply`, run.json kept the gate's and the carry's copy, and a bound reviewer file
+ * (ceo-native.a5.bound.md) quoted an owner reply verbatim. Garry's rule: nothing a person wrote leaves without their
+ * click. The fixture bundle is the GEVA shape: a gate answered in two relayed replies, revised, re-reviewed with one
+ * reviewer quoting a reply on one line and the other reflowing a reply across blockquote lines, then carried. Runs on
+ * the simulated Capy host; every spawn has a timeout.
+ */
+import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
+import { spawnSync } from 'node:child_process';
+import * as crypto from 'node:crypto';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { recHash } from '../lib/gate-list';
+import { ROOT, SMALL_PLAN, resultOf } from './helpers/autoplan-run-fixture';
+import { capyRunner, phaseVia, readJsonl, readRunJson, writePlan } from './helpers/autoplan-capy';
+
+setDefaultTimeout(60_000);
+const r = capyRunner();
+afterAll(() => r.cleanup());
+const ARTIFACT = path.join(ROOT, 'bin', 'gstack-artifact');
+const artifact = (args: string[]) => spawnSync('bun', [ARTIFACT, ...args], { cwd: ROOT, env: r.env, encoding: 'utf8', timeout: 60_000 });
+const MARK = '[owner words redacted]';
+let n = 0;
+
+const WA = 'Garry: take the flag first, I do not want a big bang rollout on the retry path.';
+const WB = 'Approved with the flag. Keep the backoff cap at thirty seconds and log every retry attempt.';
+const QUOTE_A = 'The owner wrote: "take the flag first, I do not want a big bang rollout on the retry path." That settles UC1.';
+const REFLOW_B = "The owner's approval, as relayed:\n\n> Approved with the flag. Keep the backoff cap at thirty\n> seconds and log every retry attempt.\n\nNothing else changed since round 1.";
+const CONTROL = 'No new findings. Approved with the flag, as before. Do it: keep the backoff behind the flag; the retry path, the cap and the jitter bounds read fine.';
+const AGREE = {
+ native: [{ id: 'F1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
+ outside: [{ id: 'O1', severity: 'High', title: 'Ship the backoff behind a flag first', user_challenge: true, changes: 'scope', fix: 'gate the change behind RETRY_BACKOFF=1' }],
+};
+const V2 = SMALL_PLAN.replace('cap 30 s', 'cap 30 s, behind RETRY_BACKOFF=1');
+const gateOf = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.json'), 'utf8'));
+const boundName = (out: string, phase: string, voice: 'native' | 'outside'): string => readRunJson(out).phases[phase][voice].output;
+const digest = (dir: string) => Object.fromEntries(fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter(e => e.isFile() && e.name !== 'run.lock')
+ .map(e => { const f = path.join(e.parentPath, e.name); return [path.relative(dir, f), crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex')]; }));
+const allText = (dir: string) => fs.readdirSync(dir, { withFileTypes: true }).filter(e => e.isFile()).map(e => fs.readFileSync(path.join(dir, e.name), 'utf8')).join('\n');
+/** Whitespace, blockquote markers and JSON escapes folded away, so a reflowed or escaped quote still reads as one run of words. */
+const folded = (text: string) => text.replace(/\\[nrt"]/g, ' ').replace(/[\s>]+/g, ' ').toLowerCase();
+
+/** One review round: the fixture reviewer writes each result, `prose` replaces its recommendation line, then both bind. */
+function phaseWith(out: string, phase: string, prose: { native?: string; outside?: string } = {}): void {
+ const next = r.ap(['next', '--out', out]);
+ expect(next.status, next.stderr).toBe(0);
+ for (const voice of ['native', 'outside'] as const) {
+ const rv = r.review(path.join(out, `${phase}-${voice}-prompt.md`), { FIXTURE_FINDINGS: '[]' });
+ expect(rv.status, rv.stderr).toBe(0);
+ const file = resultOf(out, phase, voice);
+ if (prose[voice]) fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('Recommendation: revise because the fixture says so.\n', `${prose[voice]}\n`));
+ }
+ const run = readRunJson(out);
+ for (const [voice, model] of [['native', 'anthropic/claude-opus-5-5'], ['outside', 'openai/gpt-6.1-sol']] as const) {
+ const s = r.ap(['submit', '--out', out, '--phase', phase, '--voice', voice, '--result', resultOf(out, phase, voice), '--model', model, '--attempt', run.phases[phase][voice].attempt, '--runner', 'host-subagent']);
+ expect(s.status, s.stderr).toBe(0);
+ }
+}
+
+/** The GEVA bundle: gate 1 answered in two relayed owner replies, revised, re-reviewed (quote, reflowed quote, control), gate 2 carried. */
+function ownerBundle(v2 = V2): { out: string; dir: string } {
+ const dir = path.join(r.host.base, `owner${++n}`);
+ const out = path.join(dir, 'run');
+ expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui', '--no-dx']).status).toBe(0);
+ phaseVia(r, out, 'ceo', AGREE);
+ expect(phaseVia(r, out, 'eng', { native: [], outside: [] }).outside.stdout).toContain('status=gate_pending');
+ const first = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1b', '--by', 'agent:gstack-minder', '--owner-reply', WA]);
+ expect(first.stdout, first.stderr).toContain('GATE_PENDING: p1');
+ const second = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'p1b', '--rec-hash', recHash(gateOf(out)), '--by', 'agent:gstack-minder', '--owner-reply', WB]);
+ expect(second.status, second.stderr).toBe(0);
+ const plan2 = path.join(dir, 'plan-v2.md');
+ fs.writeFileSync(plan2, v2);
+ expect(r.ap(['revise', '--out', out, '--plan', plan2, '--incorporates', 'uc1']).status).toBe(0);
+ phaseWith(out, 'ceo', { native: QUOTE_A, outside: REFLOW_B });
+ phaseWith(out, 'eng', { native: CONTROL });
+ expect(readRunJson(out).status).toBe('complete');
+ const e = r.ap(['export', '--out', out]);
+ expect(e.status, e.stderr).toBe(0);
+ return { out, dir };
+}
+
+describe("export --public drops the owner's own words (GST-1243)", () => {
+ test('owner_reply fields are dropped, quoted and reflowed replies are redacted, reviewer prose without owner words is byte-identical', () => {
+ const { out, dir } = ownerBundle();
+ const privateRows = readJsonl(path.join(out, 'decisions.jsonl'));
+ expect(privateRows.filter(d => typeof d.owner_reply === 'string').length).toBe(3);
+ expect(fs.readFileSync(path.join(out, 'ceo-native.a5.bound.md'), 'utf8')).toContain(QUOTE_A);
+ const before = digest(out);
+ const pub = path.join(dir, 'public');
+ const e = r.ap(['export', '--out', out, '--public', pub]);
+ expect(e.status, e.stderr).toBe(0);
+ expect(digest(out)).toEqual(before);
+
+ const publicRows = readJsonl(path.join(pub, 'decisions.jsonl'));
+ expect(publicRows.map(d => d.id)).toEqual(privateRows.map(d => d.id));
+ publicRows.forEach((row, i) => { const { owner_reply: _, ...rest } = privateRows[i]; expect(row).toEqual(rest); });
+ const manifest = readRunJson(pub);
+ expect(manifest.gate.owner_reply).toBeUndefined();
+ expect(manifest.gate.reply).toBe('p1b');
+ expect(manifest.carry.words).toBeUndefined();
+ expect(manifest.carry.lines[0]).toContain(`words=${MARK} by=agent:gstack-minder`);
+
+ const text = folded(allText(pub));
+ for (const fragment of ['big bang rollout on the retry path', 'thirty seconds and log every retry attempt', folded(WA), folded(WB)]) expect(text).not.toContain(fragment);
+ expect(fs.readFileSync(path.join(pub, 'ceo-native.a5.bound.md'), 'utf8')).toContain(`The owner wrote: "${MARK}." That settles UC1.`);
+ expect(fs.readFileSync(path.join(pub, boundName(out, 'ceo', 'outside')), 'utf8')).toContain(`The owner's approval, as relayed:\n\n> ${MARK}.\n\nNothing else changed since round 1.`);
+
+ const control = boundName(out, 'eng', 'native');
+ expect(fs.readFileSync(path.join(out, control), 'utf8')).toContain(CONTROL);
+ expect(fs.readFileSync(path.join(pub, control))).toEqual(fs.readFileSync(path.join(out, control)));
+ for (const name of ['ceo-native.a1.bound.md', 'ceo-outside.a2.bound.md', 'eng-native.a3.bound.md', 'eng-outside.a4.bound.md']) expect(fs.readFileSync(path.join(pub, name))).toEqual(fs.readFileSync(path.join(out, name)));
+
+ expect(e.stdout.split('\n').filter(l => l.startsWith('OWNER_WORDS:'))).toEqual([
+ 'OWNER_WORDS: decisions.jsonl dropped=3 redacted=0', 'OWNER_WORDS: ceo-native.a5.bound.md dropped=0 redacted=1',
+ `OWNER_WORDS: ${boundName(out, 'ceo', 'outside')} dropped=0 redacted=1`, 'OWNER_WORDS: run.json dropped=2 redacted=1',
+ ]);
+ const full = artifact(['validate', '--public', pub, '--private', path.join(out, 'run.json')]);
+ expect(full.status, full.stderr).toBe(0);
+ });
+
+ test('owner words inside plan.md code are never rewritten: export refuses, names file:line and writes nothing', () => {
+ const fenced = V2.replace('## Review record', `The owner's answer, kept for the record:\n\n\`\`\`text\n${WA}\n\`\`\`\n\n## Review record`);
+ const { out, dir } = ownerBundle(fenced);
+ const pub = path.join(dir, 'public');
+ const e = r.ap(['export', '--out', out, '--public', pub]);
+ expect(e.status, e.stdout + e.stderr).toBe(3);
+ expect(e.stderr).toMatch(/plan\.md:\d+: owner words .*\(inside code, which export never rewrites: edit the plan\).*\(ARTIFACT_OWNER_WORDS\)$/m);
+ expect(fs.existsSync(pub)).toBe(false);
+ expect(fs.readdirSync(dir).filter(f => f.includes('.public-'))).toEqual([]);
+ });
+
+ test('validate --public --private fails with file:line on owner words left in a public copy', () => {
+ const { out, dir } = ownerBundle();
+ const pub = path.join(dir, 'public');
+ expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0);
+ const record = path.join(pub, 'review-record.md');
+ const line = fs.readFileSync(record, 'utf8').split('\n').length;
+ fs.appendFileSync(record, 'Owner, verbatim: keep the backoff cap at thirty\nseconds and log every retry attempt.\n');
+ const full = artifact(['validate', '--public', pub, '--private', path.join(out, 'run.json')]);
+ expect(full.status).toBe(1);
+ expect(full.stderr).toMatch(new RegExp(`owner words .* at review-record\\.md:${line}\\).*\\(ARTIFACT_OWNER_WORDS\\)$`, 'm'));
+ });
+
+ test("a phase deferred by a human keeps its reason out of the public copy; an agent's deferral reason stays", () => {
+ const dir = path.join(r.host.base, `owner${++n}`);
+ const out = path.join(dir, 'run');
+ expect(r.ap(['next', '--out', out, '--plan', writePlan(dir), '--no-ui']).status).toBe(0);
+ const human = r.ap(['close', '--out', out, '--phase', 'ceo', '--deferred', 'out of time', '--by', 'human']);
+ expect(human.status, human.stderr).toBe(0);
+ expect(r.ap(['next', '--out', out]).status).toBe(0);
+ const agent = r.ap(['close', '--out', out, '--phase', 'eng', '--deferred', 'budget went to the CEO phase', '--by', 'agent:gstack-minder']);
+ expect(agent.status, agent.stderr).toBe(0);
+ expect(r.ap(['export', '--out', out]).status).toBe(0);
+ expect(allText(out)).toContain('out of time');
+ const pub = path.join(dir, 'public');
+ const e = r.ap(['export', '--out', out, '--public', pub]);
+ expect(e.status, e.stderr).toBe(0);
+ const text = allText(pub);
+ expect(text).not.toContain('out of time');
+ expect(readRunJson(pub).phases.ceo.deferred.reason).toBe(MARK);
+ expect(fs.readFileSync(path.join(pub, 'review-record.md'), 'utf8')).toContain(`(owner, by human): ${MARK};`);
+ expect(fs.readFileSync(path.join(pub, 'ceo-consensus.md'), 'utf8')).toContain(`(${MARK}; by human)`);
+ expect(readRunJson(pub).phases.eng.deferred.reason).toBe('budget went to the CEO phase');
+ expect(text).toContain('budget went to the CEO phase');
+ });
+});