@codyswann/lisa 4.12.0 → 4.13.0

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 (75) hide show
  1. package/dist/cli/doctor-two-channel-drift.d.ts +20 -0
  2. package/dist/cli/doctor-two-channel-drift.d.ts.map +1 -0
  3. package/dist/cli/doctor-two-channel-drift.js +190 -0
  4. package/dist/cli/doctor-two-channel-drift.js.map +1 -0
  5. package/dist/cli/doctor-wiki.d.ts +8 -0
  6. package/dist/cli/doctor-wiki.d.ts.map +1 -0
  7. package/dist/cli/doctor-wiki.js +42 -0
  8. package/dist/cli/doctor-wiki.js.map +1 -0
  9. package/dist/cli/doctor.d.ts.map +1 -1
  10. package/dist/cli/doctor.js +7 -30
  11. package/dist/cli/doctor.js.map +1 -1
  12. package/dist/core/two-channel-delivery-scan.d.ts +81 -0
  13. package/dist/core/two-channel-delivery-scan.d.ts.map +1 -0
  14. package/dist/core/two-channel-delivery-scan.js +191 -0
  15. package/dist/core/two-channel-delivery-scan.js.map +1 -0
  16. package/dist/core/two-channel-delivery.d.ts +215 -0
  17. package/dist/core/two-channel-delivery.d.ts.map +1 -0
  18. package/dist/core/two-channel-delivery.js +259 -0
  19. package/dist/core/two-channel-delivery.js.map +1 -0
  20. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  21. package/dist/core/upstream-evidence-manifest.js +13 -0
  22. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  23. package/package.json +4 -2
  24. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  30. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo-agy/plugin.json +1 -1
  37. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  57. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails-agy/plugin.json +1 -1
  62. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  67. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  72. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  74. package/scripts/generate-two-channel-couplings.ts +427 -0
  75. package/scripts/two-channel-couplings.json +214 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Deterministic gate and ledger for Lisa's two-channel delivery gap (#3050).
