flowviant 0.77.5 → 0.78.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,18 +23,19 @@
23
23
  * report nothing — ignorance is never turned into a state.
24
24
  */
25
25
 
26
+ import { execFileSync } from 'node:child_process';
26
27
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
27
28
  import { join } from 'node:path';
28
29
  import { homedir } from 'node:os';
29
30
  import { createHash } from 'node:crypto';
30
31
  import { git, baseBranchName } from './git.mjs';
31
- import { taskIdsFromMessage } from './worktreeDiff.mjs';
32
+ import { stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
32
33
  import { warn } from './ui.mjs';
33
34
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
34
35
 
35
36
  const LANDED_URL = FLEET_URL.replace(/\/agents\/?$/, '/base-landed');
36
37
  /** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
37
- * batches: the persisted tip advances to the last commit actually reported,
38
+ * batches: the persisted tip advances to the last commit actually walked,
38
39
  * so the remainder is picked up on the next beat rather than skipped forever
39
40
  * — a trailered card in commit 51 of a big catch-up still closes. */
40
41
  const MAX_COMMITS = 50;
@@ -74,27 +75,64 @@ export function createLandedObserver({ repoRoot, baseRef }) {
74
75
  }
75
76
  };
76
77
 
77
- /** New non-merge commits in from..to, OLDEST FIRST. `--no-merges` for the
78
- * same reason branchCommits keeps it: a merge commit describes a range
79
- * rather than doing work, and its constituents are walked as themselves. */
78
+ /** git with the buffer the WALK needs. git.mjs's call takes execFileSync's
79
+ * default 1MiB cap, and a catch-up range's `%B` bodies blew through it —
80
+ * the throw landed in the reseed catch below, which skipped the whole range
81
+ * and silently lost every trailer in it. 8MB is repoState's number for the
82
+ * same reason; rev-list output at 41 bytes a commit clears ~200k commits
83
+ * before it matters. */
84
+ const gitWide = (args) =>
85
+ execFileSync('git', args, {
86
+ cwd: repoRoot,
87
+ encoding: 'utf8',
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ maxBuffer: 8 * 1024 * 1024,
90
+ }).trim();
91
+
92
+ /** New non-merge commits in from..to, OLDEST FIRST — the next batch of at
93
+ * most MAX_COMMITS, plus the tip the state should advance to when the range
94
+ * held more. `--no-merges` for the same reason branchCommits keeps it: a
95
+ * merge commit describes a range rather than doing work, and its
96
+ * constituents are walked as themselves.
97
+ *
98
+ * THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG. Git
99
+ * preserves the 0x1e/0x1f delimiter bytes inside a commit BODY (verified
100
+ * empirically), so a crafted message can fabricate whole records — an
101
+ * arbitrary sha plus Flowviant-Task ids that /fleet/base-landed would close
102
+ * cards on. rev-list prints nothing an author controls, so its output is
103
+ * the set of commits that exist: a parsed record whose sha is not in the
104
+ * batch is a forgery and is dropped, a repeated sha is the same forgery
105
+ * wearing a real commit's name, and the delimiter bytes are stripped from
106
+ * every surviving field.
107
+ *
108
+ * BOUNDING THE BODY FETCH TO THE BATCH is what makes the header's batching
109
+ * contract true at any range size: `%B` over the whole range grows without
110
+ * bound, so the formatted log runs over exactly the shas being reported
111
+ * this beat (`--no-walk=unsorted` shows precisely the commits named, in
112
+ * argv order — measured). */
80
113
  const walk = (from, to) => {
81
- const raw = git(
82
- ['log', '--reverse', '--no-merges', '--format=%H%x1f%s%x1f%B%x1e', `${from}..${to}`],
83
- repoRoot
84
- );
114
+ const shas = gitWide(['rev-list', '--reverse', '--no-merges', `${from}..${to}`])
115
+ .split('\n')
116
+ .filter((s) => SHA_RE.test(s));
117
+ const batch = shas.slice(0, MAX_COMMITS);
118
+ const tipAfter = shas.length > MAX_COMMITS ? batch[batch.length - 1] : null;
119
+ if (batch.length === 0) return { commits: [], tipAfter };
120
+ const real = new Set(batch);
121
+ const raw = gitWide(['log', '--no-walk=unsorted', '--format=%H%x1f%s%x1f%B%x1e', ...batch]);
85
122
  const out = [];
86
123
  for (const rec of raw.split('\x1e')) {
87
124
  const line = rec.replace(/^\n+/, '');
88
125
  if (!line.trim()) continue;
89
- const [sha, subject, body] = line.split('\x1f');
90
- if (!SHA_RE.test(sha || '')) continue;
126
+ const [sha, subject, ...bodyParts] = line.split('\x1f');
127
+ if (!real.has(sha)) continue;
128
+ real.delete(sha);
91
129
  out.push({
92
130
  sha,
93
- subject: String(subject || '').slice(0, 200),
94
- taskIds: taskIdsFromMessage(body).slice(0, 8),
131
+ subject: stripDelims(subject).slice(0, 200),
132
+ taskIds: taskIdsFromMessage(stripDelims(bodyParts.join('\n'))).slice(0, 8),
95
133
  });
96
134
  }
97
- return out;
135
+ return { commits: out, tipAfter };
98
136
  };
