@codyswann/lisa 3.47.5 → 3.47.7

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.
Files changed (105) hide show
  1. package/all/copy-overwrite/scripts/lib/gate-failure-diagnosis.mjs +270 -0
  2. package/all/copy-overwrite/scripts/lisa-gates.mjs +30 -0
  3. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +294 -34
  4. package/all/copy-overwrite/scripts/lisa-work-item.mjs +83 -26
  5. package/dist/configs/eslint/expo.d.ts +50 -1
  6. package/dist/configs/eslint/expo.d.ts.map +1 -1
  7. package/dist/configs/eslint/expo.js +86 -5
  8. package/dist/configs/eslint/expo.js.map +1 -1
  9. package/dist/core/downstream-names.d.ts +83 -0
  10. package/dist/core/downstream-names.d.ts.map +1 -0
  11. package/dist/core/downstream-names.js +289 -0
  12. package/dist/core/downstream-names.js.map +1 -0
  13. package/dist/core/downstream-references.d.ts +21 -6
  14. package/dist/core/downstream-references.d.ts.map +1 -1
  15. package/dist/core/downstream-references.js +19 -16
  16. package/dist/core/downstream-references.js.map +1 -1
  17. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  18. package/dist/core/lisa-owned-hash-ledger.js +14 -0
  19. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  20. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  21. package/dist/core/upstream-evidence-manifest.js +27 -13
  22. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  23. package/dist/migrations/ensure-tsconfig-local-includes.d.ts +1 -1
  24. package/dist/migrations/ensure-tsconfig-local-includes.js +1 -1
  25. package/expo/copy-overwrite/eslint.config.ts +9 -0
  26. package/expo/copy-overwrite/eslint.expo.ts +92 -4
  27. package/package.json +5 -2
  28. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa/rules/reference/design-value-binding.md +4 -0
  31. package/plugins/lisa/scripts/automation-run-record.mjs +124 -4
  32. package/plugins/lisa/scripts/automation-status-claude-adapter.mjs +9 -1
  33. package/plugins/lisa/scripts/automation-status-codex-adapter.mjs +9 -1
  34. package/plugins/lisa/scripts/automation-status-run-history.mjs +34 -3
  35. package/plugins/lisa-agy/plugin.json +1 -1
  36. package/plugins/lisa-agy/scripts/automation-run-record.mjs +124 -4
  37. package/plugins/lisa-agy/scripts/automation-status-claude-adapter.mjs +9 -1
  38. package/plugins/lisa-agy/scripts/automation-status-codex-adapter.mjs +9 -1
  39. package/plugins/lisa-agy/scripts/automation-status-run-history.mjs +34 -3
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-copilot/rules/reference/design-value-binding.md +4 -0
  47. package/plugins/lisa-copilot/scripts/automation-run-record.mjs +124 -4
  48. package/plugins/lisa-copilot/scripts/automation-status-claude-adapter.mjs +9 -1
  49. package/plugins/lisa-copilot/scripts/automation-status-codex-adapter.mjs +9 -1
  50. package/plugins/lisa-copilot/scripts/automation-status-run-history.mjs +34 -3
  51. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +4 -0
  53. package/plugins/lisa-cursor/scripts/automation-run-record.mjs +124 -4
  54. package/plugins/lisa-cursor/scripts/automation-status-claude-adapter.mjs +9 -1
  55. package/plugins/lisa-cursor/scripts/automation-status-codex-adapter.mjs +9 -1
  56. package/plugins/lisa-cursor/scripts/automation-status-run-history.mjs +34 -3
  57. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-expo-agy/plugin.json +1 -1
  60. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  70. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  75. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  80. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails-agy/plugin.json +1 -1
  85. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  90. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  95. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/src/base/rules/reference/design-value-binding.md +4 -0
  98. package/plugins/src/base/scripts/automation-run-record.mjs +124 -4
  99. package/plugins/src/base/scripts/automation-status-claude-adapter.mjs +9 -1
  100. package/plugins/src/base/scripts/automation-status-codex-adapter.mjs +9 -1
  101. package/plugins/src/base/scripts/automation-status-run-history.mjs +34 -3
  102. package/scripts/lisa-mutation.mjs +13 -0
  103. package/typescript/copy-contents/.husky/pre-push +7 -1
  104. package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +496 -122
  105. package/typescript/package-lisa/package.lisa.json +1 -0
@@ -18,11 +18,19 @@
18
18
  *
19
19
  * - An `optional` gate that FAILS prints as FAILED. It does not block, and it
20
20
  * is not swallowed. "Optional" governs the response, never the verdict.
