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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 17:57:20 +00:00
1 parent c535ddc46d
commit 4975267fcb
10 files changed
+438 -153

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) 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 <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>`
+2 -2
View File
@@ -129,8 +129,8 @@ DRIVER: ${driverFile()}
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. 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
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)
@@ -1,10 +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
- **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: <file> dropped=<n> redacted=<m>` 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 <dir> --private <run.json>` runs the same check. The error never prints the words.
- **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. HTML entities are decoded, zero-width characters dropped, hyphen-newline wraps joined, Markdown link targets skipped and contractions split; 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.
- **Refusal.** A quote or rendering 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 <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; 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).
- `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.
- `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, extension, echoes, kept code spans, bounded renderings) and a ratchet on every template that renders a deferral reason.
+13 -10
View File
@@ -4696,21 +4696,24 @@ 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 <dir> --private
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, entities, Markdown 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. 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 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.
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`
+37 -18
View File
@@ -298,27 +298,46 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
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.
- **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 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
(`*.bound.md`) included, is scanned for quotes. HTML entities are decoded,
zero-width characters dropped, a hyphen-newline wrap joined, Markdown link
targets skipped and contractions split ("don't" reads "do not"); 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. 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 inside a code
span or fence there stops the export with
- **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. `gstack-artifact validate --public <pub>
--private <dir>/run.json` runs the same scan on an existing copy.
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
+170 -68
View File
@@ -1,95 +1,158 @@
/**
* 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
* <reason> --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.
* their click.
*
* `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.
* 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.
*
* 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.
* 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 normalized with an offset map back to the original: HTML
* entities decoded, zero-width characters dropped, a hyphen-newline wrap
* joined, a Markdown link target dropped, and contractions split ("don't"
* reads "do not"). 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; new words
* around them are still redacted.
*/
import type { RunState } from './autoplan-state';
import type { DecisionRow } from './headless-artifacts';
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_WORDS = 5;
export const MIN_CHARS = 20;
export const MIN_TOKENS = 3;
export const WINDOW = 8;
export interface OwnerWords { placements: Array<[string, string]>; windows: Map<number, Set<string>> }
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[] }
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 });
const ENTITIES: Record<string, string> = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", mdash: '\u2014', ndash: '\u2013', hellip: '\u2026', lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201c', rdquo: '\u201d', shy: '', zwj: '', zwnj: '' };
const CONTRACTIONS: Record<string, string[]> = { "won't": ['will', 'not'], "can't": ['can', 'not'], "shan't": ['shall', 'not'] };
const SUFFIXES: Array<[string, string]> = [["n't", 'not'], ["'re", 'are'], ["'ve", 'have'], ["'ll", 'will'], ["'m", 'am']];
const ENTITY = /&(#\d+|#x[0-9a-f]+|[a-z]+);/iy;
const LINK_TARGET = /\]\([^\s)]*\)/y;
const WRAP = /-[ \t]*\r?\n[ \t>]*(?=[\p{L}\p{N}])/uy;
const sticky = (re: RegExp, text: string, at: number) => { re.lastIndex = at; return re.exec(text); };
interface Token { key: string; start: number; end: number }
/** Words of `text` with their original [start, end) offsets, after the normalization the header describes. */
export function tokens(text: string): Token[] {
let norm = '';
const from: number[] = [], to: number[] = [];
const push = (s: string, a: number, b: number) => { for (let k = 0; k < s.length; k++) { norm += s[k]; from.push(a); to.push(b); } };
for (let i = 0; i < text.length;) {
const c = text[i]!;
const e = c === '&' ? sticky(ENTITY, text, i) : null;
const code = e?.[1]!.startsWith('#') ? (e[1]![1]?.toLowerCase() === 'x' ? parseInt(e[1]!.slice(2), 16) : parseInt(e[1]!.slice(1), 10)) : undefined;
const decoded = e ? (code !== undefined ? (code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : undefined) : ENTITIES[e[1]!.toLowerCase()]) : undefined;
if (e && decoded !== undefined) { push(decoded, i, i + e[0].length); i += e[0].length; continue; }
if (/[\u200b-\u200d\u2060\ufeff\u00ad]/.test(c)) { i++; continue; }
const l = c === ']' ? sticky(LINK_TARGET, text, i) : null;
if (l) { push(' ', i, i + l[0].length); i += l[0].length; continue; }
const w = c === '-' && /[\p{L}\p{N}]/u.test(norm.slice(-1)) ? sticky(WRAP, text, i) : null;
if (w) { i += w[0].length; continue; }
push(c, i, i + 1);
i++;
}
const out: Token[] = [];
for (const m of norm.matchAll(/[\p{L}\p{M}\p{N}]+(?:['\u2019\u02bc][\p{L}\p{M}\p{N}]+)*/gu)) {
const start = from[m.index!]!, end = to[m.index! + m[0].length - 1]!;
const key = m[0].normalize('NFKC').replace(/[\u2019\u02bc]/g, "'").toLowerCase();
const suffix = SUFFIXES.find(([s]) => key.endsWith(s) && key.length > s.length);
const parts = CONTRACTIONS[key] ?? (suffix ? [key.slice(0, -suffix[0].length), suffix[1]] : [key]);
for (const k of parts) out.push({ key: k, start, end });
}
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<RunState, 'phases' | 'gate' | 'carry'>, decisions: Array<Pick<DecisionRow, 'owner_reply' | 'owner_reply_parsed'>>): 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<number, Set<string>>();
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 };
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 for owner replies (1-2 word and grammar replies skipped), deferral reasons and carried words. */
export function ownerIndex(replies: OwnerSource[], o: { deferred?: OwnerSource[]; carried?: string[] } = {}): OwnerWords {
const keysOf = (texts: string[]) => ` ${texts.map(t => tokens(t).map(k => k.key).join(' ')).join(' \u0001 ')} `;
const seen = new Set<string>();
const indexed = [...replies, ...(o.deferred ?? [])].flatMap(s => {
const keys = tokens(s.text).map(t => t.key);
if (s.parsed || keys.length < MIN_TOKENS || seen.has(keys.join(' '))) return [];
seen.add(keys.join(' '));
return [{ keys, set: new Set(keys), prior: keysOf(s.prior ?? []) }];
});
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 quote of an owner text in `text`, as merged [start, end) spans. */
/** Every owner-words span in `text` (placements, then quotes less echoes), merged, as original [start, end) offsets. */
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]);
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]]);
if (owner.replies.length) {
const ts = 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;
}
}
}
hits.sort((a, b) => a[0] - b[0]);
return hits.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; }, []);
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 the verbatim placements, then every quote outside `keep` (code export never rewrites), with the marker. */
/** 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 } {
let redacted = 0;
const spans = ownerSpans(text, owner).filter(([s, e]) => !keep.some(([ks, ke]) => s < ke && e > ks));
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 };
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`. */
@@ -106,3 +169,42 @@ export function dropOwnerFields(value: unknown): { value: unknown; dropped: numb
};
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] : [],
});
}
+12 -8
View File
@@ -22,7 +22,7 @@ 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, readDecisions, type RunState } from './autoplan-state';
import { FILES, RunError, type RunState } from './autoplan-state';
import { sha256File, validateRun, type ValidationError } from './headless-artifacts';
export const LOCAL_NOTES = '<local notes (not committed)>';
@@ -110,12 +110,16 @@ function scan(dir: string, roots: RecordedRoot[], owner?: OwnerWords): { errors:
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)' : ''}` });
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 };
}
@@ -141,7 +145,7 @@ export function validatePublic(dir: string, privateRunJson?: string, env: NodeJS
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));
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;
@@ -164,7 +168,7 @@ 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 owner = ownerWords(state);
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 => {
+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) 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 <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' },
];
+74 -33
View File
@@ -1,67 +1,108 @@
/**
* 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.
* 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 { MIN_WORDS, OWNER_WORDS_MARK as MARK, WINDOW, dropOwnerFields, ownerSpans, ownerWords, redactOwnerWords } from '../lib/autoplan-owner-words';
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 owners = (...replies: Array<string | { owner_reply: string; owner_reply_parsed: boolean }>) =>
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));
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_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(`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', () => {
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(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`, () => {
expect(quoted('They said: please drop the Redis cache layer, entirely.', 'Drop the Redis cache layer entirely')).toEqual(['drop the Redis cache layer, entirely']);
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([]);
});
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']);
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('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 = owners(LONG);
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 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('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 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');
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 });
});
});
+120 -7
View File
@@ -37,7 +37,7 @@ const gateOf = (out: string) => JSON.parse(fs.readFileSync(path.join(out, 'gate.
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');
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();
@@ -58,28 +58,44 @@ function phaseWith(out: string, phase: string, prose: { native?: string; outside
}
}
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): { out: string; dir: string } {
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]);
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]);
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 });
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();
@@ -121,7 +137,7 @@ describe("export --public drops the owner's own words (GST-1243)", () => {
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 { 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);
@@ -164,4 +180,101 @@ describe("export --public drops the owner's own words (GST-1243)", () => {
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');
});
});