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