21
- * - When a required gate fails we stop, and every gate after it prints as
22
- * NOT RUN. Fail-fast is what the hardcoded hooks always did; what would be
23
- * new — and wrong — is letting an unrun gate look proved.
21
+ * - When a required gate fails, the cheap gates behind it still run and the
22
+ * COSTLY ones print as NOT RUN with their verdict called UNKNOWN. What would
23
+ * be new — and wrong — is letting an unrun gate look proved, or letting it
24
+ * look like a gate that had nothing to run.
24
25
  * - A gate that resolves to no command is UNPROVABLE, not passing. Nothing
25
26
  * executed, so nothing was proved, whatever its level says.
27
+ * - A FAILED gate says WHICH failure it was, and WHOSE it was. `exit 1` is the
28
+ * same sentence for a coverage regression and for a subprocess starved past
29
+ * its timeout, and when two opposite facts share a sentence the cheaper
30
+ * response — re-run — is the rational one for both, so the real regression is
31
+ * never looked at. A gate that merely SHARES the failing prover reports
32
+ * UNPROVABLE rather than FAILED: it blocks, and it stops claiming a
33
+ * measurement that never happened.
26
34
  *
27
35
  * ## Exit codes are the hook's control flow
28
36
  *
@@ -34,9 +42,17 @@
34
42
  */
35
43
 
36
44
  import { spawnSync } from "node:child_process";
37
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
45
+ import {
46
+ existsSync,
47
+ mkdtempSync,
48
+ readFileSync,
49
+ rmSync,
50
+ writeFileSync,
51
+ } from "node:fs";
52
+ import { tmpdir } from "node:os";
38
53
  import { join } from "node:path";
39
54
 
55
+ import { diagnoseFailure } from "./lib/gate-failure-diagnosis.mjs";
40
56
  import { invokedAsScript } from "./lib/invoked-as-script.mjs";
41
57
  import { readGates, resolveMoment } from "./lisa-gates.mjs";
42
58
 