3
+ *
4
+ * Lisa reaches a consumer down two channels at two speeds. Reusable workflow
5
+ * bodies travel at `@main` and are live on the consumer's next run, needing no
6
+ * action from them. Scripts and declarations travel by `lisa apply` and are
7
+ * live only where somebody applied. A change that REMOVES a built-in from a
8
+ * workflow and replaces it with something the consumer's tree must provide
9
+ * therefore lands in the wrong order, and nothing measured the gap: the
10
+ * replacement never runs, so it posts no context, and an absent required
11
+ * context is not a red one.
12
+ *
13
+ * This script measures it, for Lisa's own tree, before such a change ships.
14
+ * The reasoning about verdicts lives in `src/core/two-channel-delivery.ts`;
15
+ * the reasoning about how a workflow spells a path lives in
16
+ * `src/core/two-channel-delivery-scan.ts`. This file is the I/O around them.
17
+ *
18
+ * ## The ledger is the deliverable, not the exit code
19
+ *
20
+ * Fourteen of Lisa's fifteen host-only couplings are `apply-lagged`, and every
21
+ * one is deliberate. Failing on them would mean ratifying fourteen entries on
22
+ * day one, which turns an allowlist into a bypass. So they are RECORDED in
23
+ * `scripts/two-channel-couplings.json`, regenerated in-commit like Lisa's
24
+ * other derived artifacts, and a change that adds one shows up as a diff a
25
+ * reviewer must look at. That is the check the issue asked to be reachable
26
+ * before a change of this shape ships.
27
+ *
28
+ * The ledger also ships in the package — `scripts/` is in the npm `files`
29
+ * allowlist — which is what lets `lisa doctor` tell a consumer, on their own
30
+ * machine, which of the couplings their tree does not satisfy. The workflow
31
+ * bodies themselves are NOT in that allowlist, so a consumer cannot derive
32
+ * this locally; the ledger is the only way the fast half's requirements reach
33
+ * them at all.
34
+ *
35
+ * ## What fails
36
+ *
37
+ * - a `never-delivered` or `undelivered` coupling nobody ratified — the
38
+ * shape no apply and no bump ever closes;
39
+ * - a ratification matching no live coupling, because a permission left
40
+ * behind after its subject is gone is inherited for free by the next path
41
+ * that happens to match;
42
+ * - `--check` against a stale ledger;
43
+ * - and any run that measured nothing.
44
+ *
45
+ * Findings fail in BOTH modes. Regenerating the ledger records a coupling; it
46
+ * does not ratify one, and a gate you can regenerate your way past is not a
47
+ * gate.
48
+ *
49
+ * ## Refusing to pass on nothing
50
+ *
51
+ * Zero reusable workflows, zero steps, zero couplings, or an empty delivery
52
+ * inventory is exit 2 and never a clean pass. Every run prints what it
53
+ * inspected, so an empty sweep and a converged tree cannot produce the same
54
+ * output.
55
+ *
56
+ * CLI:
57
+ * bun scripts/generate-two-channel-couplings.ts [--root <dir>] [--check] [--json]
58
+ *
59
+ * Exit codes:
60
+ * 0 — every unrestorable coupling is ratified, and the ledger is current.
61
+ * 1 — an unratified finding, a stale ratification, or a stale ledger.
62
+ * 2 — operational: unknown flag, a flag missing its value, `--root` absent,
63
+ * or a run that measured nothing.
64
+ * @module scripts/generate-two-channel-couplings
65
+ */
66
+ import {
67
+ existsSync,
68
+ mkdirSync,
69
+ readdirSync,
70
+ readFileSync,
71
+ writeFileSync,
72
+ } from "node:fs";
73
+ import * as path from "node:path";
74
+ import process from "node:process";
75
+
76
+ import {
77
+ classifyTwoChannelDelivery,
78
+ type CouplingInput,
79
+ type TwoChannelReport,
80
+ } from "../src/core/two-channel-delivery.js";
81
+ import {
82
+ isReusable,
83
+ scanWorkflow,
84
+ } from "../src/core/two-channel-delivery-scan.js";
85
+
86
+ /** Where the fast channel's bodies live in this repository. */
87
+ const WORKFLOWS_DIR = path.join(".github", "workflows");
88
+
89
+ /** Where the ledger is written, inside the npm `files` allowlist. */
90
+ const LEDGER_PATH = path.join("scripts", "two-channel-couplings.json");
91
+
92
+ /** Every delivery lane a stack directory may carry. */
93
+ const LANES = [
94
+ "copy-overwrite",
95
+ "copy-contents",
96
+ "merge",
97
+ "tagged-merge",
98
+ "package-lisa",
99
+ "create-only",
100
+ ] as const;
101
+
102
+ /** Directories a consumer keeps its own executable artifacts in. */
103
+ const CALLER_DIRECTORIES = ["scripts", "bin", "tools"] as const;
104
+
105
+ /** Exit code for an operational failure — including "I could not look". */
106
+ const EXIT_OPERATIONAL = 2;
107
+
108
+ /** Parsed command line. */
109
+ interface Options {
110
+ readonly root: string;
111
+ readonly check: boolean;
112
+ readonly json: boolean;
113
+ }
114
+
115
+ /**
116
+ * Print usage guidance and exit.
117
+ * @param message - Why the invocation was rejected
118
+ * @returns Never; the process exits
119
+ */
120
+ function usage(message: string): never {
121
+ process.stderr.write(
122
+ `${message}\n\nUsage: bun scripts/generate-two-channel-couplings.ts [--root <dir>] [--check] [--json]\n`
123
+ );
124
+ process.exit(EXIT_OPERATIONAL);
125
+ }
126
+
127
+ /**
128
+ * Parse argv into options.
129
+ * @param argv - Arguments after the script name
130
+ * @returns The parsed options
131
+ */
132
+ function parseArgs(argv: readonly string[]): Options {
133
+ return argv.reduce<Options>(
134
+ (options, flag, index) => {
135
+ if (flag === "--check") return { ...options, check: true };
136
+ if (flag === "--json") return { ...options, json: true };
137
+ if (flag === "--root") {
138
+ const value = argv[index + 1];
139
+ if (value === undefined) usage("--root needs a value");
140
+ return { ...options, root: path.resolve(value) };
141
+ }
142
+ if (argv[index - 1] === "--root") return options;
143
+ return usage(`Unknown flag: ${flag}`);
144
+ },
145
+ { root: process.cwd(), check: false, json: false }
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Every file under one directory, as paths relative to it.
151
+ * @param directory - Absolute directory path
152
+ * @returns POSIX-style relative paths, empty when the directory is absent
153
+ */
154
+ function filesUnder(directory: string): readonly string[] {
155
+ if (!existsSync(directory)) return [];
156
+ return readdirSync(directory, { withFileTypes: true, recursive: true })
157
+ .filter(entry => entry.isFile())
158
+ .map(entry =>
159
+ path
160
+ .relative(directory, path.join(entry.parentPath, entry.name))
161
+ .split(path.sep)
162
+ .join("/")
163
+ );
164
+ }
165
+
166
+ /** One delivered caller-tree path and the lane delivering it. */
167
+ interface DeliveredPath {
168
+ readonly callerPath: string;
169
+ readonly lane: string;
170
+ }
171
+
172
+ /**
173
+ * Every caller-tree path one `<stack>/<lane>` pair delivers.
174
+ * @param root - Repository root
175
+ * @param stack - Stack directory name
176
+ * @param lane - Delivery lane name
177
+ * @returns One entry per delivered file
178
+ */
179
+ function deliveredBy(
180
+ root: string,
181
+ stack: string,
182
+ lane: string
183
+ ): readonly DeliveredPath[] {
184
+ return CALLER_DIRECTORIES.flatMap(directory =>
185
+ filesUnder(path.join(root, stack, lane, directory)).map(relative => ({
186
+ callerPath: `${directory}/${relative}`,
187
+ lane: `${stack}/${lane}`,
188
+ }))
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Index every caller-tree path Lisa delivers, and the lanes delivering it.
194
+ *
195
+ * Only the caller directories are indexed. The question this script asks is
196
+ * "does a workflow's caller-tree read arrive", and a path outside `scripts/`,
197
+ * `bin/`, or `tools/` is never one of those reads — indexing the whole
198
+ * template estate would make the inventory count large and meaningless.
199
+ *
200
+ * Stacks are sorted, and each path's lanes are sorted again below.
201
+ * `readdirSync` order is the filesystem's, and it differs between the two
202
+ * runtimes that invoke this file — the ledger rendered under bun and under
203
+ * vitest disagreed about the order of `check-threshold-ratchet.mjs`'s two
204
+ * lanes, and a derived artifact whose bytes depend on who generated it cannot
205
+ * be checked for staleness.
206
+ * @param root - Repository root
207
+ * @returns Caller path to the `<stack>/<lane>` lanes shipping it
208
+ */
209
+ export function buildDeliveryInventory(
210
+ root: string
211
+ ): ReadonlyMap<string, readonly string[]> {
212
+ const stacks = readdirSync(root, { withFileTypes: true })
213
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith("."))
214
+ .map(entry => entry.name)
215
+ .sort((left, right) => left.localeCompare(right));
216
+ const delivered = stacks.flatMap(stack =>
217
+ LANES.flatMap(lane => deliveredBy(root, stack, lane))
218
+ );
219
+ return delivered.reduce(
220
+ (inventory, entry) =>
221
+ new Map(inventory).set(
222
+ entry.callerPath,
223
+ [...(inventory.get(entry.callerPath) ?? []), entry.lane].sort(
224
+ (left, right) => left.localeCompare(right)
225
+ )
226
+ ),
227
+ new Map<string, readonly string[]>()
228
+ );
229
+ }
230
+
231
+ /** The ledger's hand-authored half plus its derived half. */
232
+ interface Ledger {
233
+ readonly ratified: Readonly<Record<string, string>>;
234
+ readonly inspected: TwoChannelReport["inspected"];
235
+ readonly counts: TwoChannelReport["counts"];
236
+ readonly couplings: readonly {
237
+ readonly key: string;
238
+ readonly workflow: string;
239
+ readonly path: string;
240
+ readonly channel: string;
241
+ readonly verdict: string;
242
+ readonly remedy: string;
243
+ readonly guarded: boolean;
244
+ readonly lanes: readonly string[];
245
+ readonly detail: string;
246
+ }[];
247
+ }
248
+
249
+ /**
250
+ * Read the ratifications already recorded, so regenerating never drops them.
251
+ * @param ledgerPath - Absolute path to the ledger
252
+ * @returns The ratification reasons, keyed `<workflow>::<path>`
253
+ */
254
+ function readRatified(ledgerPath: string): Readonly<Record<string, string>> {
255
+ if (!existsSync(ledgerPath)) return {};
256
+ const parsed = JSON.parse(readFileSync(ledgerPath, "utf8")) as
257
+ | Partial<Ledger>
258
+ | undefined;
259
+ return parsed?.ratified ?? {};
260
+ }
261
+
262
+ /**
263
+ * Render the ledger a report implies.
264
+ *
265
+ * `package-backed` couplings are omitted: they are covered by the package
266
+ * channel, they outnumber the interesting ones several times over, and a
267
+ * consumer reading this ledger can do nothing about them. Their COUNT is kept,
268
+ * so the ledger still says how many were inspected.
269
+ * @param report - The measurement
270
+ * @param ratified - The ratifications to carry forward
271
+ * @returns The ledger, ready to serialise
272
+ */
273
+ function toLedger(
274
+ report: TwoChannelReport,
275
+ ratified: Readonly<Record<string, string>>
276
+ ): Ledger {
277
+ return {
278
+ ratified,
279
+ inspected: report.inspected,
280
+ counts: report.counts,
281
+ couplings: report.entries
282
+ .filter(entry => entry.verdict !== "package-backed")
283
+ .map(entry => ({
284
+ key: entry.key,
285
+ workflow: entry.workflow,
286
+ path: entry.path,
287
+ channel: entry.channel,
288
+ verdict: entry.verdict,
289
+ remedy: entry.remedy,
290
+ guarded: entry.guarded,
291
+ lanes: entry.lanes,
292
+ detail: entry.detail,
293
+ })),
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Measure this repository's fast channel against its delivery lanes.
299
+ * @param root - Repository root
300
+ * @returns The measurement, plus the ratifications it was judged against
301
+ */
302
+ export function measure(root: string): {
303
+ readonly report: TwoChannelReport;
304
+ readonly ratified: Readonly<Record<string, string>>;
305
+ } {
306
+ const workflowsDir = path.join(root, WORKFLOWS_DIR);
307
+ const inventory = buildDeliveryInventory(root);
308
+ const lanesFor = (callerPath: string): readonly string[] =>
309
+ inventory.get(callerPath) ?? [];
310
+ const names = existsSync(workflowsDir)
311
+ ? readdirSync(workflowsDir)
312
+ .filter(name => name.endsWith(".yml") || name.endsWith(".yaml"))
313
+ .sort((left, right) => left.localeCompare(right))
314
+ : [];
315
+ const reusables = names
316
+ .map(name => ({
317
+ name,
318
+ text: readFileSync(path.join(workflowsDir, name), "utf8"),
319
+ }))
320
+ .filter(entry => isReusable(entry.text));
321
+ const scanned = reusables.map(entry => ({
322
+ steps: entry.text.split("\n").filter(line => /^\s*-\s+name:/.test(line))
323
+ .length,
324
+ couplings: scanWorkflow({
325
+ workflow: entry.name,
326
+ text: entry.text,
327
+ lanesFor,
328
+ }),
329
+ }));
330
+ const couplings: readonly CouplingInput[] = scanned.flatMap(
331
+ entry => entry.couplings
332
+ );
333
+ return {
334
+ report: classifyTwoChannelDelivery({
335
+ couplings,
336
+ inspected: {
337
+ workflows: reusables.length,
338
+ steps: scanned.reduce((total, entry) => total + entry.steps, 0),
339
+ couplings: couplings.length,
340
+ inventory: inventory.size,
341
+ },
342
+ ratified: readRatified(path.join(root, LEDGER_PATH)),
343
+ }),
344
+ ratified: readRatified(path.join(root, LEDGER_PATH)),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * One human-readable paragraph per finding, plus the inspection counts.
350
+ * @param report - The measurement
351
+ * @returns The report text
352
+ */
353
+ function humanReport(report: TwoChannelReport): string {
354
+ const counts = report.inspected;
355
+ const header =
356
+ `two-channel delivery: inspected ${counts.workflows} reusable workflow(s), ` +
357
+ `${counts.steps} step(s), ${counts.couplings} caller-tree read(s) against ` +
358
+ `${counts.inventory} delivered path(s).`;
359
+ const verdicts = Object.entries(report.counts)
360
+ .map(([verdict, total]) => ` ${verdict}: ${total}`)
361
+ .join("\n");
362
+ const findings = report.findings
363
+ .map(entry => `\nFINDING ${entry.key}\n ${entry.detail}`)
364
+ .join("");
365
+ const stale = report.staleRatifications
366
+ .map(
367
+ key =>
368
+ `\nSTALE RATIFICATION ${key}\n Nothing reads this path any more, so the ` +
369
+ `ratification is an unexamined permission the next matching path would ` +
370
+ `inherit. Delete it from ${LEDGER_PATH}.`
371
+ )
372
+ .join("");
373
+ return `${header}\n${verdicts}${findings}${stale}`;
374
+ }
375
+
376
+ /**
377
+ * Run the gate.
378
+ * @param argv - Arguments after the script name
379
+ * @returns The process exit code
380
+ */
381
+ export function main(argv: readonly string[]): number {
382
+ const options = parseArgs(argv);
383
+ if (!existsSync(options.root)) {
384
+ process.stderr.write(`error: --root does not exist: ${options.root}\n`);
385
+ return EXIT_OPERATIONAL;
386
+ }
387
+ const { report, ratified } = measure(options.root);
388
+ process.stdout.write(
389
+ `${options.json ? JSON.stringify(report, null, 2) : humanReport(report)}\n`
390
+ );
391
+ if (!report.measured) {
392
+ process.stderr.write(
393
+ `error: the measurement did not happen — ${String(report.unmeasuredReason)}. ` +
394
+ "Reporting a clean sweep here would be the exact failure this gate exists " +
395
+ "to detect, so it fails instead.\n"
396
+ );
397
+ return EXIT_OPERATIONAL;
398
+ }
399
+ const ledgerPath = path.join(options.root, LEDGER_PATH);
400
+ const rendered = `${JSON.stringify(toLedger(report, ratified), null, 2)}\n`;
401
+ const current = existsSync(ledgerPath)
402
+ ? readFileSync(ledgerPath, "utf8")
403
+ : "";
404
+ const ledgerStale = options.check && rendered !== current;
405
+ if (options.check) {
406
+ if (ledgerStale) {
407
+ process.stderr.write(
408
+ `error: ${LEDGER_PATH} is stale. Regenerate it with ` +
409
+ "`bun run generate:two-channel-couplings` and commit the result.\n"
410
+ );
411
+ }
412
+ } else {
413
+ mkdirSync(path.dirname(ledgerPath), { recursive: true });
414
+ writeFileSync(ledgerPath, rendered);
415
+ }
416
+ const failed =
417
+ report.findings.length > 0 ||
418
+ report.staleRatifications.length > 0 ||
419
+ ledgerStale;
420
+ return failed ? 1 : 0;
421
+ }
422
+
423
+ if (import.meta.main) {
424
+ // exitCode (not process.exit): when stdout is a pipe, writes are async and
425
+ // process.exit() truncates the report mid-flush.
426
+ process.exitCode = main(process.argv.slice(2));
427
+ }
@@ -0,0 +1,214 @@
1
+ {
2
+ "ratified": {
3
+ "quality.yml::scripts/check-learnings-budget.ts": "Lisa-source-only branch, and the else arm is the delivery. The step tests for BOTH scripts/check-learnings-budget.ts and src/core/learnings-budget-check.ts, which co-exist only in this repository; every consumer takes the else arm and runs the prover as `bunx @codyswann/lisa@<their own declared version>`, which is the PACKAGE channel and not a caller-tree read at all. A trailing `grep -qE \"learnings budget (passed|saturated)|no learnings file\"` then refuses a run that printed no verdict, so the arm cannot pass vacuously. Ratified because the caller-tree read is unreachable outside this repository, not because the gap is tolerated."
4
+ },
5
+ "inspected": {
6
+ "workflows": 23,
7
+ "steps": 633,
8
+ "couplings": 25,
9
+ "inventory": 55
10
+ },
11
+ "counts": {
12
+ "package-backed": 10,
13
+ "apply-lagged": 14,
14
+ "never-delivered": 0,
15
+ "undelivered": 1
16
+ },
17
+ "couplings": [
18
+ {
19
+ "key": "maestro-native-e2e.yml::scripts/classify-maestro-failures.mjs",
20
+ "workflow": "maestro-native-e2e.yml",
21
+ "path": "scripts/classify-maestro-failures.mjs",
22
+ "channel": "apply",
23
+ "verdict": "apply-lagged",
24
+ "remedy": "run-lisa-apply",
25
+ "guarded": false,
26
+ "lanes": [
27
+ "expo/copy-overwrite"
28
+ ],
29
+ "detail": "`maestro-native-e2e.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/classify-maestro-failures.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (expo/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
30
+ },
31
+ {
32
+ "key": "nightly-e2e-health.yml::scripts/check-nightly-e2e-health.mjs",
33
+ "workflow": "nightly-e2e-health.yml",
34
+ "path": "scripts/check-nightly-e2e-health.mjs",
35
+ "channel": "apply",
36
+ "verdict": "apply-lagged",
37
+ "remedy": "run-lisa-apply",
38
+ "guarded": false,
39
+ "lanes": [
40
+ "typescript/copy-overwrite"
41
+ ],
42
+ "detail": "`nightly-e2e-health.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-nightly-e2e-health.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
43
+ },
44
+ {
45
+ "key": "nightly-e2e-health.yml::scripts/nightly-e2e-suites.schema.json",
46
+ "workflow": "nightly-e2e-health.yml",
47
+ "path": "scripts/nightly-e2e-suites.schema.json",
48
+ "channel": "apply",
49
+ "verdict": "apply-lagged",
50
+ "remedy": "run-lisa-apply",
51
+ "guarded": false,
52
+ "lanes": [
53
+ "typescript/copy-overwrite"
54
+ ],
55
+ "detail": "`nightly-e2e-health.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/nightly-e2e-suites.schema.json` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
56
+ },
57
+ {
58
+ "key": "nightly-e2e-report.yml::scripts/check-nightly-e2e-health.mjs",
59
+ "workflow": "nightly-e2e-report.yml",
60
+ "path": "scripts/check-nightly-e2e-health.mjs",
61
+ "channel": "apply",
62
+ "verdict": "apply-lagged",
63
+ "remedy": "run-lisa-apply",
64
+ "guarded": false,
65
+ "lanes": [
66
+ "typescript/copy-overwrite"
67
+ ],
68
+ "detail": "`nightly-e2e-report.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-nightly-e2e-health.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
69
+ },
70
+ {
71
+ "key": "quality-rails.yml::scripts/check-threshold-ratchet.mjs",
72
+ "workflow": "quality-rails.yml",
73
+ "path": "scripts/check-threshold-ratchet.mjs",
74
+ "channel": "apply",
75
+ "verdict": "apply-lagged",
76
+ "remedy": "run-lisa-apply",
77
+ "guarded": false,
78
+ "lanes": [
79
+ "rails/copy-overwrite",
80
+ "typescript/copy-overwrite"
81
+ ],
82
+ "detail": "`quality-rails.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-threshold-ratchet.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (rails/copy-overwrite, typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
83
+ },
84
+ {
85
+ "key": "quality-rails.yml::scripts/lisa-mutation.sh",
86
+ "workflow": "quality-rails.yml",
87
+ "path": "scripts/lisa-mutation.sh",
88
+ "channel": "apply",
89
+ "verdict": "apply-lagged",
90
+ "remedy": "run-lisa-apply",
91
+ "guarded": true,
92
+ "lanes": [
93
+ "rails/copy-contents"
94
+ ],
95
+ "detail": "`quality-rails.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/lisa-mutation.sh` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (rails/copy-contents). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is guarded by an existence test, so an absent path SKIPS rather than fails — the step posts no context, and an absent required context is not a red one. Nothing reads as broken."
96
+ },
97
+ {
98
+ "key": "quality-rails.yml::scripts/lisa-work-item.mjs",
99
+ "workflow": "quality-rails.yml",
100
+ "path": "scripts/lisa-work-item.mjs",
101
+ "channel": "apply",
102
+ "verdict": "apply-lagged",
103
+ "remedy": "run-lisa-apply",
104
+ "guarded": true,
105
+ "lanes": [
106
+ "all/copy-overwrite"
107
+ ],
108
+ "detail": "`quality-rails.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/lisa-work-item.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (all/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is guarded by an existence test, so an absent path SKIPS rather than fails — the step posts no context, and an absent required context is not a red one. Nothing reads as broken."
109
+ },
110
+ {
111
+ "key": "quality.yml::scripts/check-e2e-coverage.mjs",
112
+ "workflow": "quality.yml",
113
+ "path": "scripts/check-e2e-coverage.mjs",
114
+ "channel": "apply",
115
+ "verdict": "apply-lagged",
116
+ "remedy": "run-lisa-apply",
117
+ "guarded": true,
118
+ "lanes": [
119
+ "expo/copy-overwrite"
120
+ ],
121
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-e2e-coverage.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (expo/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is guarded by an existence test, so an absent path SKIPS rather than fails — the step posts no context, and an absent required context is not a red one. Nothing reads as broken."
122
+ },
123
+ {
124
+ "key": "quality.yml::scripts/check-learnings-budget.ts",
125
+ "workflow": "quality.yml",
126
+ "path": "scripts/check-learnings-budget.ts",
127
+ "channel": "undelivered",
128
+ "verdict": "undelivered",
129
+ "remedy": "author-the-artifact",
130
+ "guarded": true,
131
+ "lanes": [],
132
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-learnings-budget.ts` from the CALLER's tree, and Lisa ships no such path in any delivery lane — no apply and no bump ever produces it. Either the consumer authors it or this step proves nothing wherever it is absent. The read is guarded by an existence test, so an absent path SKIPS rather than fails — the step posts no context, and an absent required context is not a red one. Nothing reads as broken."
133
+ },
134
+ {
135
+ "key": "quality.yml::scripts/check-skipped-required-checks.mjs",
136
+ "workflow": "quality.yml",
137
+ "path": "scripts/check-skipped-required-checks.mjs",
138
+ "channel": "apply",
139
+ "verdict": "apply-lagged",
140
+ "remedy": "run-lisa-apply",
141
+ "guarded": false,
142
+ "lanes": [
143
+ "typescript/copy-overwrite"
144
+ ],
145
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-skipped-required-checks.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
146
+ },
147
+ {
148
+ "key": "quality.yml::scripts/check-state-classification.mjs",
149
+ "workflow": "quality.yml",
150
+ "path": "scripts/check-state-classification.mjs",
151
+ "channel": "apply",
152
+ "verdict": "apply-lagged",
153
+ "remedy": "run-lisa-apply",
154
+ "guarded": true,
155
+ "lanes": [
156
+ "all/copy-overwrite"
157
+ ],
158
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-state-classification.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (all/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is guarded by an existence test, so an absent path SKIPS rather than fails — the step posts no context, and an absent required context is not a red one. Nothing reads as broken."
159
+ },
160
+ {
161
+ "key": "quality.yml::scripts/check-threshold-ratchet.mjs",
162
+ "workflow": "quality.yml",
163
+ "path": "scripts/check-threshold-ratchet.mjs",
164
+ "channel": "apply",
165
+ "verdict": "apply-lagged",
166
+ "remedy": "run-lisa-apply",
167
+ "guarded": false,
168
+ "lanes": [
169
+ "rails/copy-overwrite",
170
+ "typescript/copy-overwrite"
171
+ ],
172
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-threshold-ratchet.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (rails/copy-overwrite, typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
173
+ },
174
+ {
175
+ "key": "quality.yml::scripts/check-verification-coverage.mjs",
176
+ "workflow": "quality.yml",
177
+ "path": "scripts/check-verification-coverage.mjs",
178
+ "channel": "apply",
179
+ "verdict": "apply-lagged",
180
+ "remedy": "run-lisa-apply",
181
+ "guarded": false,
182
+ "lanes": [
183
+ "typescript/copy-overwrite"
184
+ ],
185
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/check-verification-coverage.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (typescript/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
186
+ },
187
+ {
188
+ "key": "quality.yml::scripts/lisa-floor-collisions.mjs",
189
+ "workflow": "quality.yml",
190
+ "path": "scripts/lisa-floor-collisions.mjs",
191
+ "channel": "apply",
192
+ "verdict": "apply-lagged",
193
+ "remedy": "run-lisa-apply",
194
+ "guarded": false,
195
+ "lanes": [
196
+ "all/copy-overwrite"
197
+ ],
198
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/lisa-floor-collisions.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (all/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is unguarded, so an absent path fails the job loudly. Visible, which makes it the better of the two failures."
199
+ },
200
+ {
201
+ "key": "quality.yml::scripts/lisa-work-item.mjs",
202
+ "workflow": "quality.yml",
203
+ "path": "scripts/lisa-work-item.mjs",
204
+ "channel": "apply",
205
+ "verdict": "apply-lagged",
206
+ "remedy": "run-lisa-apply",
207
+ "guarded": true,
208
+ "lanes": [
209
+ "all/copy-overwrite"
210
+ ],
211
+ "detail": "`quality.yml` is delivered at `@main`, so this step is live in every consumer on their next run. It reads `scripts/lisa-work-item.mjs` from the CALLER's tree with no package-relative candidate, and Lisa delivers that path on the `lisa apply` channel (all/copy-overwrite). The two halves land in the wrong order: this step is live everywhere immediately, the file it needs is live only where somebody applied. The read is guarded by an existence test, so an absent path SKIPS rather than fails — the step posts no context, and an absent required context is not a red one. Nothing reads as broken."
212
+ }
213
+ ]
214
+ }