@codyswann/lisa 3.33.8 → 3.35.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.
- package/all/copy-overwrite/scripts/lisa-lint-staged-preflight.mjs +399 -0
- package/dist/cli/doctor-traceability-gate.d.ts +39 -0
- package/dist/cli/doctor-traceability-gate.d.ts.map +1 -0
- package/dist/cli/doctor-traceability-gate.js +164 -0
- package/dist/cli/doctor-traceability-gate.js.map +1 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +7 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +3 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +32 -9
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +53 -3
- package/plugins/lisa/.codex-plugin/skills/lisa-intake/SKILL.md +17 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-build-intake/SKILL.md +53 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-build-intake/SKILL.md +48 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-tracker-build-intake/SKILL.md +24 -0
- package/plugins/lisa/rules/eager/automation-runbook-contract.md +2 -0
- package/plugins/lisa/rules/reference/automation-runbook-contract.md +19 -0
- package/plugins/lisa/scripts/automation-run-record.mjs +146 -33
- package/plugins/lisa/scripts/intake-blocker-reprobe.mjs +190 -0
- package/plugins/lisa/scripts/intake-prework-denominator.mjs +249 -0
- package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +53 -3
- package/plugins/lisa/skills/lisa-intake/SKILL.md +17 -1
- package/plugins/lisa/skills/lisa-jira-build-intake/SKILL.md +53 -5
- package/plugins/lisa/skills/lisa-linear-build-intake/SKILL.md +48 -7
- package/plugins/lisa/skills/lisa-tracker-build-intake/SKILL.md +24 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/scripts/automation-run-record.mjs +146 -33
- package/plugins/lisa-agy/scripts/intake-blocker-reprobe.mjs +190 -0
- package/plugins/lisa-agy/scripts/intake-prework-denominator.mjs +249 -0
- package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +53 -3
- package/plugins/lisa-agy/skills/lisa-intake/SKILL.md +17 -1
- package/plugins/lisa-agy/skills/lisa-jira-build-intake/SKILL.md +53 -5
- package/plugins/lisa-agy/skills/lisa-linear-build-intake/SKILL.md +48 -7
- package/plugins/lisa-agy/skills/lisa-tracker-build-intake/SKILL.md +24 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/automation-runbook-contract.md +2 -0
- package/plugins/lisa-copilot/rules/reference/automation-runbook-contract.md +19 -0
- package/plugins/lisa-copilot/scripts/automation-run-record.mjs +146 -33
- package/plugins/lisa-copilot/scripts/intake-blocker-reprobe.mjs +190 -0
- package/plugins/lisa-copilot/scripts/intake-prework-denominator.mjs +249 -0
- package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +53 -3
- package/plugins/lisa-copilot/skills/lisa-intake/SKILL.md +17 -1
- package/plugins/lisa-copilot/skills/lisa-jira-build-intake/SKILL.md +53 -5
- package/plugins/lisa-copilot/skills/lisa-linear-build-intake/SKILL.md +48 -7
- package/plugins/lisa-copilot/skills/lisa-tracker-build-intake/SKILL.md +24 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/automation-runbook-contract-reference.mdc +19 -0
- package/plugins/lisa-cursor/rules/automation-runbook-contract.mdc +2 -0
- package/plugins/lisa-cursor/scripts/automation-run-record.mjs +146 -33
- package/plugins/lisa-cursor/scripts/intake-blocker-reprobe.mjs +190 -0
- package/plugins/lisa-cursor/scripts/intake-prework-denominator.mjs +249 -0
- package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +53 -3
- package/plugins/lisa-cursor/skills/lisa-intake/SKILL.md +17 -1
- package/plugins/lisa-cursor/skills/lisa-jira-build-intake/SKILL.md +53 -5
- package/plugins/lisa-cursor/skills/lisa-linear-build-intake/SKILL.md +48 -7
- package/plugins/lisa-cursor/skills/lisa-tracker-build-intake/SKILL.md +24 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/automation-runbook-contract.md +2 -0
- package/plugins/src/base/rules/reference/automation-runbook-contract.md +19 -0
- package/plugins/src/base/scripts/automation-run-record.mjs +146 -33
- package/plugins/src/base/scripts/intake-blocker-reprobe.mjs +190 -0
- package/plugins/src/base/scripts/intake-prework-denominator.mjs +249 -0
- package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +53 -3
- package/plugins/src/base/skills/lisa-intake/SKILL.md +17 -1
- package/plugins/src/base/skills/lisa-jira-build-intake/SKILL.md +53 -5
- package/plugins/src/base/skills/lisa-linear-build-intake/SKILL.md +48 -7
- package/plugins/src/base/skills/lisa-tracker-build-intake/SKILL.md +24 -0
- package/typescript/copy-contents/.husky/pre-commit +64 -0
|
@@ -120,6 +120,25 @@ recovery-required Lost access to the tracker; filed #1812 with the one decisio
|
|
|
120
120
|
policy-obsolete No work has reached this queue in 30 days; proposed teardown in #1813.
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
+
### `nothing-needed` states its denominator
|
|
124
|
+
|
|
125
|
+
A queue-scanning loop that reports nothing is only as trustworthy as the set it looked at, and a
|
|
126
|
+
summary of the form "scanned N items" hides the one thing worth checking: **which lanes N came
|
|
127
|
+
from.** So a `nothing-needed` summary from a queue scanner names the lanes it swept, the count in
|
|
128
|
+
each, and the total open — `Looked in every lane holding work that has not started — Backlog (20),
|
|
129
|
+
Todo (17), Ready (2), Blocked (61) — 100 items checked out of 343 still open.`
|
|
130
|
+
|
|
131
|
+
This is a measured requirement, not a style preference. One build lane reported 31 consecutive dry
|
|
132
|
+
cycles over a queue holding 61 unswept rows, at least one of them buildable for ~15 hours. Every
|
|
133
|
+
one of the 31 records was honest and every conclusion was false, because the scanner never said
|
|
134
|
+
what its denominator was. **A missing run record is noisy on inspection; a wrong denominator is
|
|
135
|
+
silent forever and looks exactly like a healthy dry queue.** Stating the swept set is what makes
|
|
136
|
+
the second failure mode visible at all.
|
|
137
|
+
|
|
138
|
+
The build-intake loop enforces this rather than requesting it: `automation-run-record.mjs` refuses
|
|
139
|
+
a `nothing-needed` row for `intake-tickets` unless the call carries a `--denominator` built by
|
|
140
|
+
`scripts/intake-prework-denominator.mjs`.
|
|
141
|
+
|
|
123
142
|
**No silent exit.** Every run posts its outcome and its one-line summary before stopping —
|
|
124
143
|
including the trivial early terminations (empty queue, nothing eligible, already-claimed) — so
|
|
125
144
|
there is no silent exit for an operator to misread as health. A run that ends without a
|
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
382
|
+
* @param {readonly string[]} lines - Serialised rows, written verbatim.
|
|
295
383
|
*/
|
|
296
|
-
async function writeJsonlAtomically(filePath,
|
|
297
|
-
const content = `${
|
|
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
|
|
313
|
-
* records the
|
|
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
|
+
}
|