mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
fix(autoplan): owner words in every deferral rendering, superseded replies, glued punctuation, short replies, echoed plan text (GST-1243 review)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c535ddc46d
commit
4975267fcb
10 files changed
+438
-153
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) without the owner's own words (owner_reply, carried words and a human's deferral reason dropped, quotes of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public <pub> --private <dir>/run.json` on this machine.
|
||||
a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner's own words (every owner_reply the run recorded, carried words and a human's deferral reason dropped, quotes and gstack's renderings of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public <pub> --private <dir>/run.json` on this machine.
|
||||
- `gstack-autoplan status|resume --out <dir>`
|
||||
read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts).
|
||||
- `gstack-autoplan resume --out <new dir> --from <checkpoint>`
|
||||
|
||||
+2
-2
@@ -129,8 +129,8 @@ DRIVER: ${driverFile()}
|
||||
export --out <dir> --public <pub>
|
||||
write a machine-path-free copy (recorded roots rewritten, plan.md code kept) with provenance digests; the private
|
||||
run is not touched; refused (ARTIFACT_MACHINE_PATH) when a recorded root would remain. The owner's own words never
|
||||
leave: owner_reply, carried words and a human's deferral reason are dropped, quotes of them redacted (OWNER_WORDS:
|
||||
lines), refused (ARTIFACT_OWNER_WORDS) when a quote sits in plan.md code
|
||||
leave: owner_reply, carried words and a human's deferral reason are dropped, quotes and renderings of them
|
||||
redacted (OWNER_WORDS: lines), refused (ARTIFACT_OWNER_WORDS) when one sits in plan.md code
|
||||
status --out <dir> [--json] export --out <dir> release --out <dir> --attempt <id>
|
||||
contract print lib/coordinator-contract.ts (the block every lane and reviewer prompt inherits word for word)
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
**`gstack-autoplan export --public` no longer carries the owner's own words (GST-1243).** GEVA's public bundle (GEVA-225, gstack 1.93.34.0) kept all 38 `owner_reply` fields in decisions.jsonl, and a sealed reviewer file (`ceo-native.a5.bound.md`) quoted an owner reply word for word. GEVA redacted both by hand. Garry's rule: nothing a person wrote leaves without their click.
|
||||
|
||||
### Fixed
|
||||
- **Dropped fields.** The public copy drops `owner_reply` from every decision row and from `run.json`'s gate, the `words` a carried gate reused (its `CARRIED:` line keeps `words=[owner words redacted]`), and the reason a human gave for `close --deferred --by human` (replaced by `[owner words redacted]` along with the gate titles and log lines that repeat it). The decision itself stays: `chosen`, the gate-grammar `reply`, `owner_mode` and `owner_reply_parsed`. An agent's own words (`answer_words`) and an agent's deferral reason stay too.
|
||||
- **Redacted quotes.** Every exported file, sealed `*.bound.md` reviewer results included, is scanned for quotes of those words: any 8 consecutive words of a reply (the whole reply when it is shorter), compared without edge punctuation or case and across line breaks and `>` blockquote markers. Replies under 5 words or 20 characters ("yes", "do it") and replies that parsed as gate grammar are not scanned for, so common words are never redacted. Each quote becomes `[owner words redacted]`, prose without one keeps its bytes, and an `OWNER_WORDS: <file> dropped=<n> redacted=<m>` line names each changed file.
|
||||
- **Refusal.** A quote inside plan.md code, which export never rewrites, stops the export with `ARTIFACT_OWNER_WORDS`, file:line and exit 3, and nothing is written. `gstack-artifact validate --public <dir> --private <run.json>` runs the same check. The error never prints the words.
|
||||
- **Whose words.** Every owner reply the private run ever recorded counts: decision rows, `run.json`'s gate and the journal (`attempts.jsonl` keeps a reply a later answer at the same gate replaced), the words a carried gate reused, and a reason a human gave for `close --deferred --by human`. Replies that parsed as gate grammar are the decision code. An agent's own words (`answer_words`) and an agent's deferral reason stay.
|
||||
- **Dropped fields.** The public copy drops `owner_reply` everywhere and a carry record's `words`, and replaces a human deferral's `reason` with `[owner words redacted]`. The decision itself stays: `chosen`, the gate-grammar `reply`, `owner_mode` and `owner_reply_parsed`.
|
||||
- **gstack's renderings.** A human's deferral reason is replaced where gstack writes it into fixed text (voice `detail`, consensus head, gate title, a blocker's `why`, the run log), and carried words in the `CARRIED:` line. Each rendering is matched with its delimiters on both sides, so a one-word reason never rewrites a longer one that starts with it.
|
||||
- **Redacted quotes.** Every exported file, sealed `*.bound.md` reviewer results included, is scanned. HTML entities are decoded, zero-width characters dropped, hyphen-newline wraps joined, Markdown link targets skipped and contractions split; a word is a run of letters and digits, and any other character separates words. A reply of 3 or more words is a quote wherever it appears whole; from 8 words, any 8 consecutive words of it are a quote too. Replies of 1 or 2 words ("yes", "do it") are not scanned for. A quote extends over neighboring words of the same reply, and the marker replaces the original characters from its first word to its last.
|
||||
- **Echoes stay.** Words the owner repeated from text recorded before his reply that he didn't write (the plan at an earlier gate, that gate's items, reviewer results bound before it) stay; only the new words around them are redacted.
|
||||
- **Refusal.** A quote or rendering inside plan.md code, which export never rewrites, stops the export with `ARTIFACT_OWNER_WORDS`, file:line and exit 3, and nothing is written. `gstack-artifact validate --public <dir> --private <run.json>` runs the same check. The error never prints the words. Each changed file gets an `OWNER_WORDS: <file> dropped=<n> redacted=<m>` line.
|
||||
|
||||
### Tests
|
||||
- `test/autoplan-public-owner-words.test.ts`: a GEVA-shaped bundle (3 `owner_reply` rows, a carried gate, one reviewer quoting a reply on one line and one reflowing a reply across blockquote lines, a control file with no owner words) exports with every owner field dropped and both quotes redacted, while the control and the round-1 reviewer files stay byte-identical; a quote in plan.md code refuses; validate catches a quote added later; a human's deferral reason leaves, an agent's stays.
|
||||
- `test/autoplan-owner-words.test.ts`: the matching rule (short replies, grammar replies, the 8-word window, line breaks and curly quotes, kept code spans, gstack's verbatim copies).
|
||||
- `test/autoplan-public-owner-words.test.ts`: a GEVA-shaped bundle (3 `owner_reply` rows, a carried gate, one reviewer quoting a reply on one line and one reflowing a reply across blockquote lines, a control file with no owner words) exports with every owner field dropped and both quotes redacted, while the control and the round-1 reviewer files stay byte-identical. Also: a quote in plan.md code refuses; validate catches a quote or rendering added later; a human's deferral reason leaves gate.json's `why` and every other rendering, an agent's stays; a one-word human reason leaves an agent's longer one alone; a reply replaced at the same gate is still redacted; a rendering never rewrites plan.md code and an exact one there refuses; echoed plan and finding text stays; a 4-word reply is redacted.
|
||||
- `test/autoplan-owner-words.test.ts`: the matching rule (1-2 word and grammar replies, whole phrases, the 8-word window, eleven kinds of glue, extension, echoes, kept code spans, bounded renderings) and a ratchet on every template that renders a deferral reason.
|
||||
+13
-10
@@ -4696,21 +4696,24 @@ errors.
|
||||
### `ARTIFACT_OWNER_WORDS`
|
||||
|
||||
**Meaning.** A public copy of an autoplan run still quotes the owner's own
|
||||
words: a gate reply an agent relayed (`owner_reply`), the words a carried gate
|
||||
reused, or a reason a human gave for deferring a phase. Nothing a person wrote
|
||||
leaves without their click. A quote is any 8 consecutive words of one of those
|
||||
replies (the whole reply when it is shorter), compared across line breaks,
|
||||
blockquote markers, quote marks and case; replies under 5 words are not
|
||||
scanned for. `gstack-artifact validate --public <dir> --private
|
||||
words: a gate reply the private run recorded (on a decision row, the gate or
|
||||
the journal), the words a carried gate reused, or a reason a human gave for
|
||||
deferring a phase, in prose or in one of gstack's renderings of that reason.
|
||||
Nothing a person wrote leaves without their click. A reply of 3 or more words
|
||||
is a quote wherever it appears whole, and from 8 words any 8 consecutive words
|
||||
are one, compared across punctuation, line breaks, entities, Markdown and case;
|
||||
replies of 1 or 2 words are not scanned for, and words the owner echoed from
|
||||
earlier text he didn't write are not his. See
|
||||
[the public copy](unattended.md#the-public-copy). `gstack-artifact validate --public <dir> --private
|
||||
<run>/run.json` reports each quote with file:line, and `gstack-autoplan export
|
||||
--public` runs that check on a staging copy and writes nothing when it fails.
|
||||
The error never prints the words themselves.
|
||||
|
||||
**Fix.** Export with `gstack-autoplan export --out <run> --public <dir>`, which
|
||||
drops those fields and replaces each quote in prose and sealed reviewer files
|
||||
with `[owner words redacted]`. Export never rewrites inline code or fenced
|
||||
blocks in plan.md, so a quote there needs a plan edit: remove it, `revise`, and
|
||||
export again.
|
||||
drops those fields and replaces each quote or rendering in prose and sealed
|
||||
reviewer files with `[owner words redacted]`. Export never rewrites inline code
|
||||
or fenced blocks in plan.md, so a quote there needs a plan edit: remove it,
|
||||
`revise`, and export again.
|
||||
|
||||
<a id="artifact-public-target-exists"></a>
|
||||
### `ARTIFACT_PUBLIC_TARGET_EXISTS`
|
||||
|
||||
+37
-18
@@ -298,27 +298,46 @@ JSON Schema for any of `findings`, `decisions`, `tasks`, `timing`, `run`,
|
||||
share. It rewrites the machine paths the run recorded, and it never carries the
|
||||
owner's own words: nothing a person wrote leaves without their click (GST-1243).
|
||||
|
||||
- **Dropped fields.** `owner_reply` on every decision row and on `run.json`'s
|
||||
gate, the `words` a carried gate reused, and the reason a human typed for
|
||||
`close --deferred --by human` (replaced by `[owner words redacted]`, along with
|
||||
the titles and log lines that repeat it). The decision itself stays: `chosen`,
|
||||
`reply` (the gate grammar), `owner_mode` and `owner_reply_parsed`. An agent's
|
||||
own words (`answer_words`) and an agent's deferral reason stay too.
|
||||
- **Whose words.** Every owner reply the private run ever recorded: on
|
||||
decision rows, on `run.json`'s gate, and in the journal (`attempts.jsonl`
|
||||
keeps a reply that a later answer at the same gate replaced), the words a
|
||||
carried gate reused, and the reason a human gave for `close --deferred --by
|
||||
human`. A reply that parsed as gate grammar is the decision code, not owner
|
||||
prose. An agent's own words (`answer_words`) and an agent's deferral reason
|
||||
stay.
|
||||
- **Dropped fields.** `owner_reply` anywhere, a carry record's `words`, and a
|
||||
human deferral's `reason` (replaced by `[owner words redacted]`). The
|
||||
decision itself stays: `chosen`, `reply` (the gate grammar), `owner_mode`
|
||||
and `owner_reply_parsed`.
|
||||
- **gstack's own renderings.** The human's deferral reason is also replaced
|
||||
where gstack writes it into fixed text: the voice `detail`, the consensus
|
||||
head, the gate item title, a blocker's `why` and the run log; carried words
|
||||
in the `CARRIED:` line too. Each rendering is matched with its delimiters on
|
||||
both sides, so a one-word reason never rewrites a longer one that starts
|
||||
with it.
|
||||
- **Redacted quotes.** Every exported file, sealed reviewer results
|
||||
(`*.bound.md`) included, is scanned for quotes of those words. Words are
|
||||
compared without the punctuation at their edges (quote marks, `>` blockquote
|
||||
markers), case-insensitive, with line breaks read as spaces. A quote is any 8
|
||||
consecutive words of an owner reply, or the whole reply when it is shorter.
|
||||
Replies under 5 words or 20 characters ("yes", "do it") and replies that parsed
|
||||
as gate grammar are not scanned for: they are common words or the decision
|
||||
code, and the reply field itself is already dropped. Each quote becomes
|
||||
`[owner words redacted]`; prose with no quote keeps its bytes. One
|
||||
(`*.bound.md`) included, is scanned for quotes. HTML entities are decoded,
|
||||
zero-width characters dropped, a hyphen-newline wrap joined, Markdown link
|
||||
targets skipped and contractions split ("don't" reads "do not"); a word is a
|
||||
run of letters and digits, and everything else (spaces, line breaks, dashes,
|
||||
slashes, quote marks, `>` markers, JSON or attribute glue) separates words.
|
||||
Words compare case-insensitively. A reply of 3 or more words is a quote
|
||||
wherever it appears whole; from 8 words, any 8 consecutive words of it are a
|
||||
quote too. Replies of 1 or 2 words ("yes", "do it") are not scanned for:
|
||||
they are codes or grammar, and the field itself is already dropped. A quote
|
||||
extends over the neighboring words that also belong to that reply, and the
|
||||
marker replaces the original characters from its first word to its last.
|
||||
- **Echoes stay.** Words the owner repeated from text recorded before the reply,
|
||||
which he didn't write (the plan at an earlier gate, that gate's items, the
|
||||
reviewer results bound before it), are not his words: they stay, and only the
|
||||
new words around them are redacted. Prose with no quote keeps its bytes. One
|
||||
`OWNER_WORDS: <file> dropped=<n> redacted=<m>` line names each changed file.
|
||||
- **Refusal.** Export never rewrites code in plan.md, so a quote inside a code
|
||||
span or fence there stops the export with
|
||||
- **Refusal.** Export never rewrites code in plan.md, so a quote or rendering
|
||||
inside a code span or fence there stops the export with
|
||||
[`ARTIFACT_OWNER_WORDS`](troubleshooting.md#artifact-owner-words), file:line
|
||||
and exit 3, and nothing is written. `gstack-artifact validate --public <pub>
|
||||
--private <dir>/run.json` runs the same scan on an existing copy.
|
||||
and exit 3, and nothing is written. The error never prints the words.
|
||||
`gstack-artifact validate --public <pub> --private <dir>/run.json` runs the
|
||||
same scan, renderings included, on an existing copy.
|
||||
|
||||
## How a parent consumes a run
|
||||
|
||||
|
||||
+170
-68
@@ -1,95 +1,158 @@
|
||||
/**
|
||||
* autoplan-owner-words: the owner's own words a public copy of a run must not
|
||||
* carry (GST-1243, GEVA-225). Garry: nothing a person wrote leaves without
|
||||
* their click. `ownerWords` collects what the private run recorded in the
|
||||
* owner's words: every relayed reply (`owner_reply` on decision rows and on
|
||||
* run.json's gate), the words a carried gate reused (run.json `carry.words`),
|
||||
* and a reason a human typed when deferring a phase (`close --deferred
|
||||
* <reason> --by human`). The gate reply grammar (`reply`, `last_reply`) is
|
||||
* the decision code, and `answer_words` are the answering agent's own words,
|
||||
* so both stay.
|
||||
* their click.
|
||||
*
|
||||
* `dropOwnerFields` removes those fields from the export's JSON: `owner_reply`
|
||||
* anywhere, `words` on a carry record, and a human deferral's `reason`
|
||||
* (replaced by the marker, the field is required). `redactOwnerWords` handles
|
||||
* text: first the places gstack copies those words verbatim (the CARRIED line,
|
||||
* a deferral's titles and log lines), then quotes anywhere.
|
||||
* What counts. `ownerWords` reads the private run and collects every owner
|
||||
* reply it ever recorded: `owner_reply` on decision rows, on run.json's gate
|
||||
* and on every `answered` journal row (attempts.jsonl keeps replies a later
|
||||
* answer at the same gate replaced), the words a carried gate reused, and a
|
||||
* reason a human gave for `close --deferred <reason> --by human`. A reply that
|
||||
* parsed as gate grammar (`owner_reply_parsed`) is the decision code and is
|
||||
* not scanned for; `reply` and the agent's own `answer_words` stay.
|
||||
*
|
||||
* The quote rule. Text is cut into words at whitespace (line breaks included);
|
||||
* each word is compared without the punctuation and symbols at its edges
|
||||
* (quote marks, `>` blockquote markers, list dashes), NFKC-normalized,
|
||||
* lowercased, with curly apostrophes read as straight ones. Only an owner text
|
||||
* of at least MIN_WORDS words and MIN_CHARS characters is scanned for: shorter
|
||||
* replies ("yes", "do it", "rec except p2b") are common words or the gate
|
||||
* grammar, so a scan would redact unrelated prose, and the reply field itself
|
||||
* is already dropped. A reply that parsed as gate grammar (`owner_reply_parsed`)
|
||||
* is the decision code the export keeps, so it is not scanned for either. A quote is a run of WINDOW consecutive words of an owner
|
||||
* text (the whole text when it is shorter), wherever it sits and however it
|
||||
* is wrapped; overlapping runs merge into one span, which the marker replaces.
|
||||
* Fields. `dropOwnerFields` removes `owner_reply` anywhere, `words` on a
|
||||
* carry record, and replaces a human deferral's `reason` with the marker.
|
||||
*
|
||||
* Placements. gstack renders a human deferral reason and carried words into
|
||||
* fixed strings (DEFERRAL_RENDERINGS below, one per template in
|
||||
* lib/autoplan-gate.ts and lib/autoplan-export.ts; test/autoplan-owner-words
|
||||
* ratchets that list). Each is matched with its own delimiters on both sides,
|
||||
* so a one-word reason never rewrites a longer one that starts with it.
|
||||
*
|
||||
* Quotes. Text is normalized with an offset map back to the original: HTML
|
||||
* entities decoded, zero-width characters dropped, a hyphen-newline wrap
|
||||
* joined, a Markdown link target dropped, and contractions split ("don't"
|
||||
* reads "do not"). Words are runs of Unicode letters, marks and digits with
|
||||
* inner apostrophes; everything else separates them. Words compare NFKC and
|
||||
* lowercased. A reply of 1 or 2 words is not scanned for (codes and grammar;
|
||||
* the reply field itself is dropped). A reply of MIN_TOKENS (3) or more is a
|
||||
* quote wherever it appears whole, at word boundaries; from WINDOW (8) words,
|
||||
* any WINDOW consecutive words of it are a quote too. A quote then extends
|
||||
* over the neighboring words that are also words of that reply, so no owner
|
||||
* word is left on either side. The redaction replaces the original
|
||||
* characters from the first quoted word to the last.
|
||||
*
|
||||
* Echoes. A quoted window (or whole short reply) is not the owner's words when
|
||||
* the same words were already recorded before the reply, in text the owner
|
||||
* didn't write: the plan at each earlier gate, those gates' items, and the
|
||||
* reviewer results bound before it (`prior`). Those words stay; new words
|
||||
* around them are still redacted.
|
||||
*/
|
||||
import type { RunState } from './autoplan-state';
|
||||
import type { DecisionRow } from './headless-artifacts';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { readDecisions, readJournal, type RunState } from './autoplan-state';
|
||||
|
||||
export const OWNER_WORDS_MARK = '[owner words redacted]';
|
||||
export const MIN_WORDS = 5;
|
||||
export const MIN_CHARS = 20;
|
||||
export const MIN_TOKENS = 3;
|
||||
export const WINDOW = 8;
|
||||
|
||||
export interface OwnerWords { placements: Array<[string, string]>; windows: Map<number, Set<string>> }
|
||||
export type Span = [number, number];
|
||||
export interface OwnerSource { text: string; parsed?: boolean; prior?: string[] }
|
||||
interface Indexed { keys: string[]; set: Set<string>; prior: string }
|
||||
export interface OwnerWords { replies: Indexed[]; placements: RegExp[] }
|
||||
|
||||
interface Word { key: string; start: number; end: number }
|
||||
function words(text: string): Word[] {
|
||||
const out: Word[] = [];
|
||||
for (const m of text.matchAll(/\S+/g)) {
|
||||
const lead = /^[\p{P}\p{S}]*/u.exec(m[0])![0].length;
|
||||
const core = m[0].slice(lead).replace(/[\p{P}\p{S}]+$/u, '');
|
||||
if (core) out.push({ key: core.normalize('NFKC').replace(/[\u2018\u2019\u02bc]/g, "'").toLowerCase(), start: m.index! + lead, end: m.index! + lead + core.length });
|
||||
const ENTITIES: Record<string, string> = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", mdash: '\u2014', ndash: '\u2013', hellip: '\u2026', lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201c', rdquo: '\u201d', shy: '', zwj: '', zwnj: '' };
|
||||
const CONTRACTIONS: Record<string, string[]> = { "won't": ['will', 'not'], "can't": ['can', 'not'], "shan't": ['shall', 'not'] };
|
||||
const SUFFIXES: Array<[string, string]> = [["n't", 'not'], ["'re", 'are'], ["'ve", 'have'], ["'ll", 'will'], ["'m", 'am']];
|
||||
|
||||
const ENTITY = /&(#\d+|#x[0-9a-f]+|[a-z]+);/iy;
|
||||
const LINK_TARGET = /\]\([^\s)]*\)/y;
|
||||
const WRAP = /-[ \t]*\r?\n[ \t>]*(?=[\p{L}\p{N}])/uy;
|
||||
const sticky = (re: RegExp, text: string, at: number) => { re.lastIndex = at; return re.exec(text); };
|
||||
|
||||
interface Token { key: string; start: number; end: number }
|
||||
/** Words of `text` with their original [start, end) offsets, after the normalization the header describes. */
|
||||
export function tokens(text: string): Token[] {
|
||||
let norm = '';
|
||||
const from: number[] = [], to: number[] = [];
|
||||
const push = (s: string, a: number, b: number) => { for (let k = 0; k < s.length; k++) { norm += s[k]; from.push(a); to.push(b); } };
|
||||
for (let i = 0; i < text.length;) {
|
||||
const c = text[i]!;
|
||||
const e = c === '&' ? sticky(ENTITY, text, i) : null;
|
||||
const code = e?.[1]!.startsWith('#') ? (e[1]![1]?.toLowerCase() === 'x' ? parseInt(e[1]!.slice(2), 16) : parseInt(e[1]!.slice(1), 10)) : undefined;
|
||||
const decoded = e ? (code !== undefined ? (code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : undefined) : ENTITIES[e[1]!.toLowerCase()]) : undefined;
|
||||
if (e && decoded !== undefined) { push(decoded, i, i + e[0].length); i += e[0].length; continue; }
|
||||
if (/[\u200b-\u200d\u2060\ufeff\u00ad]/.test(c)) { i++; continue; }
|
||||
const l = c === ']' ? sticky(LINK_TARGET, text, i) : null;
|
||||
if (l) { push(' ', i, i + l[0].length); i += l[0].length; continue; }
|
||||
const w = c === '-' && /[\p{L}\p{N}]/u.test(norm.slice(-1)) ? sticky(WRAP, text, i) : null;
|
||||
if (w) { i += w[0].length; continue; }
|
||||
push(c, i, i + 1);
|
||||
i++;
|
||||
}
|
||||
const out: Token[] = [];
|
||||
for (const m of norm.matchAll(/[\p{L}\p{M}\p{N}]+(?:['\u2019\u02bc][\p{L}\p{M}\p{N}]+)*/gu)) {
|
||||
const start = from[m.index!]!, end = to[m.index! + m[0].length - 1]!;
|
||||
const key = m[0].normalize('NFKC').replace(/[\u2019\u02bc]/g, "'").toLowerCase();
|
||||
const suffix = SUFFIXES.find(([s]) => key.endsWith(s) && key.length > s.length);
|
||||
const parts = CONTRACTIONS[key] ?? (suffix ? [key.slice(0, -suffix[0].length), suffix[1]] : [key]);
|
||||
for (const k of parts) out.push({ key: k, start, end });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What the private run recorded in the owner's words, and the windows the quote scan looks for. */
|
||||
export function ownerWords(state: Pick<RunState, 'phases' | 'gate' | 'carry'>, decisions: Array<Pick<DecisionRow, 'owner_reply' | 'owner_reply_parsed'>>): OwnerWords {
|
||||
const deferred = Object.values(state.phases ?? {}).flatMap(p => (p?.deferred?.by === 'human' && p.deferred.reason ? [p.deferred.reason] : []));
|
||||
const carried = state.carry?.words;
|
||||
const texts = [...new Set([...decisions.map(d => d.owner_reply), (state.gate as { owner_reply?: unknown } | undefined)?.owner_reply, carried, ...deferred]
|
||||
.filter((t): t is string => typeof t === 'string' && t.trim() !== ''))];
|
||||
const placements: Array<[string, string]> = [
|
||||
...(carried ? [[`words=${JSON.stringify(carried)}`, `words=${OWNER_WORDS_MARK}`] as [string, string]] : []),
|
||||
...deferred.flatMap(r => [`deferred: ${r}`, `(${r}; by human)`, `(${r}): `, `, by human): ${r};`].map(s => [s, s.replace(r, () => OWNER_WORDS_MARK)] as [string, string])),
|
||||
];
|
||||
const grammar = new Set(decisions.filter(d => d.owner_reply_parsed).map(d => d.owner_reply));
|
||||
const windows = new Map<number, Set<string>>();
|
||||
for (const t of texts.filter(t => !grammar.has(t))) {
|
||||
const keys = words(t).map(w => w.key);
|
||||
if (keys.length < MIN_WORDS || keys.join(' ').length < MIN_CHARS) continue;
|
||||
const size = Math.min(keys.length, WINDOW);
|
||||
const set = windows.get(size) ?? windows.set(size, new Set()).get(size)!;
|
||||
for (let i = 0; i + size <= keys.length; i++) set.add(keys.slice(i, i + size).join(' '));
|
||||
}
|
||||
return { placements, windows };
|
||||
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
/** Where gstack renders a human's deferral reason (R): the voice detail, the consensus head, the gate title, a blocker's why, the run log. */
|
||||
const DEFERRAL_RENDERINGS = (r: string) => [`^deferred: (${r})$`, `\\((${r}); by human\\)`, `accepted-with-findings at round \\d+ \\((${r})\\): `, `closed deferred \\((${r})\\) with this finding open`, `, by human\\): (${r}); \\d+ open finding`];
|
||||
|
||||
/** The scan index for owner replies (1-2 word and grammar replies skipped), deferral reasons and carried words. */
|
||||
export function ownerIndex(replies: OwnerSource[], o: { deferred?: OwnerSource[]; carried?: string[] } = {}): OwnerWords {
|
||||
const keysOf = (texts: string[]) => ` ${texts.map(t => tokens(t).map(k => k.key).join(' ')).join(' \u0001 ')} `;
|
||||
const seen = new Set<string>();
|
||||
const indexed = [...replies, ...(o.deferred ?? [])].flatMap(s => {
|
||||
const keys = tokens(s.text).map(t => t.key);
|
||||
if (s.parsed || keys.length < MIN_TOKENS || seen.has(keys.join(' '))) return [];
|
||||
seen.add(keys.join(' '));
|
||||
return [{ keys, set: new Set(keys), prior: keysOf(s.prior ?? []) }];
|
||||
});
|
||||
const placements = [
|
||||
...(o.deferred ?? []).flatMap(d => DEFERRAL_RENDERINGS(escape(d.text))),
|
||||
...(o.carried ?? []).map(w => `words=(${escape(JSON.stringify(w))})(?= by=|$)`),
|
||||
].map(p => new RegExp(p, 'gd'));
|
||||
return { replies: indexed, placements };
|
||||
}
|
||||
|
||||
/** Every quote of an owner text in `text`, as merged [start, end) spans. */
|
||||
/** Every owner-words span in `text` (placements, then quotes less echoes), merged, as original [start, end) offsets. */
|
||||
export function ownerSpans(text: string, owner: OwnerWords): Span[] {
|
||||
const ws = words(text);
|
||||
const hits: Span[] = [];
|
||||
for (const [size, set] of owner.windows) {
|
||||
for (let i = 0; i + size <= ws.length; i++) if (set.has(ws.slice(i, i + size).map(w => w.key).join(' '))) hits.push([ws[i]!.start, ws[i + size - 1]!.end]);
|
||||
const spans: Span[] = [];
|
||||
for (const re of owner.placements) for (const m of text.matchAll(re)) spans.push([m.indices![1]![0], m.indices![1]![1]]);
|
||||
if (owner.replies.length) {
|
||||
const ts = tokens(text);
|
||||
const keys = ts.map(t => t.key);
|
||||
for (const r of owner.replies) {
|
||||
const size = Math.min(r.keys.length, WINDOW);
|
||||
const windows = new Set<string>();
|
||||
for (let j = 0; j + size <= r.keys.length; j++) windows.add(r.keys.slice(j, j + size).join(' '));
|
||||
const quoted = new Set<number>(), echo = new Set<number>();
|
||||
for (let i = 0; i + size <= keys.length; i++) {
|
||||
const run = keys.slice(i, i + size).join(' ');
|
||||
if (!windows.has(run)) continue;
|
||||
for (let k = i; k < i + size; k++) (r.prior.includes(` ${run} `) ? echo : quoted).add(k);
|
||||
}
|
||||
for (const k of [...quoted]) {
|
||||
for (let a = k - 1; a >= 0 && r.set.has(keys[a]!) && !echo.has(a) && !quoted.has(a); a--) quoted.add(a);
|
||||
for (let b = k + 1; b < keys.length && r.set.has(keys[b]!) && !echo.has(b) && !quoted.has(b); b++) quoted.add(b);
|
||||
}
|
||||
const marked = [...quoted].filter(k => !echo.has(k)).sort((x, y) => x - y);
|
||||
for (let s = 0; s < marked.length;) {
|
||||
let e = s;
|
||||
while (e + 1 < marked.length && marked[e + 1] === marked[e]! + 1) e++;
|
||||
spans.push([ts[marked[s]!]!.start, ts[marked[e]!]!.end]);
|
||||
s = e + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort((a, b) => a[0] - b[0]);
|
||||
return hits.reduce<Span[]>((out, s) => { const last = out[out.length - 1]; if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]); else out.push([...s]); return out; }, []);
|
||||
spans.sort((a, b) => a[0] - b[0]);
|
||||
return spans.reduce<Span[]>((out, s) => { const last = out[out.length - 1]; if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]); else out.push([...s]); return out; }, []);
|
||||
}
|
||||
|
||||
/** Replace the verbatim placements, then every quote outside `keep` (code export never rewrites), with the marker. */
|
||||
/** Replace every owner-words span outside `keep` (code export never rewrites) with the marker. */
|
||||
export function redactOwnerWords(text: string, owner: OwnerWords, keep: Span[] = []): { text: string; redacted: number } {
|
||||
let redacted = 0;
|
||||
const spans = ownerSpans(text, owner).filter(([s, e]) => !keep.some(([ks, ke]) => s < ke && e > ks));
|
||||
let out = text;
|
||||
for (const [from, to] of owner.placements) { const parts = out.split(from); redacted += parts.length - 1; out = parts.join(to); }
|
||||
const spans = ownerSpans(out, owner).filter(([s, e]) => !keep.some(([ks, ke]) => s < ke && e > ks));
|
||||
for (const [s, e] of spans.reverse()) out = out.slice(0, s) + OWNER_WORDS_MARK + out.slice(e);
|
||||
return { text: out, redacted: redacted + spans.length };
|
||||
for (const [s, e] of [...spans].reverse()) out = out.slice(0, s) + OWNER_WORDS_MARK + out.slice(e);
|
||||
return { text: out, redacted: spans.length };
|
||||
}
|
||||
|
||||
/** Drop the owner-word fields from a JSON value: `owner_reply`, a carry record's `words`, a human deferral's `reason`. */
|
||||
@@ -106,3 +169,42 @@ export function dropOwnerFields(value: unknown): { value: unknown; dropped: numb
|
||||
};
|
||||
return { value: walk(value), dropped };
|
||||
}
|
||||
|
||||
/** Every owner reply and human deferral reason the private run at `state.out` recorded, each with the text recorded before it. */
|
||||
export function ownerWords(state: Pick<RunState, 'out' | 'phases' | 'gate' | 'carry'>): OwnerWords {
|
||||
const out = state.out;
|
||||
const journal = readJournal(out);
|
||||
const read = (name: string) => { try { return fs.readFileSync(path.join(out, name), 'utf8'); } catch { return undefined; } };
|
||||
const strings = (v: unknown): string[] => (typeof v === 'string' ? [v] : Array.isArray(v) ? v.flatMap(strings) : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : []);
|
||||
const gateText = (rev: number) => {
|
||||
const archived = read(`gate.r${rev}.json`);
|
||||
const current = read('gate.json');
|
||||
const gate = archived ?? (current && JSON.parse(current).gate_rev === rev ? current : undefined);
|
||||
return gate ? strings(JSON.parse(gate).items).join('\n') : '';
|
||||
};
|
||||
const gates = journal.filter(r => r.event === 'gate_written' && typeof r.gate_rev === 'number').map(r => ({ at: String(r.ts), text: [read(`plan-at-gate.r${r.gate_rev}.md`) ?? '', gateText(r.gate_rev)].join('\n') }));
|
||||
const bound = journal.filter(r => r.event === 'submitted' && typeof r.result === 'string').map(r => ({ at: String(r.ts), text: read(path.basename(r.result)) ?? '' }));
|
||||
const prior = (at: string | undefined) => (at ? [...gates, ...bound].filter(x => x.at < at).map(x => x.text) : []);
|
||||
|
||||
const byText = new Map<string, { at?: string; parsed: boolean }>();
|
||||
const note = (text: unknown, at: unknown, parsed: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) return;
|
||||
const was = byText.get(text);
|
||||
const when = typeof at === 'string' ? at : undefined;
|
||||
byText.set(text, { at: was?.at && (!when || was.at < when) ? was.at : when, parsed: !!was?.parsed || parsed === true });
|
||||
};
|
||||
for (const r of journal) note(r.owner_reply, r.ts, r.owner_reply_parsed);
|
||||
for (const d of readDecisions(out)) note(d.owner_reply, d.answered_at, d.owner_reply_parsed);
|
||||
const gate = state.gate as { owner_reply?: unknown; answered_at?: unknown; owner_reply_parsed?: unknown } | undefined;
|
||||
note(gate?.owner_reply, gate?.answered_at, gate?.owner_reply_parsed);
|
||||
note(state.carry?.words, undefined, false);
|
||||
|
||||
const deferred = new Map<string, string | undefined>();
|
||||
for (const r of journal) if (r.event === 'phase_deferred' && r.by === 'human' && typeof r.reason === 'string') deferred.set(r.reason, deferred.get(r.reason) ?? String(r.ts));
|
||||
for (const p of Object.values(state.phases ?? {})) if (p?.deferred?.by === 'human' && p.deferred.reason && !deferred.has(p.deferred.reason)) deferred.set(p.deferred.reason, p.deferred.at);
|
||||
|
||||
return ownerIndex([...byText].map(([text, r]) => ({ text, parsed: r.parsed, prior: prior(r.at) })), {
|
||||
deferred: [...deferred].map(([text, at]) => ({ text, prior: prior(at) })),
|
||||
carried: state.carry?.words ? [state.carry.words] : [],
|
||||
});
|
||||
}
|
||||
+12
-8
@@ -22,7 +22,7 @@ import * as path from 'node:path';
|
||||
import { repoRoot } from './autoplan-plan-text';
|
||||
import { checkpointRoot } from './autoplan-checkpoint';
|
||||
import { dropOwnerFields, ownerSpans, ownerWords, redactOwnerWords, type OwnerWords, type Span } from './autoplan-owner-words';
|
||||
import { FILES, RunError, readDecisions, type RunState } from './autoplan-state';
|
||||
import { FILES, RunError, type RunState } from './autoplan-state';
|
||||
import { sha256File, validateRun, type ValidationError } from './headless-artifacts';
|
||||
|
||||
export const LOCAL_NOTES = '<local notes (not committed)>';
|
||||
@@ -110,12 +110,16 @@ function scan(dir: string, roots: RecordedRoot[], owner?: OwnerWords): { errors:
|
||||
else if (GENERIC_HOME.test(line)) warnings.push(`WARN: ${rel}:${i + 1} has a home-directory path (${GENERIC_HOME.exec(line)![0]}); check it is not a machine path`);
|
||||
});
|
||||
if (!owner) continue;
|
||||
const code = rel === 'plan.md' ? codeSpans(text) : [];
|
||||
const plain = /\.jsonl?$/.test(rel) ? text.replace(/\\[nrt"\\/]/g, ' ') : text;
|
||||
for (const [s, e] of ownerSpans(plain, owner)) {
|
||||
const inCode = code.some(([cs, ce]) => s < ce && e > cs);
|
||||
errors.push({ code: 'ARTIFACT_OWNER_WORDS', path: `${rel}:${text.slice(0, s).split('\n').length}`, message: `owner words the private run recorded appear in the public copy${inCode ? ' (inside code, which export never rewrites: edit the plan)' : ''}` });
|
||||
const words = (line: number, inCode = false) => errors.push({ code: 'ARTIFACT_OWNER_WORDS', path: `${rel}:${line}`, message: `owner words the private run recorded appear in the public copy${inCode ? ' (inside code, which export never rewrites: edit the plan)' : ''}` });
|
||||
const lineAt = (at: number) => text.slice(0, Math.max(at, 0)).split('\n').length;
|
||||
const values = (v: unknown): string[] => (typeof v === 'string' ? [v] : Array.isArray(v) ? v.flatMap(values) : v && typeof v === 'object' ? Object.values(v).flatMap(values) : []);
|
||||
const json = /\.jsonl?$/.test(rel) ? (() => { try { return rel.endsWith('.jsonl') ? text.split('\n').map(l => (l ? JSON.parse(l) : null)) : [JSON.parse(text)]; } catch { return undefined; } })() : undefined;
|
||||
if (json) {
|
||||
json.forEach((doc, i) => { for (const s of values(doc)) if (ownerSpans(s, owner).length) words(rel.endsWith('.jsonl') ? i + 1 : lineAt(text.indexOf(JSON.stringify(s)))); });
|
||||
continue;
|
||||
}
|
||||
const code = rel === 'plan.md' ? codeSpans(text) : [];
|
||||
for (const [s, e] of ownerSpans(text, owner)) words(lineAt(s), code.some(([cs, ce]) => s < ce && e > cs));
|
||||
}
|
||||
return { errors, warnings };
|
||||
}
|
||||
@@ -141,7 +145,7 @@ export function validatePublic(dir: string, privateRunJson?: string, env: NodeJS
|
||||
const priv = JSON.parse(fs.readFileSync(privateRunJson, 'utf8')) as RunState;
|
||||
priv.out = path.dirname(path.resolve(privateRunJson));
|
||||
roots = recordedRoots(priv, env);
|
||||
owner = ownerWords(priv, readDecisions(priv.out));
|
||||
owner = ownerWords(priv);
|
||||
if (prov && prov.private_run !== priv.run) errors.push({ code: 'ARTIFACT_UNBOUND_ID', path: 'run.json.provenance.private_run', message: `${prov.private_run} is not the private run ${priv.run}` });
|
||||
for (const [name, d] of Object.entries(prov?.files ?? {})) {
|
||||
const listed = name === FILES.run ? sha256File(privateRunJson) : priv.artifacts?.[name]?.sha256;
|
||||
@@ -164,7 +168,7 @@ export function exportPublic(state: RunState, dir: string, env: NodeJS.ProcessEn
|
||||
if (previous !== state.run) throw new RunError('ARTIFACT_PUBLIC_TARGET_EXISTS', `${target} is not empty and holds no earlier public copy of ${state.run}`, 3);
|
||||
}
|
||||
const roots = recordedRoots(state, env);
|
||||
const owner = ownerWords(state, readDecisions(out));
|
||||
const owner = ownerWords(state);
|
||||
const report: string[] = [];
|
||||
/** The public bytes of one file: recorded roots rewritten, owner-word fields dropped, quotes redacted (plan.md code untouched). */
|
||||
const publicText = (name: string, file: string, text: string): string => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const AUTOPLAN_DRIVER_COMMANDS: Array<{ cmd: string; does: string }> = [
|
||||
{ cmd: 'gstack-autoplan close --out <dir> --phase <p> --deferred "<reason>" --by human|agent:<id>', does: 'close a phase accepted-with-findings: its open findings go to tasks.jsonl, and Critical, urgent or user-challenge findings become gate items; also the recovery from `STOP: reason=budget_exhausted`' },
|
||||
{ cmd: 'gstack-autoplan budget --out <dir> [--spend-cap <usd>] [--max-rounds <n>] --by human|agent:<id>', does: 'the owner\'s journaled cap raise; after a budget stop, `resume` continues the run' },
|
||||
{ cmd: 'gstack-autoplan export --out <dir>', does: 'write the artifact set; then `gstack-artifact validate <dir>/run.json`' },
|
||||
{ cmd: 'gstack-autoplan export --out <dir> --public <pub>', does: 'a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner\'s own words (owner_reply, carried words and a human\'s deferral reason dropped, quotes of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public <pub> --private <dir>/run.json` on this machine' },
|
||||
{ cmd: 'gstack-autoplan export --out <dir> --public <pub>', does: 'a machine-path-free copy to commit or share (recorded roots rewritten, plan.md code kept, provenance digests) without the owner\'s own words (every owner_reply the run recorded, carried words and a human\'s deferral reason dropped, quotes and gstack\'s renderings of them redacted; refused when one sits in plan.md code); the private run is not touched; then `gstack-artifact validate --public <pub> --private <dir>/run.json` on this machine' },
|
||||
{ cmd: 'gstack-autoplan status|resume --out <dir>', does: 'read the manifest (PENDING: lines name open attempts); take ownership after a crash (EXECUTION_UNKNOWN lists unfinished attempts)' },
|
||||
{ cmd: 'gstack-autoplan resume --out <new dir> --from <checkpoint>', does: 'restore the newest valid checkpoint generation (GSTACK_AUTOPLAN_CHECKPOINT_DIR) into an empty directory and take ownership; `checkpoints --from <dir>` lists them' },
|
||||
];
|
||||
|
||||
@@ -1,67 +1,108 @@
|
||||
/**
|
||||
* GST-1243: the owner-words rule behind `gstack-autoplan export --public` (lib/autoplan-owner-words.ts). Short replies
|
||||
* and gate grammar are never scanned for; a quote is WINDOW consecutive words of an owner text (all of it when shorter),
|
||||
* matched across line breaks, blockquote markers, case and curly quotes; code export keeps is never rewritten.
|
||||
* GST-1243: the owner-words rule behind `gstack-autoplan export --public` (lib/autoplan-owner-words.ts). Replies of 1-2
|
||||
* words and gate grammar are never scanned for; a reply of 3+ words is a quote wherever it appears whole, and from 8
|
||||
* words any 8 consecutive words are a quote; glued punctuation, entities, zero-width characters, Markdown, wraps and
|
||||
* contractions don't hide one; a quote extends over neighboring reply words; echoes of earlier text stay; gstack's
|
||||
* renderings of a human's deferral reason are matched with their delimiters; code export keeps is never rewritten.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { MIN_WORDS, OWNER_WORDS_MARK as MARK, WINDOW, dropOwnerFields, ownerSpans, ownerWords, redactOwnerWords } from '../lib/autoplan-owner-words';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { MIN_TOKENS, OWNER_WORDS_MARK as MARK, WINDOW, dropOwnerFields, ownerIndex, ownerSpans, redactOwnerWords } from '../lib/autoplan-owner-words';
|
||||
|
||||
const LONG = 'Keep the backoff cap at thirty seconds and log every retry attempt so we can see it.';
|
||||
const owners = (...replies: Array<string | { owner_reply: string; owner_reply_parsed: boolean }>) =>
|
||||
ownerWords({ phases: {} } as never, replies.map(r => (typeof r === 'string' ? { owner_reply: r } : r)));
|
||||
const quoted = (text: string, ...replies: string[]) => ownerSpans(text, owners(...replies)).map(([s, e]) => text.slice(s, e));
|
||||
const quoted = (text: string, ...replies: string[]) => { const o = ownerIndex(replies.map(r => ({ text: r }))); return ownerSpans(text, o).map(([s, e]) => text.slice(s, e)); };
|
||||
|
||||
describe('the quote rule', () => {
|
||||
test(`replies under ${MIN_WORDS} words or 20 characters are never scanned for`, () => {
|
||||
const prose = 'Do it. Yes. Garry: do it. Ship it now please, the owner said.';
|
||||
expect(quoted(prose, 'do it', 'yes', 'Garry: do it', 'Ship it now please')).toEqual([]);
|
||||
expect(quoted('a b c d e f', 'a b c d e')).toEqual([]);
|
||||
test(`replies under ${MIN_TOKENS} words are never scanned for; from ${MIN_TOKENS} words a reply is matched whole at word boundaries`, () => {
|
||||
expect(quoted('Do it. Yes. Ship it, the owner said.', 'do it', 'yes', 'ship it')).toEqual([]);
|
||||
expect(quoted('Context: Jane Doe vetoed it, per the relay.', 'Jane Doe vetoed it')).toEqual(['Jane Doe vetoed it']);
|
||||
expect(quoted('Jane Doe vetoed items in March.', 'Jane Doe vetoed it')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a reply that parsed as gate grammar is the decision code, not scanned for', () => {
|
||||
const o = owners({ owner_reply: 'rec except p2b p3a c4b', owner_reply_parsed: true });
|
||||
expect(ownerSpans('reply: rec except p2b p3a c4b', o)).toEqual([]);
|
||||
expect(ownerSpans('reply: rec except p2b p3a c4b', ownerIndex([{ text: 'rec except p2b p3a c4b', parsed: true }]))).toEqual([]);
|
||||
expect(quoted('reply: rec except p2b p3a c4b', 'rec except p2b p3a c4b')).toEqual(['rec except p2b p3a c4b']);
|
||||
});
|
||||
|
||||
test(`a reply shorter than ${WINDOW} words matches only whole`, () => {
|
||||
expect(quoted('They said: please drop the Redis cache layer, entirely.', 'Drop the Redis cache layer entirely')).toEqual(['drop the Redis cache layer, entirely']);
|
||||
test(`a reply shorter than ${WINDOW} words matches only whole; any ${WINDOW} consecutive words of a longer one are a quote`, () => {
|
||||
expect(quoted('They said: please drop the Redis cache layer.', 'Drop the Redis cache layer entirely')).toEqual([]);
|
||||
});
|
||||
|
||||
test(`any ${WINDOW} consecutive words of a longer reply are a quote; ${WINDOW - 1} are not`, () => {
|
||||
expect(quoted('Per the owner, log every retry attempt so we can see it. Fine.', LONG)).toEqual(['log every retry attempt so we can see it']);
|
||||
expect(quoted('We will log every retry attempt so we can tell.', LONG)).toEqual([]);
|
||||
expect(quoted(`A: ${LONG} B: ${LONG}`, LONG)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('line breaks, blockquote markers, indentation, case and curly quotes do not hide a quote', () => {
|
||||
const text = '> \u201cDon\u2019t ship the backoff\n> without the flag, EVER\u201d\n';
|
||||
expect(quoted(text, "don't ship the backoff without the flag, ever")).toEqual(['Don\u2019t ship the backoff\n> without the flag, EVER']);
|
||||
const REPLY = 'take the flag first, I do not want a big bang rollout';
|
||||
test.each([
|
||||
['an em dash', 'take the flag first\u2014I do not want a big bang rollout'],
|
||||
['slashes', 'take/the/flag/first/I/do/not/want/a/big/bang/rollout'],
|
||||
['an ellipsis', 'take the flag first...I do not want a big bang rollout'],
|
||||
['HTML entities', 'take 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', '<q quote="take the flag first, I do not want a big bang rollout">'],
|
||||
['a label', 'owner:take the flag first, I do not want a big bang rollout'],
|
||||
['a contraction', "take the flag first, I don't want a big bang rollout"],
|
||||
])('%s does not hide a quote', (_, text) => {
|
||||
const r = redactOwnerWords(text, ownerIndex([{ text: REPLY }]));
|
||||
expect(r.redacted).toBe(1);
|
||||
for (const word of ['take', 'flag', 'want', 'bang', 'roll']) expect(r.text).not.toContain(word);
|
||||
});
|
||||
|
||||
test('a quote extends over the neighboring words of the same reply, so none is left on either side', () => {
|
||||
const reply = 'Honestly I do not want a big bang rollout on the retry path';
|
||||
expect(quoted('Well honestly, I don\u2019t want a big bang rollout on the retry path!', reply)).toEqual(['honestly, I don\u2019t want a big bang rollout on the retry path']);
|
||||
expect(quoted('> \u201cDon\u2019t ship the backoff\n> without the flag, EVER\u201d\n', "don't ship the backoff without the flag, ever")).toEqual(['Don\u2019t ship the backoff\n> without the flag, EVER']);
|
||||
});
|
||||
|
||||
test('words the owner echoed from text recorded before the reply stay; the new words around them are redacted', () => {
|
||||
const line = 'Persist the attempt count on the job row so a restarted worker resumes the schedule.';
|
||||
const reply = `${line.slice(0, -1)}, and cap the retries at five attempts in total.`;
|
||||
const o = ownerIndex([{ text: reply, prior: [`## Implementation plan\n\n${line}`] }]);
|
||||
expect(redactOwnerWords(line, o)).toEqual({ text: line, redacted: 0 });
|
||||
expect(redactOwnerWords(`Relayed: "${reply}"`, o).text).toBe(`Relayed: "${line.slice(0, -1)}, ${MARK}."`);
|
||||
const title = 'Ship the backoff behind a flag first';
|
||||
expect(redactOwnerWords(`${title} [run-1-ceo-native-1]`, ownerIndex([{ text: title, prior: [title] }])).redacted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactOwnerWords', () => {
|
||||
test('replaces each quote with the marker, keeps the punctuation around it, and leaves other prose byte for byte', () => {
|
||||
const o = owners(LONG);
|
||||
const o = ownerIndex([{ text: LONG }]);
|
||||
expect(redactOwnerWords(`The owner: "${LONG}" Noted.`, o)).toEqual({ text: `The owner: "${MARK}." Noted.`, redacted: 1 });
|
||||
const control = 'Keep the backoff cap. Log every retry. Thirty seconds is fine.';
|
||||
expect(redactOwnerWords(control, o)).toEqual({ text: control, redacted: 0 });
|
||||
});
|
||||
|
||||
test('a quote overlapping a kept span (code) is left for the validator to refuse', () => {
|
||||
const text = `\`${LONG}\``;
|
||||
expect(redactOwnerWords(text, owners(LONG), [[0, text.length]])).toEqual({ text, redacted: 0 });
|
||||
test('a quote or placement overlapping a kept span (code) is left for the validator to refuse', () => {
|
||||
const o = ownerIndex([{ text: LONG }], { deferred: [{ text: 'no time' }] });
|
||||
for (const text of [`\`${LONG}\``, 'ceo closed deferred (no time) with this finding open']) {
|
||||
expect(redactOwnerWords(text, o, [[0, text.length]])).toEqual({ text, redacted: 0 });
|
||||
expect(ownerSpans(text, o)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("gstack's verbatim copies of carried words and a human's deferral reason are replaced whatever their length", () => {
|
||||
const o = ownerWords({ phases: { ceo: { deferred: { reason: 'no time', by: 'human' } } }, carry: { words: 'Garry: do it' } } as never, []);
|
||||
expect(redactOwnerWords('CARRIED: gate_rev=2 <- gate_rev=1 words="Garry: do it" by=agent:x', o).text).toBe(`CARRIED: gate_rev=2 <- gate_rev=1 words=${MARK} by=agent:x`);
|
||||
expect(redactOwnerWords('Deferred ceo at round 1 (owner, by human): no time; 0 open', o).text).toBe(`Deferred ceo at round 1 (owner, by human): ${MARK}; 0 open`);
|
||||
expect(redactOwnerWords('ceo closed accepted-with-findings at round 1 (no time): 0 open', o).text).toBe(`ceo closed accepted-with-findings at round 1 (${MARK}): 0 open`);
|
||||
expect(redactOwnerWords('round 1 (no time; by human); open findings', o).text).toBe(`round 1 (${MARK}; by human); open findings`);
|
||||
expect(redactOwnerWords('deferred: no time', o).text).toBe(`deferred: ${MARK}`);
|
||||
expect(redactOwnerWords('we had no time to spare', o).text).toBe('we had no time to spare');
|
||||
test("gstack's renderings of carried words and a human's deferral reason are replaced whatever their length, delimiters and all", () => {
|
||||
const o = ownerIndex([], { deferred: [{ text: 'no time' }], carried: ['Garry: do it'] });
|
||||
const cases: Array<[string, string]> = [
|
||||
['CARRIED: gate_rev=2 <- gate_rev=1 words="Garry: do it" by=agent:x', `CARRIED: gate_rev=2 <- gate_rev=1 words=${MARK} by=agent:x`],
|
||||
['Deferred ceo at round 1 (owner, by human): no time; 0 open finding(s)', `Deferred ceo at round 1 (owner, by human): ${MARK}; 0 open finding(s)`],
|
||||
['ceo closed accepted-with-findings at round 1 (no time): 0 open', `ceo closed accepted-with-findings at round 1 (${MARK}): 0 open`],
|
||||
['ceo closed deferred (no time) with this finding open; only you can accept it', `ceo closed deferred (${MARK}) with this finding open; only you can accept it`],
|
||||
['round 1 (no time; by human); open findings', `round 1 (${MARK}; by human); open findings`],
|
||||
['deferred: no time', `deferred: ${MARK}`],
|
||||
];
|
||||
for (const [text, want] of cases) expect(redactOwnerWords(text, o).text).toBe(want);
|
||||
for (const kept of ['deferred: no time to spare', 'eng closed accepted-with-findings at round 1 (no time left): 0 open', '(no time; by agent:x)', 'we had no time to spare']) expect(redactOwnerWords(kept, o).text).toBe(kept);
|
||||
});
|
||||
|
||||
test('every template that renders a deferral reason has a placement (add one to DEFERRAL_RENDERINGS with any new template)', () => {
|
||||
const root = path.resolve(import.meta.dir, '..');
|
||||
const count = Object.fromEntries(fs.readdirSync(path.join(root, 'lib')).filter(f => f.startsWith('autoplan-') && f.endsWith('.ts'))
|
||||
.map(f => [f, (fs.readFileSync(path.join(root, 'lib', f), 'utf8').match(/\$\{(?:o|def|d|ps\.deferred)\.reason\}|JSON\.stringify\(o\.reason\)/g) ?? []).length]).filter(([, n]) => n));
|
||||
expect(count).toEqual({ 'autoplan-gate.ts': 5, 'autoplan-export.ts': 1, 'autoplan-run.ts': 1 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user