@codyswann/lisa 3.33.7 → 3.34.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 (117) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +35 -3
  2. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +6 -0
  3. package/dist/configs/eslint/base.d.ts.map +1 -1
  4. package/dist/configs/eslint/base.js +8 -0
  5. package/dist/configs/eslint/base.js.map +1 -1
  6. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  7. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  8. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.js +31 -12
  11. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  12. package/eslint-plugin-component-structure/rules/enforce-component-structure.js +63 -43
  13. package/package.json +2 -1
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +53 -3
  17. package/plugins/lisa/.codex-plugin/skills/lisa-intake/SKILL.md +17 -1
  18. package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +53 -5
  19. package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +48 -7
  20. package/plugins/lisa/.codex-plugin/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  21. package/plugins/lisa/rules/eager/automation-runbook-contract.md +2 -0
  22. package/plugins/lisa/rules/reference/automation-runbook-contract.md +19 -0
  23. package/plugins/lisa/scripts/automation-run-record.mjs +146 -33
  24. package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +190 -0
  25. package/plugins/lisa/scripts/intake-prework-denominator.mjs +249 -0
  26. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +53 -3
  27. package/plugins/lisa/skills/lisa-intake/SKILL.md +17 -1
  28. package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +53 -5
  29. package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +48 -7
  30. package/plugins/lisa/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  31. package/plugins/lisa-agy/plugin.json +1 -1
  32. package/plugins/lisa-agy/scripts/automation-run-record.mjs +146 -33
  33. package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +190 -0
  34. package/plugins/lisa-agy/scripts/intake-prework-denominator.mjs +249 -0
  35. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +53 -3
  36. package/plugins/lisa-agy/skills/lisa-intake/SKILL.md +17 -1
  37. package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +53 -5
  38. package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +48 -7
  39. package/plugins/lisa-agy/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  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/eager/automation-runbook-contract.md +2 -0
  47. package/plugins/lisa-copilot/rules/reference/automation-runbook-contract.md +19 -0
  48. package/plugins/lisa-copilot/scripts/automation-run-record.mjs +146 -33
  49. package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +190 -0
  50. package/plugins/lisa-copilot/scripts/intake-prework-denominator.mjs +249 -0
  51. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +53 -3
  52. package/plugins/lisa-copilot/skills/lisa-intake/SKILL.md +17 -1
  53. package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +53 -5
  54. package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +48 -7
  55. package/plugins/lisa-copilot/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  56. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-cursor/rules/automation-runbook-contract-reference.mdc +19 -0
  58. package/plugins/lisa-cursor/rules/automation-runbook-contract.mdc +2 -0
  59. package/plugins/lisa-cursor/scripts/automation-run-record.mjs +146 -33
  60. package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +190 -0
  61. package/plugins/lisa-cursor/scripts/intake-prework-denominator.mjs +249 -0
  62. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +53 -3
  63. package/plugins/lisa-cursor/skills/lisa-intake/SKILL.md +17 -1
  64. package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +53 -5
  65. package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +48 -7
  66. package/plugins/lisa-cursor/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  67. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  69. package/plugins/lisa-expo-agy/plugin.json +1 -1
  70. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  75. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  80. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  85. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  90. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails-agy/plugin.json +1 -1
  95. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  100. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  104. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  105. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  107. package/plugins/src/base/rules/eager/automation-runbook-contract.md +2 -0
  108. package/plugins/src/base/rules/reference/automation-runbook-contract.md +19 -0
  109. package/plugins/src/base/scripts/automation-run-record.mjs +146 -33
  110. package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +190 -0
  111. package/plugins/src/base/scripts/intake-prework-denominator.mjs +249 -0
  112. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +53 -3
  113. package/plugins/src/base/skills/lisa-intake/SKILL.md +17 -1
  114. package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +53 -5
  115. package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +48 -7
  116. package/plugins/src/base/skills/lisa-tracker-build-intake/SKILL.md +24 -0
  117. package/typescript/copy-contents/.husky/pre-push +88 -1
