From 4975267fcb7e1600f1f9aaa4898fc43a94152918 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 17:57:20 +0000 Subject: [PATCH] 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> --- autoplan/DRIVER.md | 2 +- bin/gstack-autoplan | 4 +- .../gst-1243-public-export-owner-words.md | 13 +- docs/troubleshooting.md | 23 +- docs/unattended.md | 55 ++-- lib/autoplan-owner-words.ts | 238 +++++++++++++----- lib/autoplan-public.ts | 20 +- scripts/resolvers/autoplan-driver.ts | 2 +- test/autoplan-owner-words.test.ts | 107 +++++--- test/autoplan-public-owner-words.test.ts | 127 +++++++++- 10 files changed, 438 insertions(+), 153 deletions(-) diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index fa41b0332..994d98564 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -86,7 +86,7 @@ line yourself; gstack never writes the plan. **Kinds (R65), first `next` only.** - `gstack-autoplan export --out ` write the artifact set; then `gstack-artifact validate /run.json`. - `gstack-autoplan export --out --public ` - a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner's own words (owner_reply, carried words and a human's deferral reason dropped, quotes of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine. + a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner's own words (every owner_reply the run recorded, carried words and a human's deferral reason dropped, quotes and gstack's renderings of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine. - `gstack-autoplan status|resume --out ` read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts). - `gstack-autoplan resume --out --from ` diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 7e6454786..bcdbd1eda 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -129,8 +129,8 @@ DRIVER: ${driverFile()} export --out --public write a machine-path-free copy (recorded roots rewritten, plan.md code kept) with provenance digests; the private run is not touched; refused (ARTIFACT_MACHINE_PATH) when a recorded root would remain. 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 [--json] export --out release --out --attempt contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word) diff --git a/changelog.d/gst-1243-public-export-owner-words.md b/changelog.d/gst-1243-public-export-owner-words.md index bb83b3dc5..8048ce00a 100644 --- a/changelog.d/gst-1243-public-export-owner-words.md +++ b/changelog.d/gst-1243-public-export-owner-words.md @@ -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: dropped= redacted=` line names each changed file. -- **Refusal.** A quote inside plan.md code, which export never rewrites, stops the export with `ARTIFACT_OWNER_WORDS`, file:line and exit 3, and nothing is written. `gstack-artifact validate --public --private ` runs the same check. The error never prints the words. +- **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 --private ` runs the same check. The error never prints the words. Each changed file gets an `OWNER_WORDS: dropped= redacted=` line. ### Tests -- `test/autoplan-public-owner-words.test.ts`: a GEVA-shaped bundle (3 `owner_reply` rows, a carried gate, one reviewer quoting a reply on one line and one reflowing a reply across blockquote lines, a control file with no owner words) exports with every owner field dropped and both quotes redacted, while the control and the round-1 reviewer files stay byte-identical; 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 85eaa0ec6..3ab7a781d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 --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 --private /run.json` reports each quote with file:line, and `gstack-autoplan export --public` runs that check on a staging copy and writes nothing when it fails. The error never prints the words themselves. **Fix.** Export with `gstack-autoplan export --out --public `, which -drops those fields and replaces each quote in prose and sealed reviewer files -with `[owner words redacted]`. Export never rewrites inline code or fenced -blocks in plan.md, so a quote there needs a plan edit: remove it, `revise`, and -export again. +drops those fields and replaces each quote or rendering in prose and sealed +reviewer files with `[owner words redacted]`. Export never rewrites inline code +or fenced blocks in plan.md, so a quote there needs a plan edit: remove it, +`revise`, and export again. ### `ARTIFACT_PUBLIC_TARGET_EXISTS` diff --git a/docs/unattended.md b/docs/unattended.md index 0c65d562d..19161ca4b 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -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: dropped= redacted=` line names each changed file. -- **Refusal.** Export never rewrites code in plan.md, so a quote inside a code - span or fence there stops the export with +- **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 - --private /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 --private /run.json` runs the + same scan, renderings included, on an existing copy. ## How a parent consumes a run diff --git a/lib/autoplan-owner-words.ts b/lib/autoplan-owner-words.ts index 825af4105..ad72c660d 100644 --- a/lib/autoplan-owner-words.ts +++ b/lib/autoplan-owner-words.ts @@ -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 - * --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 --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> } export type Span = [number, number]; +export interface OwnerSource { text: string; parsed?: boolean; prior?: string[] } +interface Indexed { keys: string[]; set: Set; 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 = { 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 = { "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, decisions: Array>): OwnerWords { - const deferred = Object.values(state.phases ?? {}).flatMap(p => (p?.deferred?.by === 'human' && p.deferred.reason ? [p.deferred.reason] : [])); - const carried = state.carry?.words; - const texts = [...new Set([...decisions.map(d => d.owner_reply), (state.gate as { owner_reply?: unknown } | undefined)?.owner_reply, carried, ...deferred] - .filter((t): t is string => typeof t === 'string' && t.trim() !== ''))]; - const placements: Array<[string, string]> = [ - ...(carried ? [[`words=${JSON.stringify(carried)}`, `words=${OWNER_WORDS_MARK}`] as [string, string]] : []), - ...deferred.flatMap(r => [`deferred: ${r}`, `(${r}; by human)`, `(${r}): `, `, by human): ${r};`].map(s => [s, s.replace(r, () => OWNER_WORDS_MARK)] as [string, string])), - ]; - const grammar = new Set(decisions.filter(d => d.owner_reply_parsed).map(d => d.owner_reply)); - const windows = new Map>(); - for (const t of texts.filter(t => !grammar.has(t))) { - const keys = words(t).map(w => w.key); - if (keys.length < MIN_WORDS || keys.join(' ').length < MIN_CHARS) continue; - const size = Math.min(keys.length, WINDOW); - const set = windows.get(size) ?? windows.set(size, new Set()).get(size)!; - for (let i = 0; i + size <= keys.length; i++) set.add(keys.slice(i, i + size).join(' ')); - } - return { placements, windows }; +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(); + 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(); + for (let j = 0; j + size <= r.keys.length; j++) windows.add(r.keys.slice(j, j + size).join(' ')); + const quoted = new Set(), echo = new Set(); + 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((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((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): 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(); + 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(); + 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] : [], + }); +} diff --git a/lib/autoplan-public.ts b/lib/autoplan-public.ts index d77ecfa32..febb70c9b 100644 --- a/lib/autoplan-public.ts +++ b/lib/autoplan-public.ts @@ -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 = ''; @@ -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 => { diff --git a/scripts/resolvers/autoplan-driver.ts b/scripts/resolvers/autoplan-driver.ts index a4c1ab8e8..5b8153e7d 100644 --- a/scripts/resolvers/autoplan-driver.ts +++ b/scripts/resolvers/autoplan-driver.ts @@ -31,7 +31,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [ { cmd: 'gstack-autoplan close --out --phase

