@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
package/scripts/ledger.mjs
CHANGED
|
@@ -6,20 +6,20 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Rows are rewritten in place, one per ticket. `filed` and `ruled` are
|
|
8
8
|
// append-only, because their whole purpose is to outlive the reasoning that
|
|
9
|
-
// produced them; `filed` appends at most one row per issue
|
|
9
|
+
// produced them; `filed` appends at most one row per issue.
|
|
10
10
|
//
|
|
11
|
-
// `dispatch`, `settle` and `drain`
|
|
11
|
+
// `dispatch`, `settle` and `drain` exist so a reader can derive every
|
|
12
12
|
// liveness count from this file alone, rather than from row text a controller
|
|
13
13
|
// typed by hand: `dispatch` writes a member's live token onto its row and
|
|
14
14
|
// appends it to `## Dispatched`, `settle` rewrites that token to
|
|
15
15
|
// `<member>=<outcome>` in both places (an implementer's settle to `PR#M` also
|
|
16
|
-
// folds the PR's own `#M` row into its ticket's
|
|
16
|
+
// folds the PR's own `#M` row into its ticket's), and `drain` writes the
|
|
17
17
|
// one marker that stops supply. The token grammar is
|
|
18
18
|
// ledger-grammar.mjs's. `## Dispatched` gains an entry per dispatch and never
|
|
19
19
|
// loses or reorders one — settling annotates an entry in place — which is what
|
|
20
20
|
// lets `merge-bot-<n>` be counted from it.
|
|
21
21
|
//
|
|
22
|
-
// Writer policy
|
|
22
|
+
// Writer policy: the controller owns run
|
|
23
23
|
// state (`row`/`settle`/`dispatch`/`drain`/`ruled`/`rotate`); filers append
|
|
24
24
|
// filings with `filed`; all writes are serialized by `<file>.lock`. Every
|
|
25
25
|
// write subcommand except `rotate` — which moves the file whole and never
|
|
@@ -45,24 +45,24 @@ const NAME = "ledger";
|
|
|
45
45
|
const AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
|
|
46
46
|
|
|
47
47
|
// die() shared with the other fleet scripts (writeSync-based, pipe-safe —
|
|
48
|
-
// see arg.mjs for the
|
|
48
|
+
// see arg.mjs for the rationale). arg()/has() themselves are
|
|
49
49
|
// NOT shared here: this file splices its flags out of argv and refuses in its
|
|
50
|
-
// own wording
|
|
50
|
+
// own wording, where arg() refuses under a message generated from the
|
|
51
51
|
// flag name.
|
|
52
52
|
//
|
|
53
|
-
// Their RULES are shared, which is a different thing
|
|
53
|
+
// Their RULES are shared, which is a different thing. isFlagLike() and
|
|
54
54
|
// hasEqualsForm() are arg.mjs's refusal rules as exported predicates; the
|
|
55
55
|
// three guards below call them and supply their own die() text. This file
|
|
56
56
|
// used to restate the expressions instead, and nothing failed when a
|
|
57
|
-
// restatement drifted from the original —
|
|
58
|
-
// fails now, and it reds even on a re-inlined copy that changes no behaviour
|
|
57
|
+
// restatement drifted from the original — a test over the shared source is
|
|
58
|
+
// what fails now, and it reds even on a re-inlined copy that changes no behaviour
|
|
59
59
|
// at all, which is the only kind a behavioural test cannot see.
|
|
60
60
|
const die = makeDie(NAME);
|
|
61
61
|
|
|
62
62
|
// The cause of a failed child process, for `check`'s repository probe and its
|
|
63
63
|
// tracker query — each captures the child's stderr instead of forwarding it, so
|
|
64
64
|
// what the child printed reaches no terminal and this string is the only copy
|
|
65
|
-
// of it
|
|
65
|
+
// of it.
|
|
66
66
|
//
|
|
67
67
|
// Trim BEFORE choosing, not after. A whitespace-only stderr is truthy, so it
|
|
68
68
|
// wins a choice made on the raw values and then trims away to nothing, leaving
|
|
@@ -87,7 +87,7 @@ function cause(...candidates) {
|
|
|
87
87
|
|
|
88
88
|
// The budget every `git` child in this file gets, in milliseconds.
|
|
89
89
|
//
|
|
90
|
-
// Both git probes ran UNBOUNDED before
|
|
90
|
+
// Both git probes ran UNBOUNDED before they were bounded, while the `gh` query between
|
|
91
91
|
// them carried 20 s. That asymmetry was the defect: measured with a `git` that
|
|
92
92
|
// answers and then never returns, `check` — the fleet's pre-filing duplicate
|
|
93
93
|
// guard — was still running at 60 s having written nothing at all to stdout,
|
|
@@ -102,8 +102,8 @@ function cause(...candidates) {
|
|
|
102
102
|
// p50 11-15 ms and max 24.7 ms, so this is ~400x the worst latency observed
|
|
103
103
|
// under load. Generous on purpose, because a probe killed while HEALTHY does
|
|
104
104
|
// not fail loudly here — defaultLedgerPath() degrades to a cwd-relative
|
|
105
|
-
// ledger and only warns (a
|
|
106
|
-
// something
|
|
105
|
+
// ledger and only warns (a fallback that predates this budget, not
|
|
106
|
+
// something the budget introduces). It also stays under the `gh` bound beside it —
|
|
107
107
|
// though "dominant" overstates that relationship: a normal invocation
|
|
108
108
|
// without `--file` pays this same budget TWICE in sequence, once here and
|
|
109
109
|
// once more in repoCheck() below, before ever reaching `gh` at all. At
|
|
@@ -135,14 +135,14 @@ const GIT_TIMEOUT_MS = gitBudget(10, process.env.LEDGER_GIT_TIMEOUT);
|
|
|
135
135
|
// is precisely the duplicate-filing guard failing open. Resolve against the
|
|
136
136
|
// git COMMON dir (shared by every worktree) rather than the cwd, via
|
|
137
137
|
// git-env.mjs's workspaceDirFromGitCommonDir() — the resolution itself is
|
|
138
|
-
// shared with fleet-state.mjs and board.mjs
|
|
138
|
+
// shared with fleet-state.mjs and board.mjs, and only the
|
|
139
139
|
// filename and the warning below are this caller's own. No canonicalisation
|
|
140
140
|
// is asked for: that is board.mjs's opt-in, and taking it here would change
|
|
141
141
|
// the path this function RETURNS (and `check` prints) without changing which
|
|
142
142
|
// file it names.
|
|
143
143
|
function defaultLedgerPath() {
|
|
144
|
-
// GIT_DIR/GIT_WORK_TREE scrubbed (
|
|
145
|
-
// probe below in this same file, this call
|
|
144
|
+
// GIT_DIR/GIT_WORK_TREE scrubbed (gitEnv()): unlike the tracker-query
|
|
145
|
+
// probe below in this same file, this call used to pass no env at all.
|
|
146
146
|
// An ambient GIT_DIR answers `--git-common-dir` for a DIFFERENT repository,
|
|
147
147
|
// so the ONE ledger every run's duplicate-filing guard reads gets resolved
|
|
148
148
|
// underneath THAT repository instead of the caller's own — measured
|
|
@@ -161,7 +161,7 @@ function defaultLedgerPath() {
|
|
|
161
161
|
// Could not resolve the shared git dir → fall back to a cwd-relative path.
|
|
162
162
|
// That re-opens the worktree fail-open this resolution exists to close (a
|
|
163
163
|
// member reads a cwd-local ledger, not the run's), so say so rather than
|
|
164
|
-
// degrading the duplicate-filing guard in silence.
|
|
164
|
+
// degrading the duplicate-filing guard in silence. This probe
|
|
165
165
|
// is bounded too, and an ETIMEDOUT stall prints byte-identical stderr to
|
|
166
166
|
// an instant "not a repository" failure without naming which — the
|
|
167
167
|
// sibling probe in runCheck() already names it via cause(); match that
|
|
@@ -189,7 +189,7 @@ const argv = process.argv.slice(2);
|
|
|
189
189
|
// process.argv, and why the scan is not anchored to a position.
|
|
190
190
|
//
|
|
191
191
|
// The same refusals arg.mjs gives the scripts that route through its arg() and
|
|
192
|
-
// has(), in this reader's own wording
|
|
192
|
+
// has(), in this reader's own wording — and that parity is
|
|
193
193
|
// the shared hasEqualsForm() predicate itself rather than a claim about two
|
|
194
194
|
// expressions that were free to drift apart.
|
|
195
195
|
// Flag names lived here AND in OWN_FLAGS below with no shared source — a
|
|
@@ -199,8 +199,8 @@ const argv = process.argv.slice(2);
|
|
|
199
199
|
// silently accepted at exit 0). One name each, declared once, reused by
|
|
200
200
|
// the indexOf() calls and OWN_FLAGS below. Not by the hasEqualsForm() calls
|
|
201
201
|
// right below, which keep their own literal text on purpose —
|
|
202
|
-
//
|
|
203
|
-
// rule
|
|
202
|
+
// a test pins that pair's source verbatim against arg.mjs's
|
|
203
|
+
// rule, so interpolating them here would fail that pin without
|
|
204
204
|
// fixing anything it exists to catch.
|
|
205
205
|
const FILE_FLAG_NAME = "file";
|
|
206
206
|
const REQUIRE_FILE_FLAG_NAME = "require-file";
|
|
@@ -208,7 +208,7 @@ if (hasEqualsForm("file", argv)) die("--file needs a space-separated value, not
|
|
|
208
208
|
if (hasEqualsForm("require-file", argv)) die("--require-file is a boolean flag, not --require-file=");
|
|
209
209
|
const fileIdx = argv.indexOf(`--${FILE_FLAG_NAME}`);
|
|
210
210
|
const file = fileIdx === -1 ? defaultLedgerPath() : argv[fileIdx + 1];
|
|
211
|
-
//
|
|
211
|
+
// `--file` took whatever token followed it, so `--file --require-file`
|
|
212
212
|
// made the FLAG the path and the splice below then ate it — `--require-file`,
|
|
213
213
|
// the flag whose entire job is to turn a missing ledger into a hard failure,
|
|
214
214
|
// silently absent, and the duplicate-filing check answering "safe to file" at
|
|
@@ -231,8 +231,8 @@ const file = fileIdx === -1 ? defaultLedgerPath() : argv[fileIdx + 1];
|
|
|
231
231
|
// The `file &&` term is load-bearing and must not fold into isFlagLike(),
|
|
232
232
|
// which answers TRUE for an absent value: without it a truly trailing `--file`
|
|
233
233
|
// would land on this clause's wording instead of the pre-existing "given with
|
|
234
|
-
// no path" one below, which is the behaviour
|
|
235
|
-
//
|
|
234
|
+
// no path" one below, which is the existing behaviour for a truly trailing
|
|
235
|
+
// `--file` and which the ledger test pins.
|
|
236
236
|
if (fileIdx !== -1 && file && isFlagLike(file)) die("--file needs a path");
|
|
237
237
|
if (fileIdx !== -1) argv.splice(fileIdx, 2);
|
|
238
238
|
if (!file) die("--file given with no path");
|
|
@@ -262,7 +262,7 @@ const headerRe = (name) => new RegExp(`(^|\\n)${name.replace(/[.*+?^${}()|[\]\\]
|
|
|
262
262
|
// first, then newline, so a `\` in entry text can never be mistaken for the
|
|
263
263
|
// start of an escape sequence introduced by this encoding. Without this, an
|
|
264
264
|
// entry containing a real newline — or a line that happens to look like
|
|
265
|
-
// `## Filed` or `-
|
|
265
|
+
// `## Filed` or `- #<issue> ...` — gets misparsed on reload: real records
|
|
266
266
|
// silently drop, or phantom ones get injected.
|
|
267
267
|
function escapeText(s) {
|
|
268
268
|
return s.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
|
|
@@ -271,7 +271,7 @@ function unescapeText(s) {
|
|
|
271
271
|
return s.replace(/\\(\\|n)/g, (_, c) => (c === "n" ? "\n" : "\\"));
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
//
|
|
274
|
+
// A `--`-prefixed token in `check`'s free-text tail used to fold
|
|
275
275
|
// straight into the duplicate-filing subject, so a misspelled flag searched
|
|
276
276
|
// for a DIFFERENT subject and read "safe to file" where the correct spelling
|
|
277
277
|
// answers ALREADY FILED at exit 1. A `--` token can legitimately BE that data
|
|
@@ -285,7 +285,7 @@ function unescapeText(s) {
|
|
|
285
285
|
// carrying a `--`-prefixed element is refused by name. Structural, not a
|
|
286
286
|
// distance threshold.
|
|
287
287
|
//
|
|
288
|
-
// `check`'s tail ALONE — narrower than
|
|
288
|
+
// `check`'s tail ALONE — narrower than the first pass, which called this
|
|
289
289
|
// from `filed`, `row` and `ruled` too. The length rule is only sound where
|
|
290
290
|
// the documented convention IS one quoted argument, and `check` is the only
|
|
291
291
|
// subcommand where it is: both places that instruct a caller to run it spell
|
|
@@ -296,36 +296,35 @@ function unescapeText(s) {
|
|
|
296
296
|
// usage strings for them. Applied there, the rule refused what the docs
|
|
297
297
|
// prescribe: measured on the tree ahead of this narrowing, `filed 999 the
|
|
298
298
|
// --basee flag is unread`, `row 42 the --basee flag is unread` and `ruled 77
|
|
299
|
-
// the --basee flag is unread` each exited 2, where the tree before
|
|
300
|
-
//
|
|
299
|
+
// the --basee flag is unread` each exited 2, where the tree before this guard
|
|
300
|
+
// existed answered all three at exit 0. The guard was ruled back to
|
|
301
301
|
// `check` rather than respelling three conventions SKILL.md carries, two
|
|
302
302
|
// other tickets holding that file open.
|
|
303
303
|
//
|
|
304
|
-
// Stated here and nowhere else
|
|
304
|
+
// Stated here and nowhere else. This paragraph is the one copy of why
|
|
305
305
|
// the rule is `check`'s alone; arg.mjs's refuseUnknown() comment and
|
|
306
|
-
// ledger
|
|
306
|
+
// the ledger test's two stray-tail blocks each used to carry their own, and
|
|
307
307
|
// now point here instead. Anything that reads on the docs or the
|
|
308
308
|
// measurement belongs in this paragraph, not beside a caller.
|
|
309
309
|
//
|
|
310
|
-
// Narrowing it does not keep
|
|
310
|
+
// Narrowing it does not keep the original guard's signature whole on those three: only the
|
|
311
311
|
// ID-slot vector survives — a stray flag one token to the LEFT is still
|
|
312
312
|
// refused by refuseStrayInId() below. The tail-slot vector does not: a
|
|
313
313
|
// stray `--` word inside filed/row/ruled's tail is now accepted as data (no
|
|
314
314
|
// rule can tell it apart from the subject a caller typed), so `filed 999
|
|
315
315
|
// --typo-flag some new subject` exits 0 and writes a row a later `check
|
|
316
316
|
// "some new subject"` cannot exact-match — a near-miss at best, the same
|
|
317
|
-
// wrong-subject shape
|
|
318
|
-
// cost of
|
|
319
|
-
// requote the docs, #1161) instead of option 1; it is why it is the
|
|
317
|
+
// wrong-subject shape the guard closed, reopened here on the tail. That is the
|
|
318
|
+
// cost of narrowing the guard rather than requoting the docs; it is why it is the
|
|
320
319
|
// DOCUMENTED convention, not the hazard, that decides where the length rule
|
|
321
320
|
// may be read at all.
|
|
322
321
|
//
|
|
323
|
-
// The cost `check` keeps is real
|
|
322
|
+
// The cost `check` keeps is real, so it is
|
|
324
323
|
// stated rather than denied: a legitimate subject carrying a `--` word
|
|
325
324
|
// anywhere, given unquoted, was accepted before this guard and is refused by
|
|
326
325
|
// it — `check the --basee flag is unread` answered at exit 0 before and exits
|
|
327
|
-
// 2 now. Quoting the subject accepts it unchanged. The trade
|
|
328
|
-
//
|
|
326
|
+
// 2 now. Quoting the subject accepts it unchanged. The trade taken
|
|
327
|
+
// is that this refusal is loud and recoverable where the wrong-subject
|
|
329
328
|
// answer it replaces was silent. One residual of that trade is not this
|
|
330
329
|
// file's to close: SKILL.md's ledger section spells `check <subject>`
|
|
331
330
|
// unquoted inside the same entry that spells `filed <issue> <subject>`, where
|
|
@@ -334,9 +333,9 @@ function unescapeText(s) {
|
|
|
334
333
|
// Narrowed on `check` too, not closed: a lone stray with no subject beside it
|
|
335
334
|
// is a one-element tail, so it is still taken as the subject and answered at
|
|
336
335
|
// exit 0. Harmless because it then searches for a string nothing matches, and
|
|
337
|
-
// ledger
|
|
336
|
+
// the ledger test's degenerate-subject case pins it so it stays deliberate.
|
|
338
337
|
//
|
|
339
|
-
//
|
|
338
|
+
// One dash short is the same slip, and the `--` test let it through —
|
|
340
339
|
// `check -require-file widget guard missing` scored "-require-file widget
|
|
341
340
|
// guard missing" and answered exit 0 where the correctly-spelled call answers
|
|
342
341
|
// ALREADY FILED at exit 1. That test cannot simply lose a dash: single-dash
|
|
@@ -345,7 +344,7 @@ function unescapeText(s) {
|
|
|
345
344
|
// negative number. Measured when this was written, 23 of the repo's 1068
|
|
346
345
|
// issue titles carry one as a whitespace-split word, 20 of them letter-led,
|
|
347
346
|
// so neither the prefix nor a `-<letter>` shape tells the slip apart. A NAME
|
|
348
|
-
// does, the way arg.mjs's stray() tells a stray from a value
|
|
347
|
+
// does, the way arg.mjs's stray() tells a stray from a value: a
|
|
349
348
|
// single-dash word is refused only when one more dash, ahead of any `=value`,
|
|
350
349
|
// makes it a flag this script reads — OWN_FLAGS, the two spliced out of argv
|
|
351
350
|
// above. None of those titles carries one. It sits behind the same length
|
|
@@ -357,26 +356,26 @@ function unescapeText(s) {
|
|
|
357
356
|
// `-f` is exactly the other-tool short flag above; both still fold into the
|
|
358
357
|
// subject.
|
|
359
358
|
const OWN_FLAGS = [`--${FILE_FLAG_NAME}`, `--${REQUIRE_FILE_FLAG_NAME}`].map((f) => f.toLowerCase());
|
|
360
|
-
// The one-dash name clause below
|
|
359
|
+
// The one-dash name clause below once matched only a's FIRST
|
|
361
360
|
// whitespace-split word (ahead of any `=value`), not the whole element: a
|
|
362
361
|
// quoted multi-word tail element (`check "-require-file widget" guard
|
|
363
362
|
// missing`, one argv element) reconstructed to "--require-file widget"
|
|
364
363
|
// under a whole-token match, which is not in OWN_FLAGS, so it fell through
|
|
365
|
-
// to exit 0 — the same verdict flip
|
|
364
|
+
// to exit 0 — the same verdict flip the one-dash clause exists to refuse, just via a
|
|
366
365
|
// quoted shape rather than a bare token. Splitting first mirrored the `--`
|
|
367
366
|
// clause above it, which already matches on startsWith rather than
|
|
368
|
-
// requiring the whole element.
|
|
369
|
-
//
|
|
370
|
-
// why the first-word version stopped being enough.
|
|
367
|
+
// requiring the whole element. The split now checks EVERY word of the
|
|
368
|
+
// element, not only the first — see the paragraph on the four missed shapes
|
|
369
|
+
// for why the first-word version stopped being enough.
|
|
371
370
|
//
|
|
372
|
-
//
|
|
371
|
+
// That first-word, exact-case split still missed four shapes of the
|
|
373
372
|
// same name, each confirmed by direct probe against the shipped script: a
|
|
374
373
|
// case-variant spelling (`-REQUIRE-FILE`), whitespace ahead of the dash —
|
|
375
374
|
// including U+00A0, which `\s` already matches, but the FIRST-word split
|
|
376
375
|
// still lost the real word to the empty token that whitespace leaves at
|
|
377
376
|
// index 0 — and the flag name landing anywhere but first in a quoted
|
|
378
377
|
// multi-word element (`"widget -require-file"`, the mirror of the shape
|
|
379
|
-
// just above, where
|
|
378
|
+
// just above, where the one-dash fix only checked the front). None of the four
|
|
380
379
|
// changes the rule itself — still a NAME match against OWN_FLAGS, never a
|
|
381
380
|
// prefix or shape test — only how a word is pulled out of the element:
|
|
382
381
|
// every whitespace-split word, lower-cased, before the OWN_FLAGS lookup —
|
|
@@ -384,7 +383,7 @@ const OWN_FLAGS = [`--${FILE_FLAG_NAME}`, `--${REQUIRE_FILE_FLAG_NAME}`].map((f)
|
|
|
384
383
|
// stays correct even if FILE_FLAG_NAME/REQUIRE_FILE_FLAG_NAME ever gain a
|
|
385
384
|
// capital letter.
|
|
386
385
|
//
|
|
387
|
-
//
|
|
386
|
+
// The per-word test above only ever builds the ONE-dash-PREPENDED
|
|
388
387
|
// form of a word (`-${word...}`), so it matches a word already spelled with
|
|
389
388
|
// one dash but never a word already spelled with this script's own TWO-dash
|
|
390
389
|
// flag name — that spelling comes out of the split as `--require-file`,
|
|
@@ -393,15 +392,15 @@ const OWN_FLAGS = [`--${FILE_FLAG_NAME}`, `--${REQUIRE_FILE_FLAG_NAME}`].map((f)
|
|
|
393
392
|
// two-dash name when it leads the WHOLE tail element; the same name landing
|
|
394
393
|
// anywhere else in a quoted multi-word element — trailing, in the middle, or
|
|
395
394
|
// alone behind leading whitespace — was invisible to both clauses and folded
|
|
396
|
-
// back into the subject: the identical verdict flip
|
|
397
|
-
// refuse, one dash count over. Reproducible
|
|
398
|
-
//
|
|
399
|
-
// started being refused. Fixed the same way
|
|
400
|
-
//
|
|
395
|
+
// back into the subject: the identical verdict flip the one-dash clauses exist to
|
|
396
|
+
// refuse, one dash count over. Reproducible before the widened split too;
|
|
397
|
+
// the split only made it visible by contrast, once the one-dash mirror shape
|
|
398
|
+
// started being refused. Fixed the same way the one-dash case is
|
|
399
|
+
// — a NAME match, never a prefix or shape test — by also checking a
|
|
401
400
|
// word's own AS-SPELLED form against OWN_FLAGS, alongside the
|
|
402
401
|
// one-dash-prepended form already there.
|
|
403
402
|
//
|
|
404
|
-
//
|
|
403
|
+
// A Unicode format character (general category Cf — U+200B zero-width
|
|
405
404
|
// space, U+2060 word joiner, U+00AD soft hyphen, the bidi controls, …) is
|
|
406
405
|
// invisible and sits outside `\s`, so the split above never cut on it and it
|
|
407
406
|
// rode along inside the word: `\u200B-require-file` reads as `-require-file`
|
|
@@ -421,7 +420,7 @@ const OWN_FLAGS = [`--${FILE_FLAG_NAME}`, `--${REQUIRE_FILE_FLAG_NAME}`].map((f)
|
|
|
421
420
|
// among others); stripping them costs a refusal only of such a mark glued to
|
|
422
421
|
// an own-flag name, which no subject carries.
|
|
423
422
|
//
|
|
424
|
-
//
|
|
423
|
+
// Invisible characters OUTSIDE Cf — U+3164 and the other Hangul
|
|
425
424
|
// fillers (letters), U+034F and the variation selectors (combining marks) —
|
|
426
425
|
// are text rather than format controls, so the Cf strip above let them ride
|
|
427
426
|
// inside the word exactly as a zero-width space rode before it. The strip is
|
|
@@ -430,8 +429,8 @@ const OWN_FLAGS = [`--${FILE_FLAG_NAME}`, `--${REQUIRE_FILE_FLAG_NAME}`].map((f)
|
|
|
430
429
|
// rather than a hand-curated block list, which would miss the members no
|
|
431
430
|
// ticket named (U+17B4, the Mongolian selectors U+180B–180F, …). A union,
|
|
432
431
|
// not DICP alone: DICP leaves out the visible Cf marks just above, so
|
|
433
|
-
// swapping it in would drop cases
|
|
434
|
-
// same after-the-split order as
|
|
432
|
+
// swapping it in would drop cases the Cf strip already refuses. Same site and the
|
|
433
|
+
// same after-the-split order as the Cf strip; U+FEFF stays the one code point in
|
|
435
434
|
// both the strip and `\s`, no DICP member adding another. An emoji's own
|
|
436
435
|
// presentation selector (U+FE0F) is stripped too, which costs nothing: only
|
|
437
436
|
// a word that is an own-flag spelling once stripped is refused.
|
|
@@ -456,7 +455,7 @@ function refuseStrayInCheckTail(tail) {
|
|
|
456
455
|
|
|
457
456
|
// The id slot ahead of `filed`/`row`/`ruled`'s tail is a hazard of its own,
|
|
458
457
|
// and the only one of the three a rule can act on. refuseStrayInCheckTail()
|
|
459
|
-
// above is not read on those subcommands at all
|
|
458
|
+
// above is not read on those subcommands at all, and would not
|
|
460
459
|
// catch this shape if it were: a stray flag one token earlier lands in
|
|
461
460
|
// `issue`/`ticket`/`pr`, and the tail left behind carries no `--` element to
|
|
462
461
|
// find. Nor could that helper be read over the whole of `rest` instead —
|
|
@@ -464,12 +463,12 @@ function refuseStrayInCheckTail(tail) {
|
|
|
464
463
|
// tail with a `--` element, pinned here as must-keep-working. A legitimate
|
|
465
464
|
// id is bare digits, optionally `#`-prefixed, and never carries a leading
|
|
466
465
|
// dash at all — single or double — so this slot refuses ANY leading dash,
|
|
467
|
-
// not only a double one
|
|
466
|
+
// not only a double one. The free-text tail cannot take that same
|
|
468
467
|
// test: a subject can legitimately open with a double-dash-leading word —
|
|
469
468
|
// exactly what `--flag-like subject text` above and check's own
|
|
470
469
|
// dash-leading-subject pin exist to keep working. A single-dash-leading word
|
|
471
470
|
// is ordinary subject text too, which is why check's tail refuses one only
|
|
472
|
-
// by name (
|
|
471
|
+
// by name (see refuseStrayInCheckTail() above). So only the id slot
|
|
473
472
|
// can take the bare prefix test at all, for the same no-free-text reason
|
|
474
473
|
// given above.
|
|
475
474
|
function refuseStrayInId(value, what) {
|
|
@@ -477,7 +476,7 @@ function refuseStrayInId(value, what) {
|
|
|
477
476
|
}
|
|
478
477
|
|
|
479
478
|
// Set by load(), the only function that reads the file, so `ledger.ok` can
|
|
480
|
-
// report what the parse saw rather than what a later stat() guesses
|
|
479
|
+
// report what the parse saw rather than what a later stat() guesses.
|
|
481
480
|
let ledgerParsed = false;
|
|
482
481
|
|
|
483
482
|
function load() {
|
|
@@ -501,7 +500,8 @@ function load() {
|
|
|
501
500
|
// Set here, past the read and the early return, so it can only be true of a
|
|
502
501
|
// file this function actually opened and recognised as a ledger.
|
|
503
502
|
ledgerParsed = headerRe(FILED).test(text);
|
|
504
|
-
// A ledger written before
|
|
503
|
+
// A ledger written before `dispatch` and `drain` existed has neither
|
|
504
|
+
// `## Dispatched` nor `## Drain`,
|
|
505
505
|
// which reads as nothing dispatched through `dispatch` and no drain — the
|
|
506
506
|
// truth about that file, not a default standing in for it.
|
|
507
507
|
const drainEntries = section(DRAIN);
|
|
@@ -536,7 +536,7 @@ function save(d) {
|
|
|
536
536
|
// atomic on a POSIX filesystem — a crash mid-write leaves the temp file
|
|
537
537
|
// corrupt but never truncates the durability file itself. The temp name
|
|
538
538
|
// carries the pid: a shared `${file}.tmp` let one writer rename another's
|
|
539
|
-
// temp file out from under it, and the loser died on ENOENT
|
|
539
|
+
// temp file out from under it, and the loser died on ENOENT.
|
|
540
540
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
541
541
|
writeFileSync(tmp, out);
|
|
542
542
|
renameSync(tmp, file);
|
|
@@ -546,7 +546,7 @@ function save(d) {
|
|
|
546
546
|
console.error(` wrote ${file}`);
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
-
// ── The write lock
|
|
549
|
+
// ── The write lock ───────────────────────────────────────────────────────────
|
|
550
550
|
//
|
|
551
551
|
// Held from before load() through save() on every write subcommand, because
|
|
552
552
|
// save() rewrites the whole document from what load() read: without it two
|
|
@@ -572,8 +572,8 @@ function save(d) {
|
|
|
572
572
|
// the same dead pid, and RE-CHECKING that pid is still dead. Unlink-then-
|
|
573
573
|
// create, or a bare rename, lets two waiters that both saw the dead pid each
|
|
574
574
|
// remove the other's FRESH lock and write at once; the re-read is what makes
|
|
575
|
-
// the takeover safe. The re-check narrows the one window the re-read leaves
|
|
576
|
-
//
|
|
575
|
+
// the takeover safe. The re-check narrows the one window the re-read leaves,
|
|
576
|
+
// and the order the two run in is what narrows it: isDead(deadPid)
|
|
577
577
|
// runs before the re-read, not after, so a live writer that takes the lock in
|
|
578
578
|
// the gap between them is caught by the read that follows, not hidden behind
|
|
579
579
|
// one taken before it arrived. Read first, and a recycled pid that came back
|
|
@@ -610,8 +610,8 @@ function save(d) {
|
|
|
610
610
|
//
|
|
611
611
|
// What the lock does NOT cover: `check` -> `gh issue create` -> `filed` is
|
|
612
612
|
// three commands, and each takes the lock (or not) alone, so two filers can
|
|
613
|
-
// both read `clean` before either records. Accepted
|
|
614
|
-
// same-run accidental duplicate
|
|
613
|
+
// both read `clean` before either records. Accepted: the measured
|
|
614
|
+
// same-run accidental duplicate was filed minutes apart, and a
|
|
615
615
|
// claim/reserve subcommand closing that window was ruled out.
|
|
616
616
|
const LOCK = `${file}.lock`;
|
|
617
617
|
const REAP = `${LOCK}.reap`;
|
|
@@ -771,11 +771,8 @@ async function acquireLock() {
|
|
|
771
771
|
// the other four subcommands ignored it entirely: `read --require-file` on an
|
|
772
772
|
// absent file printed the empty payload at exit 0, byte-identical to a real
|
|
773
773
|
// empty ledger on both streams, and `row --require-file` CREATED the very file
|
|
774
|
-
// whose absence the flag exists to refuse
|
|
775
|
-
//
|
|
776
|
-
// already documents
|
|
777
|
-
// — "Exit 2 on any subcommand — ... `--require-file` with no ledger file" — so
|
|
778
|
-
// the implementation is what had drifted, not the contract.
|
|
774
|
+
// whose absence the flag exists to refuse. One flag read in one place is
|
|
775
|
+
// what makes it hold on every subcommand, not only `check`.
|
|
779
776
|
//
|
|
780
777
|
// Ahead of load() rather than after it: load() answers "absent" and "present
|
|
781
778
|
// but unparseable" with the same empty lists, which is the ambiguity this flag
|
|
@@ -787,8 +784,8 @@ async function acquireLock() {
|
|
|
787
784
|
//
|
|
788
785
|
// Also ahead of "unknown subcommand" validation: `--require-file` against a
|
|
789
786
|
// missing file with a bogus subcommand reports the require-file refusal, not
|
|
790
|
-
// a usage error. Deliberate/accepted, not reordered —
|
|
791
|
-
//
|
|
787
|
+
// a usage error. Deliberate/accepted, not reordered — both refusals exit 2,
|
|
788
|
+
// so a caller gating on the exit code reads the same failure either way.
|
|
792
789
|
if (requireFile && !existsSync(file)) die(`--require-file given but ledger file does not exist: ${file}`);
|
|
793
790
|
|
|
794
791
|
const WRITE_COMMANDS = new Set(["row", "filed", "ruled", "dispatch", "settle", "drain", "rotate"]);
|
|
@@ -801,7 +798,7 @@ const data = cmd === "rotate" ? null : load();
|
|
|
801
798
|
// The payload subcommands end by falling out of this chain, never by calling
|
|
802
799
|
// process.exit(). On a pipe, process.stdout.write is async and process.exit()
|
|
803
800
|
// discards whatever is still queued, so a payload past the buffer arrives cut
|
|
804
|
-
// — at exit 0, which types a corrupt read as a successful one
|
|
801
|
+
// — at exit 0, which types a corrupt read as a successful one.
|
|
805
802
|
// candidates.mjs states the same reason at its own exit line; this was the
|
|
806
803
|
// second script on that shape. The consumer that made it visible is board.mjs,
|
|
807
804
|
// which reads `read` through execFileSync — a pipe — and accepts its ledger
|
|
@@ -816,7 +813,7 @@ const data = cmd === "rotate" ? null : load();
|
|
|
816
813
|
// statement of its branch and became one directly. Its already-filed exit sits
|
|
817
814
|
// mid-branch, where falling through would run the near-miss ranking and the
|
|
818
815
|
// tracker search that exit exists to skip — so `check`'s branch is a function
|
|
819
|
-
// now (runCheck, below the chain), and a `return` is what skips them
|
|
816
|
+
// now (runCheck, below the chain), and a `return` is what skips them.
|
|
820
817
|
//
|
|
821
818
|
// What that leaves is a property of every payload this file emits rather than
|
|
822
819
|
// of whichever branches a sweep happened to reach: none of them reaches
|
|
@@ -840,17 +837,17 @@ if (cmd === "read") {
|
|
|
840
837
|
// Every reader parses member tokens anywhere in a row's text, so a token
|
|
841
838
|
// with an outcome outside its family's vocabulary would land as a permanent
|
|
842
839
|
// settle — `settle merge-bot-1 done` then refuses as "already settled as
|
|
843
|
-
// dispatched"
|
|
840
|
+
// dispatched". Refused before anything is written, new row or
|
|
844
841
|
// rewrite alike; a well-formed token, live or settled, still goes through.
|
|
845
842
|
const malformed = memberTokens(line).find((t) => t.error !== null);
|
|
846
843
|
if (malformed) die(`row ${key}: malformed member token '${malformed.name}=${malformed.outcome}' — ${malformed.error}`);
|
|
847
|
-
//
|
|
844
|
+
// `label-off=<attempt>` tells the tick a missing `ready-to-merge` is
|
|
848
845
|
// the controller's own removal, not a finisher's miss — so one naming no
|
|
849
846
|
// finisher-pr member this run has would mask a real miss behind a record of
|
|
850
847
|
// nothing. Known means in `## Dispatched` or among a row's member tokens as
|
|
851
848
|
// they stand before this write, the two places dispatch's live-sibling check
|
|
852
849
|
// reads: a token `row` wrote never enters `## Dispatched`, and this line's
|
|
853
|
-
// own copy of a name vouches for nothing. Local only — never `gh
|
|
850
|
+
// own copy of a name vouches for nothing. Local only — never `gh`.
|
|
854
851
|
const known = new Set([...data.dispatched.map(parseToken), ...data.rows.flatMap(memberTokens)]
|
|
855
852
|
.filter((t) => t && t.family === "finisher-pr").map((t) => t.name));
|
|
856
853
|
const badOff = line.split(/\s+/).find((tok) => {
|
|
@@ -884,17 +881,17 @@ if (cmd === "read") {
|
|
|
884
881
|
const id = issue.replace(/^#/, "");
|
|
885
882
|
// isDigits() gates id the same way the sibling ticket/PR check gates key
|
|
886
883
|
// (line ~1527): left unvalidated, a non-digit id becomes a `#<id> ` prefix
|
|
887
|
-
// subjectOf()'s digits-only regex
|
|
884
|
+
// subjectOf()'s digits-only regex cannot strip, so a near-duplicate
|
|
888
885
|
// `check` against that row silently degrades from the hard "already
|
|
889
886
|
// filed" block to a soft near-miss suggestion instead.
|
|
890
887
|
if (!isDigits(id)) die(`'${issue}' is not an issue number`);
|
|
891
888
|
// Both callers retry once on a failure they observed, and an observed
|
|
892
889
|
// failure is not proof the write failed — a timeout after the row landed
|
|
893
|
-
// reads as one — so a repeat filing of an issue already on file is a no-op
|
|
894
|
-
//
|
|
890
|
+
// reads as one — so a repeat filing of an issue already on file is a no-op.
|
|
891
|
+
// Identity is the issue number alone, matched NUMERICALLY — so a
|
|
895
892
|
// non-canonical spelling (`08`) still dedupes against `8` — as the token
|
|
896
893
|
// between `#` and the first whitespace: never `check`'s fuzzy subject
|
|
897
|
-
// matcher, and never a prefix test, which would dedupe
|
|
894
|
+
// matcher, and never a prefix test, which would dedupe issue 8 against issue 80.
|
|
898
895
|
// The existing row wins, subject and all — a retry reworded is still the
|
|
899
896
|
// same filing.
|
|
900
897
|
const existing = data.filed.find((row) => Number(row.match(/^#(\S+)/)?.[1]) === Number(id));
|
|
@@ -953,7 +950,7 @@ if (cmd === "read") {
|
|
|
953
950
|
// it, whose results that answer would only discard. A function is what makes
|
|
954
951
|
// leaving early expressible — `process.exitCode` and a `return`, reaching the
|
|
955
952
|
// same exit by the same path every other subcommand takes, where an arm of the
|
|
956
|
-
// chain had only `process.exit()` and the payload it abandoned
|
|
953
|
+
// chain had only `process.exit()` and the payload it abandoned.
|
|
957
954
|
// Hoisted, so the dispatch chain above stays the file's spine.
|
|
958
955
|
function runCheck() {
|
|
959
956
|
// The first check of a run legitimately has no file yet, so absence alone
|
|
@@ -962,7 +959,7 @@ function runCheck() {
|
|
|
962
959
|
// would notice. Warn loudly by default; --require-file makes absence a
|
|
963
960
|
// hard failure for callers that know the file must already exist — and that
|
|
964
961
|
// refusal is read ahead of the dispatch now, not here, because four other
|
|
965
|
-
// subcommands needed the same one
|
|
962
|
+
// subcommands needed the same one. Reaching this line at all means
|
|
966
963
|
// the flag was absent.
|
|
967
964
|
//
|
|
968
965
|
// `ledger.ok` is the parse's answer, not this stat()'s: load() sets it only
|
|
@@ -973,21 +970,21 @@ function runCheck() {
|
|
|
973
970
|
// contract is absence rather than shape — so the two are two observations
|
|
974
971
|
// now, each true of its own question, where before they were one that was
|
|
975
972
|
// true of neither. Narrower, not airtight: a ledger truncated AFTER the
|
|
976
|
-
// header still parses, with the rows below it lost
|
|
973
|
+
// header still parses, with the rows below it lost.
|
|
977
974
|
const ledger = { ok: ledgerParsed };
|
|
978
975
|
if (!existsSync(file)) {
|
|
979
976
|
console.error(
|
|
980
977
|
`${NAME}: WARNING — ledger file not found: ${file}. Every check will read "safe to file" until it exists.`,
|
|
981
978
|
);
|
|
982
979
|
} else if (!ledger.ok) {
|
|
983
|
-
//
|
|
980
|
+
// The existence probe above is silent once `file` exists, so a
|
|
984
981
|
// `--file` landing on a real-but-wrong path — a typo'd neighbour, a
|
|
985
982
|
// corrupted "## Filed" header, a 0-byte file — got the SAME silence as a
|
|
986
983
|
// clean read. `ledger.ok` (above) already tells the machine-readable half
|
|
987
984
|
// apart; this is that same "opened it, it did not parse" fact stated on
|
|
988
985
|
// stderr, in wording that does not borrow "file not found" — the file
|
|
989
986
|
// demonstrably exists, so claiming otherwise would be a fresh version of
|
|
990
|
-
// the defect
|
|
987
|
+
// the defect already removed from the JSON half.
|
|
991
988
|
console.error(
|
|
992
989
|
`${NAME}: WARNING — ${file} exists but does not look like a ledger (no "${FILED}" header found). Every check will read "safe to file" until it is fixed.`,
|
|
993
990
|
);
|
|
@@ -997,7 +994,7 @@ function runCheck() {
|
|
|
997
994
|
// Quoted here, unlike the three usage strings above, because
|
|
998
995
|
// refuseStrayInCheckTail() is the reason: `check` is the subcommand whose tail
|
|
999
996
|
// must arrive as ONE argument, and this is the only spelling of its call
|
|
1000
|
-
// inside the script
|
|
997
|
+
// inside the script. The other three take their tail as the words a
|
|
1001
998
|
// caller typed.
|
|
1002
999
|
if (!subject) die("usage: ledger.mjs check \"<subject>\"");
|
|
1003
1000
|
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
@@ -1022,7 +1019,7 @@ function runCheck() {
|
|
|
1022
1019
|
// first walk had already produced — equal sizes are exactly the case
|
|
1023
1020
|
// where the two walks are the same walk.
|
|
1024
1021
|
// Not `small.isSubsetOf(big)`: that's Node v22.0.0+, above this repo's
|
|
1025
|
-
// declared >=20.11.0 floor (
|
|
1022
|
+
// declared >=20.11.0 floor (a test reds it).
|
|
1026
1023
|
return (small.size === big.size || small.size >= 4) && [...small].every((t) => big.has(t));
|
|
1027
1024
|
};
|
|
1028
1025
|
// Strip the leading `#NNN ` issue number: it is metadata, not part of the
|
|
@@ -1049,7 +1046,7 @@ function runCheck() {
|
|
|
1049
1046
|
// ordinary four-word check against one wide filed row arrived cut at the
|
|
1050
1047
|
// pipe buffer. The code it arrived under was already this 1, so what the
|
|
1051
1048
|
// exit lost was the payload alone, the `verdict` field parsing consumers
|
|
1052
|
-
// read included
|
|
1049
|
+
// read included. The `return` is what keeps the ranking and the
|
|
1053
1050
|
// tracker query below unreached; nothing after it assigns exitCode on
|
|
1054
1051
|
// this path, so 1 is what the process leaves with.
|
|
1055
1052
|
process.exitCode = 1;
|
|
@@ -1060,7 +1057,7 @@ function runCheck() {
|
|
|
1060
1057
|
// "was this near-verbatim wording already filed" — and answers it well. It
|
|
1061
1058
|
// says nothing about the same finding described in DIFFERENT words, which is
|
|
1062
1059
|
// how a second discoverer actually words it: one measured run rediscovered
|
|
1063
|
-
//
|
|
1060
|
+
// one issue five times, each in its own phrasing, and the check caught none of
|
|
1064
1061
|
// them. Rank the filed list by token overlap and hand the top rows back with
|
|
1065
1062
|
// a score, instead of collapsing all of that into `found: false`.
|
|
1066
1063
|
//
|
|
@@ -1068,7 +1065,7 @@ function runCheck() {
|
|
|
1068
1065
|
// and the singular fold only sharpen a ranking, but folding them into
|
|
1069
1066
|
// isMatch() would widen what counts as ALREADY FILED — the one behaviour
|
|
1070
1067
|
// here that callers gate on and that must not move.
|
|
1071
|
-
// Widened
|
|
1068
|
+
// Widened to cover modals, negations and temporals ("should", "never",
|
|
1072
1069
|
// "still"...) are noise words that are never the subject's distinctive
|
|
1073
1070
|
// term, yet the old, shorter list let them survive to outrank one — "the
|
|
1074
1071
|
// guard should never fail open on a fork" picked "should guard never" over
|
|
@@ -1109,7 +1106,7 @@ function runCheck() {
|
|
|
1109
1106
|
};
|
|
1110
1107
|
const round2 = (n) => Math.round(n * 100) / 100;
|
|
1111
1108
|
const scored = scoreTokens(subject);
|
|
1112
|
-
// No floor was tuned here: the measured
|
|
1109
|
+
// No floor was tuned here: the measured rewording shares exactly one
|
|
1113
1110
|
// content word, and a threshold picked to look tidy would drop the very case
|
|
1114
1111
|
// this exists for. The top-3 cap, not the floor, is what keeps the output
|
|
1115
1112
|
// short. `rankedNear`'s filter still imposes one, because it tests the
|
|
@@ -1122,13 +1119,13 @@ function runCheck() {
|
|
|
1122
1119
|
// output would need a wider precision, not just a different filter.
|
|
1123
1120
|
const NEAR_SHOWN = 3;
|
|
1124
1121
|
// The floor at which a near-miss stops being decoration and becomes the
|
|
1125
|
-
// verdict's own answer — `soft-hit` rather than `clean
|
|
1122
|
+
// verdict's own answer — `soft-hit` rather than `clean`. A tuning
|
|
1126
1123
|
// value, deliberately named and deliberately here beside the display cap
|
|
1127
1124
|
// rather than inlined in the verdict below.
|
|
1128
1125
|
//
|
|
1129
|
-
// Chosen from
|
|
1126
|
+
// Chosen from measured rows: the near-misses a reader went on to
|
|
1130
1127
|
// confirm as the genuinely adjacent issue scored from 0.20 up to 0.45, so a
|
|
1131
|
-
// floor at 0.20 admits them.
|
|
1128
|
+
// floor at 0.20 admits them. The same measurement includes an adjacent row at 0.09, which
|
|
1132
1129
|
// this floor does not reach — the floor buys a short answer, never
|
|
1133
1130
|
// completeness, and the rows themselves stay printed and stay in the payload
|
|
1134
1131
|
// at every verdict for exactly that reason. Raise it and adjacent rows fall
|
|
@@ -1144,8 +1141,8 @@ function runCheck() {
|
|
|
1144
1141
|
// scores tie across it routinely — measured, five rows at 1.00 with the
|
|
1145
1142
|
// caller shown three — so the cut is arbitrary among equals. A bare
|
|
1146
1143
|
// three-row list is then indistinguishable from a complete one, which is
|
|
1147
|
-
// the "no silent caps" rule `candidates.mjs`'s `refuseIfCapped` legislates one script over
|
|
1148
|
-
//
|
|
1144
|
+
// the "no silent caps" rule `candidates.mjs`'s `refuseIfCapped` legislates one script over.
|
|
1145
|
+
// It enforces that rule by refusing outright; refusing is wrong
|
|
1149
1146
|
// here — these rows are advisory context for a decision, not the work queue,
|
|
1150
1147
|
// and a single tracker hit already forces exit 3. Report the COUNT withheld
|
|
1151
1148
|
// and the best score among them, never the rows: printing the rows would
|
|
@@ -1157,22 +1154,22 @@ function runCheck() {
|
|
|
1157
1154
|
// The ledger can only see what THIS run recorded. An issue that already
|
|
1158
1155
|
// exists on the tracker but never reached this `filed` list — filed by an
|
|
1159
1156
|
// earlier run, by the maintainer, by hand — is structurally invisible to
|
|
1160
|
-
// every match above, and that is the measured failure:
|
|
1157
|
+
// every match above, and that is the measured failure: one issue was rediscovered
|
|
1161
1158
|
// five times in one run and `check` reported it safe to file every time. Ask
|
|
1162
1159
|
// the tracker.
|
|
1163
1160
|
//
|
|
1164
1161
|
// gh ANDs the search terms, so every extra term can only narrow the result —
|
|
1165
1162
|
// keep the query SHORT: three of the subject's most distinctive words.
|
|
1166
1163
|
// Measured against the live tracker, "candidates opposite states" returns
|
|
1167
|
-
//
|
|
1168
|
-
// occur in the target issue's text — gh searches bodies, not just titles —
|
|
1169
|
-
// which the subject cannot know: "code" leaves
|
|
1164
|
+
// that issue. Whether a fourth term helps or hurts turns on whether it
|
|
1165
|
+
// happens to occur in the target issue's text — gh searches bodies, not just titles —
|
|
1166
|
+
// which the subject cannot know: "code" leaves it in, "open" drops it. So
|
|
1170
1167
|
// three is a recall-preserving floor, not a measured optimum. Longest-first is a crude stand-in for distinctiveness (no
|
|
1171
1168
|
// corpus to weigh terms against) and the >= 3 filter erases short but
|
|
1172
1169
|
// distinctive identifiers like `CI` or `gh`; upgrade to a real frequency
|
|
1173
1170
|
// weighting if the query starts missing.
|
|
1174
1171
|
//
|
|
1175
|
-
//
|
|
1172
|
+
// STOP was widened (above) to stop modals/negations/temporals from
|
|
1176
1173
|
// outranking real content words, and stops there — this is a pin, not a
|
|
1177
1174
|
// retune. Two things it deliberately leaves broken: longest-first still
|
|
1178
1175
|
// prefers a long ordinary word over a short distinctive one ("postgres"
|
|
@@ -1198,7 +1195,7 @@ function runCheck() {
|
|
|
1198
1195
|
// How many tracker rows the caller is shown. gh is asked for one MORE than
|
|
1199
1196
|
// this (below): its list arrives with no total, so a full page and a
|
|
1200
1197
|
// truncated one are byte-identical, and the extra row's presence is the only
|
|
1201
|
-
// truncation signal available
|
|
1198
|
+
// truncation signal available. Cheap: one row, one query, and it is
|
|
1202
1199
|
// ranked alongside the rest rather than discarded.
|
|
1203
1200
|
const TRACKER_SHOWN = 5;
|
|
1204
1201
|
let tracker;
|
|
@@ -1207,7 +1204,7 @@ function runCheck() {
|
|
|
1207
1204
|
// every subject as a tracker hit. Not searching is the honest answer.
|
|
1208
1205
|
// `hits` is omitted, not `[]` — the tracker was never read, so an empty
|
|
1209
1206
|
// list here would be a claim of cleanliness this branch never earned
|
|
1210
|
-
// (
|
|
1207
|
+
// (a consumer testing `.length` must not read this as clean).
|
|
1211
1208
|
tracker = { ok: false, query: null, error: "subject has no distinctive terms to search for" };
|
|
1212
1209
|
} else {
|
|
1213
1210
|
// gh resolves "the repository" from the child process's cwd, which
|
|
@@ -1216,7 +1213,7 @@ function runCheck() {
|
|
|
1216
1213
|
// --git-common-dir above; in the documented flow (no --file, run from
|
|
1217
1214
|
// the repo) the two agree, but an explicit --file naming a ledger
|
|
1218
1215
|
// outside the caller's repo diverges silently: the query searches the
|
|
1219
|
-
// wrong tracker and reports a confident, empty result
|
|
1216
|
+
// wrong tracker and reports a confident, empty result. Bind gh's
|
|
1220
1217
|
// cwd to the ledger's OWN repository instead of leaving it implicit. No
|
|
1221
1218
|
// --repo flag needed — gh's remote-based resolution does the rest once it
|
|
1222
1219
|
// is pointed at the right directory, and a worktree ledger (shared .git,
|
|
@@ -1224,18 +1221,17 @@ function runCheck() {
|
|
|
1224
1221
|
//
|
|
1225
1222
|
// cwd is not the whole story: inherited git vars outrank it, so an
|
|
1226
1223
|
// ambient GIT_DIR/GIT_WORK_TREE retargets BOTH the probe below and gh's
|
|
1227
|
-
// own remote resolution at the other repository, and the
|
|
1224
|
+
// own remote resolution at the other repository, and the same
|
|
1228
1225
|
// confident-empty result comes straight back with `ok: true` on it.
|
|
1229
1226
|
// Plausible here: a git hook, `rebase --exec`, `bisect run`. Scrub them
|
|
1230
|
-
// off both children — the fleet's own fixtures already do exactly this
|
|
1231
|
-
// (inflight.test.mjs).
|
|
1227
|
+
// off both children — the fleet's own fixtures already do exactly this.
|
|
1232
1228
|
//
|
|
1233
1229
|
// GH_REPO is the same hazard one layer up, and worse: gh reads it BEFORE
|
|
1234
1230
|
// it ever consults git, so no amount of git-var hygiene covers it and the
|
|
1235
1231
|
// bound cwd is simply ignored. Measured against a live tracker with the
|
|
1236
1232
|
// cwd binding in place: `GH_REPO=<some other real repo>` searched that
|
|
1237
|
-
// repo and returned `{"ok":true,"hits":[],"verdict":"clean"}` —
|
|
1238
|
-
//
|
|
1233
|
+
// repo and returned `{"ok":true,"hits":[],"verdict":"clean"}` — the
|
|
1234
|
+
// same wrong-tracker answer, in the documented flow, no --file divergence needed. Not
|
|
1239
1235
|
// hypothetical here either: this repo's own
|
|
1240
1236
|
// .github/workflows/release-label.yml exports GH_REPO to every step that
|
|
1241
1237
|
// shells out to gh.
|
|
@@ -1260,14 +1256,14 @@ function runCheck() {
|
|
|
1260
1256
|
// More than one cause lands here: a ledger path genuinely outside any
|
|
1261
1257
|
// repository, but also git missing entirely (spawn ENOENT, so `status`
|
|
1262
1258
|
// is null and `null !== 0`), a dubious-ownership refusal, an unreadable
|
|
1263
|
-
// `.git` gitfile, and
|
|
1259
|
+
// `.git` gitfile, and a probe that overran GIT_TIMEOUT_MS —
|
|
1264
1260
|
// spawnSync reports that the same way ENOENT arrives, `status` null with
|
|
1265
1261
|
// the reason in `error.message`, so it needs no arm of its own and
|
|
1266
1262
|
// cause() names it (`spawnSync git ETIMEDOUT`) without this branch
|
|
1267
1263
|
// having to. Do not name one of them — carry git's own reason,
|
|
1268
1264
|
// because this call leaves stdio at the default pipe, so git's stderr
|
|
1269
1265
|
// reaches no terminal and this string is the only place the cause is
|
|
1270
|
-
// ever seen (the same call the gh catch below makes
|
|
1266
|
+
// ever seen (the same call the gh catch below makes). The CAUSE is
|
|
1271
1267
|
// capped for the same reason: it ships on stdout inside `tracker.error`
|
|
1272
1268
|
// — see cause(), which owns both that cap and the choice between the
|
|
1273
1269
|
// fields a failed git can put a reason in. Empty here is a tolerable
|
|
@@ -1278,7 +1274,7 @@ function runCheck() {
|
|
|
1278
1274
|
// That cap covers the cause and NOTHING else. `tracker.error` as a whole
|
|
1279
1275
|
// is deliberately unbounded here, because the directory interpolates
|
|
1280
1276
|
// raw: the field measured 1100 characters from a 557-character
|
|
1281
|
-
// directory, a cause already cut to 500, and 43 of fixed text
|
|
1277
|
+
// directory, a cause already cut to 500, and 43 of fixed text.
|
|
1282
1278
|
// Capping the directory would buy the payload no ceiling — `subject`
|
|
1283
1279
|
// and `tracker.query` ride in the same JSON uncapped: as JSON fields
|
|
1284
1280
|
// from a 3000-character subject word they measure 3012 and 3010
|
|
@@ -1324,14 +1320,14 @@ function runCheck() {
|
|
|
1324
1320
|
// field still describes itself truthfully. Validating `title` here would
|
|
1325
1321
|
// degrade that row to `unverified` over the one field the hit line does
|
|
1326
1322
|
// not need, which is the opposite of what a guard against undescribable
|
|
1327
|
-
// hits is for
|
|
1323
|
+
// hits is for.
|
|
1328
1324
|
//
|
|
1329
1325
|
// Absence is the whole of that claim, deliberately: a `title` that is
|
|
1330
1326
|
// PRESENT and not a string is not handled here or anywhere below —
|
|
1331
1327
|
// `h.title || ""` keeps a truthy non-string, and scoreTokens then calls
|
|
1332
1328
|
// `.toLowerCase()` on it, so the read degrades to `unverified` reporting
|
|
1333
|
-
// a TypeError where a tracker reason belongs. That is
|
|
1334
|
-
//
|
|
1329
|
+
// a TypeError where a tracker reason belongs. That is a question of field TYPE; this
|
|
1330
|
+
// guard rules on field PRESENCE.
|
|
1335
1331
|
if (!Array.isArray(parsed) || parsed.some((h) => !h || typeof h.number !== "number"
|
|
1336
1332
|
|| typeof h.state !== "string" || typeof h.url !== "string")) {
|
|
1337
1333
|
throw new Error("gh returned JSON that is not an issue list");
|
|
@@ -1360,7 +1356,7 @@ function runCheck() {
|
|
|
1360
1356
|
// --git-common-dir resolution above already closed once. Degrade to the
|
|
1361
1357
|
// ledger-only answer and say so.
|
|
1362
1358
|
// Capped, and deliberately still carrying the stderr — the opposite call
|
|
1363
|
-
// from the fleet's other gh catches
|
|
1359
|
+
// from the fleet's other gh catches. Those omit it because
|
|
1364
1360
|
// execFileSync forwarded the child's bytes to our stderr already, so
|
|
1365
1361
|
// interpolating emits them twice; this call sets `stdio`, which turns
|
|
1366
1362
|
// that forwarding OFF, so this string is the only place the cause is
|
|
@@ -1372,8 +1368,8 @@ function runCheck() {
|
|
|
1372
1368
|
//
|
|
1373
1369
|
// stderr first, then the message: on a non-zero exit Node builds the
|
|
1374
1370
|
// message out of the same bytes prefixed by the command, so it is the
|
|
1375
|
-
// LONGER copy of the cause, not a smaller fallback (
|
|
1376
|
-
//
|
|
1371
|
+
// LONGER copy of the cause, not a smaller fallback (measured). The
|
|
1372
|
+
// message earns its place wherever stderr holds no
|
|
1377
1373
|
// cause — and stderr holds none in three shapes, only one of which is
|
|
1378
1374
|
// the field being absent: no `stderr` property at all on the output
|
|
1379
1375
|
// this file refused to parse or refused the shape of, the property
|
|
@@ -1391,7 +1387,7 @@ function runCheck() {
|
|
|
1391
1387
|
// in `e.stderr`, which on a timeout holds whatever gh printed before
|
|
1392
1388
|
// the kill: measured end to end, a gh that warned about cached
|
|
1393
1389
|
// credentials and then hung reported that warning as the cause of a
|
|
1394
|
-
// 20-second stall and named the timeout nowhere
|
|
1390
|
+
// 20-second stall and named the timeout nowhere. The flip moves
|
|
1395
1391
|
// exactly one failure, the timeout that printed something — the other
|
|
1396
1392
|
// aborts leave `e.stderr` undefined, so the message already won there.
|
|
1397
1393
|
// A Node that did set `e.code` on a plain non-zero exit would degrade
|
|
@@ -1440,7 +1436,7 @@ function runCheck() {
|
|
|
1440
1436
|
// HIT` is imperative, blocking language, and a row the scorer rates 0.00
|
|
1441
1437
|
// rendered in it was indistinguishable from a genuine duplicate — measured,
|
|
1442
1438
|
// and recovered from only by readers who went and searched the tracker by
|
|
1443
|
-
// hand
|
|
1439
|
+
// hand. Under this heading stderr and the verdict now say the same
|
|
1444
1440
|
// thing: a set with a scoring row is a hit, a set without one is rows to
|
|
1445
1441
|
// read.
|
|
1446
1442
|
const heading = bestHit > 0 ? "TRACKER HIT" : "TRACKER ROW";
|
|
@@ -1449,7 +1445,7 @@ function runCheck() {
|
|
|
1449
1445
|
}
|
|
1450
1446
|
// `more than N`, never `N`: with the probe row back, the exact count is
|
|
1451
1447
|
// precisely what is not known, and printing `5` for it is the silent cap
|
|
1452
|
-
// restated as a number
|
|
1448
|
+
// restated as a number.
|
|
1453
1449
|
console.error(bestHit > 0
|
|
1454
1450
|
? `${NAME}: not in this run's filed list, but ${tracker.truncated ? `more than ${TRACKER_SHOWN}` : tracker.hits.length} tracker issue(s) match '${query}' — review before filing`
|
|
1455
1451
|
: `${NAME}: not in this run's filed list; the tracker rows matching '${query}' all score 0.00 against this subject — gh matched something the title-based score cannot see, so read them, but they are not a finding of duplication`);
|
|
@@ -1459,16 +1455,16 @@ function runCheck() {
|
|
|
1459
1455
|
} else {
|
|
1460
1456
|
// Not "found no related issues" — that asserts the tracker has nothing,
|
|
1461
1457
|
// when all that is actually established is that a ${terms.length}-term
|
|
1462
|
-
// heuristic query came back empty
|
|
1458
|
+
// heuristic query came back empty. A query built from a few
|
|
1463
1459
|
// longest-surviving words can miss the very issue it should have found
|
|
1464
1460
|
// (see the STOP comment above); "no matches for this query" says what was
|
|
1465
1461
|
// established and leaves the rest unclaimed.
|
|
1466
1462
|
console.error(`${NAME}: not previously filed; tracker search '${query}' (${terms.length} term${terms.length === 1 ? "" : "s"}) returned no matches — not a certification the tracker has nothing on this`);
|
|
1467
1463
|
}
|
|
1468
1464
|
// Named explicitly so a consumer does not have to reconstruct it from
|
|
1469
|
-
// `tracker.ok` plus `tracker.hits
|
|
1465
|
+
// `tracker.ok` plus `tracker.hits`.
|
|
1470
1466
|
//
|
|
1471
|
-
// The scores decide, not the presence of rows
|
|
1467
|
+
// The scores decide, not the presence of rows. Both halves of the
|
|
1472
1468
|
// answer were measured reporting the opposite of what their own numbers said:
|
|
1473
1469
|
// `tracker-hit` over rows this file rates 0.00, and `clean` printed directly
|
|
1474
1470
|
// above near-miss rows that named the right issue. Nothing new is computed
|
|
@@ -1500,13 +1496,13 @@ function runCheck() {
|
|
|
1500
1496
|
}
|
|
1501
1497
|
// `nearTotal` alongside `near`, and `tracker.truncated` alongside `hits`:
|
|
1502
1498
|
// both lists are capped and neither cap was previously visible from the
|
|
1503
|
-
// payload a consumer parses
|
|
1499
|
+
// payload a consumer parses. They differ in what is knowable —
|
|
1504
1500
|
// the ledger is fully in hand, so the near-miss total is exact, while gh
|
|
1505
1501
|
// reports no total, so the tracker can only say that more exist.
|
|
1506
1502
|
// `ledger.ok` beside `tracker.ok`, and deliberately NOT inside `verdict`:
|
|
1507
1503
|
// the two halves each report their own readability, which is what makes an
|
|
1508
1504
|
// unread ledger distinguishable from one read and found empty — those
|
|
1509
|
-
// payloads were otherwise identical in every field
|
|
1505
|
+
// payloads were otherwise identical in every field. Folding it into
|
|
1510
1506
|
// the verdict instead would answer `unverified` for the run's FIRST check
|
|
1511
1507
|
// on any fresh clone, where `.fleet/` does not exist until save() creates
|
|
1512
1508
|
// it — see the comment on the repository probe above.
|
|
@@ -1517,16 +1513,16 @@ function runCheck() {
|
|
|
1517
1513
|
// suggestion, not a finding of duplication. Exit 0 covers "clean",
|
|
1518
1514
|
// "unverified" and "soft-hit" alike, which `verdict` names explicitly and the
|
|
1519
1515
|
// exit code deliberately still does not: minting a code for unverified would
|
|
1520
|
-
// break `check "$s" && gh issue create` on every offline run
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1516
|
+
// break `check "$s" && gh issue create` on every offline run, and a soft hit
|
|
1517
|
+
// is the same kind of answer — advisory rows, no established duplicate — so
|
|
1518
|
+
// it inherits that decision rather than reopening it. The exit code is a
|
|
1519
|
+
// pure function of `verdict`; only `tracker-hit` blocks, so a verdict added
|
|
1520
|
+
// later leaves 3 alone unless it says so here.
|
|
1525
1521
|
//
|
|
1526
|
-
// A hit set scoring 0.00 no longer forces 3
|
|
1522
|
+
// A hit set scoring 0.00 no longer forces 3. gh can match an issue
|
|
1527
1523
|
// body the title-based score cannot see, which is why those rows are still
|
|
1528
1524
|
// printed and still shipped in the payload — but the instruction the callers
|
|
1529
|
-
// carry makes exit 3 binding, and
|
|
1525
|
+
// carry makes exit 3 binding, and measurement shows what that costs: every
|
|
1530
1526
|
// recorded case of a hard stop over rows this file rates zero was survived
|
|
1531
1527
|
// only by a reader who overrode it and searched the tracker by hand, and
|
|
1532
1528
|
// obeying it would have dropped a real deferral.
|
|
@@ -1589,7 +1585,7 @@ function runRotate() {
|
|
|
1589
1585
|
console.log(JSON.stringify({ rotated: true, archive }));
|
|
1590
1586
|
}
|
|
1591
1587
|
|
|
1592
|
-
// ── dispatch / settle
|
|
1588
|
+
// ── dispatch / settle ────────────────────────────────────────────────────────
|
|
1593
1589
|
//
|
|
1594
1590
|
// Hoisted beside runCheck() for the same reason: each has several refusals
|
|
1595
1591
|
// ahead of its one write, and the chain above stays the file's spine. Every
|
|
@@ -1603,7 +1599,7 @@ function rowKey(r) {
|
|
|
1603
1599
|
|
|
1604
1600
|
// A row's PR is its first `PR#<n>` mention: the `→ PR#346` arrow or the
|
|
1605
1601
|
// implementer's settled `impl-324=PR#346` token, which name the same PR.
|
|
1606
|
-
// compute-board.mjs parseRow() reads the settled token
|
|
1602
|
+
// compute-board.mjs parseRow() reads the settled token, so the row a
|
|
1607
1603
|
// PR-bound member lands on is the card the cockpit shows that PR on.
|
|
1608
1604
|
function rowPr(r) {
|
|
1609
1605
|
const m = /\bPR\s*#(\d+)\b/.exec(r);
|
|
@@ -1750,8 +1746,8 @@ function runDispatch() {
|
|
|
1750
1746
|
}
|
|
1751
1747
|
|
|
1752
1748
|
// The definition the `task` call names, printed so the controller reads it
|
|
1753
|
-
// off this output rather than recalling it from prose a compaction drops
|
|
1754
|
-
//
|
|
1749
|
+
// off this output rather than recalling it from prose a compaction drops.
|
|
1750
|
+
// An implementer's comes off its row's `tier=`, the same function
|
|
1755
1751
|
// tier-check.mjs judges it by — so a row that names no single definition,
|
|
1756
1752
|
// or names one with no file under `agents/` (a well-formed `tier=` whose
|
|
1757
1753
|
// definition has not shipped: a `task` call naming it cannot resolve), is
|
|
@@ -1759,9 +1755,9 @@ function runDispatch() {
|
|
|
1759
1755
|
// after the call.
|
|
1760
1756
|
//
|
|
1761
1757
|
// A fix-applier's turns on whether its PR sits on an unresolved conflict
|
|
1762
|
-
// hold
|
|
1758
|
+
// hold, and that is the tick's own verdict, not a second reading
|
|
1763
1759
|
// of row `i`: a PR's tokens can be split across its ticket row and its own
|
|
1764
|
-
// `#<pr>` row
|
|
1760
|
+
// `#<pr>` row, and a settle can live in `## Dispatched` alone, so
|
|
1765
1761
|
// one row's text can read held where the tick reads cleared, and the
|
|
1766
1762
|
// reverse. A ledger the tick refuses is refused here too, before the member
|
|
1767
1763
|
// is live, rather than named a definition off a reading the tick rejects.
|
|
@@ -1792,7 +1788,7 @@ function runDispatch() {
|
|
|
1792
1788
|
}
|
|
1793
1789
|
// Every error caught here carries its own cause and remedy — a row's `tier=`
|
|
1794
1790
|
// (fix it with `ledger.mjs row`) for an implementer, a family
|
|
1795
|
-
// ledger-grammar.mjs has no definition case for otherwise
|
|
1791
|
+
// ledger-grammar.mjs has no definition case for otherwise — so this
|
|
1796
1792
|
// refusal's tail names neither.
|
|
1797
1793
|
let agent;
|
|
1798
1794
|
try {
|
|
@@ -1860,7 +1856,7 @@ function runSettle() {
|
|
|
1860
1856
|
return;
|
|
1861
1857
|
}
|
|
1862
1858
|
|
|
1863
|
-
//
|
|
1859
|
+
// Capture whether the row this settle is about to write to already
|
|
1864
1860
|
// named a PR, before the rewrite below touches it. The fold further down
|
|
1865
1861
|
// must refuse to run once a row already has PR-specific content on it —
|
|
1866
1862
|
// see the fold's own comment for why.
|
|
@@ -1878,12 +1874,12 @@ function runSettle() {
|
|
|
1878
1874
|
i = memberRowIndex(parsed);
|
|
1879
1875
|
if (i !== -1 && !carries(data.rows[i], name)) data.rows[i] = `${data.rows[i]} · ${token}`;
|
|
1880
1876
|
}
|
|
1881
|
-
//
|
|
1877
|
+
// The tick owes an open PR a review while its implementer is still
|
|
1882
1878
|
// live, so a PR-bound member can be dispatched before this settle names the
|
|
1883
1879
|
// PR here — and memberRowIndex() then finds no row naming it, so
|
|
1884
1880
|
// runDispatch() keys a row of its own to the PR. Once this row names the PR
|
|
1885
1881
|
// too, two rows would: the cockpit draws two cards and the tick merges the
|
|
1886
|
-
// two rows' tokens in row order
|
|
1882
|
+
// two rows' tokens in row order, not in the order they were written.
|
|
1887
1883
|
// Fold that row's tokens onto this one, in order, so later PR-bound writes
|
|
1888
1884
|
// find the one row through rowPr(). Only
|
|
1889
1885
|
// an implementer settles to `PR#M` (ledger-grammar.mjs), and only when the
|