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.
- package/bin/lib/authproxy.mjs +101 -32
- package/bin/lib/claude.mjs +12 -19
- package/bin/lib/deploy.mjs +150 -15
- package/bin/lib/env.mjs +37 -24
- package/bin/lib/fleet.mjs +136 -46
- package/bin/lib/git.mjs +0 -263
- package/bin/lib/grant.mjs +24 -3
- package/bin/lib/landed.mjs +69 -22
- package/bin/lib/localSessions.mjs +85 -3
- package/bin/lib/preflight.mjs +1 -1
- package/bin/lib/preview.mjs +35 -16
- package/bin/lib/prompts.mjs +40 -23
- package/bin/lib/shipSweep.mjs +16 -5
- package/bin/lib/work.mjs +364 -55
- package/bin/lib/worktreeDiff.mjs +55 -20
- package/package.json +1 -1
package/bin/lib/landed.mjs
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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,
|
|
90
|
-
if (!
|
|
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:
|
|
94
|
-
taskIds: taskIdsFromMessage(
|
|
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
|
|
153
|
+
let walked;
|
|
116
154
|
try {
|
|
117
|
-
|
|
155
|
+
walked = walk(st.tip, ref);
|
|
118
156
|
} catch {
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
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
|
|
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 =
|
|
129
|
-
const reportedTip =
|
|
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);
|
package/bin/lib/preflight.mjs
CHANGED
package/bin/lib/preview.mjs
CHANGED
|
@@ -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
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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
|
|
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
|
-
|
|
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 {
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -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)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
319
|
+
When cards are deployed to agents, the split respects it — an 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
|
|
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,
|
|
496
|
-
`Treat
|
|
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` +
|
package/bin/lib/shipSweep.mjs
CHANGED
|
@@ -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 —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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 {
|