--deferred "" --by human|agent:', does: 'close a phase accepted-with-findings: its open findings go to tasks.jsonl, and Critical, urgent or user-challenge findings become gate items; also the recovery from `STOP: reason=budget_exhausted`' }, { cmd: 'gstack-autoplan budget --out

[--spend-cap ] [--max-rounds ] --by human|agent:', does: 'the owner\'s journaled cap raise; after a budget stop, `resume` continues the run' }, { cmd: 'gstack-autoplan export --out ', does: 'write the artifact set; then `gstack-artifact validate /run.json`' }, - { cmd: 'gstack-autoplan export --out --public ', does: 'a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner\'s own words (owner_reply, carried words and a human\'s deferral reason dropped, quotes of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine' }, + { cmd: 'gstack-autoplan export --out --public ', does: 'a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner\'s own words (every owner_reply the run recorded, carried words and a human\'s deferral reason dropped, quotes and gstack\'s renderings of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public --private /run.json` on this machine' }, { cmd: 'gstack-autoplan status|resume --out ', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' }, { cmd: 'gstack-autoplan resume --out --from ', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from ` lists them' }, ]; diff --git a/test/autoplan-owner-words.test.ts b/test/autoplan-owner-words.test.ts index 5c4754708..01320b845 100644 --- a/test/autoplan-owner-words.test.ts +++ b/test/autoplan-owner-words.test.ts @@ -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) => - 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 the flag 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', ''], + ['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 }); }); }); diff --git a/test/autoplan-public-owner-words.test.ts b/test/autoplan-public-owner-words.test.ts index 5c47b50d2..e8d6d68f8 100644 --- a/test/autoplan-public-owner-words.test.ts +++ b/test/autoplan-public-owner-words.test.ts @@ -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'); + }); });