@@ -11,6 +11,8 @@ import path from "node:path";
11
11
  import { realpathSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
+ import { assertDenominatorReported } from "./intake-prework-denominator.mjs";
15
+
14
16
  export const AUTOMATION_RUN_OUTCOMES = [
15
17
  "nothing-needed",
16
18
  "candidate-proposed",
@@ -33,6 +35,10 @@ export const DEFAULT_AUTOMATION_RUN_HISTORY_MAX_ENTRIES = 50;
33
35
  * readonly run_id: string
34
36
  * }} AutomationRunRecord
35
37
  *
38
+ * `refs` above is the AUTHORED contract. A row already on disk whose `refs` is
39
+ * some other shape is round-tripped as stored rather than coerced — see
40
+ * {@link preserveStoredRefs}.
41
+ *
36
42
  * @typedef {{
37
43
  * readonly projectRoot?: string
38
44
  * readonly loopId: string
@@ -55,6 +61,15 @@ export const DEFAULT_AUTOMATION_RUN_HISTORY_MAX_ENTRIES = 50;
55
61
  * @returns {Promise<{ readonly path: string, readonly record: AutomationRunRecord, readonly records: readonly AutomationRunRecord[], readonly appended: boolean, readonly skippedCorruptLines: number, readonly maxEntries: number }>}
56
62
  */
57
63
  export async function recordAutomationRun(input) {
64
+ // Write path only. Stored rows are re-validated on read through
65
+ // `validateStoredRecord`, and a history written before this contract existed
66
+ // must stay readable — so the denominator gate lives here, where a NEW row is
67
+ // minted, and never on the read path.
68
+ assertDenominatorReported({
69
+ loopId: input.loopId,
70
+ outcome: input.outcome,
71
+ denominator: input.extras?.denominator,
72
+ });
58
73
  const projectRoot = path.resolve(input.projectRoot ?? process.cwd());
59
74
  const maxEntries =
60
75
  input.maxEntries ??
@@ -74,14 +89,26 @@ export async function recordAutomationRun(input) {
74
89
  };
75
90
  }
76
91
 
77
- const nextRecords = [...readResult.records, record].slice(-maxEntries);
92
+ // Prior rows are carried across as the RAW LINES they were read as, never
93
+ // re-serialised from the parsed form. Re-serialising is what let an append
94
+ // rewrite rows it did not author: a stored `refs` shape the writer did not
95
+ // recognise came back as `[]`, and a row that failed validation came back as
96
+ // nothing at all (#2682, #2578). Only the row being appended is serialised
97
+ // here, so an append can no longer be a fleet-wide migration nobody ran.
98
+ const nextEntries = [
99
+ ...readResult.entries,
100
+ { line: JSON.stringify(record), record },
101
+ ].slice(-maxEntries);
78
102
  await mkdir(path.dirname(filePath), { recursive: true });
79
- await writeJsonlAtomically(filePath, nextRecords);
103
+ await writeJsonlAtomically(
104
+ filePath,
105
+ nextEntries.map(entry => entry.line)
106
+ );
80
107
 
81
108
  return {
82
109
  path: filePath,
83
110
  record,
84
- records: nextRecords,
111
+ records: nextEntries.flatMap(entry => (entry.record ? [entry.record] : [])),
85
112
  appended: true,
86
113
  skippedCorruptLines: readResult.skippedCorruptLines,
87
114
  maxEntries,
@@ -126,8 +153,12 @@ export function automationRunRecordPath(projectRoot, loopId) {
126
153
  }
127
154
 
128
155
  /**
156
+ * Read a ledger, keeping every non-blank line verbatim alongside its parsed
157
+ * form. `records` is the validated view for consumers; `entries` is what the
158
+ * append path rewrites, so a row this module cannot parse or validate survives
159
+ * instead of being deleted by the next unrelated append (#2578).
129
160
  * @param {string} filePath
130
- * @returns {Promise<{ readonly records: readonly AutomationRunRecord[], readonly skippedCorruptLines: number }>}
161
+ * @returns {Promise<{ readonly records: readonly AutomationRunRecord[], readonly entries: readonly { readonly line: string, readonly record?: AutomationRunRecord }[], readonly skippedCorruptLines: number }>}
131
162
  */
132
163
  export async function readAutomationRunRecords(filePath) {
133
164
  let content = "";
@@ -135,12 +166,13 @@ export async function readAutomationRunRecords(filePath) {
135
166
  content = await readFile(filePath, "utf8");
136
167
  } catch (error) {
137
168
  if (error?.code === "ENOENT") {
138
- return { records: [], skippedCorruptLines: 0 };
169
+ return { records: [], entries: [], skippedCorruptLines: 0 };
139
170
  }
140
171
  throw error;
141
172
  }
142
173
 
143
- const records = [];
174
+ /** @type {{ readonly line: string, readonly record?: AutomationRunRecord }[]} */
175
+ const entries = [];
144
176
  let skippedCorruptLines = 0;
145
177
  for (const line of content.split(/\n/)) {
146
178
  if (!line.trim()) {
@@ -148,13 +180,21 @@ export async function readAutomationRunRecords(filePath) {
148
180
  }
149
181
  try {
150
182
  const parsed = JSON.parse(line);
151
- records.push(validateStoredRecord(parsed));
183
+ entries.push({ line, record: validateStoredRecord(parsed) });
152
184
  } catch {
185
+ // Kept, not dropped. The caller rewrites the file from `entries`, so a
186
+ // row omitted here would be DELETED from disk by the next unrelated
187
+ // append (#2578). It stays exactly as read; only `records` excludes it.
188
+ entries.push({ line });
153
189
  skippedCorruptLines += 1;
154
190
  }
155
191
  }
156
192
 
157
- return { records, skippedCorruptLines };
193
+ return {
194
+ records: entries.flatMap(entry => (entry.record ? [entry.record] : [])),
195
+ entries,
196
+ skippedCorruptLines,
197
+ };
158
198
  }
159
199
 
160
200
  /**
@@ -162,12 +202,58 @@ export async function readAutomationRunRecords(filePath) {
162
202
  * @returns {AutomationRunRecord}
163
203
  */
164
204
  function buildAutomationRunRecord(input) {
205
+ return assembleAutomationRunRecord(input, normalizeAuthoredRefs(input.refs));
206
+ }
207
+
208
+ /**
209
+ * `refs` as an author supplied it.
210
+ *
211
+ * A shape this module cannot store as written is REJECTED, not quietly
212
+ * emptied. Coercing to `[]` is how a caller could record a run whose evidence
213
+ * links were dropped while the write still reported success (#2682).
214
+ *
215
+ * @param {unknown} refs
216
+ * @returns {readonly string[]}
217
+ */
218
+ function normalizeAuthoredRefs(refs) {
219
+ if (refs === undefined || refs === null) {
220
+ return [];
221
+ }
222
+ if (!Array.isArray(refs)) {
223
+ throw new Error(
224
+ `Automation run refs must be an array of strings; received ${typeof refs}.`
225
+ );
226
+ }
227
+ return refs.map(ref => String(ref));
228
+ }
229
+
230
+ /**
231
+ * `refs` as it was stored, round-tripped rather than coerced.
232
+ *
233
+ * A stored shape this module does not recognise — the object form
234
+ * `{tickets, prs, commits}` observed in the field — is returned unchanged.
235
+ * Coercing it to `[]` here destroyed the evidence links on every prior row at
236
+ * the next append: silently, retroactively, and irreversibly (#2682).
237
+ *
238
+ * @param {unknown} refs
239
+ * @returns {unknown}
240
+ */
241
+ function preserveStoredRefs(refs) {
242
+ if (refs === undefined || refs === null) {
243
+ return [];
244
+ }
245
+ return Array.isArray(refs) ? refs.map(ref => String(ref)) : refs;
246
+ }
247
+
248
+ /**
249
+ * @param {RecordAutomationRunInput} input
250
+ * @param {unknown} refs - Already-resolved `refs` for this record.
251
+ * @returns {AutomationRunRecord}
252
+ */
253
+ function assembleAutomationRunRecord(input, refs) {
165
254
  const loopId = normalizeLoopId(input.loopId);
166
255
  const summary = String(input.summary ?? "").trim();
167
256
  const runbook = String(input.runbook ?? "").trim();
168
- const refs = Array.isArray(input.refs)
169
- ? input.refs.map(ref => String(ref))
170
- : [];
171
257
  const ts =
172
258
  input.ts instanceof Date
173
259
  ? input.ts.toISOString()
@@ -240,21 +326,23 @@ function validateStoredRecord(value) {
240
326
  if (!value || typeof value !== "object") {
241
327
  throw new Error("Automation run record must be an object.");
242
328
  }
243
- return buildAutomationRunRecord({
244
- ts: String(value.ts ?? ""),
245
- loopId: String(value.loop_id ?? ""),
246
- outcome: String(value.outcome ?? ""),
247
- summary: String(value.summary ?? ""),
248
- runbook: String(value.runbook ?? ""),
249
- refs: Array.isArray(value.refs) ? value.refs.map(ref => String(ref)) : [],
250
- runId: String(value.run_id ?? ""),
251
- // Carry every unrecognised key forward. Without this, re-validating a
252
- // stored record on read silently strips it, and the next append writes the
253
- // stripped history back over the file (#2524).
254
- extras: Object.fromEntries(
255
- Object.entries(value).filter(([key]) => !KNOWN_RECORD_KEYS.has(key))
256
- ),
257
- });
329
+ return assembleAutomationRunRecord(
330
+ {
331
+ ts: String(value.ts ?? ""),
332
+ loopId: String(value.loop_id ?? ""),
333
+ outcome: String(value.outcome ?? ""),
334
+ summary: String(value.summary ?? ""),
335
+ runbook: String(value.runbook ?? ""),
336
+ runId: String(value.run_id ?? ""),
337
+ // Carry every unrecognised key forward. Without this, re-validating a
338
+ // stored record on read silently strips it, and the next append writes
339
+ // the stripped history back over the file (#2524).
340
+ extras: Object.fromEntries(
341
+ Object.entries(value).filter(([key]) => !KNOWN_RECORD_KEYS.has(key))
342
+ ),
343
+ },
344
+ preserveStoredRefs(value.refs)
345
+ );
258
346
  }
259
347
 
260
348
  /**
@@ -291,10 +379,10 @@ async function readJsonIfPresent(filePath) {
291
379
 
292
380
  /**
293
381
  * @param {string} filePath
294
- * @param {readonly AutomationRunRecord[]} records
382
+ * @param {readonly string[]} lines - Serialised rows, written verbatim.
295
383
  */
296
- async function writeJsonlAtomically(filePath, records) {
297
- const content = `${records.map(record => JSON.stringify(record)).join("\n")}\n`;
384
+ async function writeJsonlAtomically(filePath, lines) {
385
+ const content = `${lines.join("\n")}\n`;
298
386
  const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
299
387
  await writeFile(tempPath, content, "utf8");
300
388
  await rename(tempPath, filePath);
@@ -303,14 +391,19 @@ async function writeJsonlAtomically(filePath, records) {
303
391
  const CLI_USAGE = `Usage: node automation-run-record.mjs \\
304
392
  --loop-id <slug> --outcome <${AUTOMATION_RUN_OUTCOMES.join("|")}> \\
305
393
  --summary "<operator-readable one-liner>" --runbook <path> \\
306
- [--ref <url>]... [--run-id <id>] [--project-root <dir>]`;
394
+ [--ref <url>]... [--run-id <id>] [--project-root <dir>] \\
395
+ [--denominator '<json from buildIntakeDenominator()>']
396
+
397
+ --denominator is REQUIRED for a "nothing-needed" run on a build-intake loop: a
398
+ dry lane that does not say which lanes it looked in cannot be checked.`;
307
399
 
308
400
  /**
309
401
  * Translate a repeatable-flag argv into a {@link RecordAutomationRunInput}.
310
402
  *
311
403
  * Every flag takes a following value; `--ref` may repeat and accumulates into
312
- * `refs`. Unknown flags and value-less flags throw so a typo never silently
313
- * records the wrong thing.
404
+ * `refs`, and `--denominator` carries the swept-lane JSON into `extras`.
405
+ * Unknown flags and value-less flags throw so a typo never silently records the
406
+ * wrong thing.
314
407
  *
315
408
  * @param {readonly string[]} argv
316
409
  * @returns {RecordAutomationRunInput}
@@ -320,6 +413,8 @@ function parseAutomationRunRecordArgv(argv) {
320
413
  const single = {};
321
414
  /** @type {string[]} */
322
415
  const refs = [];
416
+ /** @type {Record<string, unknown>} */
417
+ const extras = {};
323
418
  const flags = {
324
419
  "--loop-id": "loopId",
325
420
  "--outcome": "outcome",
@@ -349,6 +444,10 @@ function parseAutomationRunRecordArgv(argv) {
349
444
  refs.push(takeValue());
350
445
  continue;
351
446
  }
447
+ if (flag === "--denominator") {
448
+ extras.denominator = parseDenominatorFlag(takeValue());
449
+ continue;
450
+ }
352
451
  const key = flags[flag];
353
452
  if (!key) {
354
453
  throw new Error(`Unknown flag "${flag}".`);
@@ -356,7 +455,21 @@ function parseAutomationRunRecordArgv(argv) {
356
455
  single[key] = takeValue();
357
456
  }
358
457
 
359
- return { ...single, refs };
458
+ return { ...single, refs, extras };
459
+ }
460
+
461
+ /**
462
+ * @param {string} value
463
+ * @returns {unknown}
464
+ */
465
+ function parseDenominatorFlag(value) {
466
+ try {
467
+ return JSON.parse(value);
468
+ } catch {
469
+ throw new Error(
470
+ "--denominator must be JSON produced by buildIntakeDenominator()."
471
+ );
472
+ }
360
473
  }
361
474
 
362
475
  /**
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Blocker re-probe gate for pre-work build-intake candidates.
4
+ *
5
+ * A blocker is a claim with a timestamp, not a fact. It is written once, and it
6
+ * decays the moment its condition goes true — a dependency lands on trunk, a
7
+ * CVE gets patched, a package publishes. Nothing in the old intake loop ever
8
+ * re-read one, so a discharged blocker held its item out of the queue forever.
9
+ *
10
+ * This module decides, per candidate, whether intake may select it. It never
11
+ * probes anything itself: the caller runs the probe and hands back the result,
12
+ * which keeps the decision auditable and testable apart from the network.
13
+ */
14
+
15
+ import { isPreWorkLaneType } from "./intake-prework-denominator.mjs";
16
+
17
+ /** Marker a writer stamps on an item a human deliberately parked. */
18
+ export const HUMAN_GATE_MARKER = "[lisa-human-gate]";
19
+
20
+ /** Default label naming a block only a human can clear. */
21
+ export const DEFAULT_HUMAN_NEEDED_LABEL = "human-needed";
22
+
23
+ /**
24
+ * @param {unknown} labels
25
+ * @returns {readonly string[]}
26
+ */
27
+ function normalizeLabels(labels) {
28
+ return (Array.isArray(labels) ? labels : [])
29
+ .map(label =>
30
+ typeof label === "string"
31
+ ? label
32
+ : typeof label?.name === "string"
33
+ ? label.name
34
+ : ""
35
+ )
36
+ .map(label => label.trim().toLowerCase())
37
+ .filter(label => label.length > 0);
38
+ }
39
+
40
+ /**
41
+ * @param {{ labels?: unknown, body?: unknown, humanNeededLabel?: unknown }} input
42
+ * @returns {boolean}
43
+ */
44
+ function isHumanGated(input) {
45
+ const configured = String(
46
+ input.humanNeededLabel ?? DEFAULT_HUMAN_NEEDED_LABEL
47
+ )
48
+ .trim()
49
+ .toLowerCase();
50
+ if (
51
+ configured.length > 0 &&
52
+ normalizeLabels(input.labels).includes(configured)
53
+ ) {
54
+ return true;
55
+ }
56
+
57
+ return String(input.body ?? "").includes(HUMAN_GATE_MARKER);
58
+ }
59
+
60
+ /**
61
+ * @param {unknown} value
62
+ * @returns {string}
63
+ */
64
+ function trimmedString(value) {
65
+ return typeof value === "string" ? value.trim() : "";
66
+ }
67
+
68
+ /**
69
+ * @param {{ discharged?: unknown, evidence?: unknown }} probe
70
+ * @returns {{ selectable: boolean, reason: string, evidence: string }}
71
+ */
72
+ function judgeProbe(probe) {
73
+ const evidence = trimmedString(probe.evidence);
74
+
75
+ if (probe.discharged !== true) {
76
+ return {
77
+ selectable: false,
78
+ reason: "blocker-holds",
79
+ evidence,
80
+ };
81
+ }
82
+ if (evidence.length === 0) {
83
+ return {
84
+ selectable: false,
85
+ reason: "blocker-discharge-unevidenced",
86
+ evidence,
87
+ };
88
+ }
89
+
90
+ return { selectable: true, reason: "blocker-discharged", evidence };
91
+ }
92
+
93
+ /**
94
+ * Decide whether a pre-work item may be selected as a build candidate.
95
+ *
96
+ * Order is load-bearing. The human gate is checked before anything else, so no
97
+ * probe result — however conclusive — can promote an item a human parked.
98
+ *
99
+ * @param {{
100
+ * laneType?: unknown
101
+ * labels?: unknown
102
+ * body?: unknown
103
+ * humanNeededLabel?: unknown
104
+ * statedBlocker?: unknown
105
+ * probe?: { discharged?: unknown, evidence?: unknown } | null
106
+ * }} input
107
+ * @returns {{ selectable: boolean, reason: string, humanGated: boolean, evidence: string }}
108
+ */
109
+ export function classifyPreWorkCandidate(input = {}) {
110
+ if (isHumanGated(input)) {
111
+ return {
112
+ selectable: false,
113
+ reason: "human-gate",
114
+ humanGated: true,
115
+ evidence: "",
116
+ };
117
+ }
118
+ if (!isPreWorkLaneType(input.laneType)) {
119
+ return {
120
+ selectable: false,
121
+ reason: "not-pre-work",
122
+ humanGated: false,
123
+ evidence: "",
124
+ };
125
+ }
126
+ if (trimmedString(input.statedBlocker).length === 0) {
127
+ return {
128
+ selectable: true,
129
+ reason: "no-blocker",
130
+ humanGated: false,
131
+ evidence: "",
132
+ };
133
+ }
134
+ if (!input.probe || typeof input.probe !== "object") {
135
+ return {
136
+ selectable: false,
137
+ reason: "blocker-unprobed",
138
+ humanGated: false,
139
+ evidence: "",
140
+ };
141
+ }
142
+
143
+ return { ...judgeProbe(input.probe), humanGated: false };
144
+ }
145
+
146
+ /** Plain-English wording for each verdict, for the note left on the item. */
147
+ const REASON_COPY = Object.freeze({
148
+ "human-gate": "A person parked this one on purpose, so intake left it alone.",
149
+ "not-pre-work": "This item is not sitting in a not-yet-started lane.",
150
+ "no-blocker": "No blocker was written on this item, so it is buildable.",
151
+ "blocker-unprobed":
152
+ "This item states a blocker that was not re-checked this cycle, so it stays put.",
153
+ "blocker-holds":
154
+ "The blocker was re-checked and still holds, so it stays put.",
155
+ "blocker-discharge-unevidenced":
156
+ "The blocker looked clear but no proof was recorded, so it stays put.",
157
+ "blocker-discharged":
158
+ "The blocker was re-checked and no longer applies, so this item is buildable again.",
159
+ });
160
+
161
+ /**
162
+ * Render the re-probe result to leave on the item, so the next cycle reads the
163
+ * answer instead of deriving it again.
164
+ *
165
+ * @param {{
166
+ * statedBlocker?: unknown
167
+ * checkedAt?: string | Date
168
+ * }} candidate
169
+ * @param {{ reason: string, evidence?: string }} verdict
170
+ * @returns {string}
171
+ */
172
+ export function formatReprobeNote(candidate = {}, verdict) {
173
+ const checkedAt =
174
+ candidate.checkedAt instanceof Date
175
+ ? candidate.checkedAt.toISOString()
176
+ : trimmedString(candidate.checkedAt) || new Date().toISOString();
177
+ const lines = [
178
+ "**Blocker re-check**",
179
+ "",
180
+ `- What has to be true: ${trimmedString(candidate.statedBlocker) || "(none written on this item)"}`,
181
+ `- What we found: ${REASON_COPY[verdict.reason] ?? verdict.reason}`,
182
+ ];
183
+ const evidence = trimmedString(verdict.evidence);
184
+ if (evidence.length > 0) {
185
+ lines.push(`- Proof: ${evidence}`);
186
+ }
187
+ lines.push(`- Checked at: ${checkedAt}`);
188
+
189
+ return lines.join("\n");
190
+ }