@feigi/fleet-ctl 3.18.10 → 3.18.12
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/package.json +1 -1
- package/scripts/citation-sweep-prose.test.mjs +1 -1
- package/scripts/claim-lifecycle.test.mjs +2 -1
- package/scripts/ledger.mjs +143 -147
- package/scripts/member-record.mjs +51 -53
- package/scripts/reap.test.mjs +19 -13
- package/scripts/release-ticket.test.mjs +9 -7
- package/scripts/worktree-porcelain.mjs +35 -0
- package/scripts/worktree-porcelain.test.mjs +102 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The member-telemetry adapter
|
|
1
|
+
// The member-telemetry adapter. ONE per-member
|
|
2
2
|
// record shape, TWO readers a tree walk chooses by content, never by
|
|
3
3
|
// caller-supplied config: board.mjs (the live spend panel) and
|
|
4
4
|
// member-outcomes.mjs (the scraper) build on the primitives here rather than
|
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
// — no pricing table exists in this repo, and inventing one is not this
|
|
14
14
|
// module's business.
|
|
15
15
|
//
|
|
16
|
-
// `thinking` is the harness-written level, blank when a hole is visible
|
|
17
|
-
//
|
|
18
|
-
// DISPATCHED AS — the agent DEFINITION the dispatch named, never the
|
|
16
|
+
// `thinking` is the harness-written level, blank when a hole is visible.
|
|
17
|
+
// `subagent_type` is what the member was DISPATCHED AS — the agent DEFINITION the dispatch named, never the
|
|
19
18
|
// member's own name: it is the only field that separates a deliberate
|
|
20
19
|
// alternate-tier pair from two members whose models already happened to
|
|
21
20
|
// differ, and both readers keep it because it is derived rather than
|
|
@@ -46,7 +45,7 @@ import { classifyRole, canonicalMemberName, CANONICAL_MEMBER_NAME_PREFIXES } fro
|
|
|
46
45
|
// (`.claude` -> `-.claude`); non-home paths are realpath-resolved (so `/tmp/x`,
|
|
47
46
|
// a symlink to `/private/tmp/x` on macOS, encodes under the resolved name)
|
|
48
47
|
// and double-dash-wrapped. Both forms are measured against real
|
|
49
|
-
// `~/.omp/agent/sessions/*` directory names
|
|
48
|
+
// `~/.omp/agent/sessions/*` directory names - a
|
|
50
49
|
// `~/dev/fleet-plugin`, `--private-tmp-fx685-scratch--` all exist on disk
|
|
51
50
|
// today.
|
|
52
51
|
export function encodeProjectDir(cwd, { home = process.env.HOME, realpath = realpathSync } = {}) {
|
|
@@ -81,7 +80,7 @@ export function normalizeModel(raw) {
|
|
|
81
80
|
|
|
82
81
|
// A member's name is the only place its unit of work is recorded — there is
|
|
83
82
|
// no dispatch sidecar at all (see ompMemberRecord). The one exception is a
|
|
84
|
-
// PR the member OPENED
|
|
83
|
+
// PR the member OPENED: an `impl-<ticket>` name can never carry it,
|
|
85
84
|
// so ompMemberRecord reads it off the member's own `gh pr create` result
|
|
86
85
|
// (createdPrNumbers) — this function still answers `pr: ""` for that name.
|
|
87
86
|
//
|
|
@@ -91,7 +90,7 @@ export function normalizeModel(raw) {
|
|
|
91
90
|
// members their join key to tier-outcomes.tsv. The fix-pr-<n> and
|
|
92
91
|
// review-pr-<n> families share the first pattern only because the infix is
|
|
93
92
|
// the same — they are NOT finisher spellings. `finisher-pr-<n>` is the
|
|
94
|
-
// canonical name run-team now fixes
|
|
93
|
+
// canonical name run-team now fixes; the other three stay matched
|
|
95
94
|
// because the runs that used them are already in the record.
|
|
96
95
|
//
|
|
97
96
|
// merge-bot-<n> is deliberately excluded: its number is a per-run dispatch
|
|
@@ -101,7 +100,7 @@ export function normalizeModel(raw) {
|
|
|
101
100
|
// re-dispatched member works the same unit. A trailing `-v<n>` (`-v2`,
|
|
102
101
|
// `-v10`, ...) is a DIFFERENT spelling of the same re-dispatch, used when a
|
|
103
102
|
// controller re-dispatches a finisher/reviewer against a PR whose head moved
|
|
104
|
-
// after label (
|
|
103
|
+
// after label (measured: ~67% of one finisher's tokens fell through
|
|
105
104
|
// to a blank pr column under the old letter-only regex); it is stripped for
|
|
106
105
|
// the same reason, not because it looks like a second-ticket suffix — no
|
|
107
106
|
// naming convention in this repo otherwise uses a literal `-v` + digits
|
|
@@ -120,28 +119,28 @@ export function normalizeModel(raw) {
|
|
|
120
119
|
// ticket the way `impl-<ticket>-<n>`'s can — there is only ever one PR per
|
|
121
120
|
// such name. A trailing `-\d+` there (`finisher-pr-1440-2`, `fix-pr-1281-2`)
|
|
122
121
|
// is the same re-dispatch retry the letter and `-v<n>` suffixes above
|
|
123
|
-
// already cover, and is now stripped for PR-shaped names only
|
|
124
|
-
// measured: 476,202 cache-create tokens across 7 real rows fell through to a
|
|
122
|
+
// already cover, and is now stripped for PR-shaped names only
|
|
123
|
+
// (measured: 476,202 cache-create tokens across 7 real rows fell through to a
|
|
125
124
|
// blank pr column this way — more than the 37,580 the `-v<n>` fix above
|
|
126
125
|
// addressed). The ticket-shaped `impl-<ticket>-<n>` family above is
|
|
127
126
|
// untouched: its second-ticket ambiguity is real, and a PR-shaped name's is
|
|
128
127
|
// not.
|
|
129
128
|
//
|
|
130
|
-
// `resolve` joins the `-pr-` alternation
|
|
129
|
+
// `resolve` joins the `-pr-` alternation: `resolve-pr-<n>` is a
|
|
131
130
|
// controller-dispatched conflict/rebase resolver against an already-open PR
|
|
132
|
-
// (measured session descriptions: "Resolve conflict on PR
|
|
133
|
-
// and resolve conflicts for PR
|
|
131
|
+
// (measured session descriptions: "Resolve conflict on PR <n>", "Rebase
|
|
132
|
+
// and resolve conflicts for PR #<n>") — the same shape as `fix-pr-<n>`'s
|
|
134
133
|
// applier and `review-pr-<n>`'s reviewer, just not a name run-team's own
|
|
135
134
|
// naming convention fixes, so it stays out of the canonical list in
|
|
136
135
|
// SKILL.md/member-lifecycle.md the same way `finish-<n>`/`finisher-<n>` do.
|
|
137
136
|
// Unrecognised, a `resolve-pr-<n>` member fell through to `{ticket:"",
|
|
138
137
|
// pr:""}`, losing its join key into tier-outcomes.tsv exactly the way an
|
|
139
|
-
// unmatched finisher spelling once did
|
|
138
|
+
// unmatched finisher spelling once did.
|
|
140
139
|
//
|
|
141
140
|
// Every pattern below is written against the lower-kebab spelling, so the
|
|
142
141
|
// name is first rewritten by `canonicalMemberName` (compute-spend.mjs), the
|
|
143
142
|
// same rewrite classifyRole applies: `Impl327` and `FixPr774` book the
|
|
144
|
-
// ticket and PR their kebab forms do instead of blanks
|
|
143
|
+
// ticket and PR their kebab forms do instead of blanks.
|
|
145
144
|
export function parseMemberName(name) {
|
|
146
145
|
const s = canonicalMemberName(name).replace(/-(?:[a-z]|v\d+)$/, "");
|
|
147
146
|
let m = /^(?:fix|review|finish(?:er)?|resolve)-pr-(\d+)(?:-\d+)?$/.exec(s);
|
|
@@ -161,7 +160,7 @@ export function parseMemberName(name) {
|
|
|
161
160
|
// lives in the DIRECTORY name, never per line; omp's envelope is
|
|
162
161
|
// `{type,id,parentId,timestamp,message}`. That is the structural signature
|
|
163
162
|
// this reader refuses on if it is ever absent — a validation CHECK inside
|
|
164
|
-
// this reader
|
|
163
|
+
// this reader: every root this module is ever handed is
|
|
165
164
|
// an omp `~/.omp/agent/sessions/**` tree, so a line failing this shape
|
|
166
165
|
// signals a corrupted or foreign file, not a harness to dispatch to.
|
|
167
166
|
//
|
|
@@ -185,7 +184,7 @@ function assertOmpShaped(d, filePath) {
|
|
|
185
184
|
}
|
|
186
185
|
}
|
|
187
186
|
|
|
188
|
-
// `openedPrs`
|
|
187
|
+
// `openedPrs` is every PR number the member's OWN `gh pr create`
|
|
189
188
|
// printed — the source of `pr` for a member whose name carries none, which is
|
|
190
189
|
// every implementer (`impl-<ticket>` names a ticket, never a PR). Chosen over
|
|
191
190
|
// the ledger's `impl-<N>=PR#M` settle token because that token is written by a
|
|
@@ -195,8 +194,8 @@ function assertOmpShaped(d, filePath) {
|
|
|
195
194
|
// transcripts printed a created PR from a `bash` toolCall whose result held
|
|
196
195
|
// gh's stdout — the URL on a line of its own — then omp's "Wall time"
|
|
197
196
|
// trailer. A create is also sometimes run via `eval` instead of `bash` —
|
|
198
|
-
// measured on impl-1113 (a Python subprocess, `gh pr create` printing PR
|
|
199
|
-
//
|
|
197
|
+
// measured on impl-1113 (a Python subprocess, `gh pr create` printing a PR
|
|
198
|
+
// URL) — and stays blank there too: `eval` is not read, because a cell
|
|
200
199
|
// that only READS transcripts (or `gh pr view`s) prints the very same URLs.
|
|
201
200
|
//
|
|
202
201
|
// Three filters keep a URL that is NOT a PR this member opened out:
|
|
@@ -205,7 +204,7 @@ function assertOmpShaped(d, filePath) {
|
|
|
205
204
|
// keyword, optionally through `rtk`/`env`/`command`/`timeout <n>`, a
|
|
206
205
|
// `NAME=value` prefix, or a path-qualified `.../gh` — a `gh pr view`/
|
|
207
206
|
// `gh pr list` result prints bare PR URLs too, and `gh pr create` quoted
|
|
208
|
-
// as text is no invocation (measured false-miss shapes
|
|
207
|
+
// as text is no invocation (measured false-miss shapes);
|
|
209
208
|
// - the URL must be a LINE of its own, the shape gh prints, never a URL
|
|
210
209
|
// inside prose;
|
|
211
210
|
// - a URL right after gh's "... already exists:" refusal is skipped: that PR
|
|
@@ -225,7 +224,7 @@ function assertOmpShaped(d, filePath) {
|
|
|
225
224
|
// `view` copy of an "already exists:" URL is not caught by the one-line-back
|
|
226
225
|
// skip above. Measured 0 real occurrences of any of these across the whole
|
|
227
226
|
// local `~/.omp/agent/sessions/**/*.jsonl` corpus and every filled `pr` in
|
|
228
|
-
// the committed TSV
|
|
227
|
+
// the committed TSV; accepted as known gaps rather than
|
|
229
228
|
// hand-rolling shell-quote/heredoc awareness into a single regex.
|
|
230
229
|
const GH_PR_CREATE_RE =
|
|
231
230
|
/(?:^|[;&|(\n]|\b(?:if|then|do|!)\s)\s*(?:(?:env|command|rtk|timeout\s+\S+)\s+|[A-Za-z_]\w*=\S*\s+)*(?:\S*\/)?gh\s+pr\s+create\b/;
|
|
@@ -253,22 +252,22 @@ function createdPrNumbers(text) {
|
|
|
253
252
|
// "cacheRead":0,"cacheWrite":0.0783,"total":0.0803}}}}
|
|
254
253
|
//
|
|
255
254
|
// `thinking` reads the harness-WRITTEN level, never the frontmatter — that
|
|
256
|
-
// is what makes
|
|
257
|
-
//
|
|
258
|
-
// record's `-`) when no `thinking_level_change` line exists, rather than
|
|
255
|
+
// is what makes a declared-vs-resolved comparison possible. It stays `null`
|
|
256
|
+
// here (ompMemberRecord turns that into the record's `-`) when no `thinking_level_change` line exists, rather than
|
|
259
257
|
// guessing the default: a member that is not a fleet definition genuinely
|
|
260
258
|
// has no recorded level, and the hole must stay visible.
|
|
261
259
|
//
|
|
262
260
|
// `session_init.task` is the closest thing to a dispatch sidecar this
|
|
263
261
|
// module has — there is no separate description file at all.
|
|
264
262
|
// ompMemberRecord below uses it, together with the transcript's own nesting
|
|
265
|
-
// depth AND the AgentId itself, as REAL classifyRole() signals.
|
|
266
|
-
//
|
|
267
|
-
// `
|
|
268
|
-
// `
|
|
269
|
-
// pair that names nothing
|
|
263
|
+
// depth AND the AgentId itself, as REAL classifyRole() signals. The AgentId
|
|
264
|
+
// is passed as `memberName` whatever its shape (the RULED paragraph below): a
|
|
265
|
+
// canonically-stemmed one (`impl-<n>`, `fix-pr-<n>`, `finisher-<n>`,
|
|
266
|
+
// `review-pr-<n>`, `merge-bot-<n>`) matches a name-driven branch, while a
|
|
267
|
+
// generated word pair that names nothing satisfies no hyphen-anchored or
|
|
268
|
+
// multi-word branch, so it falls through to the name-plus-description blend.
|
|
270
269
|
//
|
|
271
|
-
// `resolvedModelIdentity`
|
|
270
|
+
// `resolvedModelIdentity` is `session_init`'s OWN field, written at
|
|
272
271
|
// DISPATCH — before the member's first assistant turn exists, which is what
|
|
273
272
|
// makes it different from `model` above: a member still working folds to
|
|
274
273
|
// `model: null` (no assistant turn yet) but already carries
|
|
@@ -280,14 +279,14 @@ function createdPrNumbers(text) {
|
|
|
280
279
|
// per-turn value (which can in principle change mid-run) is the fact they
|
|
281
280
|
// want, not the dispatch-time identity.
|
|
282
281
|
//
|
|
283
|
-
// `agent`
|
|
282
|
+
// `agent` is `session_init`'s dispatch-time record of WHICH AGENT
|
|
284
283
|
// DEFINITION this member is. Measured 2026-09-12 across real
|
|
285
284
|
// `~/.omp/agent/sessions/**`: present on every one of the 1,191 transcripts
|
|
286
285
|
// carrying a `session_init` line (`fleet-implementer` 51,
|
|
287
286
|
// `fleet-implementer-alt` 17, the default `task` 220, plus the review
|
|
288
287
|
// fan-out's own definitions), absent only where the line itself is.
|
|
289
288
|
//
|
|
290
|
-
// `entries`
|
|
289
|
+
// `entries` is the per-agent tool stream compute-spend.mjs's
|
|
291
290
|
// attributeTools reads: `assistant`/`result` entries. Measured 2026-09-26
|
|
292
291
|
// across 5,085 real `~/.omp/agent/sessions/**/*.jsonl` files, the call is a
|
|
293
292
|
// block on the assistant message and the result a line of its own:
|
|
@@ -323,7 +322,7 @@ function createdPrNumbers(text) {
|
|
|
323
322
|
// reached the API its usage is all zero, and attributeTools books the batch
|
|
324
323
|
// before it at 0.
|
|
325
324
|
//
|
|
326
|
-
// `openedPrs`
|
|
325
|
+
// `openedPrs` folds the `bash` calls that invoke `gh pr create`
|
|
327
326
|
// against their own results, via createdPrNumbers above — distinct PR
|
|
328
327
|
// numbers in first-seen order.
|
|
329
328
|
export function foldOmpTranscript(jsonlText, filePath) {
|
|
@@ -342,7 +341,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
342
341
|
let d;
|
|
343
342
|
// A torn tail (transcript read mid-write) is dropped, and costs at most
|
|
344
343
|
// that one turn either way. A MIDDLE line failing the same parse is not
|
|
345
|
-
// a live write in progress — it is lost data, and
|
|
344
|
+
// a live write in progress — it is lost data, and it can desync
|
|
346
345
|
// a toolCall from its toolResult, not just a turn's totals, so it is
|
|
347
346
|
// counted, never for the last line.
|
|
348
347
|
try { d = JSON.parse(raw); }
|
|
@@ -404,7 +403,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
404
403
|
// One member record from one omp transcript's fold. `member` is the AgentId
|
|
405
404
|
// (the filename stem, e.g. `InstallVerifySearch`) — there is no separate
|
|
406
405
|
// display name, so `ticket`/`pr` extraction runs against it directly — except
|
|
407
|
-
// a `pr` the name cannot carry, read off the fold's `openedPrs
|
|
406
|
+
// a `pr` the name cannot carry, read off the fold's `openedPrs`.
|
|
408
407
|
//
|
|
409
408
|
// `role` is NEVER guessed off the bare AgentId ALONE — a generated CamelCase
|
|
410
409
|
// word pair names nothing classifyRole can read. FOUR real signals exist:
|
|
@@ -412,16 +411,16 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
412
411
|
// `session_init.task` (the dispatch prompt, when present), `spawnDepth` (the
|
|
413
412
|
// transcript's own nesting depth, supplied by readOmpSession from the walk —
|
|
414
413
|
// a fact about where the file lives, not a guess about what it is), and —
|
|
415
|
-
// per
|
|
414
|
+
// per the RULED paragraph below — the AgentId itself, handed to classifyRole as
|
|
416
415
|
// `memberName`. Depth matters on its own: classifyRole checks depth BEFORE
|
|
417
416
|
// any text match, specifically so a nested member whose task happens to
|
|
418
|
-
// read like a reviewer's ("Review PR
|
|
417
|
+
// read like a reviewer's ("Review PR <n> correctness") still books as the
|
|
419
418
|
// fan-out specialist it structurally is, not a reviewer. None present
|
|
420
419
|
// yields `"-"` — the same visible-hole spelling as `thinking`, never a
|
|
421
420
|
// default like "other", which only makes sense where a real dispatch record
|
|
422
421
|
// backs it.
|
|
423
422
|
//
|
|
424
|
-
// `agent` reaches classifyRole as its `agentDefinition
|
|
423
|
+
// `agent` reaches classifyRole as its `agentDefinition`. It
|
|
425
424
|
// is the same value the `subagent_type` column below already records, and
|
|
426
425
|
// withholding it here made classifyRole's FIRST branch — the one whose
|
|
427
426
|
// comment says memory-system work must "never land in review spend" —
|
|
@@ -431,7 +430,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
431
430
|
// definition (`fleet-review-verifier`) split across four buckets on nothing
|
|
432
431
|
// but how each prompt happened to read.
|
|
433
432
|
//
|
|
434
|
-
//
|
|
433
|
+
// The gap this closes: `memberName` used to be left unset here on the theory that
|
|
435
434
|
// the AgentId genuinely is not a name — a generated CamelCase word pair
|
|
436
435
|
// (`InstallVerifySearch`) names nothing the classifier can read. True for
|
|
437
436
|
// the ordinary case, but false for members dispatched under run-team's own
|
|
@@ -441,7 +440,7 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
441
440
|
// dispatched under the generic default `task` definition (`folded.agent` is
|
|
442
441
|
// `undefined`), so the definition-based fix above cannot reach them, and
|
|
443
442
|
// they fall through to prose classification of `folded.task` alone.
|
|
444
|
-
// Measured against docs/metrics/member-outcomes.tsv
|
|
443
|
+
// Measured against docs/metrics/member-outcomes.tsv: 478 omp rows
|
|
445
444
|
// carry such an AgentId, 52 of them booked `other` for want of this signal —
|
|
446
445
|
// 31 `fix-pr-*`, 9 `impl-*` and 12 `merge-bot-*` members whose dispatch
|
|
447
446
|
// prompt never happens to name the role.
|
|
@@ -452,9 +451,9 @@ export function foldOmpTranscript(jsonlText, filePath) {
|
|
|
452
451
|
// multi-word phrase ("implement ticket", "review pr"), so a stray generated
|
|
453
452
|
// word pair cannot coincidentally satisfy one; the one bare-word pattern,
|
|
454
453
|
// `finisher`, carries that risk today and has not fired on the 4,574
|
|
455
|
-
// sidecars measured
|
|
454
|
+
// sidecars measured. `OMP_CANONICAL_STEM_RE` below exists only to
|
|
456
455
|
// widen `hasRoleSignal` itself: a canonically-named member whose transcript
|
|
457
|
-
// predates
|
|
456
|
+
// predates `session_init` (no such line at all, so neither `task` nor
|
|
458
457
|
// `agent` exist) still holds a readable identity and must not fall back to
|
|
459
458
|
// the "-" hole. Measured 2026-09-16: zero such rows on disk today, but the
|
|
460
459
|
// gate exists so the design does not assume that stays true forever. Built
|
|
@@ -471,7 +470,7 @@ export function ompMemberRecord(folded, agentStem, spawnDepth = 0) {
|
|
|
471
470
|
const member = agentStem;
|
|
472
471
|
const { ticket, pr: namedPr } = parseMemberName(member);
|
|
473
472
|
// A name-carried PR (`fix-pr-<n>`, `finisher-<n>`, ...) is the member's unit
|
|
474
|
-
// of work by construction and wins. Otherwise
|
|
473
|
+
// of work by construction and wins. Otherwise the one PR its own
|
|
475
474
|
// `gh pr create` printed; none, or more than one (measured: impl-1578 opened
|
|
476
475
|
// its real PR plus a throwaway probe PR), stays blank — never a guess.
|
|
477
476
|
// `folded.openedPrs` is `foldOmpTranscript`'s own unconditional return
|
|
@@ -487,11 +486,11 @@ export function ompMemberRecord(folded, agentStem, spawnDepth = 0) {
|
|
|
487
486
|
role,
|
|
488
487
|
member,
|
|
489
488
|
model: folded.model,
|
|
490
|
-
// Additive only
|
|
489
|
+
// Additive only — `model` above stays the per-turn value
|
|
491
490
|
// board.mjs/member-outcomes.mjs already key cost/spend attribution on;
|
|
492
491
|
// this is the dispatch-time identity `session_init` wrote before any
|
|
493
|
-
// turn existed, `null` when the transcript
|
|
494
|
-
//
|
|
492
|
+
// turn existed, `null` when the transcript carries no `session_init`
|
|
493
|
+
// line at all (never guessed).
|
|
495
494
|
resolvedModelIdentity: folded.resolvedModelIdentity ?? null,
|
|
496
495
|
thinking: folded.thinking ?? "-",
|
|
497
496
|
// The dispatch record's own agent definition, `""` when the transcript
|
|
@@ -507,7 +506,7 @@ export function ompMemberRecord(folded, agentStem, spawnDepth = 0) {
|
|
|
507
506
|
|
|
508
507
|
// ompMemberRecord straight from the transcript text. board.mjs's live spend
|
|
509
508
|
// panel calls the two halves itself instead, because it needs the fold's tool
|
|
510
|
-
// stream
|
|
509
|
+
// stream beside the record, and folding the file twice for it would
|
|
511
510
|
// parse every transcript twice on every tick.
|
|
512
511
|
export function readOmpMember(jsonlText, filePath, agentStem, spawnDepth = 0) {
|
|
513
512
|
return ompMemberRecord(foldOmpTranscript(jsonlText, filePath), agentStem, spawnDepth);
|
|
@@ -515,7 +514,7 @@ export function readOmpMember(jsonlText, filePath, agentStem, spawnDepth = 0) {
|
|
|
515
514
|
|
|
516
515
|
// One omp session directory's member transcripts, as the walk both readers of
|
|
517
516
|
// that directory need it: readOmpSession below, and board.mjs's live spend
|
|
518
|
-
// panel
|
|
517
|
+
// panel, which folds each file itself so it can keep its own
|
|
519
518
|
// per-transcript skip tally. RECURSIVE: a member can itself dispatch further
|
|
520
519
|
// members (measured on disk — a research session's `Facts1303/` held seven
|
|
521
520
|
// more `.jsonl` files one level down), and `agent` is the path-relative stem
|
|
@@ -539,9 +538,8 @@ export function ompSessionTranscripts(sessionDir) {
|
|
|
539
538
|
//
|
|
540
539
|
// Deliberately NOT wrapped in a blanket try/catch around readOmpMember: the
|
|
541
540
|
// wrong-shape refusal (assertOmpShaped, inside foldOmpTranscript) must
|
|
542
|
-
// propagate all the way out of readMembers, uncaught
|
|
543
|
-
//
|
|
544
|
-
// ordinary per-member tolerance.
|
|
541
|
+
// propagate all the way out of readMembers, uncaught. Only the directory
|
|
542
|
+
// listing and the raw file read are given the ordinary per-member tolerance.
|
|
545
543
|
export function readOmpSession(sessionDir) {
|
|
546
544
|
let transcripts;
|
|
547
545
|
try { transcripts = ompSessionTranscripts(sessionDir); }
|
|
@@ -567,7 +565,7 @@ export function readOmpSession(sessionDir) {
|
|
|
567
565
|
// per-session directories, so `findOmpSessionDirs` recursing from the
|
|
568
566
|
// encoded-cwd dir hit the `.jsonl` test there first, returned the whole
|
|
569
567
|
// project as "one session", stamped every row `session=-dev-fleet-plugin`
|
|
570
|
-
// instead of the `<ISO>_<uuid>` name
|
|
568
|
+
// instead of the `<ISO>_<uuid>` name the row key is built on, and booked
|
|
571
569
|
// the controller's own top-level transcripts as members. Name-only matching
|
|
572
570
|
// costs nothing a real fixture needs: every fixture in this repo already
|
|
573
571
|
// names its session dir in the real shape.
|
|
@@ -595,7 +593,7 @@ function findOmpSessionDirs(root) {
|
|
|
595
593
|
|
|
596
594
|
// The adapter's public entry point. A root that resolves to no session
|
|
597
595
|
// directory anywhere is refused rather than silently skipped: silently
|
|
598
|
-
// returning no rows for a typo'd path is the blackout
|
|
596
|
+
// returning no rows for a typo'd path is the blackout this refusal exists
|
|
599
597
|
// to prevent.
|
|
600
598
|
export function readMembers(roots) {
|
|
601
599
|
const rows = [];
|
package/scripts/reap.test.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { join } from "node:path";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { slowTransport, SSH_URL } from "./slow-transport.mjs";
|
|
19
19
|
import { writeExecStub } from "./exec-stub.mjs";
|
|
20
|
+
import { hasAttribute, worktreeNames } from "./worktree-porcelain.mjs";
|
|
20
21
|
|
|
21
22
|
const SCRIPT = fileURLToPath(new URL("./reap.sh", import.meta.url));
|
|
22
23
|
|
|
@@ -43,6 +44,9 @@ const ENV = {
|
|
|
43
44
|
const git = (cwd, ...args) =>
|
|
44
45
|
execFileSync("git", args, { cwd, env: ENV, encoding: "utf8" }).trim();
|
|
45
46
|
|
|
47
|
+
/** worktreeNames() of `w`'s listing: never its paths, which carry TMPDIR (#2531). */
|
|
48
|
+
const listedNames = (w) => worktreeNames(git(w, "worktree", "list", "--porcelain"));
|
|
49
|
+
|
|
46
50
|
// Absolute path to the real git, for any test that shadows `git` on PATH.
|
|
47
51
|
const REAL_GIT = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
|
|
48
52
|
|
|
@@ -746,14 +750,14 @@ test("a successful --apply run still reaps, still prunes, and exits 0 unchanged"
|
|
|
746
750
|
const wtDir = join(w, "..", "stale-wt");
|
|
747
751
|
git(w, "worktree", "add", "-q", "-b", "scratch/stale", wtDir, "main");
|
|
748
752
|
rmSync(wtDir, { recursive: true, force: true });
|
|
749
|
-
assert.
|
|
753
|
+
assert.ok(listedNames(w).includes("stale-wt"), "fixture must start with a prunable registration");
|
|
750
754
|
|
|
751
755
|
const { code, json } = runReap(w, ["--apply"]);
|
|
752
756
|
|
|
753
757
|
assert.equal(code, 0);
|
|
754
758
|
assert.deepEqual(json.reaped, ["feature/merged"]);
|
|
755
759
|
assert.deepEqual(json.kept, []);
|
|
756
|
-
assert.
|
|
760
|
+
assert.ok(!listedNames(w).includes("stale-wt"), "the prune must still run and clear the stale registration");
|
|
757
761
|
});
|
|
758
762
|
|
|
759
763
|
// The other half of the same guard, and the half no test had: the default
|
|
@@ -1307,7 +1311,7 @@ test("a [gone] branch whose worktree directory was deleted by hand is reaped, ne
|
|
|
1307
1311
|
const w = repo(t);
|
|
1308
1312
|
const wt = mergedGoneBranchWithWorktree(w, "feature/merged", "merged work");
|
|
1309
1313
|
rmSync(wt, { recursive: true, force: true });
|
|
1310
|
-
assert.
|
|
1314
|
+
assert.ok(listedNames(w).includes("feature/merged"), "fixture: the stale registration must still be listed");
|
|
1311
1315
|
|
|
1312
1316
|
const { code, json } = runReap(w, ["--apply"]);
|
|
1313
1317
|
|
|
@@ -1315,7 +1319,7 @@ test("a [gone] branch whose worktree directory was deleted by hand is reaped, ne
|
|
|
1315
1319
|
assert.deepEqual(json.reaped, ["feature/merged"], "a directory that is not there holds no work to protect");
|
|
1316
1320
|
assert.deepEqual(json.kept, []);
|
|
1317
1321
|
assert.equal(branchExists(w, "feature/merged"), false);
|
|
1318
|
-
assert.
|
|
1322
|
+
assert.ok(!listedNames(w).includes("feature/merged"), "the stale registration must not survive the run");
|
|
1319
1323
|
});
|
|
1320
1324
|
|
|
1321
1325
|
test("a dry run and --apply agree about a deleted worktree directory (#128)", (t) => {
|
|
@@ -2010,7 +2014,7 @@ test("a refusal that LEFT the registration in place is reported as such, distinc
|
|
|
2010
2014
|
assert.doesNotMatch(json.kept[0].reason, /\n/, "git's message here really is two lines — it must arrive flattened");
|
|
2011
2015
|
assert.match(stderr, /KEEP feature\/merged/);
|
|
2012
2016
|
|
|
2013
|
-
assert.
|
|
2017
|
+
assert.ok(listedNames(w).includes("feature/merged"), "the registration really did survive");
|
|
2014
2018
|
assert.equal(existsSync(wt), true);
|
|
2015
2019
|
assert.equal(branchExists(w, "feature/merged"), true);
|
|
2016
2020
|
});
|
|
@@ -2543,9 +2547,9 @@ test("the dry run removes nothing, and still cannot predict a refusal (#391)", (
|
|
|
2543
2547
|
// Nothing was touched: directories, registrations and branches all intact.
|
|
2544
2548
|
assert.equal(existsSync(healthy), true, "a dry run must not remove the worktree it COULD have removed");
|
|
2545
2549
|
assert.equal(existsSync(locked), true);
|
|
2546
|
-
const reg =
|
|
2547
|
-
assert.
|
|
2548
|
-
assert.
|
|
2550
|
+
const reg = listedNames(w);
|
|
2551
|
+
assert.ok(reg.includes("feature/a-healthy"));
|
|
2552
|
+
assert.ok(reg.includes("feature/b-locked"));
|
|
2549
2553
|
assert.equal(branchExists(w, "feature/a-healthy"), true);
|
|
2550
2554
|
assert.equal(branchExists(w, "feature/b-locked"), true);
|
|
2551
2555
|
});
|
|
@@ -2663,7 +2667,8 @@ test("a detached worktree whose HEAD is merged is removed, not walked past (#381
|
|
|
2663
2667
|
const w = repo(t);
|
|
2664
2668
|
const wt = detachedMergedWorktree(w, "docs/79-brief", "work that landed");
|
|
2665
2669
|
assert.ok(existsSync(wt), "fixture");
|
|
2666
|
-
assert.
|
|
2670
|
+
assert.ok(hasAttribute(git(w, "worktree", "list", "--porcelain"), "detached"), "fixture: the worktree must really be detached");
|
|
2671
|
+
assert.ok(listedNames(w).includes("79-brief"), "fixture: the worktree must start out listed");
|
|
2667
2672
|
|
|
2668
2673
|
const { code, json, stderr } = runReap(w, ["--apply"]);
|
|
2669
2674
|
|
|
@@ -2674,7 +2679,7 @@ test("a detached worktree whose HEAD is merged is removed, not walked past (#381
|
|
|
2674
2679
|
assert.ok(stderr.includes(` REMOVED worktree ${wt}`), `the removal must be reported: ${stderr}`);
|
|
2675
2680
|
// The registration goes with it, or the in-flight probe still reads the
|
|
2676
2681
|
// ticket as taken — the whole cost this ticket exists to stop.
|
|
2677
|
-
assert.
|
|
2682
|
+
assert.ok(!listedNames(w).includes("79-brief"));
|
|
2678
2683
|
});
|
|
2679
2684
|
|
|
2680
2685
|
test("a branchless sweep whose awk cannot read the listing declines, never sweeps silently (#993)", (t) => {
|
|
@@ -2798,7 +2803,7 @@ test("a detached worktree whose HEAD git cannot resolve is kept, never swept (#3
|
|
|
2798
2803
|
writeFileSync(join(adminEntry(w, wt), "HEAD"), "not an object id\n");
|
|
2799
2804
|
assert.match(
|
|
2800
2805
|
git(w, "worktree", "list", "--porcelain"),
|
|
2801
|
-
|
|
2806
|
+
/^HEAD 0{40}$/m,
|
|
2802
2807
|
"fixture: git must report the null object id for this worktree",
|
|
2803
2808
|
);
|
|
2804
2809
|
|
|
@@ -2958,14 +2963,15 @@ test("a detached worktree whose directory was deleted by hand still has its regi
|
|
|
2958
2963
|
const w = repo(t);
|
|
2959
2964
|
const wt = detachedMergedWorktree(w, "docs/79-brief", "work that landed");
|
|
2960
2965
|
rmSync(wt, { recursive: true, force: true });
|
|
2961
|
-
assert.
|
|
2966
|
+
assert.ok(hasAttribute(git(w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the stale registration must still be listed");
|
|
2967
|
+
assert.ok(listedNames(w).includes("79-brief"), "fixture: the stale registration must be listed by name before the run");
|
|
2962
2968
|
|
|
2963
2969
|
const { code, json } = runReap(w, ["--apply"]);
|
|
2964
2970
|
|
|
2965
2971
|
assert.equal(code, 0);
|
|
2966
2972
|
assert.deepEqual(json.worktreesRemoved, [wt]);
|
|
2967
2973
|
assert.deepEqual(json.kept, []);
|
|
2968
|
-
assert.
|
|
2974
|
+
assert.ok(!listedNames(w).includes("79-brief"));
|
|
2969
2975
|
});
|
|
2970
2976
|
|
|
2971
2977
|
test("a detached worktree behind an unreadable parent is kept, never removed as absent (#381, #83)", (t) => {
|
|
@@ -22,6 +22,7 @@ import { dirname, join } from "node:path";
|
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
import { slowTransport, SSH_URL } from "./slow-transport.mjs";
|
|
24
24
|
import { writeExecStub } from "./exec-stub.mjs";
|
|
25
|
+
import { hasAttribute } from "./worktree-porcelain.mjs";
|
|
25
26
|
|
|
26
27
|
const SCRIPT = fileURLToPath(new URL("./release-ticket.sh", import.meta.url));
|
|
27
28
|
const INFLIGHT = fileURLToPath(new URL("./inflight.sh", import.meta.url));
|
|
@@ -994,6 +995,7 @@ test("the locked-stray unlock hint survives a literal ' in the worktree path (#1
|
|
|
994
995
|
git(c.wt, "checkout", "-q", "--detach", "HEAD");
|
|
995
996
|
git(r.w, "worktree", "lock", c.wt, "--reason", "held by a review");
|
|
996
997
|
rmSync(c.wt, { recursive: true, force: true });
|
|
998
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"), "fixture: the stray must really be locked before the remedy runs");
|
|
997
999
|
|
|
998
1000
|
const { code, json, stderr } = release(r, c);
|
|
999
1001
|
assert.equal(code, 1, stderr);
|
|
@@ -1005,7 +1007,7 @@ test("the locked-stray unlock hint survives a literal ' in the worktree path (#1
|
|
|
1005
1007
|
execFileSync("sh", ["-c", remedy], { cwd: r.w, encoding: "utf8", env: ENV });
|
|
1006
1008
|
|
|
1007
1009
|
assert.ok(
|
|
1008
|
-
!git(r.w, "worktree", "list", "--porcelain")
|
|
1010
|
+
!hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"),
|
|
1009
1011
|
`the pasted command actually unlocked the real entry, not a truncated one: ${remedy}`,
|
|
1010
1012
|
);
|
|
1011
1013
|
});
|
|
@@ -1034,6 +1036,7 @@ test("the locked-stray unlock hint survives a plain space in the worktree path (
|
|
|
1034
1036
|
git(c.wt, "checkout", "-q", "--detach", "HEAD");
|
|
1035
1037
|
git(r.w, "worktree", "lock", c.wt, "--reason", "held by a review");
|
|
1036
1038
|
rmSync(c.wt, { recursive: true, force: true });
|
|
1039
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"), "fixture: the stray must really be locked before the remedy runs");
|
|
1037
1040
|
|
|
1038
1041
|
const { code, json, stderr } = release(r, c);
|
|
1039
1042
|
assert.equal(code, 1, stderr);
|
|
@@ -1045,7 +1048,7 @@ test("the locked-stray unlock hint survives a plain space in the worktree path (
|
|
|
1045
1048
|
execFileSync("sh", ["-c", remedy], { cwd: r.w, encoding: "utf8", env: ENV });
|
|
1046
1049
|
|
|
1047
1050
|
assert.ok(
|
|
1048
|
-
!git(r.w, "worktree", "list", "--porcelain")
|
|
1051
|
+
!hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"),
|
|
1049
1052
|
`the pasted command actually unlocked the real entry, not a truncated one: ${remedy}`,
|
|
1050
1053
|
);
|
|
1051
1054
|
});
|
|
@@ -2732,7 +2735,7 @@ test("an unrelated prunable worktree does not block release of a branch it never
|
|
|
2732
2735
|
const stale = join(r.w, "..", "stale-wt");
|
|
2733
2736
|
git(r.w, "worktree", "add", "-q", "--detach", stale, "origin/main");
|
|
2734
2737
|
rmSync(stale, { recursive: true, force: true });
|
|
2735
|
-
assert.
|
|
2738
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the stale registration must still be listed");
|
|
2736
2739
|
|
|
2737
2740
|
const { code, json, stderr } = release(r, c);
|
|
2738
2741
|
assert.equal(code, 0, stderr);
|
|
@@ -2748,7 +2751,7 @@ test("a prunable sibling that genuinely holds the claim via a stopped rebase sti
|
|
|
2748
2751
|
// no longer exists to be entered.
|
|
2749
2752
|
const { r, c, sib } = heldSibling(t, stopAtEditRebase);
|
|
2750
2753
|
rmSync(sib, { recursive: true, force: true });
|
|
2751
|
-
assert.
|
|
2754
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the sibling's directory must be gone, its registration still there");
|
|
2752
2755
|
|
|
2753
2756
|
const { code, json, stderr } = release(r, c);
|
|
2754
2757
|
assert.equal(code, 2, stderr);
|
|
@@ -3605,9 +3608,8 @@ test("a worktree with no surviving ancestor below / still releases, never a perm
|
|
|
3605
3608
|
// died BEFORE it, so a fix that merely downgraded that `die` to a warning
|
|
3606
3609
|
// would exit 0 here too, with the stale registration still standing and the
|
|
3607
3610
|
// claim reported released — the same false success from the other side.
|
|
3608
|
-
assert.
|
|
3609
|
-
git(r.w, "worktree", "list", "--porcelain"),
|
|
3610
|
-
/nonexistent-top-level-178/,
|
|
3611
|
+
assert.ok(
|
|
3612
|
+
!git(r.w, "worktree", "list", "--porcelain").split("\n").includes(`worktree ${dest}`),
|
|
3611
3613
|
"the registration must actually be gone, not merely un-refused",
|
|
3612
3614
|
);
|
|
3613
3615
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// What a test guard may ask of a `git worktree list --porcelain` listing.
|
|
2
|
+
//
|
|
3
|
+
// Every `worktree` line carries the full path, and a fixture's path starts
|
|
4
|
+
// with the operator's TMPDIR, so a guard that greps the whole listing for a
|
|
5
|
+
// fixture's name or for an attribute answers for TMPDIR as well: one holding
|
|
6
|
+
// `locked`, `stale-wt` or `79-brief` failed the guard that looks for its
|
|
7
|
+
// absence (#2531). These read only the parts a fixture controls.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The names the listing gives its worktrees: each `worktree` line's last path
|
|
11
|
+
* component, and each `branch refs/heads/` line's branch. The directories
|
|
12
|
+
* above a worktree are never a name.
|
|
13
|
+
*/
|
|
14
|
+
export const worktreeNames = (porcelain) =>
|
|
15
|
+
porcelain.split("\n").flatMap((l) =>
|
|
16
|
+
l.startsWith("worktree ") ? [l.slice("worktree ".length).replace(/.*\//, "")]
|
|
17
|
+
: l.startsWith("branch refs/heads/") ? [l.slice("branch refs/heads/".length)]
|
|
18
|
+
: []);
|
|
19
|
+
|
|
20
|
+
const ATTRIBUTES = new Set(["locked", "prunable", "detached"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether any worktree in the listing carries the attribute `attr`
|
|
24
|
+
* (`locked`, `prunable`, `detached`). git prints an attribute on a line of its
|
|
25
|
+
* own, the bare word or the word, a space and a reason, so the line is matched
|
|
26
|
+
* whole and a path containing the word never is. Any other `attr` throws: a
|
|
27
|
+
* misspelling would answer false for ever, and a guard that asserts the
|
|
28
|
+
* attribute's absence would pass on it.
|
|
29
|
+
*/
|
|
30
|
+
export const hasAttribute = (porcelain, attr) => {
|
|
31
|
+
if (!ATTRIBUTES.has(attr)) {
|
|
32
|
+
throw new Error(`hasAttribute: unknown attribute ${JSON.stringify(attr)}; one of ${[...ATTRIBUTES].join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
return porcelain.split("\n").some((l) => l === attr || l.startsWith(`${attr} `));
|
|
35
|
+
};
|