flowviant 0.78.0 → 0.79.0

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.
package/bin/lib/git.mjs CHANGED
@@ -1,11 +1,6 @@
1
1
  /** Git worktree helpers (fleet & static-fleet modes). */
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
- import { existsSync, readFileSync, statSync } from 'node:fs';
5
- import { resolve, join } from 'node:path';
6
- import { rmSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
- import { materializedFiles } from './env.mjs';
9
4
 
10
5
  export function git(args, cwd) {
11
6
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -144,161 +139,6 @@ export function baseBranchName(baseRef) {
144
139
  return String(baseRef || '').replace(/^origin\//, '') || 'main';
145
140
  }
146
141
 
147
- /**
148
- * Get a detached worktree at `wt`, creating it at `ref` if it isn't there.
149
- *
150
- * Returns `{ path, fresh }` — `fresh` is the whole point. A worktree that
151
- * ALREADY existed is one somebody was mid-way through, and the caller must not
152
- * reset it; a freshly created one is at base by construction and has nothing to
153
- * preserve. That single bit replaces the in-memory `resuming` flag and the
154
- * on-disk task marker for the common case, because once a worktree is named
155
- * after its task, "does this directory exist" IS "am I resuming".
156
- *
157
- * The prune-and-retry is not paranoia: `git worktree add` refuses a path that
158
- * is still REGISTERED even when the directory is gone (`flowviant clean` rm's
159
- * the dirs, `git worktree list` keeps the stale entries), and that failure is
160
- * permanent until pruned.
161
- */
162
- export function ensureWorktree(repoRoot, wt, ref) {
163
- // Resolve first. `existsSync` answers relative to THIS process's cwd while
164
- // `git worktree add` answers relative to repoRoot, so a relative path makes
165
- // the two disagree: the check says "not there", the add says "already
166
- // exists", and the prune-and-retry can't fix a path that was never the one
167
- // we looked at. Callers pass absolute paths today; this makes that not matter.
168
- wt = resolve(wt);
169
- if (existsSync(wt)) return { path: wt, fresh: false };
170
- try {
171
- git(['worktree', 'add', '--detach', wt, ref], repoRoot);
172
- } catch {
173
- git(['worktree', 'prune'], repoRoot);
174
- git(['worktree', 'add', '--detach', wt, ref], repoRoot);
175
- }
176
- return { path: wt, fresh: true };
177
- }
178
-
179
- // ── WIP checkpoints: the sandbox's state, on the remote ────────────────────
180
- //
181
- // A task's uncommitted work used to exist in exactly one place — a directory on
182
- // whichever machine claimed it. That made the checkout precious: losing the box
183
- // lost the work, so a task was pinned to a host, the host had to be named in the
184
- // UI, and a container could never be thrown away. Pushing the work somewhere
185
- // durable inverts all of that. The sandbox becomes a cache.
186
- //
187
- // These snapshots go to `refs/flowviant-wip/<intentId>`, NOT to a branch: they
188
- // are machine state, not history, and they must never appear in a PR, a branch
189
- // listing, or anyone's `git log`. Force-pushed, because only the latest matters.
190
-
191
- const wipRef = (intentId) => `refs/flowviant-wip/${intentId}`;
192
-
193
- /** git, with extra environment — for GIT_INDEX_FILE and a committer identity we
194
- * can't assume the machine has configured. */
195
- function gitWithEnv(args, cwd, extraEnv) {
196
- return execFileSync('git', args, {
197
- cwd,
198
- encoding: 'utf8',
199
- stdio: ['ignore', 'pipe', 'pipe'],
200
- env: { ...process.env, ...extraEnv },
201
- }).trim();
202
- }
203
-
204
- /**
205
- * Snapshot everything in the worktree — staged, unstaged and untracked — and
206
- * push it, WITHOUT touching the agent's HEAD, index or files.
207
- *
208
- * That constraint is why this doesn't just commit. The agent is a live process
209
- * with its own git intentions; committing under it would rewrite state it is
210
- * mid-way through reasoning about, and `git stash` would rip the files out from
211
- * under an editor. Building the tree in a throwaway index leaves the agent's
212
- * world untouched — it cannot tell this happened.
213
- *
214
- * Returns the commit sha, or null if there was nothing dirty / no remote.
215
- *
216
- * This PUSHES, so what it stages is a security boundary, not a detail: the
217
- * daemon materializes plaintext env-vault secrets into this same worktree.
218
- * They are gitignored (materializeInto refuses to write them otherwise), and
219
- * `git add -A` honours .gitignore — but the whole point of the bug this guards
220
- * against was an exclusion mechanism that silently did nothing, so the paths
221
- * are ALSO subtracted by pathspec here. Two independent mechanisms, because one
222
- * of them failing quietly is exactly what put secrets on a remote branch.
223
- */
224
- export function checkpointWip(wt, intentId, baseRef) {
225
- if (!isSafePathSegment(intentId)) return null;
226
- const idx = join(tmpdir(), `flowviant-idx-${intentId}-${process.pid}`);
227
- const env = {
228
- GIT_INDEX_FILE: idx,
229
- // A snapshot must never fail because the machine has no user.name — this is
230
- // ours, not the user's, and it never lands in history anyone reads.
231
- GIT_AUTHOR_NAME: 'Flowviant',
232
- GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
233
- GIT_COMMITTER_NAME: 'Flowviant',
234
- GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
235
- };
236
- try {
237
- const head = git(['rev-parse', 'HEAD'], wt);
238
- gitWithEnv(['read-tree', head], wt, env);
239
- gitWithEnv(
240
- ['add', '-A', '--', '.', ...materializedFiles(wt).map((p) => `:(exclude)${p}`)],
241
- wt,
242
- env
243
- );
244
- const tree = gitWithEnv(['write-tree'], wt, env);
245
- // Nothing changed since HEAD — no snapshot worth pushing.
246
- if (tree === git(['rev-parse', `${head}^{tree}`], wt)) return null;
247
- const commit = gitWithEnv(
248
- ['commit-tree', tree, '-p', head, '-m', `flowviant wip ${intentId}`],
249
- wt,
250
- env
251
- );
252
- git(['push', '--force', 'origin', `${commit}:${wipRef(intentId)}`], wt);
253
- return commit;
254
- } catch {
255
- // Offline, no push rights, a repo with no origin — a checkpoint is an
256
- // optimisation, never a reason to fail a task.
257
- return null;
258
- } finally {
259
- try {
260
- rmSync(idx, { force: true });
261
- } catch {
262
- /* best-effort */
263
- }
264
- void baseRef;
265
- }
266
- }
267
-
268
- /**
269
- * Rebuild a worktree from its last pushed checkpoint. Returns true if one was
270
- * found and applied.
271
- *
272
- * The reset is MIXED on purpose: it leaves the snapshot's content in the files
273
- * with HEAD back at the parent, which is what the agent had before — dirty
274
- * working tree, nothing staged it didn't stage itself. A soft reset would hand
275
- * it a fully-staged index it never created.
276
- */
277
- export function restoreWip(wt, intentId) {
278
- if (!isSafePathSegment(intentId)) return false;
279
- const ref = wipRef(intentId);
280
- try {
281
- git(['fetch', 'origin', `+${ref}:${ref}`], wt);
282
- const commit = git(['rev-parse', ref], wt);
283
- const parent = git(['rev-parse', `${commit}^`], wt);
284
- git(['checkout', '--detach', commit], wt);
285
- git(['reset', parent], wt);
286
- return true;
287
- } catch {
288
- return false; // no checkpoint for this task, or it's unreachable
289
- }
290
- }
291
-
292
- /** Drop a task's checkpoint once its work has landed somewhere real. */
293
- export function clearWip(wt, intentId) {
294
- if (!isSafePathSegment(intentId)) return;
295
- try {
296
- git(['push', 'origin', '--delete', wipRef(intentId)], wt);
297
- } catch {
298
- /* already gone, or no remote */
299
- }
300
- }
301
-
302
142
  export function resetWorktree(wt, baseRef) {
303
143
  try {
304
144
  git(['fetch', 'origin', '--quiet'], wt);
@@ -314,106 +154,3 @@ export function resetWorktree(wt, baseRef) {
314
154
  }
315
155
  }
316
156
 
317
- /**
318
- * What has changed in this worktree since `baseRef` — committed or not.
319
- *
320
- * The definition matters. `git diff --numstat <base>` (no `..HEAD`) compares the
321
- * base against the WORKING TREE, so it covers commits the agent has made, staged
322
- * work, and edits it has not committed yet. Anything narrower would go blank at
323
- * the exact moments you look: right after a commit, or before the first one.
324
- *
325
- * Untracked files are added separately — they are invisible to `git diff` and
326
- * are usually the most interesting thing an agent has done (a new module, a new
327
- * test). Their line counts are read here rather than inferred; a file too large
328
- * to be source is reported as a path with no counts instead of being read into
329
- * memory.
330
- *
331
- * PATHS AND COUNTS ONLY. Nothing in here returns file content.
332
- *
333
- * Both git calls are `-z`, for the reason gitRaw exists: git's line-based output
334
- * QUOTES any path that is not plain ASCII, so an accented filename arrives as
335
- * "n\303\251w.txt" — a string that is not the path, cannot be stat'd, and reads
336
- * as garbage in the tray. `-z` emits paths verbatim.
337
- */
338
- export function worktreeDiffstat(cwd, baseRef, { maxFiles = 200 } = {}) {
339
- const files = [];
340
- let additions = 0;
341
- let deletions = 0;
342
-
343
- const add = (path, added, removed) => {
344
- additions += added;
345
- deletions += removed;
346
- files.push({ path, added, removed });
347
- };
348
-
349
- try {
350
- // `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
351
- // but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
352
- // then the new one. An empty path is therefore the rename marker, and the
353
- // next two fields belong to it — read line-wise instead, a rename would
354
- // report a file literally named "old => new".
355
- const fields = splitNul(gitRaw(['diff', '--numstat', '-z', baseRef, '--'], cwd));
356
- for (let i = 0; i < fields.length; i++) {
357
- const [a, d, ...rest] = fields[i].split('\t');
358
- let path = rest.join('\t');
359
- if (!path) {
360
- path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
361
- i += 2;
362
- if (!path) continue;
363
- }
364
- // Binary files report '-' for both counts; they changed, but not by lines.
365
- add(path, a === '-' ? 0 : Number(a) || 0, d === '-' ? 0 : Number(d) || 0);
366
- }
367
- } catch {
368
- // No base ref yet, or not a repo — nothing to report rather than a crash.
369
- return null;
370
- }
371
-
372
- try {
373
- const untracked = splitNul(
374
- gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], cwd)
375
- );
376
- for (const path of untracked) {
377
- let added = 0;
378
- // Past the cap this path will not be shown, so do not pay to read it.
379
- // This is the one place the totals can undercount, and reaching it takes
380
- // an untracked tree bigger than the list itself — a generated directory
381
- // .gitignore missed. Statting and reading all of it on a 20s interval
382
- // would block the roster poll and every other lane on this daemon.
383
- if (files.length < maxFiles) {
384
- try {
385
- const st = statSync(join(cwd, path));
386
- // Regular files ONLY. `git ls-files --others` will happily name a
387
- // symlink or a fifo, and readFileSync on a fifo or a character device
388
- // BLOCKS — on a 20s interval, on the daemon's single thread, that is
389
- // the whole process wedged waiting for a device that may never write.
390
- // 2 MB: past that it is a build artifact or a binary, and reading it
391
- // to count newlines would be the most expensive thing this daemon does.
392
- if (st.isFile() && st.size <= 2_000_000) {
393
- const text = readFileSync(join(cwd, path), 'utf8');
394
- // A NUL byte means binary. Counting "lines" in a PNG produces a
395
- // number that is not wrong so much as meaningless, and it was being
396
- // summed into the total shown beside git's real counts.
397
- if (text.includes('\0')) throw new Error('binary');
398
- // Lines, not segments. A file ending in a newline — i.e. essentially
399
- // every source file an agent writes — splits into one more piece
400
- // than it has lines, and that +1 was landing in the totals shown
401
- // beside git's own counts.
402
- added = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
403
- }
404
- } catch {
405
- /* vanished between listing and reading — report the path, no counts */
406
- }
407
- }
408
- add(path, added, 0);
409
- }
410
- } catch {
411
- /* untracked listing failed — the tracked half still stands */
412
- }
413
-
414
- if (files.length === 0) return null;
415
- // Totals stay whole while the LIST is capped: a truncated list must never
416
- // quietly shrink the number printed beside it.
417
- const truncated = Math.max(0, files.length - maxFiles);
418
- return { files: files.slice(0, maxFiles), additions, deletions, truncated };
419
- }
package/bin/lib/grant.mjs CHANGED
@@ -171,12 +171,33 @@ export function safePathname(url) {
171
171
  * `//evil.com` and `/\evil.com` are protocol-relative to a browser, so a naive
172
172
  * "starts with /" check turns the callback into an open redirect on the tunnel
173
173
  * origin. Anything that is not a single-slash relative path becomes '/'.
174
+ *
175
+ * STRUCTURAL, NOT LEXICAL, and that distinction is the whole fix. This used to
176
+ * be `/^\/(?![/\\])/` plus a CR/LF check — and an audit walked through it with
177
+ * a TAB: browsers STRIP tab, CR and LF out of a URL before parsing, so
178
+ * `"/\t/evil.com"` passed the guard, was written verbatim into Location by the
179
+ * callback, and navigated to `//evil.com`. Parsing against a dummy base and
180
+ * re-serialising reproduces exactly what the browser will do — there is no
181
+ * second implementation of URL parsing here to drift, and the value returned
182
+ * is the one the parser produced. The lexical checks stay in front of it as
183
+ * the cheap first line; the origin comparison is the guarantee. Ported from
184
+ * the server twins (previewAuthorize.routes.ts, preview-relay util.ts), which
185
+ * closed the same hole the same way.
174
186
  */
175
187
  export function safeRelative(raw) {
176
188
  if (!raw) return '/';
177
189
  const s = String(raw);
178
190
  if (s.length > 512) return '/';
179
- if (/[\r\n]/.test(s)) return '/';
180
- if (!/^\/(?![/\\])/.test(s)) return '/';
181
- return s;
191
+ if (!s.startsWith('/')) return '/';
192
+ if (/^\/[/\\]/.test(s)) return '/';
193
+ // Every C0 control, space and DEL — not just CR/LF.
194
+ // eslint-disable-next-line no-control-regex
195
+ if (/[\x00-\x20\x7f]/.test(s)) return '/';
196
+ try {
197
+ const u = new URL(s, 'https://x.invalid');
198
+ if (u.origin !== 'https://x.invalid') return '/';
199
+ return u.pathname + u.search + u.hash;
200
+ } catch {
201
+ return '/';
202
+ }
182
203
  }
@@ -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, {
@@ -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
@@ -483,8 +480,8 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
483
480
 
484
481
 
485
482
  /**
486
- * THE FEATURE NAME AND THE FILE LIST ARE FENCED, and they were the only two
487
- * unfenced strings left in this file.
483
+ * EVERY SERVER-CARRIED STRING HERE IS FENCED the feature name, the file
484
+ * list, and the predicted-page list.
488
485
  *
489
486
  * A card title is member-authored, and worse: ticket triage falls back to the
490
487
  * REPORTER's ticket title verbatim, so a stranger can put words in it. That
@@ -496,9 +493,11 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
496
493
  * reset is the backstop, and it only cleans the wiki worktree. Anything written
497
494
  * outside it survives.
498
495
  *
499
- * Every other kickoff in this file already fences its untrusted input; this one
500
- * was simply missed. The file list is fenced for the same reason at lower
501
- * stakes a path is attacker-influenceable too, and there is no cost to it.
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.
502
501
  */
503
502
  export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
504
503
  `A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
@@ -510,10 +509,10 @@ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages =
510
509
  // misses a page whose file list has drifted or that documents a CONCEPT rather
511
510
  // than a directory. This is a hint to CHECK, never a list to trust.
512
511
  (predictedPages.length
513
- ? `When this work was planned, these vault pages were expected to go stale.\n` +
514
- `Treat it as a lead, not a fact — verify each against the code before\n` +
515
- `editing, and ignore any that turned out to be unaffected:\n` +
516
- `${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`
517
516
  : '') +
518
517
  `Follow your instructions: update the touched vault pages (and any docs/\n` +
519
518
  `chapter that covers them), append the feature-history entry to log.md,\n` +