99
137
 
100
138
  /** Look at the base tip; if it moved, report the range. Call after anything
@@ -112,21 +150,30 @@ export function createLandedObserver({ repoRoot, baseRef }) {
112
150
  return;
113
151
  }
114
152
  if (st.tip === tip) return;
115
- let all;
153
+ let walked;
116
154
  try {
117
- all = walk(st.tip, ref);
155
+ walked = walk(st.tip, ref);
118
156
  } catch {
119
- // The old tip is no longer answerable (force-push, gc) reseed and
120
- // report nothing rather than guess at a range.
121
- writeState({ ref, tip });
157
+ // Two failures land here and only one may reseed. Probe the range
158
+ // directly: if rev-list cannot COUNT it, the old tip is genuinely gone
159
+ // (force-push, gc) and observation reseeds at the new one — ignorance is
160
+ // never turned into a state. Anything else (a transient spawn failure,
161
+ // an over-buffer) keeps the stored tip so the next beat retries the same
162
+ // range; reseeding on those was what skipped a whole catch-up range and
163
+ // permanently lost every trailer in it.
164
+ try {
165
+ git(['rev-list', '--count', `${st.tip}..${ref}`], repoRoot);
166
+ } catch {
167
+ writeState({ ref, tip });
168
+ }
122
169
  return;
123
170
  }
124
171
  // Oldest-first BATCH: a range past the server's cap advances the tip only
125
- // to the last commit reported, so the remainder rides the next beat —
172
+ // to the last commit walked, so the remainder rides the next beat —
126
173
  // nothing is skipped forever. (A range of nothing but merge commits still
127
174
  // reports, tip-only: the tip moving is the fact deploy-on-merge rides.)
128
- const commits = all.slice(0, MAX_COMMITS);
129
- const reportedTip = all.length > MAX_COMMITS ? commits[commits.length - 1].sha : tip;
175
+ const commits = walked.commits;
176
+ const reportedTip = walked.tipAfter ?? tip;
130
177
  inFlight = true;
131
178
  try {
132
179
  const res = await fetch(LANDED_URL, {
@@ -234,6 +234,72 @@ function firstCwdRecord(file) {
234
234
  }
235
235
  }
236
236
 
237
+ /**
238
+ * THE MARKER NAMES THE DAEMON PINS A TAB'S CONVERSATION UNDER.
239
+ *
240
+ * One owner, because two things read them and a drift here is silent: `work.mjs`
241
+ * WRITES them (`sessionMetaPath(wt, <name>, sessionId)`) and this file READS
242
+ * them to know which conversations are its own.
243
+ */
244
+ export const SESSION_MARKERS = [
245
+ 'flowviant-claude-session',
246
+ 'flowviant-codex-thread',
247
+ 'flowviant-agy-conversation',
248
+ ];
249
+
250
+ /**
251
+ * EVERY CONVERSATION THIS DAEMON STARTED, so the adopt strip never offers you
252
+ * your own reflection.
253
+ *
254
+ * The machine OPERATOR's tabs work in the checkout itself (their place is
255
+ * `'repo'`), so their CLI transcripts land in `~/.claude/projects/<munge(repoRoot)>/`
256
+ * — the very directory this scan reads. The `excludeDirs` fence cannot help:
257
+ * repoRoot is the scan ROOT, not something under it. So the daemon's own tabs
258
+ * were reported as adoptable "terminal sessions", and two things followed:
259
+ * the `+` menu offered to adopt a tab you already have open (accepting FORKS
260
+ * that conversation and copies the checkout's uncommitted and untracked files
261
+ * into a new worktree), and — because the ended walk keeps only the newest row
262
+ * per directory, and a live tab's transcript is always the freshest thing in
263
+ * the checkout — a REAL terminal session started in the repo root could never
264
+ * be offered at all.
265
+ *
266
+ * Excluded by conversation ID rather than by directory, because the directory
267
+ * is shared with exactly the sessions we want to keep offering.
268
+ */
269
+ export function ourConversationIds(repoRoot, gitDirs = []) {
270
+ const ids = new Set();
271
+ const dirs = new Set(gitDirs.filter(Boolean));
272
+ try {
273
+ dirs.add(
274
+ execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
275
+ cwd: repoRoot,
276
+ encoding: 'utf8',
277
+ timeout: 5_000,
278
+ }).trim()
279
+ );
280
+ } catch {
281
+ /* not a repo, or git unavailable — the fence is simply empty */
282
+ }
283
+ for (const dir of dirs) {
284
+ let names = [];
285
+ try {
286
+ names = readdirSync(dir);
287
+ } catch {
288
+ continue;
289
+ }
290
+ for (const n of names) {
291
+ if (!SESSION_MARKERS.some((m) => n === m || n.startsWith(`${m}-`))) continue;
292
+ try {
293
+ const v = readFileSync(join(dir, n), 'utf8').trim();
294
+ if (v) ids.add(v);
295
+ } catch {
296
+ /* unreadable marker: nothing to fence */
297
+ }
298
+ }
299
+ }
300
+ return ids;
301
+ }
302
+
237
303
  /**
238
304
  * Every Claude terminal session belonging to this repo: LIVE ones from the
239
305
  * liveness registry, ENDED ones from the transcript store. Returns
@@ -245,7 +311,11 @@ function firstCwdRecord(file) {
245
311
  * itself spawned are tabs already, and offering to adopt one would be the
246
312
  * product offering the user their own reflection.
247
313
  */