@@ -170,6 +186,8 @@ export const CONDITIONAL_FLOOR = Object.freeze({
170
186
  * @property {string} state One of `STATE`.
171
187
  * @property {string} detail The command, the skip reason, or the failure note.
172
188
  * @property {number|null} code Exit code; null when nothing ran or was killed.
189
+ * @property {string|null} [diagnosis] Which failure this was, from `DIAGNOSIS`.
190
+ * @property {string[]} [evidence] Concrete lines backing the diagnosis.
173
191
  */
174
192
 
175
193
  /**
@@ -177,10 +195,12 @@ export const CONDITIONAL_FLOOR = Object.freeze({
177
195
  * @typedef {object} GateRun
178
196
  * @property {string} moment The moment that was run.
179
197
  * @property {boolean} blocked Whether a required gate went unproved.
198
+ * @property {string|null} blockedBy The first required gate that went unproved.
180
199
  * @property {number} total Gates declared at this moment.
181
200
  * @property {GateOutcome[]} results Every gate, in execution order.
182
201
  * @property {GateOutcome[]} passed Gates that ran and exited zero.
183
202
  * @property {GateOutcome[]} failed Gates that failed or could not be proved.
203
+ * @property {GateOutcome[]} unprovable Gates that ran and proved nothing.
184
204
  * @property {GateOutcome[]} skipped Gates with nothing to run locally.
185
205
  * @property {GateOutcome[]} notRun Gates queued behind a blocking failure.
186
206
  */
@@ -232,26 +252,102 @@ function formatLine(state, gate, detail) {
232
252
  return ` ${token}${gate.level.padEnd(9)}${id}${detail}`;
233
253
  }
234
254
 
255
+ /**
256
+ * Read an executor's answer, whichever of the two shapes it returned.
257
+ *
258
+ * An executor may answer with a bare exit code or with `{code, output}`. Both
259
+ * are supported on purpose: the exit code is the whole verdict, and the output
260
+ * is only ever used to EXPLAIN a verdict already reached. An executor that
261
+ * cannot capture output — a stub, a Windows shell, a run with capture switched
262
+ * off — therefore loses the diagnosis and nothing else.
263
+ * @param {number|null|undefined|{code: number|null, output: string|null}} raw
264
+ * Whatever the executor returned.
265
+ * @returns {{code: number|null, output: string|null}} The normalised answer.
266
+ */
267
+ function normaliseExec(raw) {
268
+ if (raw !== null && typeof raw === "object") {
269
+ return {
270
+ code: typeof raw.code === "number" ? raw.code : null,
271
+ output: typeof raw.output === "string" ? raw.output : null,
272
+ };
273
+ }
274
+ return { code: typeof raw === "number" ? raw : null, output: null };
275
+ }
276
+
235
277
  /**
236
278
  * Execute one gate and classify the result.
237
279
  *
238
280
  * A `null` or `undefined` exit code — which `spawnSync` produces when a child
239
281
  * is killed by a signal — is a failure. It is emphatically not a pass: the
240
282
  * command was terminated, so whatever it was proving went unproved.
283
+ *
284
+ * A failure carries WHICH failure it was. `exit 1` alone cannot distinguish a
285
+ * coverage regression from a starved subprocess, so the two render identically
286
+ * and an operator's only rational response to either is to re-run — which is
287
+ * how a real regression hides behind a flake.
241
288
  * @param {GateOutcome} gate The resolved gate.
242
- * @param {function(string, GateOutcome): (number|null)} exec Command executor.
243
- * @returns {{state: string, detail: string, code: number|null}} The outcome.
289
+ * @param {function(string, GateOutcome): (number|null|object)} exec Executor.
290
+ * @returns {{state: string, detail: string, code: number|null,
291
+ * diagnosis: string|null, evidence: string[]}} The outcome.
244
292
  */
245
293
  function execute(gate, exec) {
246
- const code = exec(gate.command, gate);
294
+ const { code, output } = normaliseExec(exec(gate.command, gate));
247
295
  if (code === 0) {
248
- return { state: STATE.PASSED, detail: gate.command, code: 0 };
296
+ return {
297
+ state: STATE.PASSED,
298
+ detail: gate.command,
299
+ code: 0,
300
+ diagnosis: null,
301
+ evidence: [],
302
+ };
249
303
  }
250
304
  const shown = typeof code === "number" ? code : "terminated";
305
+ const diagnosis = diagnoseFailure(output);
251
306
  return {
252
307
  state: STATE.FAILED,
253
- detail: `${gate.command} (exit ${shown})`,
308
+ detail: `${gate.command} (exit ${shown}) — ${diagnosis.summary}`,
254
309
  code: typeof code === "number" ? code : null,
310
+ diagnosis: diagnosis.kind,
311
+ evidence: diagnosis.evidence,
312
+ proves: diagnosis.proves,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Re-attribute one command's failure to the property it actually belongs to.
318
+ *
319
+ * Saying WHICH failure it was leaves half the defect standing. Two gates
320
+ * legitimately share one prover, so when that prover exits nonzero the runner
321
+ * still reports the failure against BOTH — and one of the two properties was
322
+ * never measured at all. A starved suite therefore still printed
323
+ * `coverage-adequacy FAILED`, now with a sentence attached saying it was not a
324
+ * coverage problem: better, and still a gate claiming a verdict it does not
325
+ * have.
326
+ *
327
+ * So a gate that shares a failing prover with the property the transcript
328
+ * actually indicts reports UNPROVABLE instead. It still blocks — an unmeasured
329
+ * required property is not a pass — but it stops asserting a measurement that
330
+ * never happened, which is what made a real coverage regression indistinguishable
331
+ * from a busy machine.
332
+ *
333
+ * `siblings` is the guard that keeps this honest: an attribution may only ever
334
+ * move a verdict onto a gate that is itself declared on this same command at
335
+ * this same moment. A phrase in some unrelated tool's output cannot invent one.
336
+ * @param {GateOutcome} gate The gate the verdict is being reported for.
337
+ * @param {object} outcome The outcome the command produced.
338
+ * @param {Set<string>} siblings Gate ids sharing this command at this moment.
339
+ * @returns {object} The outcome as it should read for THIS gate.
340
+ */
341
+ function attributed(gate, outcome, siblings) {
342
+ if (outcome.state !== STATE.FAILED) return outcome;
343
+ const owner = outcome.proves;
344
+ if (!owner || owner === gate.id || !siblings.has(owner)) return outcome;
345
+ return {
346
+ ...outcome,
347
+ state: STATE.UNPROVABLE,
348
+ detail:
349
+ `${outcome.detail}. That is a ${owner} failure, so ${gate.id} was ` +
350
+ `never measured by this run`,
255
351
  };
256
352
  }
257
353
 
@@ -284,7 +380,8 @@ function classifyStatic(gate) {
284
380
  */
285
381
  function summarise(result) {
286
382
  const lines = [];
287
- const byLevel = level => result.failed.filter(entry => entry.level === level);
383
+ const truly = result.failed.filter(entry => entry.state === STATE.FAILED);
384
+ const byLevel = level => truly.filter(entry => entry.level === level);
288
385
  const failedOptional = byLevel("optional");
289
386
 
290
387
  for (const entry of byLevel("required")) {
@@ -296,19 +393,44 @@ function summarise(result) {
296
393
  `(reported, not blocking)`
297
394
  );
298
395
  }
396
+ // Its own headline, and deliberately not the word "failed". A property
397
+ // nobody measured has not been found wanting, and saying it failed sends the
398
+ // reader hunting a regression that may not exist — which is exactly what six
399
+ // sightings of a starved suite each cost.
400
+ for (const entry of result.unprovable) {
401
+ lines.push(
402
+ `❓ ${entry.level} gate NOT PROVED: ${entry.id} — ${entry.detail}`
403
+ );
404
+ }
405
+ // Two different sentences on purpose. Rendering these as one "skipped"
406
+ // bucket is what makes an early failure produce a report whose later gates
407
+ // are silently unknown, with nothing telling the reader that they are.
299
408
  if (result.notRun.length) {
300
409
  lines.push(
301
- `⏭️ ${result.notRun.length} gate(s) NOT RUN after the blocking failure: ` +
302
- `${result.notRun.map(entry => entry.id).join(", ")}`
410
+ `❓ ${result.notRun.length} gate(s) UNKNOWN never ran, verdict not ` +
411
+ `established: ${result.notRun.map(entry => entry.id).join(", ")}`
412
+ );
413
+ lines.push(
414
+ ` Each of those may pass or fail; this run does not say which. ` +
415
+ `${result.blockedBy} failed first and stopped them.`
303
416
  );
304
417
  }
305
- if (result.blocked) return lines;
418
+ if (result.blocked) {
419
+ if (result.skipped.length) {
420
+ lines.push(
421
+ `⏭️ ${result.skipped.length} gate(s) NOT APPLICABLE here — their ` +
422
+ `verdict IS established, there was nothing to run: ` +
423
+ `${result.skipped.map(entry => entry.id).join(", ")}`
424
+ );
425
+ }
426
+ return lines;
427
+ }
306
428
 
307
429
  // Every count is stated, including the ones that are zero, so the headline
308
430
  // can never imply more was proved than actually ran.
309
431
  const counts =
310
432
  `${result.passed.length} proved, ${failedOptional.length} failed ` +
311
- `(optional), ${result.skipped.length} not provable here`;
433
+ `(optional), ${result.skipped.length} not applicable here`;
312
434
  lines.push(
313
435
  `${failedOptional.length ? "⚠️ " : "✅"} ${result.moment}: ${counts}, ` +
314
436
  `of ${result.total} gate(s) declared.${
@@ -332,33 +454,62 @@ function summarise(result) {
332
454
  * would understate what was proved — the mirror image of overstating it, and
333
455
  * just as untrue.
334
456
  * @param {GateOutcome} gate The resolved gate.
335
- * @param {{proved: Map<string, object>, blocked: boolean, exec: Function}} ctx Run state.
457
+ * @param {{proved: Map<string, object>, blockedBy: string|null, exec: Function}} ctx Run state.
336
458
  * @returns {{outcome: object, shared: object|undefined, ran: boolean}} The verdict.
337
459
  */
338
- function verdictFor(gate, { proved, blocked, exec }) {
460
+ function verdictFor(gate, { proved, blockedBy, exec, siblings }) {
339
461
  const own = classifyStatic(gate);
340
462
  if (own) return { outcome: own, shared: undefined, ran: false };
341
463
 
342
464
  const shared = gate.command ? proved.get(gate.command) : undefined;
343
465
  if (shared) {
466
+ // The RAW outcome is what is shared, and each gate reads it for itself.
467
+ // Sharing the already-attributed view would hand the second gate the first
468
+ // gate's reading of whose failure it was, which is how one exit code came
469
+ // to be reported as two failures in the first place.
470
+ const view = attributed(gate, shared.outcome, siblings);
344
471
  return {
345
472
  outcome: {
346
- ...shared.outcome,
347
- detail: `${shared.outcome.detail} (proved by the ${shared.id} run)`,
473
+ ...view,
474
+ detail: `${view.detail} (proved by the ${shared.id} run)`,
348
475
  },
349
476
  shared,
350
477
  ran: false,
351
478
  };
352
479
  }
353
480
 
354
- if (blocked) {
481
+ // "Not run" and "not applicable" are opposite facts and must never render
482
+ // as one another. A SKIPPED gate has a known verdict — there was nothing to
483
+ // run here, and that is the answer. This one has NO verdict: it might pass,
484
+ // it might fail, and this run does not say. Naming the blocker is what lets
485
+ // a reader tell which of the two they are looking at without guessing.
486
+ //
487
+ // Only COSTLY gates stop, though. Short-circuiting everything is what let an
488
+ // intermittent test failure take the work-item check and the type check with
489
+ // it — two checks that finish in under a minute and answer questions a test
490
+ // suite says nothing about. Those now run, so one attempt reports everything
491
+ // that is wrong. A second full suite still does not: paying minutes for
492
+ // information about a push that cannot land is a bad trade.
493
+ if (blockedBy && gate.costly) {
355
494
  return {
356
- outcome: { state: STATE.NOT_RUN, detail: "not run", code: null },
495
+ outcome: {
496
+ state: STATE.NOT_RUN,
497
+ detail:
498
+ `verdict UNKNOWN — never ran; ${blockedBy} failed first and this ` +
499
+ `gate is too expensive to run for information alone`,
500
+ code: null,
501
+ },
357
502
  shared: undefined,
358
503
  ran: false,
359
504
  };
360
505
  }
361
- return { outcome: execute(gate, exec), shared: undefined, ran: true };
506
+ const raw = execute(gate, exec);
507
+ return {
508
+ outcome: attributed(gate, raw, siblings),
509
+ raw,
510
+ shared: undefined,
511
+ ran: true,
512
+ };
362
513
  }
363
514
 
364
515
  /**
@@ -384,7 +535,24 @@ export function runGates({
384
535
  }) {
385
536
  const resolved = resolveMoment({ gates, moment, runner });
386
537
  const results = [];
387
- let blocked = false;
538
+ // The id of the first required gate to go unproved, not merely a boolean:
539
+ // every gate queued behind it has to be able to say WHAT stopped it, or its
540
+ // line is indistinguishable from a gate that had nothing to run.
541
+ let blockedBy = null;
542
+
543
+ // Which gates name each command, built before anything runs. Attribution
544
+ // needs the answer on the FIRST execution: gates run alphabetically, so the
545
+ // gate that executes a shared prover is usually not the gate whose property
546
+ // the failure belongs to — `coverage-adequacy` runs `test:cov` and
547
+ // `test-correctness` is the one the timeout indicts.
548
+ const sharers = new Map();
549
+ for (const gate of resolved) {
550
+ if (!gate.command) continue;
551
+ sharers.set(
552
+ gate.command,
553
+ (sharers.get(gate.command) ?? new Set()).add(gate.id)
554
+ );
555
+ }
388
556
 
389
557
  // One command proves every gate that names it, so it runs once.
390
558
  //
@@ -401,10 +569,11 @@ export function runGates({
401
569
  // gate can run for minutes, and an operator watching a hook needs to know
402
570
  // which gate is being proved right now, not only what the tally was.
403
571
  for (const gate of resolved) {
404
- const { outcome, shared, ran } = verdictFor(gate, {
572
+ const { outcome, shared, ran, raw } = verdictFor(gate, {
405
573
  proved,
406
- blocked,
574
+ blockedBy,
407
575
  exec,
576
+ siblings: sharers.get(gate.command) ?? new Set(),
408
577
  });
409
578
 
410
579
  // Only what this iteration actually executed is shareable, and `ran` says
@@ -414,28 +583,32 @@ export function runGates({
414
583
  // next gate to run it again — re-running a whole suite an operator has
415
584
  // just interrupted.
416
585
  if (ran && gate.command) {
417
- proved.set(gate.command, { id: gate.id, outcome });
586
+ proved.set(gate.command, { id: gate.id, outcome: raw ?? outcome });
418
587
  }
419
588
 
420
589
  results.push({ ...gate, ...outcome, provedBy: shared?.id ?? null });
421
590
  out(formatLine(outcome.state, gate, outcome.detail));
591
+ for (const line of outcome.evidence ?? []) out(`${" ".repeat(6)}↳ ${line}`);
422
592
  const unproved =
423
593
  outcome.state === STATE.FAILED || outcome.state === STATE.UNPROVABLE;
424
594
  // The strictest level wins when gates share a prover. Letting a required
425
595
  // gate inherit only the pass, or letting a failure count only against the
426
596
  // optional gate that ran first, would be the original defect again: a
427
597
  // required gate satisfied by a run that failed.
428
- if (unproved && gate.level === "required") blocked = true;
598
+ if (unproved && gate.level === "required" && !blockedBy)
599
+ blockedBy = gate.id;
429
600
  }
430
601
 
431
602
  const bucket = state => results.filter(entry => entry.state === state);
432
603
  const result = {
433
604
  moment,
434
- blocked,
605
+ blocked: blockedBy !== null,
606
+ blockedBy,
435
607
  total: resolved.length,
436
608
  results,
437
609
  passed: bucket(STATE.PASSED),
438
610
  failed: [...bucket(STATE.FAILED), ...bucket(STATE.UNPROVABLE)],
611
+ unprovable: bucket(STATE.UNPROVABLE),
439
612
  skipped: bucket(STATE.SKIPPED),
440
613
  notRun: bucket(STATE.NOT_RUN),
441
614
  };
@@ -522,18 +695,105 @@ function readPackageScripts(cwd) {
522
695
  }
523
696
 
524
697
  /**
525
- * The default executor: run the command in a shell, inheriting stdio.
698
+ * How much of a gate's output is kept for diagnosis. A suite of 14,000 tests
699
+ * prints megabytes; the signatures that matter are all near the end.
700
+ */
701
+ const CAPTURE_TAIL_BYTES = 512 * 1024;
702
+
703
+ /**
704
+ * Run the command in a shell with stdio inherited, capturing nothing.
705
+ *
706
+ * This is the original executor, kept verbatim as the fallback, so that
707
+ * everything about capture is additive: if capture is unavailable or refused,
708
+ * the exit code is produced by exactly the code path that produced it before.
709
+ * @param {string} command The command line to run.
710
+ * @returns {{code: number|null, output: null}} Exit code; null when killed.
711
+ */
712
+ function plainExec(command) {
713
+ const child = spawnSync(command, { shell: true, stdio: "inherit" });
714
+ if (child.error) return { code: null, output: null };
715
+ return { code: child.status, output: null };
716
+ }
717
+
718
+ /**
719
+ * Whether this machine can tee a gate's output without changing its verdict.
720
+ *
721
+ * Probed rather than assumed: on a shell without `tee` — Windows `cmd`, a
722
+ * stripped container — the wrapper below would produce no status file, and the
723
+ * runner would report every gate as terminated. A capability the runner cannot
724
+ * confirm is one it does not use.
725
+ * @returns {boolean} Whether to take the capturing path.
726
+ */
727
+ function captureAvailable() {
728
+ if (process.env.LISA_GATES_CAPTURE === "0") return false;
729
+ const probe = spawnSync("sh", ["-c", "command -v tee"], { stdio: "ignore" });
730
+ return !probe.error && probe.status === 0;
731
+ }
732
+
733
+ /**
734
+ * Read back what the wrapper recorded, keeping only the tail of the log.
735
+ * @param {string} statusPath File the wrapper wrote the exit code into.
736
+ * @param {string} logPath File the wrapper tee'd the output into.
737
+ * @returns {{code: number|null, output: string|null}} The recorded answer.
738
+ */
739
+ function readCaptured(statusPath, logPath) {
740
+ let output = null;
741
+ try {
742
+ output = readFileSync(logPath, "utf8").slice(-CAPTURE_TAIL_BYTES);
743
+ } catch {
744
+ output = null;
745
+ }
746
+ try {
747
+ const code = Number.parseInt(readFileSync(statusPath, "utf8").trim(), 10);
748
+ // Fail closed. An unreadable status is not a zero: the one thing this
749
+ // runner may never do is turn "I do not know" into "it passed".
750
+ return { code: Number.isInteger(code) ? code : null, output };
751
+ } catch {
752
+ return { code: null, output };
753
+ }
754
+ }
755
+
756
+ /**
757
+ * The default executor: run the command in a shell, streaming AND recording.
526
758
  *
527
759
  * stdio is inherited so a failing gate's own output reaches the operator
528
- * unaltered. Swallowing it and reprinting a summary is how a failure becomes
529
- * unactionable.
760
+ * unaltered and as it happens a push gate runs for minutes and an operator
761
+ * needs to watch it, not receive it in a lump at the end. `tee` is what lets
762
+ * both be true: the operator sees the stream, and the runner keeps a copy to
763
+ * say WHICH failure it was rather than only that there was one.
764
+ *
765
+ * The price is that the command's stdout is a pipe rather than a terminal, so
766
+ * tools that colourise or animate for a TTY print their plain form. That is a
767
+ * deliberate trade: a plain, diagnosable failure beats a coloured, mute one.
768
+ * `LISA_GATES_CAPTURE=0` buys the colour back at the cost of the diagnosis.
769
+ *
770
+ * The exit code comes from a status file written INSIDE the pipeline, never
771
+ * from the pipeline itself — a pipeline reports `tee`'s status, which is
772
+ * almost always zero, and reading it would report every failing gate as
773
+ * passing. That is the single most dangerous mistake available here.
530
774
  * @param {string} command The command line to run.
531
- * @returns {number|null} Exit code, or null when killed by a signal.
775
+ * @returns {{code: number|null, output: string|null}} Exit code and output.
532
776
  */
533
777
  function spawnExec(command) {
534
- const child = spawnSync(command, { shell: true, stdio: "inherit" });
535
- if (child.error) return null;
536
- return child.status;
778
+ if (!captureAvailable()) return plainExec(command);
779
+ let dir;
780
+ try {
781
+ dir = mkdtempSync(join(tmpdir(), "lisa-gate-run-"));
782
+ } catch {
783
+ return plainExec(command);
784
+ }
785
+ const logPath = join(dir, "output.log");
786
+ const statusPath = join(dir, "status");
787
+ // Newline-separated inside a group, so a command ending in a trailing
788
+ // comment or redirection still has `echo` run as its own statement.
789
+ const script = `{\n${command}\necho $? > '${statusPath}'\n} 2>&1 | tee '${logPath}'\n`;
790
+ try {
791
+ const child = spawnSync("sh", ["-c", script], { stdio: "inherit" });
792
+ if (child.error) return { code: null, output: null };
793
+ return readCaptured(statusPath, logPath);
794
+ } finally {
795
+ rmSync(dir, { force: true, recursive: true });
796
+ }
537
797
  }
538
798
 
539
799
  /**
@@ -1856,9 +1856,10 @@ const SCOPE_ORDER = [OUTSIDE_THIS_PR, IN_THIS_PR];
1856
1856
  /**
1857
1857
  * Validate the commits, keeping a refusal rather than aborting the run.
1858
1858
  *
1859
- * The pull-request gate checks four separate things and used to stop at the
1859
+ * The pull-request gate checks several separate things and used to stop at the
1860
1860
  * first unmet one, so each CI cycle revealed exactly one requirement. It knew
1861
- * the rest at the same moment; it was simply not saying.
1861
+ * the rest at the same moment; it was simply not saying. See `gateSummary` for
1862
+ * the full count, which is five and was itself miscounted as four.
1862
1863
  * @param {string[]} commits Commits in the pull request range.
1863
1864
  * @returns {{result?: object, error?: Error}} Outcome, never a throw.
1864
1865
  */
@@ -1914,37 +1915,82 @@ function backlinkAdvice(ref, prUrl, contract) {
1914
1915
  }
1915
1916
 
1916
1917
  /**
1917
- * The four gates a work item passes on its way to merged, and when each bites.
1918
- *
1919
- * They are four separate checks, enforced in four different places, at four
1920
- * different moments — so clearing three says nothing about the fourth, and the
1921
- * reader has no way to learn the fourth exists until it goes red. Measured: two
1922
- * agents in one day each satisfied three and were surprised by the remaining
1923
- * one, on a pull request that was otherwise finished.
1924
- *
1925
- * Listing all four in the report an operator actually reads costs five lines
1926
- * and turns a sequence of surprises into one checklist. The role NAMES come
1927
- * from the resolved contract rather than from Lisa's own defaults, because a
1928
- * project configures its own, and a summary that confidently names the wrong
1929
- * label is worse than none.
1918
+ * The FIVE gates a work item passes on its way to merged, and when each bites.
1919
+ *
1920
+ * They are five separate checks, enforced in five different places, at five
1921
+ * different moments — so clearing four says nothing about the fifth, and the
1922
+ * reader has no way to learn the fifth exists until it goes red. Measured: two
1923
+ * agents in one day each satisfied three of them and were surprised by another,
1924
+ * on a pull request that was otherwise finished.
1925
+ *
1926
+ * This said FOUR until #2681 counted them properly. Gates 3 and 4 shared a
1927
+ * line "every commit AND the pull-request body carry ONE matching trailer"
1928
+ * which reads as one requirement and is two: the commit-msg hook enforces the
1929
+ * message, and a separate check enforces the BODY. An operator whose commits
1930
+ * carried the trailer read that line as cleared. Measured: an agent went
1931
+ * BLOCKED on "No Work-Item trailer anywhere in the pull request body" holding a
1932
+ * commit that carried the trailer, an item in the claimed role, and a backlink
1933
+ * already posted — four of the five satisfied, and the fifth invisible until CI
1934
+ * said so, one cycle later.
1935
+ *
1936
+ * `Closes owner/repo#N` is called out by name because it is the substitution a
1937
+ * reader reaches for: it closes the item on merge and satisfies nothing here.
1938
+ *
1939
+ * Listing all five in the report an operator actually reads costs six lines and
1940
+ * turns a sequence of surprises into one checklist. The role NAMES come from
1941
+ * the resolved contract rather than from Lisa's own defaults, because a project
1942
+ * configures its own — lifecycle is a workflow STATE on Jira and Linear and a
1943
+ * LABEL only on GitHub — and a summary that confidently names the wrong label
1944
+ * is worse than none.
1930
1945
  * @param {object} contract Resolved tracker contract.
1931
1946
  * @returns {string} The checklist.
1932
1947
  */
1933
1948
  function gateSummary(contract) {
1934
1949
  const backlink =
1935
1950
  contract.verify === "full"
1936
- ? `4. the item carries a managed \`${MARKER}\` backlink comment to this pull request — checked here, at CI time, and only under workItem.verify "full"`
1937
- : `4. the tracker backlink is NOT required here: workItem.verify is "trailer", so this run contacted no tracker`;
1951
+ ? `5. the item carries a managed \`${MARKER}\` backlink comment to this pull request — checked at push once a pull request exists, again at CI time, and only under workItem.verify "full"`
1952
+ : `5. the tracker backlink is NOT required here: workItem.verify is "trailer", so this run contacted no tracker`;
1938
1953
  return [
1939
1954
  "",
1940
- "All four gates, and when each one bites:",
1955
+ "All five gates, and when each one bites:",
1941
1956
  ` 1. the item carries the ready role "${contract.lifecycle.ready}" — required before the work may be created or claimed`,
1942
1957
  ` 2. the item carries the claimed role "${contract.lifecycle.claimed}" — required by the commit-msg hook, on every single commit`,
1943
- " 3. every commit AND the pull-request body carry ONE matching `Work-Item:` trailer — required on commit, on push, and here",
1958
+ " 3. every commit message carries ONE matching `Work-Item:` trailer — required by the commit-msg hook, on every single commit",
1959
+ " 4. the pull-request BODY carries that same `Work-Item:` trailer — a SEPARATE check from gate 3, run at push once a pull request exists and again at CI time; `Closes owner/repo#N` does NOT satisfy it",
1944
1960
  ` ${backlink}`,
1945
1961
  ].join("\n");
1946
1962
  }
1947
1963
 
1964
+ /**
1965
+ * The same refusal, with the whole checklist appended.
1966
+ *
1967
+ * Used where a check knows only its OWN gate — the commit-msg hook, and a push
1968
+ * with no pull request yet — so that the gates it cannot check are still named
1969
+ * at the earliest moment they are known, rather than at the next moment they
1970
+ * happen to be enforced.
1971
+ *
1972
+ * Re-resolving the contract here is safe rather than lucky, and the reason is
1973
+ * worth writing down because the obvious defensive try/catch around it is DEAD
1974
+ * CODE. Every caller reaches this only after a refusal, and both refusal paths
1975
+ * — `validateMessage` and `validateCommits` — call `trackerContract()` as their
1976
+ * first real step. So either the contract resolves, and resolving it a second
1977
+ * time from the same files resolves again; or it does not, in which case the
1978
+ * refusal being decorated IS the configuration error and re-resolving rethrows
1979
+ * that identical error. There is no third case, and no input reaches a branch
1980
+ * where the original message could be lost.
1981
+ *
1982
+ * That was established by mutation: a guard clause was written here first, with
1983
+ * a test for it, and deleting the guard left the test passing. An unreachable
1984
+ * branch and a test that cannot fail are the same defect wearing two hats.
1985
+ * @param {Error} error The refusal as raised.
1986
+ * @returns {Error} The refusal, with the checklist appended.
1987
+ */
1988
+ function withGateSummary(error) {
1989
+ return new TrackingError(
1990
+ `${error.message}\n${gateSummary(trackerContract())}`
1991
+ );
1992
+ }
1993
+
1948
1994
  /**
1949
1995
  * One refusal naming every unmet requirement, unrecoverable ones first.
1950
1996
  * @param {object[]} findings Unmet requirements.
@@ -2366,9 +2412,18 @@ function validateCommit(args) {
2366
2412
  const file = args[0];
2367
2413
  if (!file)
2368
2414
  throw new TrackingError("validate-commit requires the commit message file");
2369
- const result = validateMessage(readFileSync(file, "utf8"), {
2370
- allowInProgressMerge: true,
2371
- });
2415
+ let result;
2416
+ try {
2417
+ result = validateMessage(readFileSync(file, "utf8"), {
2418
+ allowInProgressMerge: true,
2419
+ });
2420
+ } catch (error) {
2421
+ // The commit-msg hook is the EARLIEST moment an operator meets any of this,
2422
+ // and all five gates are knowable here even though only two are enforced
2423
+ // here. Withholding the other three buys nothing and costs a CI cycle each.
2424
+ if (!(error instanceof TrackingError)) throw error;
2425
+ throw withGateSummary(error);
2426
+ }
2372
2427
  console.log(`WORK_ITEM_TRACKING_OK ${result.exempt ?? result.ref}`);
2373
2428
  }
2374
2429
 
@@ -2378,11 +2433,13 @@ function validatePush(args) {
2378
2433
  const outcome = commitOutcome(parsePushLines(input, remote));
2379
2434
  const pr = currentPullRequest();
2380
2435
  if (!pr) {
2381
- // No pull request means no body and no backlink to check, so there is
2382
- // nothing to report alongside a commit refusal raise it as it stands.
2383
- if (outcome.error) throw outcome.error;
2436
+ // No pull request means gates 4 and 5 cannot be CHECKED here. They are
2437
+ // still perfectly well KNOWN here, and this is the last local moment before
2438
+ // CI — so the checklist goes out either way. Saying nothing is precisely
2439
+ // what made those two gates separate CI-cycle surprises (#2681).
2440
+ if (outcome.error) throw withGateSummary(outcome.error);
2384
2441
  console.log(
2385
- `WORK_ITEM_TRACKING_OK ${outcome.result.relevant} commit(s); no pull request exists yet, CI will verify PR linkage`
2442
+ `WORK_ITEM_TRACKING_OK ${outcome.result.relevant} commit(s); no pull request exists yet, so gates 4 and 5 could not be checked here — CI will verify both${gateSummary(outcome.result.contract)}`
2386
2443
  );
2387
2444
  return;
2388
2445
  }