@feigi/fleet-ctl 3.81.1 → 3.81.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.81.1",
3
+ "version": "3.81.3",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -2337,11 +2337,6 @@ async function runSettleFromReport() {
2337
2337
  const report = read.report;
2338
2338
  const invalid = (violation) => die(`${where}: ${read.path} is not a ${reportFamily(member)} report — ${violation}; nothing written`);
2339
2339
  if (report.member !== member) invalid(`$.member: ${JSON.stringify(report.member)} is not ${member}`);
2340
- // An issue number is a positive integer. A negative id writes a row `#-3 …`
2341
- // that `filed` refuses and `check`'s subjectOf() cannot strip.
2342
- for (const [k, { issue }] of (report.unrecorded ?? []).entries()) {
2343
- if (!Number.isInteger(issue) || issue < 1) invalid(`$.unrecorded[${k}].issue: ${JSON.stringify(issue)} is not an issue number`);
2344
- }
2345
2340
 
2346
2341
  // Every child runs from the ledger's repository with runCheck()'s
2347
2342
  // environment, for runCheck()'s reasons: GH_REPO would point gh at another
@@ -2411,7 +2406,7 @@ async function runSettleFromReport() {
2411
2406
  let outcome;
2412
2407
  if (live.family === "impl") {
2413
2408
  if (report.outcome === "pr") {
2414
- if (report.pr === null || report.pr < 1) invalid(`$.pr: ${JSON.stringify(report.pr)} is not a PR number, with outcome "pr"`);
2409
+ if (report.pr === null) invalid('$.pr: null is not a PR number, with outcome "pr"');
2415
2410
  if (report.head === null) invalid('$.head: null, with outcome "pr"');
2416
2411
  outcome = `PR#${report.pr}`;
2417
2412
  const sha = look(`verify-sha.sh ${report.branch} ${report.head}`, "sh", [verifySha, report.branch, report.head], [0, 1]);
@@ -446,10 +446,17 @@ export function describe(check, live = null) {
446
446
  switch (check.state) {
447
447
  case "clean":
448
448
  return null;
449
- case "dirty":
450
- // The HEAD entry is text, not a path: quoting it would blur it.
449
+ case "dirty": {
450
+ // The HEAD entry is text, not a path: quoting it would blur it, and
451
+ // ignoring cannot clear it, so only a path entry names the ignore remedy.
452
+ // An exclude hides untracked paths alone, and changedPaths reads a
453
+ // baseline entry that vanishes as changed, so the remedy is limited to
454
+ // untracked paths new since the baseline.
455
+ const hasPath = check.changed.some((p) => p !== check.headMove);
451
456
  return `MAIN-CHECKOUT-DIRTY ${check.changed.map((p) => (p === check.headMove ? p : shown(p))).join(" ")} — changed since the run's baseline${members}`
452
- + ` — dispatch held: resolve the stray paths FIRST, then re-baseline with ${RECORD_COMMAND} (it overwrites, it does not compare)`;
457
+ + ` — dispatch held: resolve the stray paths FIRST, then re-baseline with ${RECORD_COMMAND} (it overwrites, it does not compare)`
458
+ + (hasPath ? " — or, for an untracked path new since the baseline, the maintainer may instead ignore it in .git/info/exclude or the global excludes file: it clears on the next check with no re-baseline, so long as the pattern matches no path the baseline holds; ignoring never clears a tracked or staged file's change" : "");
459
+ }
453
460
  case "absent":
454
461
  return `MAIN-CHECKOUT-NO-BASELINE no baseline at ${check.baseline} — dispatch held: record it at run start with ${RECORD_COMMAND}`;
455
462
  default:
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { readdirSync, readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
- import { SCHEMAS, validate } from "./report-schema.mjs";
17
+ import { SCHEMAS, parseReport, validate } from "./report-schema.mjs";
18
18
  import { parseMember } from "./ledger-grammar.mjs";
19
19
  import { scratchRoot } from "./fleet-dir.mjs";
20
20
 
@@ -53,14 +53,9 @@ export function readReportIn(dir, member) {
53
53
  if (e.code === "ENOENT" || e.code === "ENOTDIR") return { state: "absent", path };
54
54
  return { state: "invalid", path, violation: `$: unreadable (${e.code ?? e.message})` };
55
55
  }
56
- let report;
57
- try {
58
- report = JSON.parse(text);
59
- } catch (e) {
60
- return { state: "invalid", path, violation: `$: not JSON (${e.message})` };
61
- }
62
- const violation = validate(SCHEMAS[family], report);
63
- return violation === null ? { state: "ok", path, report } : { state: "invalid", path, violation };
56
+ const parsed = parseReport(text);
57
+ const violation = parsed.violation ?? validate(SCHEMAS[family], parsed.value);
58
+ return violation === null ? { state: "ok", path, report: parsed.value } : { state: "invalid", path, violation };
64
59
  }
65
60
 
66
61
  /** `ok`, `invalid`, `absent`, or `-` when no schema covers the member. */
@@ -17,7 +17,8 @@
17
17
  //
18
18
  // 0 print: the schema is on stdout. validate: `valid` on stdout
19
19
  // 1 validate: `invalid: <violation>` on stdout, naming the FIRST violation;
20
- // a file that is not JSON is invalid too, as `$: not JSON (...)`
20
+ // a file that is not JSON is invalid too, as `$: not JSON (...)`, and so
21
+ // is one that repeats a key, as `$.merged: duplicate key "merged"`
21
22
  // 2 usage: no or unknown subcommand, a wrong argument count, an unknown
22
23
  // family, any flag; or <file> could not be read; or print could not
23
24
  // write the schema to stdout. Refused on stderr, nothing on stdout
@@ -31,16 +32,21 @@
31
32
  // their schema names.
32
33
  //
33
34
  // The validator below is hand-written and covers exactly the keywords these
34
- // schemas use: `type` (one or a list; `integer` is a whole number), `enum`,
35
- // `required`, `properties`, `additionalProperties: false`, `items` and
36
- // `pattern`. A keyword it does not know is not checked, so a schema here
37
- // that starts using one needs the validator to learn it first. It walks in a
38
- // fixed order, which is what makes "the first violation" one answer: at
39
- // each value `type`, then `enum`, then `pattern`; in an object every
40
- // `required` field in the schema's order, then any unexpected field in the
41
- // report's order, then each property in the schema's order; in an array each
42
- // item in turn. A violation is named by its path from the report's root, `$`,
43
- // as `$.merged[1].sha`.
35
+ // schemas use: `type` (one or a list), `enum`, `pattern`, `minimum` and
36
+ // `maximum` (numbers only: a value that is not a number, `null` included,
37
+ // passes them), `required`, `properties`, `additionalProperties: false` and
38
+ // `items`. A keyword it does not know is not checked, so a schema here that
39
+ // starts using one needs the validator to learn it first. An integer is
40
+ // bounded by the schema, not by `type`: ids by 1 and the largest safe
41
+ // integer, the other integers by the safe range, beyond which JSON.parse has
42
+ // already rounded the number. A duplicate key, compared decoded and at any
43
+ // depth, is a violation named before any schema check. The walk is in a
44
+ // fixed order, which is what makes "the first violation" one answer: at each
45
+ // value `type`, then `enum`, then `pattern`, then `minimum`, then `maximum`;
46
+ // in an object every `required` field in the schema's order, then any
47
+ // unexpected field in the report's order, then each property in the schema's
48
+ // order; in an array each item in turn. A violation is named by its path
49
+ // from the report's root, `$`, as `$.merged[1].sha`.
44
50
  //
45
51
  // Strict mode. A `task` call can carry this schema as `outputSchema` with
46
52
  // `schemaMode: "strict"`. Measured 2026-10-14 under omp/18.10.3, and only on
@@ -85,7 +91,10 @@ const USAGE = "usage: report-schema.mjs print <family> | validate <family> <file
85
91
 
86
92
  const HEX = "^[0-9a-f]{40}$";
87
93
  const str = { type: "string" };
88
- const int = { type: "integer" };
94
+ // An id is a positive safe integer; a negative one writes a ledger row `#-3 …` that `filed` refuses and `check`'s subjectOf() cannot strip.
95
+ const id = { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER };
96
+ // A position or a count: the safe range only, so a truthful negative `exit` stays valid.
97
+ const int = { type: "integer", minimum: -Number.MAX_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER };
89
98
  const sha = { type: "string", pattern: HEX };
90
99
  const strings = { type: "array", items: str };
91
100
 
@@ -96,13 +105,13 @@ const listOf = (properties) => ({ type: "array", items: obj(properties) });
96
105
  // A family's top level: every field required, nothing else allowed.
97
106
  const report = (properties) => ({ ...obj(properties), additionalProperties: false });
98
107
 
99
- const issues = listOf({ issue: int, subject: str });
108
+ const issues = listOf({ issue: id, subject: str });
100
109
 
101
110
  export const SCHEMAS = {
102
111
  impl: report({
103
112
  member: str,
104
113
  outcome: { type: "string", enum: ["pr", "bailed"] },
105
- pr: { type: ["integer", "null"] },
114
+ pr: { ...id, type: ["integer", "null"] },
106
115
  head: { type: ["string", "null"], pattern: HEX },
107
116
  branch: str,
108
117
  sizing: obj(
@@ -119,7 +128,7 @@ export const SCHEMAS = {
119
128
  head: sha,
120
129
  branch: str,
121
130
  applied: listOf({ bucket: str, index: int }),
122
- deferred: listOf({ bucket: str, index: int, issue: int }),
131
+ deferred: listOf({ bucket: str, index: int, issue: id }),
123
132
  filed: issues,
124
133
  unrecorded: issues,
125
134
  dispositionsRecord: { type: "string", pattern: "^/" },
@@ -141,11 +150,11 @@ export const SCHEMAS = {
141
150
  }),
142
151
  "merge-bot": report({
143
152
  member: str,
144
- merged: listOf({ pr: int, sha }),
145
- held: listOf({ pr: int, behind: int }),
146
- conflictHold: { type: "array", items: int },
147
- refused: listOf({ pr: int, reason: str }),
148
- branchKept: listOf({ pr: int, by: int }),
153
+ merged: listOf({ pr: id, sha }),
154
+ held: listOf({ pr: id, behind: id }),
155
+ conflictHold: { type: "array", items: id },
156
+ refused: listOf({ pr: id, reason: str }),
157
+ branchKept: listOf({ pr: id, by: id }),
149
158
  }),
150
159
  };
151
160
 
@@ -172,6 +181,10 @@ export function validate(schema, value, path = "$") {
172
181
  if (typeof value === "string" && schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
173
182
  return `${path}: ${show(value)} does not match ${schema.pattern}`;
174
183
  }
184
+ if (typeof value === "number") {
185
+ if (schema.minimum !== undefined && value < schema.minimum) return `${path}: ${show(value)} is below minimum ${schema.minimum}`;
186
+ if (schema.maximum !== undefined && value > schema.maximum) return `${path}: ${show(value)} is above maximum ${schema.maximum}`;
187
+ }
175
188
  if (isObject(value)) {
176
189
  for (const key of schema.required ?? []) {
177
190
  if (!Object.hasOwn(value, key)) return `${path}: missing required field ${show(key)}`;
@@ -196,6 +209,57 @@ export function validate(schema, value, path = "$") {
196
209
  return null;
197
210
  }
198
211
 
212
+ // The first duplicate key of `text` in text order, as `<path>: duplicate key
213
+ // "<key>"`, or null. `text` is well-formed JSON, so nothing here can meet a
214
+ // malformed token. Keys are compared decoded, as JSON.parse collides them.
215
+ function firstDuplicateKey(text) {
216
+ // One frame per open container: an object's keys so far, an array's index.
217
+ const open = [];
218
+ let next = "$"; // the path of the value that starts next
219
+ let i = 0;
220
+ while (i < text.length) {
221
+ const c = text[i];
222
+ const top = open[open.length - 1];
223
+ if (c === "{") {
224
+ open.push({ path: next, keys: new Set(), wantKey: true });
225
+ } else if (c === "[") {
226
+ open.push({ path: next, index: 0 });
227
+ next = `${next}[0]`;
228
+ } else if (c === "}" || c === "]") {
229
+ open.pop();
230
+ } else if (c === ",") {
231
+ if (top.keys === undefined) next = `${top.path}[${++top.index}]`;
232
+ else top.wantKey = true;
233
+ } else if (c === '"') {
234
+ let end = i + 1;
235
+ while (text[end] !== '"') end += text[end] === "\\" ? 2 : 1;
236
+ if (top?.wantKey) {
237
+ const key = JSON.parse(text.slice(i, end + 1));
238
+ next = `${top.path}.${key}`;
239
+ if (top.keys.has(key)) return `${next}: duplicate key ${show(key)}`;
240
+ top.keys.add(key);
241
+ top.wantKey = false;
242
+ }
243
+ i = end;
244
+ }
245
+ i++;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /** `text` read as a report: `{ value }`, or `{ violation }` for text that is
251
+ * not JSON or that repeats a key in any object at any depth. */
252
+ export function parseReport(text) {
253
+ let value;
254
+ try {
255
+ value = JSON.parse(text);
256
+ } catch (e) {
257
+ return { violation: `$: not JSON (${e.message})` };
258
+ }
259
+ const violation = firstDuplicateKey(text);
260
+ return violation === null ? { value } : { violation };
261
+ }
262
+
199
263
  function familyOf(name) {
200
264
  if (!Object.hasOwn(SCHEMAS, name)) die(`unknown family '${name}' — known: ${Object.keys(SCHEMAS).join(", ")}`);
201
265
  return SCHEMAS[name];
@@ -222,14 +286,8 @@ function main() {
222
286
  } catch (e) {
223
287
  die(`cannot read ${path}: ${e.code ?? e.message}`);
224
288
  }
225
- let value;
226
- try {
227
- value = JSON.parse(text);
228
- } catch (e) {
229
- writeAll(1, `invalid: $: not JSON (${e.message})\n`);
230
- process.exit(1);
231
- }
232
- const violation = validate(schema, value);
289
+ const parsed = parseReport(text);
290
+ const violation = parsed.violation ?? validate(schema, parsed.value);
233
291
  if (violation !== null) {
234
292
  writeAll(1, `invalid: ${violation}\n`);
235
293
  process.exit(1);
@@ -75,6 +75,7 @@ older-version baseline and a baseline whose commit is missing from the
75
75
  repository; Phase 3's tick lines say how each is cleared.
76
76
  `main-checkout.mjs --check` prints the same answer
77
77
  without a tick.
78
+ On `MAIN-CHECKOUT-DIRTY` the maintainer may instead ignore a stray path that is untracked and new since the baseline: an entry in `.git/info/exclude` or in the global excludes file hides it from the check, and the hold clears on the next tick with no re-record. Ignoring never clears a tracked or staged file's change, which `git status` reports whatever the excludes say, nor a pattern that also matches a path the baseline holds, which then reads as changed. Ignoring is the maintainer's decision, never the controller's: the controller never writes an exclude or a `.gitignore` entry. Keep the pattern narrow, because an ignored path is invisible to this check, so a later member write that matches it is never seen.
78
79
 
79
80
  ## Run the pre-flight
80
81
 
@@ -129,6 +129,7 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
129
129
  checkout and the close-out commit's round trip through a branch; **Fix the
130
130
  tooling mid-run** and the commit section of references/close-out.md say how
131
131
  each is cleared.
132
+ The maintainer may instead ignore a stray path that is untracked and new since the baseline: an entry in `.git/info/exclude` or in the global excludes file hides it from the check, and the hold clears on the next tick with no re-baseline. Ignoring never clears a tracked or staged file's change, which `git status` reports whatever the excludes say, nor a pattern that also matches a path the baseline holds, which then reads as changed. Ignoring is the maintainer's decision; the controller never writes an exclude or a `.gitignore` entry. Keep the pattern narrow, because an ignored path is invisible to this check, so a member write that matches it is never seen.
132
133
  - `MAIN-CHECKOUT-UNKNOWN …` with `HOLD (main checkout unknown)` — the tick
133
134
  could not look: a git or hash read failed (`could not look:`), or the
134
135
  baseline exists but cannot be read (`baseline unreadable:`). Never clean,