248
- export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
314
+ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
315
+ /** Conversations this daemon started — never offered for adoption. See
316
+ * `ourConversationIds`: the operator's tabs share the checkout with real
317
+ * terminal sessions, so the fence has to be by id, not by directory. */
318
+ const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
249
319
  const live = [];
250
320
  const ended = [];
251
321
  try {
@@ -311,6 +381,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
311
381
  /* no transcript yet */
312
382
  }
313
383
  if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
384
+ if (mine.has(rec.sessionId)) continue; // our own tab, not a terminal session
314
385
  live.push({
315
386
  id: rec.sessionId,
316
387
  cwd,
@@ -387,6 +458,13 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
387
458
  continue;
388
459
  }
389
460
  if (!ours(cwd)) continue;
461
+ /**
462
+ * OUR OWN TAB IS NOT A CANDIDATE, and it is skipped BEFORE `seenCwds`
463
+ * claims the directory — otherwise the daemon's transcript (always the
464
+ * freshest thing in the checkout) took the one slot that directory gets
465
+ * and a genuine terminal session there could never be offered at all.
466
+ */
467
+ if (mine.has(cand.id)) continue;
390
468
  if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
391
469
  seenCwds.add(cwd);
392
470
  const title = transcriptTitle(cand.file, cand.mtimeMs);
@@ -405,7 +483,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
405
483
  const claude = [...live.slice(0, REPORT_CAP), ...ended];
406
484
  // agy rides in whatever room the cap leaves — Claude sessions first, they
407
485
  // are the ones adoption serves best (fork, never move).
408
- const agy = scanAgyConversations({ repoRoot, excludeDirs }).slice(
486
+ const agy = scanAgyConversations({ repoRoot, excludeDirs, excludeIds: mine }).slice(
409
487
  0,
410
488
  Math.max(0, REPORT_CAP - claude.length)
411
489
  );
@@ -511,7 +589,8 @@ export function isAgyConversationLive(id) {
511
589
 
512
590
  /** The repo's agy conversations, via the cwd registry — see the section
513
591
  * comment for why this is deliberately a subset. */
514
- function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
592
+ function scanAgyConversations({ repoRoot, excludeDirs = [], excludeIds }) {
593
+ const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
515
594
  const out = [];
516
595
  try {
517
596
  let realRoot;
@@ -537,6 +616,9 @@ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
537
616
  const processUp = agyProcessAlive();
538
617
  for (const [cwd, id] of Object.entries(map)) {
539
618
  if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
619
+ // The operator's own agy TAB, whose conversation is registered against
620
+ // the checkout exactly as a terminal one would be.
621
+ if (mine.has(id)) continue;
540
622
  let real;
541
623
  try {
542
624
  real = realpathSync(cwd);
@@ -20,7 +20,7 @@ function present(cmd) {
20
20
 
21
21
  function ghAuthed() {
22
22
  try {
23
- execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' });
23
+ execFileSync('gh', ['auth', 'status'], { stdio: 'ignore', timeout: 20_000 });
24
24
  return true;
25
25
  } catch {
26
26
  return false;
@@ -367,14 +367,17 @@ const TAIL_BYTES = 2000;
367
367
  * origin with 502, so without this the product would report "live" over a 502 —
368
368
  * Flowviant asserting a state it never measured.
369
369
  *
370
- * `stillServing` (optional, async → boolean) is the ATTRIBUTION re-check the
371
- * probe runs instead of a bare TCP connect. Ports are global to a box and a
372
- * worktree is not: when the driver's dev server dies and anything else — a
373
- * teammate's worktree, a database binds the same number, a bare
374
- * `isListening` keeps the probe green and the existing URL+password serve the
375
- * NEW process, outside every consent gate. The caller passes the same
376
- * `listenersIn(worktree)` check the open path uses, so "the origin is alive"
377
- * keeps meaning "THIS session's origin".
370
+ * `stillServing` (optional, async → boolean) is the ATTRIBUTION check the
371
+ * same `listenersIn(worktree)` predicate the caller ran at the boundary
372
+ * and it guards EVERY gate in here, not just the probe: the open-time
373
+ * re-validation, one more look immediately before cloudflared spawns, and the
374
+ * recurring probe. Ports are global to a box and a worktree is not: when the
375
+ * driver's dev server dies and anything else a teammate's worktree, a
376
+ * database binds the same number, a bare `isListening` answers yes and the
377
+ * URL+password serve the NEW process, outside every consent gate. Three gates
378
+ * on one predicate, so "the origin is alive" always means "THIS session's
379
+ * origin"; a bare TCP connect stands in only when no predicate was given (an
380
+ * older caller).
378
381
  *
379
382
  * `onAbuse` fires when the gate closes itself after repeated failed password
380
383
  * attempts — AFTER the share is torn down locally — so the caller can report
@@ -400,10 +403,21 @@ export async function openTunnel({
400
403
  shareId,
401
404
  authorizeUrl,
402
405
  }) {
406
+ // ONE predicate for every liveness question this function asks. Attribution
407
+ // when the caller gave it, a bare TCP connect only when it did not; an
408
+ // attribution check that errors is not a "yes".
409
+ const serving = async () => {
410
+ try {
411
+ return stillServing ? await stillServing() : await isListening(port);
412
+ } catch {
413
+ return false;
414
+ }
415
+ };
416
+
403
417
  // Re-validate at the machine. The server checked this port against the last
404
418
  // report; reports are up to a minute old and a dev server is a process a
405
419
  // human can stop at any moment.
406
- if (!(await isListening(port))) {
420
+ if (!(await serving())) {
407
421
  return { error: `nothing is listening on port ${port} in this worktree any more.` };
408
422
  }
409
423
 
@@ -464,6 +478,17 @@ export async function openTunnel({
464
478
  return { error: 'could not start the password gate for this preview, so nothing was published.' };
465
479
  }
466
480
 
481
+ // The last look BEFORE anything becomes public. Between the check above and
482
+ // here sit a possible cloudflared download and the gate's own bind — long
483
+ // enough for the dev server to die and an unrelated process to take the
484
+ // port, which a check that ran only at the top would never see again until
485
+ // the probe's first beat, up to probeMs later. Same predicate, so the moment
486
+ // the hostname exists it can only be pointing at THIS session's origin.
487
+ if (!(await serving())) {
488
+ stop();
489
+ return { error: `nothing is listening on port ${port} in this worktree any more.` };
490
+ }
491
+
467
492
  const args = ['tunnel', '--url', `http://localhost:${gate.port}`];
468
493
  // Send the origin the Host it expects. Vite and Next reject a Host they do
469
494
  // not recognise, so without this the tunnel resolves and then 403s.
@@ -513,13 +538,7 @@ export async function openTunnel({
513
538
  // share would keep serving a process nobody consented to publish.
514
539
  probe = setInterval(async () => {
515
540
  if (stopped) return;
516
- let serving;
517
- try {
518
- serving = stillServing ? await stillServing() : await isListening(port);
519
- } catch {
520
- serving = false; // an attribution check that errors is not a "yes"
521
- }
522
- if (!serving) {
541
+ if (!(await serving())) {
523
542
  const dead = onDead;
524
543
  stop();
525
544
  try {
@@ -298,21 +298,16 @@ rules:
298
298
  it down: file_card the slice you are starting, raise_card the rest so the
299
299
  queue holds the plan instead of your context.
300
300
  FILL IN THE SHAPE when you do — \`points\`, \`acceptanceCriteria\` ("done
301
- when", one line each), \`codeAnchors\` (the modules the card owns), and
302
- \`priority\`. This is not bookkeeping: the forecast is computed from points and
303
- anchors, and the ship review quiz is generated from the criteria. Leave them
304
- empty and nothing breaks the forecast quietly falls back to a flat default
305
- and the review has less to ask about. A card you have just designed is the
306
- only moment anyone knows those answers.
307
- NAME THE FEATURE. When one ask becomes several cards, give them all the same
308
- \`featureName\` — a short name a human would recognise ("Password reset",
309
- "Billing export"). That is what lets the Board show them as one piece of work
310
- instead of five loose rows. Reuse a name already on the board rather than
311
- coining a synonym for it.
301
+ when", one line each), and \`codeAnchors\` (the modules the card owns). This
302
+ is not bookkeeping: points are how an agent's workload is budgeted when
303
+ cards are deployed, the ship review quiz is generated from the criteria,
304
+ and the anchors are what the planner reads. Leave them empty and nothing
305
+ breaks the review just has less to ask about. A card you have just
306
+ designed is the only moment anyone knows those answers.
312
307
  10. YOU CAN CORRECT A CARD YOU ALREADY FILED. update_cards patches the SHAPE of
313
- cards that exist — \`points\`, \`priority\`, \`featureName\` — up to 25 in one
314
- call. This is the tool for "help me plan the backlog": list_cards, decide,
315
- then send every change in ONE call. It cannot move a card, close one, assign
308
+ cards that exist — \`points\` and \`waitsOn\` — up to 25 in one call. This is
309
+ the tool for "help me plan the backlog": list_cards, decide, then send
310
+ every change in ONE call. It cannot move a card, close one, assign
316
311
  anyone or touch a receipt — organising a backlog is not working on it, so do
317
312
  not log_work or deliver anything you have not actually built. A card that is already delivered is refused, because its
318
313
  spec is what somebody's review is about. And when list_cards says
@@ -321,9 +316,11 @@ rules:
321
316
  SAY WHAT WAITS ON WHAT. \`waitsOn\` takes the task ids a card cannot start
322
317
  until, and it is what turns a feature from a heap into a sequence: the
323
318
  migration before the endpoint, the endpoint before the UI, the polish last.
324
- The Board orders and bands cards from it READY vs WAITING so a person who
325
- was not in this conversation can still see where to start. Declare it while
326
- you are decomposing, because that is the one moment anyone knows.
319
+ When cards are deployed to agents, the split respects itan agent holding
320
+ a card's prerequisite merges before the dependent one starts and the task
321
+ page shows it, so a person who was not in this conversation can still see
322
+ where to start. Declare it while you are decomposing, because that is the
323
+ one moment anyone knows.
327
324
  11. DELIVER WITH RECEIPTS. When a card's work is committed, deliver_card with a
328
325
  one-paragraph summary and the commit shas. Delivered is ASSERTED; done is
329
326
  OBSERVED (the merge, on their word). Never claim done, and never deliver
@@ -482,20 +479,40 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
482
479
  });
483
480
 
484
481
 
482
+ /**
483
+ * EVERY SERVER-CARRIED STRING HERE IS FENCED — the feature name, the file
484
+ * list, and the predicted-page list.
485
+ *
486
+ * A card title is member-authored, and worse: ticket triage falls back to the
487
+ * REPORTER's ticket title verbatim, so a stranger can put words in it. That
488
+ * string rides the ship report into `code_map_reground_jobs`, comes back on the
489
+ * roster, and landed here as a bare `Feature: <text>` line — no delimiter, no
490
+ * instruction not to obey it. This turn runs UNATTENDED under `WIKI_PERM`,
491
+ * which grants Write, Edit, `Bash(mkdir:*)` and `Bash(rm:*)`, and whose own
492
+ * comment concedes that Write/Edit cannot be path-scoped here — the worktree
493
+ * reset is the backstop, and it only cleans the wiki worktree. Anything written
494
+ * outside it survives.
495
+ *
496
+ * The predicted pages come off the roster exactly as the title does — a
497
+ * planner wrote them from card text, and card text is member-authored — so an
498
+ * unfenced `- <page>` line was the same injection lane with a different field
499
+ * name. The file list is fenced for the same reason at lower stakes: a path is
500
+ * attacker-influenceable too, and there is no cost to it.
501
+ */
485
502
  export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
486
503
  `A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
487
- `Feature: ${title}\n` +
504
+ `Feature:\n${fence('FEATURE NAME', title)}\n` +
488
505
  `Grounded commit: ${sha}\n` +
489
- `Changed files:\n${files.map((f) => `- ${f}`).join('\n')}\n\n` +
506
+ `Changed files:\n${fence('CHANGED FILES', files.map((f) => `- ${f}`).join('\n'))}\n\n` +
490
507
  // The plan's own prediction, made when this work was drafted. Overlapping
491
508
  // changed files against each page's frontmatter finds most of what moved, but
492
509
  // misses a page whose file list has drifted or that documents a CONCEPT rather
493
510
  // than a directory. This is a hint to CHECK, never a list to trust.
494
511
  (predictedPages.length
495
- ? `When this work was planned, these vault pages were expected to go stale.\n` +
496
- `Treat it as a lead, not a fact — verify each against the code before\n` +
497
- `editing, and ignore any that turned out to be unaffected:\n` +
498
- `${predictedPages.map((p) => `- ${p}`).join('\n')}\n\n`
512
+ ? `When this work was planned, the vault pages listed below were expected\n` +
513
+ `to go stale. Treat the list as a lead, not a fact — verify each against\n` +
514
+ `the code before editing, and ignore any that turned out to be unaffected:\n` +
515
+ `${fence('PREDICTED PAGES', predictedPages.map((p) => `- ${p}`).join('\n'))}\n\n`
499
516
  : '') +
500
517
  `Follow your instructions: update the touched vault pages (and any docs/\n` +
501
518
  `chapter that covers them), append the feature-history entry to log.md,\n` +
@@ -19,11 +19,15 @@ import { baseBranchName } from './git.mjs';
19
19
  *
20
20
  * `git branch -d` IS THE GUARD, deliberately, rather than a stack of checks
21
21
  * of our own. It refuses an UNMERGED branch and it refuses one CHECKED OUT in
22
- * any worktree — which are two of the three conditions, enforced by the tool
23
- * that owns the truth instead of by our reading of it. Never `-D`: if git
24
- * objects, git is right and we stop. The third condition is ours and is the
25
- * name: only `session/<id>` exactly, so a branch the agent cut is never in
26
- * scope no matter what it was merged into.
22
+ * any worktree — enforced by the tool that owns the truth instead of by our
23
+ * reading of it. Never `-D`: if git objects, git is right and we stop. Two
24
+ * conditions are ours on top of it. The NAME: only `session/<id>` exactly, so
25
+ * a branch the agent cut is never in scope no matter what it was merged into.
26
+ * And ANCESTRY OF BASE: `-d` with no upstream judges "merged" against HEAD,
27
+ * so an operator parked on an experimental branch that merged the session
28
+ * work would get the delete while the narration below claimed "merged into
29
+ * <base>" for commits base has never seen — the claim is checked against the
30
+ * thing it names before git is asked at all.
27
31
  *
28
32
  * ORDERING IS LOAD-BEARING. This must not run while a ship report is still
29
33
  * undelivered. Ship's idempotency path recovers from a lost report by asking
@@ -45,6 +49,13 @@ export function sweepMergedBranch(sessionId, { git, repoRoot, baseRef, note, isR
45
49
  } catch {
46
50
  return false; // already gone, or never existed
47
51
  }
52
+ try {
53
+ git(['merge-base', '--is-ancestor', `refs/heads/${name}`, baseRef], repoRoot);
54
+ } catch {
55
+ // Not on base — merged into something, perhaps, but not into the thing the
56
+ // narration names, and not ours to retire on HEAD's say-so.
57
+ return false;
58
+ }
48
59
  try {
49
60
  git(['branch', '-d', name], repoRoot);
50
61
  } catch {