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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-04 03:42:16 +00:00
1 parent 9e826b2994
commit e5366a52b7
13 files changed
+958 -16

No files matched your search

+1 -1
View File
@@ -86,7 +86,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.**
- `gstack-autoplan export --out <dir>`
write the artifact set; then `gstack-artifact validate <dir>/run.json`.
- `gstack-autoplan export --out <dir> --public <pub>`
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 <pub> --private <dir>/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 <pub> --private <dir>/run.json` on this machine.
- `gstack-autoplan status|resume --out <dir>`
read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
- `gstack-autoplan resume --out <new dir> --from <checkpoint>`
+3 -2
View File
@@ -29,7 +29,8 @@ const HELP = `gstack-artifact — the unattended artifact contract (docs/unatten
validate <run.json> [--json] run-directory completion check
validate --public <dir> [--private <run.json>] 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 <file> --as <name> [--run <id>] [--json] one artifact against its schema
schema <name> | --list JSON Schema for ${ARTIFACT_NAMES.join(', ')}
ack <run.json> --consumer <id> [--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 <run.json> 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 <run.json> 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;
}
+5 -3
View File
@@ -16,7 +16,7 @@
* gstack-autoplan resume --out <dir> [--from <checkpoint>] [--json] take ownership; intent without a terminal record becomes EXECUTION_UNKNOWN
* gstack-autoplan status --out <dir> [--json] query: the manifest, no GSTACK_RESULT line
* gstack-autoplan export --out <dir> [--json] rewrite plan.md, review-record.md, findings/tasks.jsonl and run.json hashes
* gstack-autoplan export --out <dir> --public <pub> a machine-path-free copy in <pub>; the private run is only read
* gstack-autoplan export --out <dir> --public <pub> a machine-path-free copy in <pub> without the owner's own words; the private run is only read
* gstack-autoplan release --out <dir> --attempt <id> drop a reservation for an attempt that never ran
* gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by <human|agent:<id>> close a phase accepted-with-findings
* gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by <human|agent:<id>> 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 <dir> --public <pub>
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 <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
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<number> {
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;
});
}
+1
View File
@@ -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' ;;
@@ -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 `&eacute;` 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 <dir> --private <run.json>` runs the same check. The error never prints the words. Each changed file gets an `OWNER_WORDS: <file> dropped=<n> redacted=<m>` 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.
+24
View File
@@ -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.
<a id="artifact-owner-words"></a>
### `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 <dir> --private
<run>/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 <run> --public <dir>`, 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.
<a id="artifact-public-target-exists"></a>
### `ARTIFACT_PUBLIC_TARGET_EXISTS`
+59 -1
View File
@@ -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` (`<run>-<label>` at gate revision 1, `<run>-r<rev>-<label>` 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` (`<run>-<label>` at gate revision 1, `<run>-r<rev>-<label>` 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<rev>.jsonl` (one hidden prediction per gate item, written before the answer), `r<rev>.prompt.md` and one `<call>.reply.txt` per model call, the audit `salt`, and `r<rev>.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,64 @@ only place the run id is declared. `gstack-artifact schema <name>` 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 <dir> --public <pub>` 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).
- **Whose words.** Every owner reply the private run ever recorded: on
decision rows, on `run.json`'s gate, and in the journal (`attempts.jsonl`
keeps a reply that a later answer at the same gate replaced), the words a
carried gate reused, and the reason a human gave for `close --deferred --by
human`. A reply that parsed as gate grammar is the decision code, not owner
prose. An agent's own words (`answer_words`) and an agent's deferral reason
stay.
- **Dropped fields.** `owner_reply` anywhere, a carry record's `words`, and a
human deferral's `reason` (replaced by `[owner words redacted]`). The
decision itself stays: `chosen`, `reply` (the gate grammar), `owner_mode`
and `owner_reply_parsed`.
- **gstack's own renderings.** The human's deferral reason is also replaced
where gstack writes it into fixed text: the voice `detail`, the consensus
head, the gate item title, a blocker's `why` and the run log; carried words
in the `CARRIED:` line too. 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 reviewer results
(`*.bound.md`) included, is scanned for quotes. Before matching, the text is
normalized: JSON string escapes (`\n`, `\u2019`) and HTML entities are
decoded, format characters are dropped by Unicode category (`\p{Cf}`, such as
zero-width spaces, soft hyphens and direction marks, plus variation selectors
and the combining grapheme joiner), an HTML tag name reads as a space, and a
hyphen-newline wrap is joined. The text is read twice: as rendered (HTML
comments, footnote markers, reference definitions and link targets and titles
dropped, link text kept) and as written (all of those kept as words), so a
quote is found whether markup splits it or hides it. Contractions split
("don't" reads "do not", "can't" and "cannot" read "can not", "it's" "it is",
"I'd" "I would"). A word is a run of letters and digits, and everything else
(spaces, line breaks, dashes, slashes, quote marks, `>` markers, JSON or
attribute glue) separates words.
Words compare case-insensitively. 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:
they are codes or grammar, and the field itself is already dropped. A quote
extends over the neighboring words that also belong to that 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 the reply,
which he didn't write (the plan at an earlier gate, that gate's items, the
reviewer results bound before it), are not his words: they stay, and 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 too (and by any 8 of its words from 8),
so the new words are redacted even where they are quoted without the echo.
Prose with no quote keeps its bytes. One
`OWNER_WORDS: <file> dropped=<n> redacted=<m>` line names each changed file.
- **Refusal.** Export never rewrites code in plan.md, so a quote or rendering
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. The error never prints the words.
`gstack-artifact validate --public <pub> --private <dir>/run.json` runs the
same scan, renderings included, on an existing copy.
## How a parent consumes a run
1. **Decide whether the run finished.** For an `/autoplan` run directory,
+281
View File
@@ -0,0 +1,281 @@
/**
* 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.
*
* What counts. `ownerWords` reads the private run and collects every owner
* reply it ever recorded: `owner_reply` on decision rows, on run.json's gate
* and on every `answered` journal row (attempts.jsonl keeps replies a later
* answer at the same gate replaced), the words a carried gate reused, and a
* reason a human gave for `close --deferred <reason> --by human`. A reply that
* parsed as gate grammar (`owner_reply_parsed`) is the decision code and is
* not scanned for; `reply` and the agent's own `answer_words` stay.
*
* Fields. `dropOwnerFields` removes `owner_reply` anywhere, `words` on a
* carry record, and replaces a human deferral's `reason` with the marker.
*
* Placements. gstack renders a human deferral reason and carried words into
* fixed strings (DEFERRAL_RENDERINGS below, one per template in
* lib/autoplan-gate.ts and lib/autoplan-export.ts; test/autoplan-owner-words
* ratchets that list). Each is matched with its own delimiters on both sides,
* so a one-word reason never rewrites a longer one that starts with it.
*
* Quotes. Text is read in two views, each with an offset map back to the
* original. Both decode JSON string escapes (\n, \u2019) and HTML entities (an
* accented-letter name such as &eacute; reads as its letter, any other unknown
* name as a space), drop format characters by category (\p{Cf},
* variation selectors, the combining grapheme joiner), read an HTML tag name as
* a space and join a hyphen-newline wrap. The rendered view reads a whole tag,
* attributes included, as a space, and also drops HTML
* comments, footnote markers, reference definitions and link targets and
* titles, keeping link text; the raw view keeps those as words, so owner words
* inside a comment or a title are found too. A reply is indexed in both views,
* so the words of a link target it gave are found as well. Contractions split: "don't" reads
* "do not", "can't" and "cannot" read "can not", "it's" "it is", "I'd" "I
* would", and -n't, -'re, -'ve, -'ll, -'d, -'m likewise. Words are runs of
* Unicode letters, marks and digits with inner apostrophes; everything else
* separates them. Words compare NFKC and lowercased. A reply of 1 or 2 words is
* not scanned for (codes and grammar; the reply field itself is dropped). A
* reply of MIN_TOKENS (3) or more is a quote wherever it appears whole, at word
* boundaries; from WINDOW (8) words, any WINDOW consecutive words of it are a
* quote too. A quote then extends over the neighboring words that are also
* words of that reply, so no owner word is left on either side. The redaction
* replaces the original characters from the first quoted word to the last.
*
* Echoes. A quoted window (or whole short reply) is not the owner's words when
* the same words were already recorded before the reply, in text the owner
* didn't write: the plan at each earlier gate, those gates' items, and the
* reviewer results bound before it (`prior`). Those words stay. Each run of
* MIN_TOKENS or more reply words left between echoed windows is a needle of
* its own (whole, and by WINDOW from WINDOW words), so the words a reply added
* to an echo are redacted wherever they are quoted, with or without the echo.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { readDecisions, readJournal, type RunState } from './autoplan-state';
export const OWNER_WORDS_MARK = '[owner words redacted]';
export const MIN_TOKENS = 3;
export const WINDOW = 8;
export type Span = [number, number];
export interface OwnerSource { text: string; parsed?: boolean; prior?: string[] }
interface Indexed { keys: string[]; set: Set<string>; prior: string }
export interface OwnerWords { replies: Indexed[]; placements: RegExp[] }
/** A normalized string with, per UTF-16 unit, the original [from, to) range it came from. */
interface Mapped { s: string; from: number[]; to: number[] }
type Piece = string | [number, number];
/** Rewrite each match of `re` as its pieces: a string (mapped to the whole match) or a kept [start, end) range, whose edges take the match's edges. */
function pass(m: Mapped, re: RegExp, pieces: (x: RegExpMatchArray) => Piece[]): Mapped {
const out: Mapped = { s: '', from: [], to: [] };
const copy = (a: number, b: number) => { out.s += m.s.slice(a, b); for (let k = a; k < b; k++) { out.from.push(m.from[k]!); out.to.push(m.to[k]!); } };
let last = 0;
for (const x of m.s.matchAll(re)) {
const a = x.index!, b = a + x[0].length;
if (b === a) continue;
copy(last, a);
for (const piece of pieces(x)) {
if (typeof piece !== 'string') {
copy(piece[0], piece[1]);
if (piece[1] > piece[0]) { out.from[out.s.length - (piece[1] - piece[0])] = m.from[a]!; out.to[out.s.length - 1] = m.to[b - 1]!; }
continue;
}
for (let k = 0; k < piece.length; k++) { out.s += piece[k]; out.from.push(m.from[a]!); out.to.push(m.to[b - 1]!); }
}
last = b;
}
copy(last, m.s.length);
return out;
}
/** Common punctuation and spaces, and every HTML named entity for a format character (U+00AD, U+200B-U+200F, U+2060-U+2063). */
const NAMED: Record<string, number> = { amp: 0x26, lt: 0x3c, gt: 0x3e, quot: 0x22, apos: 0x27, nbsp: 0xa0, mdash: 0x2014, ndash: 0x2013, hellip: 0x2026, lsquo: 0x2018, rsquo: 0x2019, ldquo: 0x201c, rdquo: 0x201d, shy: 0xad, zerowidthspace: 0x200b, negativeverythinspace: 0x200b, negativethinspace: 0x200b, negativemediumspace: 0x200b, negativethickspace: 0x200b, zwnj: 0x200c, zwj: 0x200d, lrm: 0x200e, rlm: 0x200f, nobreak: 0x2060, applyfunction: 0x2061, af: 0x2061, invisibletimes: 0x2062, it: 0x2062, invisiblecomma: 0x2063, ic: 0x2063 };
/** A named entity for an accented Latin letter (eacute, Ntilde, ccedil): the base letter and its combining mark. */
const ACCENT: Record<string, string> = { acute: '\u0301', grave: '\u0300', circ: '\u0302', uml: '\u0308', tilde: '\u0303', ring: '\u030a', cedil: '\u0327', caron: '\u030c', macr: '\u0304', breve: '\u0306', ogon: '\u0328', dot: '\u0307' };
const LETTERS: Record<string, string> = { szlig: 'ß', aelig: 'æ', AElig: 'Æ', oslash: 'ø', Oslash: 'Ø', eth: 'ð', ETH: 'Ð', thorn: 'þ', THORN: 'Þ', oelig: 'œ', OElig: 'Œ' };
const namedEntity = (name: string): string | undefined => {
const code = NAMED[name.toLowerCase()];
if (code !== undefined) return String.fromCodePoint(code);
const accent = /^([A-Za-z])([a-z]+)$/.exec(name);
return LETTERS[name] ?? (accent && ACCENT[accent[2]!] ? (accent[1]! + ACCENT[accent[2]!]).normalize('NFC') : undefined);
};
const JSON_ESCAPES: Record<string, string> = { n: ' ', r: ' ', t: ' ', b: ' ', f: ' ', '"': '"', '\\': '\\', '/': '/' };
/** What a rendered page doesn't show as words: HTML comments, reference definitions, footnote markers, link targets and titles. */
const RENDERED: Array<[RegExp, (x: RegExpMatchArray) => Piece[]]> = [
[/<!--[\s\S]*?-->/g, () => [' ']],
[/^[ \t]*\[[^\]\n]+\]:[ \t]*\S+(?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?[ \t]*$/gm, () => [' ']],
[/\[\^[^\]\s]+\]/g, () => [' ']],
[/\[([^\]\n]*)\](?:\[[^\]\n]*\]|\((?:[^()\s]|\([^()\s]*\))*(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\))/gd, x => [' ', x.indices![1]!, ' ']],
];
/** The text words are read from, in the raw or the rendered view (the header's Quotes paragraph). */
function normalize(text: string, rendered: boolean): Mapped {
let m: Mapped = { s: text, from: Array.from({ length: text.length }, (_, i) => i), to: Array.from({ length: text.length }, (_, i) => i + 1) };
m = pass(m, /\\(?:u([0-9a-fA-F]{4})|([nrtbf"\\/]))/g, x => [x[1] ? String.fromCharCode(parseInt(x[1], 16)) : JSON_ESCAPES[x[2]!]!]);
m = pass(m, /&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z][a-z0-9]*));/gi, x => {
if (x[3]) return [namedEntity(x[3]) ?? ' '];
const code = x[1] ? parseInt(x[1], 10) : parseInt(x[2]!, 16);
return [code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : ' '];
});
m = pass(m, /[\p{Cf}\p{Variation_Selector}\u034f]/gu, () => []);
m = pass(m, rendered ? /<\/?[A-Za-z](?:[^<>]*>|[\w:-]*)/g : /<\/?[A-Za-z][\w:-]*/g, () => [' ']);
if (rendered) for (const [re, pieces] of RENDERED) m = pass(m, re, pieces);
return pass(m, /(?<=[\p{L}\p{N}])-[ \t]*\r?\n[ \t>]*(?=[\p{L}\p{N}])/gu, () => []);
}
const WHOLE: Record<string, string[]> = { "can't": ['can', 'not'], cannot: ['can', 'not'], "won't": ['will', 'not'], "shan't": ['shall', 'not'] };
const SUFFIXES: Array<[string, string]> = [["n't", 'not'], ["'re", 'are'], ["'ve", 'have'], ["'ll", 'will'], ["'d", 'would'], ["'m", 'am']];
/** Pronouns whose -'s reads "is"; on any other word it is a possessive and stays. */
const IS_BASES = new Set(['it', 'that', 'there', 'here', 'what', 'who', 'where', 'he', 'she']);
interface Token { key: string; start: number; end: number }
/** Words of `text` in the rendered (default) or raw view, with their original [start, end) offsets. */
export function tokens(text: string, rendered = true): Token[] {
const m = normalize(text, rendered);
const out: Token[] = [];
for (const w of m.s.matchAll(/[\p{L}\p{M}\p{N}]+(?:['\u2019\u02bc][\p{L}\p{M}\p{N}]+)*/gu)) {
const start = m.from[w.index!]!, end = m.to[w.index! + w[0].length - 1]!;
const key = w[0].normalize('NFKC').replace(/[\u2019\u02bc]/g, "'").toLowerCase();
const suffix = SUFFIXES.find(([s]) => key.endsWith(s) && key.length > s.length);
const parts = WHOLE[key] ?? (key.endsWith("'s") && IS_BASES.has(key.slice(0, -2)) ? [key.slice(0, -2), 'is'] : suffix ? [key.slice(0, -suffix[0].length), suffix[1]] : [key]);
for (const k of parts) out.push({ key: k, start, end });
}
return out;
}
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Where gstack renders a human's deferral reason (R): the voice detail, the consensus head, the gate title, a blocker's why, the run log. */
const DEFERRAL_RENDERINGS = (r: string) => [`^deferred: (${r})$`, `\\((${r}); by human\\)`, `accepted-with-findings at round \\d+ \\((${r})\\): `, `closed deferred \\((${r})\\) with this finding open`, `, by human\\): (${r}); \\d+ open finding`];
/**
* The scan index: each owner reply and deferral reason of MIN_TOKENS+ words (grammar skipped), each run of MIN_TOKENS+
* of its words between echoed windows, and the placements of deferral reasons and carried words.
*/
export function ownerIndex(replies: OwnerSource[], o: { deferred?: OwnerSource[]; carried?: string[] } = {}): OwnerWords {
const keysOf = (texts: string[]) => ` ${texts.flatMap(t => [tokens(t, false), tokens(t)].map(ts => ts.map(k => k.key).join(' '))).join(' \u0001 ')} `;
const seen = new Set<string>();
const indexed: Indexed[] = [];
const add = (keys: string[], prior: string) => {
if (keys.length < MIN_TOKENS || seen.has(keys.join(' '))) return;
seen.add(keys.join(' '));
indexed.push({ keys, set: new Set(keys), prior });
};
for (const s of [...replies, ...(o.deferred ?? [])]) {
if (s.parsed) continue;
const prior = keysOf(s.prior ?? []);
for (const rendered of [true, false]) {
const keys = tokens(s.text, rendered).map(t => t.key);
add(keys, prior);
const size = Math.min(keys.length, WINDOW);
const echo = new Set<number>();
for (let j = 0; j + size <= keys.length; j++) if (prior.includes(` ${keys.slice(j, j + size).join(' ')} `)) for (let k = j; k < j + size; k++) echo.add(k);
for (let j = 0; echo.size && j < keys.length;) {
let k = j;
while (k < keys.length && !echo.has(k)) k++;
if (k > j) add(keys.slice(j, k), prior);
j = k + 1;
}
}
}
const placements = [
...(o.deferred ?? []).flatMap(d => DEFERRAL_RENDERINGS(escape(d.text))),
...(o.carried ?? []).map(w => `words=(${escape(JSON.stringify(w))})(?= by=|$)`),
].map(p => new RegExp(p, 'gd'));
return { replies: indexed, placements };
}
/** Every owner-words span in `text` (placements, then quotes less echoes, in both views), merged, as original [start, end) offsets. */
export function ownerSpans(text: string, owner: OwnerWords): Span[] {
const spans: Span[] = [];
for (const re of owner.placements) for (const m of text.matchAll(re)) spans.push([m.indices![1]![0], m.indices![1]![1]]);
for (const ts of owner.replies.length ? [tokens(text, false), tokens(text)] : []) {
const keys = ts.map(t => t.key);
for (const r of owner.replies) {
const size = Math.min(r.keys.length, WINDOW);
const windows = new Set<string>();
for (let j = 0; j + size <= r.keys.length; j++) windows.add(r.keys.slice(j, j + size).join(' '));
const quoted = new Set<number>(), echo = new Set<number>();
for (let i = 0; i + size <= keys.length; i++) {
const run = keys.slice(i, i + size).join(' ');
if (!windows.has(run)) continue;
for (let k = i; k < i + size; k++) (r.prior.includes(` ${run} `) ? echo : quoted).add(k);
}
for (const k of [...quoted]) {
for (let a = k - 1; a >= 0 && r.set.has(keys[a]!) && !echo.has(a) && !quoted.has(a); a--) quoted.add(a);
for (let b = k + 1; b < keys.length && r.set.has(keys[b]!) && !echo.has(b) && !quoted.has(b); b++) quoted.add(b);
}
const marked = [...quoted].filter(k => !echo.has(k)).sort((x, y) => x - y);
for (let s = 0; s < marked.length;) {
let e = s;
while (e + 1 < marked.length && marked[e + 1] === marked[e]! + 1) e++;
spans.push([ts[marked[s]!]!.start, ts[marked[e]!]!.end]);
s = e + 1;
}
}
}
spans.sort((a, b) => a[0] - b[0]);
return spans.reduce<Span[]>((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 every owner-words span outside `keep` (code export never rewrites) with the marker. */
export function redactOwnerWords(text: string, owner: OwnerWords, keep: Span[] = []): { text: string; redacted: number } {
const spans = ownerSpans(text, owner).filter(([s, e]) => !keep.some(([ks, ke]) => s < ke && e > ks));
let out = text;
for (const [s, e] of [...spans].reverse()) out = out.slice(0, s) + OWNER_WORDS_MARK + out.slice(e);
return { text: out, 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<string, unknown> = { ...(v as Record<string, unknown>) };
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 };
}
/** Every owner reply and human deferral reason the private run at `state.out` recorded, each with the text recorded before it. */
export function ownerWords(state: Pick<RunState, 'out' | 'phases' | 'gate' | 'carry'>): OwnerWords {
const out = state.out;
const journal = readJournal(out);
const read = (name: string) => { try { return fs.readFileSync(path.join(out, name), 'utf8'); } catch { return undefined; } };
const strings = (v: unknown): string[] => (typeof v === 'string' ? [v] : Array.isArray(v) ? v.flatMap(strings) : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : []);
const gateText = (rev: number) => {
const archived = read(`gate.r${rev}.json`);
const current = read('gate.json');
const gate = archived ?? (current && JSON.parse(current).gate_rev === rev ? current : undefined);
return gate ? strings(JSON.parse(gate).items).join('\n') : '';
};
const gates = journal.filter(r => r.event === 'gate_written' && typeof r.gate_rev === 'number').map(r => ({ at: String(r.ts), text: [read(`plan-at-gate.r${r.gate_rev}.md`) ?? '', gateText(r.gate_rev)].join('\n') }));
const bound = journal.filter(r => r.event === 'submitted' && typeof r.result === 'string').map(r => ({ at: String(r.ts), text: read(path.basename(r.result)) ?? '' }));
const prior = (at: string | undefined) => (at ? [...gates, ...bound].filter(x => x.at < at).map(x => x.text) : []);
const byText = new Map<string, { at?: string; parsed: boolean }>();
const note = (text: unknown, at: unknown, parsed: unknown) => {
if (typeof text !== 'string' || !text.trim()) return;
const was = byText.get(text);
const when = typeof at === 'string' ? at : undefined;
byText.set(text, { at: was?.at && (!when || was.at < when) ? was.at : when, parsed: !!was?.parsed || parsed === true });
};
for (const r of journal) note(r.owner_reply, r.ts, r.owner_reply_parsed);
for (const d of readDecisions(out)) note(d.owner_reply, d.answered_at, d.owner_reply_parsed);
const gate = state.gate as { owner_reply?: unknown; answered_at?: unknown; owner_reply_parsed?: unknown } | undefined;
note(gate?.owner_reply, gate?.answered_at, gate?.owner_reply_parsed);
note(state.carry?.words, undefined, false);
const deferred = new Map<string, string | undefined>();
for (const r of journal) if (r.event === 'phase_deferred' && r.by === 'human' && typeof r.reason === 'string') deferred.set(r.reason, deferred.get(r.reason) ?? String(r.ts));
for (const p of Object.values(state.phases ?? {})) if (p?.deferred?.by === 'human' && p.deferred.reason && !deferred.has(p.deferred.reason)) deferred.set(p.deferred.reason, p.deferred.at);
return ownerIndex([...byText].map(([text, r]) => ({ text, parsed: r.parsed, prior: prior(r.at) })), {
deferred: [...deferred].map(([text, at]) => ({ text, prior: prior(at) })),
carried: state.carry?.words ? [state.carry.words] : [],
});
}
+54 -8
View File
@@ -21,6 +21,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { repoRoot } from './autoplan-plan-text';
import { checkpointRoot } from './autoplan-checkpoint';
import { dropOwnerFields, ownerSpans, ownerWords, redactOwnerWords, type OwnerWords, type Span } from './autoplan-owner-words';
import { FILES, RunError, type RunState } from './autoplan-state';
import { sha256File, validateRun, type ValidationError } from './headless-artifacts';
@@ -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(/(?<!`)(`+)(?!`).*?(?<!`)\1(?!`)/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,17 @@ 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 words = (line: number, inCode = false) => errors.push({ code: 'ARTIFACT_OWNER_WORDS', path: `${rel}:${line}`, message: `owner words the private run recorded appear in the public copy${inCode ? ' (inside code, which export never rewrites: edit the plan)' : ''}` });
const lineAt = (at: number) => text.slice(0, Math.max(at, 0)).split('\n').length;
const values = (v: unknown): string[] => (typeof v === 'string' ? [v] : Array.isArray(v) ? v.flatMap(values) : v && typeof v === 'object' ? Object.values(v).flatMap(values) : []);
const json = /\.jsonl?$/.test(rel) ? (() => { try { return rel.endsWith('.jsonl') ? text.split('\n').map(l => (l ? JSON.parse(l) : null)) : [JSON.parse(text)]; } catch { return undefined; } })() : undefined;
if (json) {
json.forEach((doc, i) => { for (const s of values(doc)) if (ownerSpans(s, owner).length) words(rel.endsWith('.jsonl') ? i + 1 : lineAt(text.indexOf(JSON.stringify(s)))); });
continue;
}
const code = rel === 'plan.md' ? codeSpans(text) : [];
for (const [s, e] of ownerSpans(text, owner)) words(lineAt(s), code.some(([cs, ce]) => s < ce && e > cs));
}
return { errors, warnings };
}
@@ -115,23 +140,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);
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 +168,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);
const report: string[] = [];
/** The public bytes of one file: owner-word fields dropped, quotes redacted on the original bytes, recorded roots rewritten, then quotes redacted again on the rewritten bytes the validator reads (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(v); dropped += d.dropped; return strings(rewriteJson(strings(d.value), roots)); };
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(redact(text, codeSpans(text)), roots); body = redact(prose, codeSpans(prose)); }
else body = redact(rewriteRoots(redact(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 +197,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 +209,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 });
}
+6
View File
@@ -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 <n> ...), 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 <run> --public <dir>`, 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<string, ResultCode>;
+1 -1
View File
@@ -31,7 +31,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
{ cmd: 'gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by human|agent:<id>', 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 <dir> [--spend-cap <usd>] [--max-rounds <n>] --by human|agent:<id>', does: 'the owner\'s journaled cap raise; after a budget stop, `resume` continues the run' },
{ cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' },
{ cmd: 'gstack-autoplan export --out <dir> --public <pub>', 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 <pub> --private <dir>/run.json` on this machine' },
{ cmd: 'gstack-autoplan export --out <dir> --public <pub>', 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 (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 <pub> --private <dir>/run.json` on this machine' },
{ cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them' },
];
+176
View File
@@ -0,0 +1,176 @@
/**
* GST-1243: the owner-words rule behind `gstack-autoplan export --public` (lib/autoplan-owner-words.ts). Replies of 1-2
* words and gate grammar are never scanned for; a reply of 3+ words is a quote wherever it appears whole, and from 8
* words any 8 consecutive words are a quote; glued punctuation, entities, zero-width characters, Markdown, wraps and
* contractions don't hide one; a quote extends over neighboring reply words; echoes of earlier text stay; gstack's
* renderings of a human's deferral reason are matched with their delimiters; code export keeps is never rewritten.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { MIN_TOKENS, OWNER_WORDS_MARK as MARK, WINDOW, dropOwnerFields, ownerIndex, ownerSpans, 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 quoted = (text: string, ...replies: string[]) => { const o = ownerIndex(replies.map(r => ({ text: r }))); return ownerSpans(text, o).map(([s, e]) => text.slice(s, e)); };
describe('the quote rule', () => {
test(`replies under ${MIN_TOKENS} words are never scanned for; from ${MIN_TOKENS} words a reply is matched whole at word boundaries`, () => {
expect(quoted('Do it. Yes. Ship it, the owner said.', 'do it', 'yes', 'ship it')).toEqual([]);
expect(quoted('Context: Jane Doe vetoed it, per the relay.', 'Jane Doe vetoed it')).toEqual(['Jane Doe vetoed it']);
expect(quoted('Jane Doe vetoed items in March.', 'Jane Doe vetoed it')).toEqual([]);
});
test('a reply that parsed as gate grammar is the decision code, not scanned for', () => {
expect(ownerSpans('reply: rec except p2b p3a c4b', ownerIndex([{ text: 'rec except p2b p3a c4b', parsed: true }]))).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; any ${WINDOW} consecutive words of a longer one are a quote`, () => {
expect(quoted('They said: please drop the Redis cache layer.', 'Drop the Redis cache layer entirely')).toEqual([]);
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);
});
const REPLY = 'take the flag first, I do not want a big bang rollout';
test.each([
['an em dash', 'take the flag first\u2014I do not want a big bang rollout'],
['slashes', 'take/the/flag/first/I/do/not/want/a/big/bang/rollout'],
['an ellipsis', 'take the flag first...I do not want a big bang rollout'],
['HTML entities', 'take&nbsp;the&#32;flag&#x20;first, I do not want a big bang rollout'],
['a zero-width character', 'take the fl\u200bag first, I do not want a big bang rollout'],
['Markdown links and emphasis', '[take the flag first](https://x.test/a) I do *not* want a **big bang** rollout'],
['a hyphenated line wrap', 'take the flag first, I do not want a big bang roll-\n> out'],
['JSON glue', '{"evidence":"take the flag first, I do not want a big bang rollout"}'],
['attribute glue', '<q quote="take the flag first, I do not want a big bang rollout">'],
['a label', 'owner:take the flag first, I do not want a big bang rollout'],
['a contraction', "take the flag first, I don't want a big bang rollout"],
])('%s does not hide a quote', (_, text) => {
const r = redactOwnerWords(text, ownerIndex([{ text: REPLY }]));
expect(r.redacted).toBe(1);
for (const word of ['take', 'flag', 'want', 'bang', 'roll']) expect(r.text).not.toContain(word);
});
test.each([
['an HTML tag', 'Jane Doe<br>vetoed it'],
['bold tags', '<b>Jane Doe</b> vetoed it'],
['an HTML comment', 'Jane <!-- aside --> Doe vetoed it'],
['a reference link', '[Jane Doe][1] vetoed it\n\n[1]: https://x.test "the source"'],
['a footnote marker', 'Jane Doe[^1] vetoed it'],
['a link title', '[Jane Doe](https://x.test "the source") vetoed it'],
['a literal JSON newline escape', '{"q": "Jane\\nDoe vetoed it"}'],
['a zero-width space entity', 'Jane Do&#8203;e vetoed it'],
['a soft hyphen entity', 'Jane Do&#173;e vetoed it'],
['a named zero-width entity', 'Jane Do&ZeroWidthSpace;e vetoed it'],
['a left-to-right mark', 'Jane Do\u200ee vetoed it'],
['a right-to-left mark', 'Jane Do\u200fe vetoed it'],
['a directional isolate', 'Jane \u2066Doe\u2069 vetoed it'],
['a combining grapheme joiner', 'Jane Do\u034fe vetoed it'],
['a variation selector', 'Jane Doe\ufe0f vetoed it'],
['an HTML tag with attributes', 'Jane <span class="highlight">Doe</span> vetoed it'],
])('%s does not hide a short quote', (_, text) => {
const r = redactOwnerWords(text, ownerIndex([{ text: 'Jane Doe vetoed it' }]));
expect(r.redacted).toBeGreaterThanOrEqual(1);
for (const word of ['Jane', 'vetoed']) expect(r.text).not.toContain(word);
});
test('an accented letter written as a named entity, an astral character reference and a private link target do not hide a quote', () => {
const accented = redactOwnerWords('Context: Jos&eacute; Alvarez vetoed it.', ownerIndex([{ text: 'José Alvarez vetoed it' }]));
expect(accented.text).toBe(`Context: ${MARK}.`);
expect(redactOwnerWords('Hello &#x1f600; Jane Doe vetoed it', ownerIndex([{ text: 'Jane Doe vetoed it' }])).text).toBe(`Hello &#x1f600; ${MARK}`);
const link = redactOwnerWords('Ref: https://x.test/acquisition-of-private-company-closes-next-monday', ownerIndex([{ text: 'see [the notes](https://x.test/acquisition-of-private-company-closes-next-monday) today' }]));
expect(link.text).not.toContain('acquisition');
});
test('the redaction covers the original characters, markup inside the quote included', () => {
expect(redactOwnerWords('X: <b>Jane Doe</b> vetoed it.', ownerIndex([{ text: 'Jane Doe vetoed it' }])).text).toBe(`X: <b>${MARK}.`);
expect(redactOwnerWords('{"q": "I don\\u2019t want it now"}', ownerIndex([{ text: "I don't want it now" }])).text).toBe(`{"q": "${MARK}"}`);
});
test.each([
['I cannot accept it', "I can't accept it"],
['I cannot accept it', 'I can not accept it'],
['It is too risky now', "It's too risky now"],
['I would ship it today', "I'd ship it today"],
['We will not ship it', "We won't ship it"],
['They are not ready yet', "They aren't ready yet"],
['They are not ready yet', "They're not ready yet"],
])('the reply %p matches %p', (reply, text) => {
expect(redactOwnerWords(text, ownerIndex([{ text: reply }])).redacted).toBe(1);
});
test('new words a reply adds to echoed text are a quote on their own, whatever the whole reply\'s length', () => {
const line = 'Persist the attempt count on the job row so a restarted worker resumes the schedule.';
const o = ownerIndex([{ text: `${line.slice(0, -1)}, and page Jane Doe on call.`, prior: [line] }]);
expect(redactOwnerWords('Note: and page Jane Doe on call.', o).text).toBe(`Note: ${MARK}.`);
expect(redactOwnerWords(line, o).redacted).toBe(0);
});
test('a quote extends over the neighboring words of the same reply, so none is left on either side', () => {
const reply = 'Honestly I do not want a big bang rollout on the retry path';
expect(quoted('Well honestly, I don\u2019t want a big bang rollout on the retry path!', reply)).toEqual(['honestly, I don\u2019t want a big bang rollout on the retry path']);
expect(quoted('> \u201cDon\u2019t ship the backoff\n> without the flag, EVER\u201d\n', "don't ship the backoff without the flag, ever")).toEqual(['Don\u2019t ship the backoff\n> without the flag, EVER']);
});
test('words the owner echoed from text recorded before the reply stay; the new words around them are redacted', () => {
const line = 'Persist the attempt count on the job row so a restarted worker resumes the schedule.';
const reply = `${line.slice(0, -1)}, and cap the retries at five attempts in total.`;
const o = ownerIndex([{ text: reply, prior: [`## Implementation plan\n\n${line}`] }]);
expect(redactOwnerWords(line, o)).toEqual({ text: line, redacted: 0 });
expect(redactOwnerWords(`Relayed: "${reply}"`, o).text).toBe(`Relayed: "${line.slice(0, -1)}, ${MARK}."`);
const title = 'Ship the backoff behind a flag first';
expect(redactOwnerWords(`${title} [run-1-ceo-native-1]`, ownerIndex([{ text: title, prior: [title] }])).redacted).toBe(0);
});
});
describe('redactOwnerWords', () => {
test('replaces each quote with the marker, keeps the punctuation around it, and leaves other prose byte for byte', () => {
const o = ownerIndex([{ text: 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 or placement overlapping a kept span (code) is left for the validator to refuse', () => {
const o = ownerIndex([{ text: LONG }], { deferred: [{ text: 'no time' }] });
for (const text of [`\`${LONG}\``, 'ceo closed deferred (no time) with this finding open']) {
expect(redactOwnerWords(text, o, [[0, text.length]])).toEqual({ text, redacted: 0 });
expect(ownerSpans(text, o)).toHaveLength(1);
}
});
test("gstack's renderings of carried words and a human's deferral reason are replaced whatever their length, delimiters and all", () => {
const o = ownerIndex([], { deferred: [{ text: 'no time' }], carried: ['Garry: do it'] });
const cases: Array<[string, string]> = [
['CARRIED: gate_rev=2 <- gate_rev=1 words="Garry: do it" by=agent:x', `CARRIED: gate_rev=2 <- gate_rev=1 words=${MARK} by=agent:x`],
['Deferred ceo at round 1 (owner, by human): no time; 0 open finding(s)', `Deferred ceo at round 1 (owner, by human): ${MARK}; 0 open finding(s)`],
['ceo closed accepted-with-findings at round 1 (no time): 0 open', `ceo closed accepted-with-findings at round 1 (${MARK}): 0 open`],
['ceo closed deferred (no time) with this finding open; only you can accept it', `ceo closed deferred (${MARK}) with this finding open; only you can accept it`],
['round 1 (no time; by human); open findings', `round 1 (${MARK}; by human); open findings`],
['deferred: no time', `deferred: ${MARK}`],
];
for (const [text, want] of cases) expect(redactOwnerWords(text, o).text).toBe(want);
for (const kept of ['deferred: no time to spare', 'eng closed accepted-with-findings at round 1 (no time left): 0 open', '(no time; by agent:x)', 'we had no time to spare']) expect(redactOwnerWords(kept, o).text).toBe(kept);
});
test('every template that renders a deferral reason has a placement (add one to DEFERRAL_RENDERINGS with any new template)', () => {
const root = path.resolve(import.meta.dir, '..');
const count = Object.fromEntries(fs.readdirSync(path.join(root, 'lib')).filter(f => f.startsWith('autoplan-') && f.endsWith('.ts'))
.map(f => [f, (fs.readFileSync(path.join(root, 'lib', f), 'utf8').match(/\$\{(?:o|def|d|ps\.deferred)\.reason\}|JSON\.stringify\(o\.reason\)/g) ?? []).length]).filter(([, n]) => n));
expect(count).toEqual({ 'autoplan-gate.ts': 5, 'autoplan-export.ts': 1, 'autoplan-run.ts': 1 });
});
});
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);
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* 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 { exportPublic } from '../lib/autoplan-public';
import { readState } from '../lib/autoplan-state';
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, { recursive: true, withFileTypes: true }).filter(e => e.isFile()).map(e => fs.readFileSync(path.join(e.parentPath, 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);
}
}
interface Bundle { v2?: string; wa?: string; wb?: string; ceo?: { native?: string; outside?: string }; eng?: { native?: string; outside?: string } }
/** 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, wa = WA, wb = WB, ceo = { native: QUOTE_A, outside: REFLOW_B }, eng = { native: CONTROL } }: Bundle = {}): { 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', ceo);
phaseWith(out, 'eng', eng);
expect(readRunJson(out).status).toBe('complete');
const e = r.ap(['export', '--out', out]);
expect(e.status, e.stderr).toBe(0);
return { out, dir };
}
/** CEO native binds a Critical finding, then a human defers CEO with `reason`; Eng runs clean to the gate. */
function deferredRun(reason: string, plan = SMALL_PLAN): { 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, plan), '--no-ui', '--no-dx']).status).toBe(0);
expect(r.review(path.join(out, 'ceo-native-prompt.md'), { FIXTURE_FINDINGS: JSON.stringify([{ id: 'F1', severity: 'Critical', title: 'Retries can storm the webhook target' }]) }).status).toBe(0);
const bind = r.ap(['submit', '--out', out, '--phase', 'ceo', '--voice', 'native', '--result', resultOf(out, 'ceo', 'native'), '--model', 'anthropic/claude-opus-5-5', '--attempt', readRunJson(out).phases.ceo.native.attempt, '--runner', 'host-subagent']);
expect(bind.status, bind.stderr).toBe(0);
const close = r.ap(['close', '--out', out, '--phase', 'ceo', '--deferred', reason, '--by', 'human']);
expect(close.status, close.stderr).toBe(0);
expect(phaseVia(r, out, 'eng', { native: [], outside: [] }).outside.stdout).toContain('status=gate_pending');
expect(r.ap(['export', '--out', out]).status).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({ v2: 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('owner words inside double-backtick inline code in plan.md are code too: export refuses', () => {
const inline = V2.replace('## Review record', `The owner's answer: \`\` ${WA} \`\`\n\n## Review record`);
const { out, dir } = ownerBundle({ v2: inline });
const e = r.ap(['export', '--out', out, '--public', path.join(dir, 'public')]);
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);
});
test('a reply that names a recorded root is redacted before the root is rewritten', () => {
const next = path.join(r.host.base, `owner${n + 1}`, 'run');
const wa = `Read ${next}/acquisition-merger-notes first.`;
const { out, dir } = ownerBundle({ wa, ceo: { native: `Owner: ${wa}` }, eng: {} });
expect(out).toBe(next);
const pub = path.join(dir, 'public');
expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0);
expect(allText(pub)).not.toContain('acquisition-merger-notes');
expect(fs.readFileSync(path.join(pub, 'ceo-native.a5.bound.md'), 'utf8')).toContain(`Owner: ${MARK}`);
});
test('a reply that names a repo-relative path is redacted where the record holds the absolute worktree path', () => {
const repo = path.join(r.host.base, `owner${n + 1}`);
const wa = 'please also update run/notes-merger.md tests';
const { out, dir } = ownerBundle({ wa, ceo: { native: `Decision: please also update ${path.join(repo, 'run', 'notes-merger.md')} tests` }, eng: {} });
expect(dir).toBe(repo);
fs.mkdirSync(path.join(repo, '.git'));
const state = readState(out);
state.plan_input = path.join(repo, 'plans', 'plan.md');
const pub = path.join(dir, 'public');
exportPublic(state, pub);
expect(allText(pub)).not.toContain('notes-merger');
expect(fs.readFileSync(path.join(pub, 'ceo-native.a5.bound.md'), 'utf8')).toContain(`Decision: ${MARK}`);
});
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');
});
test("a human's deferral reason leaves no rendering behind (gate.json why included), and validate catches one added later", () => {
const { out, dir } = deferredRun('zebra quokka');
expect(fs.readFileSync(path.join(out, 'gate.json'), 'utf8')).toContain('closed deferred (zebra quokka) with this finding open');
const pub = path.join(dir, 'public');
const e = r.ap(['export', '--out', out, '--public', pub]);
expect(e.status, e.stderr).toBe(0);
expect(allText(pub)).not.toContain('zebra quokka');
expect(fs.readFileSync(path.join(pub, 'gate.json'), 'utf8')).toContain(`ceo closed deferred (${MARK}) with this finding open`);
const record = path.join(pub, 'review-record.md');
const line = fs.readFileSync(record, 'utf8').split('\n').length;
fs.appendFileSync(record, 'ceo closed deferred (zebra quokka) with this finding open\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'));
expect(full.stderr).not.toContain('zebra quokka');
});
test('a reply superseded at the same gate stays in the corpus: the journal keeps it, so a later quote of it is redacted', () => {
const early = 'Hold off on jitter until we know how the single proxy host behaves under a storm of retries.';
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');
expect(r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1b p1c', '--by', 'agent:gstack-minder', '--owner-reply', early]).stdout).toContain('interrogate recorded');
const rerun = r.ap(['answer', '--out', out, '--gate-rev', '1', '--reply', 'uc1a p1d', '--by', 'agent:gstack-minder', '--owner-reply', WB]);
expect(rerun.status, rerun.stderr).toBe(0);
expect(fs.readFileSync(path.join(out, 'decisions.jsonl'), 'utf8')).not.toContain('single proxy host');
expect(fs.readFileSync(path.join(out, 'attempts.jsonl'), 'utf8')).toContain('single proxy host');
phaseWith(out, 'ceo', { native: `The owner first said: "${early}"` });
phaseWith(out, 'eng');
if (readRunJson(out).status === 'gate_pending') expect(r.ap(['answer', '--out', out, '--gate-rev', String(readRunJson(out).gate_rev), '--reply', 'p1a', '--by', 'agent:gstack-minder', '--owner-reply', 'ok']).status).toBe(0);
expect(r.ap(['export', '--out', out]).status).toBe(0);
const pub = path.join(dir, 'public');
const e = r.ap(['export', '--out', out, '--public', pub]);
expect(e.status, e.stderr).toBe(0);
expect(folded(allText(pub))).not.toContain('single proxy host');
expect(fs.readFileSync(path.join(pub, boundName(out, 'ceo', 'native')), 'utf8')).toContain(`The owner first said: "${MARK}."`);
});
test("a deferral placement never rewrites plan.md code; an exact rendering inside code refuses", () => {
const probe = SMALL_PLAN.replace('## Review record', '```js\nconsole.log("deferred: medical appointment");\n```\n\n## Review record');
const kept = deferredRun('medical appointment', probe);
const pub = path.join(kept.dir, 'public');
const e = r.ap(['export', '--out', kept.out, '--public', pub]);
expect(e.status, e.stderr).toBe(0);
expect(fs.readFileSync(path.join(pub, 'plan.md'), 'utf8')).toContain('console.log("deferred: medical appointment");');
const rendered = SMALL_PLAN.replace('## Review record', '```text\nceo closed deferred (medical appointment) with this finding open\n```\n\n## Review record');
const refused = deferredRun('medical appointment', rendered);
const pub2 = path.join(refused.dir, 'public');
const x = r.ap(['export', '--out', refused.out, '--public', pub2]);
expect(x.status, x.stdout + x.stderr).toBe(3);
expect(x.stderr).toMatch(/plan\.md:\d+: owner words .*\(inside code, which export never rewrites: edit the plan\).*\(ARTIFACT_OWNER_WORDS\)$/m);
expect(x.stderr).not.toContain('medical appointment');
expect(fs.existsSync(pub2)).toBe(false);
});
test("a human's one-word deferral reason never rewrites an agent's longer reason that starts with it", () => {
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);
expect(r.ap(['close', '--out', out, '--phase', 'ceo', '--deferred', 'budget', '--by', 'human']).status).toBe(0);
expect(r.ap(['next', '--out', out]).status).toBe(0);
expect(r.ap(['close', '--out', out, '--phase', 'eng', '--deferred', 'budget went to the CEO phase', '--by', 'agent:gstack-minder']).status).toBe(0);
expect(r.ap(['export', '--out', out]).status).toBe(0);
const pub = path.join(dir, 'public');
expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0);
const manifest = readRunJson(pub);
expect(manifest.phases.ceo.native.detail).toBe(`deferred: ${MARK}`);
expect(manifest.phases.eng.native.detail).toBe('deferred: budget went to the CEO phase');
expect(fs.readFileSync(path.join(pub, 'gate.json'), 'utf8')).toContain('eng closed accepted-with-findings at round 1 (budget went to the CEO phase): ');
expect(fs.readFileSync(path.join(pub, 'eng-consensus.md'), 'utf8')).toContain('(budget went to the CEO phase; by agent:gstack-minder)');
});
test('words the owner only echoed stay where they were first written; the new words around them are redacted', () => {
const PLAN_LINE = 'Persist the attempt count on the job row so a restarted worker resumes the schedule.';
const withNew = `${PLAN_LINE.slice(0, -1)}, and cap the retries at five attempts in total.`;
const { out, dir } = ownerBundle({ wa: 'Ship the backoff behind a flag first', wb: withNew, ceo: { native: `Relayed: "${withNew}"` }, eng: {} });
const pub = path.join(dir, 'public');
const e = r.ap(['export', '--out', out, '--public', pub]);
expect(e.status, e.stderr).toBe(0);
expect(fs.readFileSync(path.join(pub, 'plan.md'))).toEqual(fs.readFileSync(path.join(out, 'plan.md')));
expect(fs.readFileSync(path.join(pub, 'plan.md'), 'utf8')).toContain(PLAN_LINE);
const titles = (dir: string) => readJsonl(path.join(dir, 'decisions.jsonl')).map(d => d.title);
expect(titles(pub)).toEqual(titles(out));
expect(fs.readFileSync(path.join(pub, 'ceo-native.a5.bound.md'), 'utf8')).toContain(`Relayed: "${PLAN_LINE.slice(0, -1)}, ${MARK}."`);
expect(folded(allText(pub))).not.toContain('five attempts in total');
});
test('a reply of 3 or more words is matched whole, however short', () => {
const { out, dir } = ownerBundle({ wa: 'Jane Doe vetoed it', ceo: { native: 'Context: Jane Doe vetoed it, per the relay.' }, eng: {} });
const pub = path.join(dir, 'public');
expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0);
expect(fs.readFileSync(path.join(pub, 'ceo-native.a5.bound.md'), 'utf8')).toContain(`Context: ${MARK}, per the relay.`);
expect(allText(pub)).not.toContain('Jane Doe');
});
test('markup and reference links around a short quote do not hide it from export or validate', () => {
const { out, dir } = ownerBundle({ wa: 'Jane Doe vetoed it', ceo: { native: 'Context: Jane Doe<br>vetoed it, and [Jane Doe][1] vetoed it.\n\n[1]: https://x.test' }, eng: {} });
const pub = path.join(dir, 'public');
expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0);
const bound = fs.readFileSync(path.join(pub, 'ceo-native.a5.bound.md'), 'utf8');
expect(bound).not.toContain('Jane');
expect(bound).toContain(`Context: ${MARK}, and ${MARK}.`);
expect(artifact(['validate', '--public', pub, '--private', path.join(out, 'run.json')]).status).toBe(0);
});
test('new words a reply adds to an echoed plan line are redacted when quoted alone', () => {
const PLAN_LINE = 'Persist the attempt count on the job row so a restarted worker resumes the schedule.';
const { out, dir } = ownerBundle({ wb: `${PLAN_LINE.slice(0, -1)}, and page Jane Doe on call.`, ceo: { native: 'Ops note: and page Jane Doe on call.' }, eng: {} });
const pub = path.join(dir, 'public');
expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0);
expect(fs.readFileSync(path.join(pub, 'ceo-native.a5.bound.md'), 'utf8')).toContain(`Ops note: ${MARK}.`);
expect(fs.readFileSync(path.join(pub, 'plan.md'))).toEqual(fs.readFileSync(path.join(out, 'plan.md')));
});
});