@codyswann/lisa 3.45.9 → 3.46.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 (134) hide show
  1. package/all/copy-overwrite/scripts/lisa-floor-collisions.mjs +17 -1
  2. package/all/copy-overwrite/scripts/lisa-gates.mjs +2 -2
  3. package/all/copy-overwrite/scripts/lisa-work-item.mjs +54 -12
  4. package/dist/configs/eslint/expo.d.ts.map +1 -1
  5. package/dist/configs/eslint/expo.js +5 -0
  6. package/dist/configs/eslint/expo.js.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  8. package/dist/core/lisa-owned-hash-ledger.js +15 -0
  9. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +70 -17
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/eslint-plugin-ui-standards/README.md +90 -0
  14. package/eslint-plugin-ui-standards/index.js +4 -0
  15. package/eslint-plugin-ui-standards/rules/no-unbound-design-value.js +375 -0
  16. package/expo/copy-overwrite/eslint.expo.ts +5 -0
  17. package/expo/copy-overwrite/scripts/bdd/discover.mjs +20 -4
  18. package/expo/copy-overwrite/scripts/bdd/markdown-cell.mjs +7 -1
  19. package/expo/copy-overwrite/scripts/bdd/parse.mjs +8 -2
  20. package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +8 -2
  21. package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +91 -10
  22. package/package.json +1 -1
  23. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/SKILL.md +169 -0
  26. package/plugins/lisa/.codex-plugin/skills/lisa-design-intake/agents/openai.yaml +4 -0
  27. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +2 -0
  28. package/plugins/lisa/.codex-plugin/skills/lisa-quality-review/SKILL.md +2 -0
  29. package/plugins/lisa/.codex-plugin/skills/lisa-review-local/SKILL.md +9 -0
  30. package/plugins/lisa/.codex-plugin/skills/lisa-tdd-implementation/SKILL.md +1 -0
  31. package/plugins/lisa/commands/design/intake.md +7 -0
  32. package/plugins/lisa/rules/eager/design-value-binding.md +80 -0
  33. package/plugins/lisa/rules/reference/design-value-binding.md +204 -0
  34. package/plugins/lisa/scripts/design-bindings-probe.mjs +713 -0
  35. package/plugins/lisa/scripts/design-intake-gate.mjs +470 -0
  36. package/plugins/lisa/scripts/design-variable-ids.mjs +565 -0
  37. package/plugins/lisa/skills/lisa-design-intake/SKILL.md +169 -0
  38. package/plugins/lisa/skills/lisa-design-intake/agents/openai.yaml +4 -0
  39. package/plugins/lisa/skills/lisa-implement/SKILL.md +2 -0
  40. package/plugins/lisa/skills/lisa-quality-review/SKILL.md +2 -0
  41. package/plugins/lisa/skills/lisa-review-local/SKILL.md +9 -0
  42. package/plugins/lisa/skills/lisa-tdd-implementation/SKILL.md +1 -0
  43. package/plugins/lisa-agy/commands/lisa/design/intake.md +7 -0
  44. package/plugins/lisa-agy/plugin.json +1 -1
  45. package/plugins/lisa-agy/scripts/design-bindings-probe.mjs +713 -0
  46. package/plugins/lisa-agy/scripts/design-intake-gate.mjs +470 -0
  47. package/plugins/lisa-agy/scripts/design-variable-ids.mjs +565 -0
  48. package/plugins/lisa-agy/skills/lisa-design-intake/SKILL.md +169 -0
  49. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +2 -0
  50. package/plugins/lisa-agy/skills/lisa-quality-review/SKILL.md +2 -0
  51. package/plugins/lisa-agy/skills/lisa-review-local/SKILL.md +9 -0
  52. package/plugins/lisa-agy/skills/lisa-tdd-implementation/SKILL.md +1 -0
  53. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  56. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-copilot/commands/lisa/design/intake.md +7 -0
  60. package/plugins/lisa-copilot/rules/eager/design-value-binding.md +80 -0
  61. package/plugins/lisa-copilot/rules/reference/design-value-binding.md +204 -0
  62. package/plugins/lisa-copilot/scripts/design-bindings-probe.mjs +713 -0
  63. package/plugins/lisa-copilot/scripts/design-intake-gate.mjs +470 -0
  64. package/plugins/lisa-copilot/scripts/design-variable-ids.mjs +565 -0
  65. package/plugins/lisa-copilot/skills/lisa-design-intake/SKILL.md +169 -0
  66. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +2 -0
  67. package/plugins/lisa-copilot/skills/lisa-quality-review/SKILL.md +2 -0
  68. package/plugins/lisa-copilot/skills/lisa-review-local/SKILL.md +9 -0
  69. package/plugins/lisa-copilot/skills/lisa-tdd-implementation/SKILL.md +1 -0
  70. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-cursor/commands/lisa/design/intake.md +7 -0
  72. package/plugins/lisa-cursor/rules/design-value-binding-reference.mdc +209 -0
  73. package/plugins/lisa-cursor/rules/design-value-binding.mdc +85 -0
  74. package/plugins/lisa-cursor/scripts/design-bindings-probe.mjs +713 -0
  75. package/plugins/lisa-cursor/scripts/design-intake-gate.mjs +470 -0
  76. package/plugins/lisa-cursor/scripts/design-variable-ids.mjs +565 -0
  77. package/plugins/lisa-cursor/skills/lisa-design-intake/SKILL.md +169 -0
  78. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +2 -0
  79. package/plugins/lisa-cursor/skills/lisa-quality-review/SKILL.md +2 -0
  80. package/plugins/lisa-cursor/skills/lisa-review-local/SKILL.md +9 -0
  81. package/plugins/lisa-cursor/skills/lisa-tdd-implementation/SKILL.md +1 -0
  82. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-expo-agy/plugin.json +1 -1
  85. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  90. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  95. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  100. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  105. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  109. package/plugins/lisa-rails-agy/plugin.json +1 -1
  110. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  114. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  115. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  119. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  120. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  122. package/plugins/src/base/commands/design/intake.md +7 -0
  123. package/plugins/src/base/rules/eager/design-value-binding.md +80 -0
  124. package/plugins/src/base/rules/reference/design-value-binding.md +204 -0
  125. package/plugins/src/base/scripts/design-bindings-probe.mjs +713 -0
  126. package/plugins/src/base/scripts/design-intake-gate.mjs +470 -0
  127. package/plugins/src/base/scripts/design-variable-ids.mjs +565 -0
  128. package/plugins/src/base/skills/lisa-design-intake/SKILL.md +169 -0
  129. package/plugins/src/base/skills/lisa-implement/SKILL.md +2 -0
  130. package/plugins/src/base/skills/lisa-quality-review/SKILL.md +2 -0
  131. package/plugins/src/base/skills/lisa-review-local/SKILL.md +9 -0
  132. package/plugins/src/base/skills/lisa-tdd-implementation/SKILL.md +1 -0
  133. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +22 -6
  134. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +10 -1
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic, fail-closed gate for the `design-value-binding` contract.
4
+ *
5
+ * Values come from design variables where a variable system exists. Visual
6
+ * measurement is supplemental there, and it is the legitimate primary source
7
+ * where no variable system exists. Missing design information blocks the work
8
+ * item; it is never worked around.
9
+ *
10
+ * The judgment lives in `/lisa:design:intake`, which gathers the facts — which
11
+ * axes the design source publishes variable collections for, and what the
12
+ * referenced frames and components actually do. This module turns those facts
13
+ * into a verdict, because a verdict an agent reasons out fresh each time is a
14
+ * verdict that decides differently on Tuesday.
15
+ *
16
+ * **The whole design is one distinction: block on *unbound*, never on
17
+ * *unsure*.** "I cannot tell what they meant" is a judgment call, and an agent
18
+ * asked to make it blocks on everything or nothing depending on temperament.
19
+ * "The design does not bind a value I need" is a fact, and two agents check it
20
+ * the same way. So every blocking condition here is a fact about the design,
21
+ * and the non-blocking kinds are enumerated just as explicitly — a gate with no
22
+ * enumerated non-blocks fires constantly, and a gate that fires constantly gets
23
+ * switched off.
24
+ *
25
+ * **The regime is per-axis, not per-project.** A library with a mature colour
26
+ * system and no spacing scale is the common case: colour blocks while spacing
27
+ * gets measured, in the same work item, without contradiction.
28
+ *
29
+ * Run it: `node design-intake-gate.mjs --findings=<file.json> [--json]`
30
+ * Exit 0 = PROCEED, 1 = BLOCK, 2 = usage.
31
+ * @module design-intake-gate
32
+ */
33
+ import { readFileSync, realpathSync } from "node:fs";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ /**
37
+ * Every condition that blocks a work item.
38
+ *
39
+ * Exported so the suite can pin the set itself, not just its members' behavior.
40
+ * Classifying a finding is not a guard; *blocking the work item* because of it
41
+ * is. With only per-condition behavior asserted, deleting a condition from this
42
+ * set flips the verdict to PROCEED with the whole suite still green — the
43
+ * failure mode `design-source-gate.mjs` measured on its own violation set.
44
+ */
45
+ export const BLOCK_CONDITIONS = new Set([
46
+ "escalation-target-unset",
47
+ "hardcoded-in-design",
48
+ "measured-over-binding",
49
+ "missing-state",
50
+ "missing-token",
51
+ "regime-unknown",
52
+ "source-disagreement",
53
+ "unpublished-component",
54
+ ]);
55
+
56
+ /**
57
+ * Who owns each blocking condition.
58
+ *
59
+ * Three failures, two owners, and conflating them sends the wrong person the
60
+ * wrong work. `design` means the design does not settle a value. `us` means the
61
+ * design is fine and our own machinery cannot read it — a stale or incomplete
62
+ * variable-id map, or a project that never named an escalation target. Handing
63
+ * an `us` failure to a designer teaches them to ignore the next one, which is
64
+ * expensive in a way that is hard to undo.
65
+ */
66
+ export const CONDITION_OWNERS = {
67
+ "hardcoded-in-design": "design",
68
+ "missing-token": "design",
69
+ "missing-state": "design",
70
+ "source-disagreement": "design",
71
+ "unpublished-component": "design",
72
+ "measured-over-binding": "us",
73
+ "regime-unknown": "us",
74
+ "escalation-target-unset": "us",
75
+ };
76
+
77
+ /**
78
+ * Words that must not reach the operator standing at the gate.
79
+ *
80
+ * Every one is correct and every one is useless to the person being asked for a
81
+ * decision. The house standard is that a non-technical operator reads these
82
+ * comments, so the vocabulary is enforced rather than merely requested — a
83
+ * style note in a contract document is advice, and a list the tests check is a
84
+ * rule.
85
+ */
86
+ export const ENGINEERING_VOCABULARY = [
87
+ "token",
88
+ "binding",
89
+ "unbound",
90
+ "axis",
91
+ "untyped",
92
+ "typed",
93
+ "regime",
94
+ "variable collection",
95
+ "node id",
96
+ "lint",
97
+ "hardcode",
98
+ ];
99
+
100
+ /** Human names for each axis. The identifiers never reach a comment. */
101
+ const AXIS_LABELS = {
102
+ color: "colour",
103
+ spacing: "spacing",
104
+ typography: "text style",
105
+ radius: "corner radius",
106
+ elevation: "shadow",
107
+ motion: "animation timing",
108
+ };
109
+
110
+ /** Finding kinds that are explicitly never a block. */
111
+ const NON_BLOCKING_KINDS = new Set(["bound", "one-off", "aesthetic-concern"]);
112
+
113
+ /** Finding kinds whose verdict depends on the axis regime. */
114
+ const REGIME_SENSITIVE_KINDS = new Set(["hardcoded-in-design", "measured"]);
115
+
116
+ /** Finding kinds that block regardless of regime. */
117
+ const UNCONDITIONAL_BLOCK_KINDS = new Set([
118
+ "missing-token",
119
+ "missing-state",
120
+ "source-disagreement",
121
+ "unpublished-component",
122
+ ]);
123
+
124
+ /**
125
+ * @param {string | undefined} axis - Axis identifier.
126
+ * @returns {string} The human name, falling back to the identifier.
127
+ */
128
+ function labelOf(axis) {
129
+ return AXIS_LABELS[axis] ?? "value";
130
+ }
131
+
132
+ /**
133
+ * @param {object} finding - The finding under evaluation.
134
+ * @returns {string} The component name, or a readable stand-in.
135
+ */
136
+ function componentOf(finding) {
137
+ return finding?.component ?? "this component";
138
+ }
139
+
140
+ /**
141
+ * Plain-language reasons, one per blocking condition.
142
+ *
143
+ * Each names the artifact, names the specific value, and says what to do in
144
+ * consequences rather than in vocabulary. "Published as a variable" is an
145
+ * action a designer can take; "unbound reference in a typed axis" is a
146
+ * diagnosis nobody outside the factory can act on.
147
+ */
148
+ const REASONS = {
149
+ "missing-token": finding =>
150
+ `The '${componentOf(finding)}' design asks for a ${labelOf(finding.axis)} called '${finding.name ?? finding.token}', but the design library does not publish anything by that name. I need it published, or the design pointed at one that already exists — otherwise I would be inventing a value nobody agreed to.`,
151
+
152
+ "hardcoded-in-design": finding =>
153
+ `The '${componentOf(finding)}' component uses the ${labelOf(finding.axis)} ${finding.value} directly rather than a ${labelOf(finding.axis)} variable. I need that ${labelOf(finding.axis)} published as a variable so the app and the design stay in sync — otherwise I'd be copying a number that changes without warning.`,
154
+
155
+ "unpublished-component": finding =>
156
+ `The '${componentOf(finding)}' component only exists in a working draft, so there is nothing settled for me to build against — it could be renamed or removed at any time and the app would quietly break. I need it published to the shared library first.`,
157
+
158
+ "missing-state": finding =>
159
+ `The work asks for the '${finding.state}' version of '${componentOf(finding)}', and the design does not have one. I need someone to draw it — guessing what it should look like is how two screens end up disagreeing with each other.`,
160
+
161
+ "source-disagreement": finding =>
162
+ `The '${componentOf(finding)}' design gives two different answers for its ${labelOf(finding.axis)}: the shared library says ${finding.tokenValue} and the drawing shows ${finding.frameValue}. I need someone to say which one is right — if I choose, I am making a design decision on my own.`,
163
+
164
+ "measured-over-binding": finding =>
165
+ `I could measure the ${labelOf(finding.axis)} of '${componentOf(finding)}' off the picture — ${finding.value} — but this project publishes its ${labelOf(finding.axis)} choices as shared variables, and a number copied off a picture drifts the moment the shared one changes. I need to know which shared ${labelOf(finding.axis)} this is meant to use.`,
166
+
167
+ "regime-unknown": finding =>
168
+ `I could not find out whether this project publishes its ${labelOf(finding.axis)} choices as shared variables, so I do not know whether to use one or measure it. I need that checked before I build '${componentOf(finding)}' — guessing either way is how the app and the design drift apart.`,
169
+
170
+ "escalation-target-unset": () =>
171
+ `No one is set up to receive design questions for this project — 'design.escalation.assignee' in .lisa.config.json is empty — so anything I stopped on would sit where nobody sees it. I need that filled in before I can hand a design question to a person.`,
172
+ };
173
+
174
+ /**
175
+ * Build one block entry.
176
+ *
177
+ * Deliberately carries no resolved value. Where two sources disagree, choosing
178
+ * between them is a design decision, and a gate that quietly made it would be
179
+ * guessing in exactly the situation the condition exists to catch.
180
+ * @param {string} condition - A member of {@link BLOCK_CONDITIONS}.
181
+ * @param {object} finding - The finding that produced it.
182
+ * @returns {{ condition: string, component: string, axis: string | null, reason: string }} Block entry.
183
+ */
184
+ function blockFor(condition, finding) {
185
+ return {
186
+ condition,
187
+ owner: CONDITION_OWNERS[condition],
188
+ component: finding?.component ?? null,
189
+ axis: finding?.axis ?? null,
190
+ reason: REASONS[condition](finding ?? {}),
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Classify a regime-sensitive finding against the axis regime.
196
+ *
197
+ * An unresolved regime is a block, never a fallback to untyped: defaulting a
198
+ * failed query to "no variable system here" converts every access problem into
199
+ * silent permission to copy numbers out of a picture.
200
+ * @param {object} finding - Finding with an `axis`.
201
+ * @param {Record<string, string>} regime - Observed regime per axis.
202
+ * @returns {{ block: object | null, derived: object | null }} The outcome.
203
+ */
204
+ function classifyRegimeSensitive(finding, regime) {
205
+ const state = regime?.[finding.axis];
206
+
207
+ if (state !== "typed" && state !== "untyped") {
208
+ return { block: blockFor("regime-unknown", finding), derived: null };
209
+ }
210
+
211
+ if (state === "untyped") {
212
+ // Measuring is the correct source of truth here. The measurement is still
213
+ // recorded, so what the variable system is missing accumulates on its own,
214
+ // ranked by what people actually needed.
215
+ return {
216
+ block: null,
217
+ derived: {
218
+ axis: finding.axis,
219
+ component: finding.component ?? null,
220
+ value: finding.value ?? null,
221
+ },
222
+ };
223
+ }
224
+
225
+ const condition =
226
+ finding.kind === "measured"
227
+ ? "measured-over-binding"
228
+ : "hardcoded-in-design";
229
+ return { block: blockFor(condition, finding), derived: null };
230
+ }
231
+
232
+ /**
233
+ * Classify one finding.
234
+ * @param {object} finding - The finding.
235
+ * @param {Record<string, string>} regime - Observed regime per axis.
236
+ * @returns {{ block: object | null, derived: object | null }} The outcome.
237
+ */
238
+ function classifyFinding(finding, regime) {
239
+ const kind = finding?.kind;
240
+
241
+ if (NON_BLOCKING_KINDS.has(kind)) return { block: null, derived: null };
242
+ if (REGIME_SENSITIVE_KINDS.has(kind)) {
243
+ return classifyRegimeSensitive(finding, regime);
244
+ }
245
+ if (UNCONDITIONAL_BLOCK_KINDS.has(kind)) {
246
+ return { block: blockFor(kind, finding), derived: null };
247
+ }
248
+
249
+ // A kind this module does not recognise is a caller defect, and guessing at
250
+ // it would be the gate inventing a verdict. Fail loudly instead.
251
+ throw new TypeError(
252
+ `design-intake-gate: unrecognised finding kind '${kind}'`
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Read a non-empty string from a config path, or null.
258
+ * @param {unknown} value - Candidate value.
259
+ * @returns {string | null} The trimmed string, or null.
260
+ */
261
+ function configuredString(value) {
262
+ return typeof value === "string" && value.trim().length > 0
263
+ ? value.trim()
264
+ : null;
265
+ }
266
+
267
+ /**
268
+ * Evaluate one work item's design facts.
269
+ *
270
+ * @param {{
271
+ * config?: { design?: { escalation?: { assignee?: string, label?: string }, tokens?: object } },
272
+ * regime?: Record<string, string>,
273
+ * findings?: readonly object[]
274
+ * }} input - Facts gathered by `/lisa:design:intake`.
275
+ * @returns {{
276
+ * verdict: "BLOCK" | "PROCEED",
277
+ * owner: "design" | "us" | null,
278
+ * assignee: string | null,
279
+ * label: string | null,
280
+ * blocks: object[],
281
+ * derived: object[],
282
+ * comment: string | null
283
+ * }} The verdict.
284
+ */
285
+ export function evaluateDesignIntake(input = {}) {
286
+ const escalation = input?.config?.design?.escalation ?? {};
287
+ const assignee = configuredString(escalation.assignee);
288
+ const label = configuredString(escalation.label);
289
+
290
+ const blocks = [];
291
+ const derived = [];
292
+
293
+ // Checked first and unconditionally. A blocked item assigned to nobody is an
294
+ // item nobody sees, which is operationally identical to having skipped the
295
+ // block — except that it also consumed the work item. Guessing a target is
296
+ // worse still: it routes a design question to whoever happened to be nearby.
297
+ if (assignee === null) blocks.push(blockFor("escalation-target-unset", {}));
298
+
299
+ for (const finding of input?.findings ?? []) {
300
+ const { block, derived: record } = classifyFinding(finding, input?.regime);
301
+ if (block !== null) blocks.push(block);
302
+ if (record !== null) derived.push(record);
303
+ }
304
+
305
+ // Design wins when both are present: an unbound value is the thing that
306
+ // actually stops the build, and it needs a person either way.
307
+ const owners = new Set(blocks.map(block => block.owner));
308
+ const owner = owners.has("design")
309
+ ? "design"
310
+ : owners.has("us")
311
+ ? "us"
312
+ : null;
313
+
314
+ return {
315
+ verdict: blocks.length === 0 ? "PROCEED" : "BLOCK",
316
+ owner,
317
+ assignee,
318
+ label,
319
+ blocks,
320
+ derived,
321
+ comment: blocks.length === 0 ? null : renderComment(blocks),
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Render the comment posted on a blocked work item.
327
+ *
328
+ * Written for the non-technical operator standing at the gate: plain language,
329
+ * naming the specific missing artifact and what to do about it.
330
+ * @param {readonly { reason: string }[]} blocks - The blocking findings.
331
+ * @returns {string} The comment body.
332
+ */
333
+ export function renderComment(blocks) {
334
+ const opening =
335
+ blocks.length === 1
336
+ ? "I've stopped on this because the design does not settle something I need:"
337
+ : `I've stopped on this because the design does not settle ${blocks.length} things I need:`;
338
+ const body = blocks.map(block => `- ${block.reason}`).join("\n");
339
+ return `${opening}\n\n${body}`;
340
+ }
341
+
342
+ /**
343
+ * Render the operator-readable report for a CLI run.
344
+ * @param {ReturnType<typeof evaluateDesignIntake>} result - The verdict.
345
+ * @returns {string} Report text.
346
+ */
347
+ export function renderReport(result) {
348
+ const lines = [`design-intake gate: ${result.verdict}`];
349
+
350
+ if (result.verdict === "PROCEED") {
351
+ lines.push(
352
+ ` Nothing is missing. ${result.derived.length} value(s) were measured and recorded.`
353
+ );
354
+ } else {
355
+ lines.push(
356
+ ` Owner: ${result.owner}${result.owner === "us" ? " — do NOT send this to a designer" : ""}`,
357
+ ` Assign to: ${result.assignee ?? "(nobody — that is itself one of the reasons below)"}`,
358
+ "",
359
+ result.comment ?? ""
360
+ );
361
+ }
362
+
363
+ for (const record of result.derived) {
364
+ lines.push(
365
+ ` · measured ${labelOf(record.axis)} on '${record.component}': ${record.value}`
366
+ );
367
+ }
368
+
369
+ return lines.join("\n");
370
+ }
371
+
372
+ /**
373
+ * Read merged project config, local overriding global per key.
374
+ * @returns {object} Parsed config, or {}.
375
+ */
376
+ function readProjectConfig() {
377
+ const load = file => {
378
+ try {
379
+ return JSON.parse(readFileSync(file, "utf8"));
380
+ } catch {
381
+ return {};
382
+ }
383
+ };
384
+ const global = load(".lisa.config.json");
385
+ const local = load(".lisa.config.local.json");
386
+ return {
387
+ ...global,
388
+ ...local,
389
+ design: { ...global.design, ...local.design },
390
+ };
391
+ }
392
+
393
+ /**
394
+ * CLI entrypoint.
395
+ * @param {readonly string[]} argv - Arguments after the script name.
396
+ * @returns {number} Process exit code.
397
+ */
398
+ export function runCli(argv) {
399
+ const args = new Map(
400
+ argv
401
+ .filter(arg => arg.startsWith("--"))
402
+ .map(arg => {
403
+ const eq = arg.indexOf("=");
404
+ return eq === -1
405
+ ? [arg.slice(2), "true"]
406
+ : [arg.slice(2, eq), arg.slice(eq + 1)];
407
+ })
408
+ );
409
+
410
+ const findingsPath = args.get("findings");
411
+ if (!findingsPath) {
412
+ process.stderr.write(
413
+ "usage: design-intake-gate.mjs --findings=<file.json> [--json]\n"
414
+ );
415
+ return 2;
416
+ }
417
+
418
+ let payload;
419
+ try {
420
+ payload = JSON.parse(readFileSync(findingsPath, "utf8"));
421
+ } catch (error) {
422
+ // Unreadable facts are a block, never a quiet pass: a gate that returns
423
+ // PROCEED on what it could not look at proves nothing.
424
+ process.stderr.write(
425
+ `design-intake gate: BLOCK — could not read ${findingsPath}: ${String(error?.message ?? error)}\n`
426
+ );
427
+ return 1;
428
+ }
429
+
430
+ const result = evaluateDesignIntake({
431
+ config: payload.config ?? readProjectConfig(),
432
+ regime: payload.regime,
433
+ findings: payload.findings,
434
+ });
435
+
436
+ process.stdout.write(
437
+ args.get("json") === "true"
438
+ ? `${JSON.stringify(result, null, 2)}\n`
439
+ : `${renderReport(result)}\n`
440
+ );
441
+ return result.verdict === "PROCEED" ? 0 : 1;
442
+ }
443
+
444
+ /**
445
+ * True when `moduleUrl` names the module node was asked to run.
446
+ *
447
+ * Both sides are realpath'd. Reached through a symlinked checkout, a git
448
+ * worktree, or a `/tmp` path on macOS the naive comparisons disagree, the body
449
+ * never runs, and the process exits 0 having done nothing — and every
450
+ * Lisa-driven agent runs in a worktree, so that is the routine path.
451
+ *
452
+ * Written out rather than imported: this ships inside a plugin payload, which
453
+ * has no `./lib/` to resolve against. Same rule and reasoning as
454
+ * `scripts/lib/invoked-as-script.mjs`.
455
+ * @param {string} moduleUrl - The caller's own `import.meta.url`.
456
+ * @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
457
+ * @returns {boolean} Whether the caller should run its CLI body.
458
+ */
459
+ export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
460
+ if (!argv1) return false;
461
+ try {
462
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
463
+ } catch {
464
+ return false;
465
+ }
466
+ }
467
+
468
+ if (invokedAsScript(import.meta.url)) {
469
+ process.exit(runCli(process.argv.slice(2)));
470
+ }