@codyswann/lisa 2.253.0 → 2.254.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 (64) hide show
  1. package/package.json +1 -1
  2. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  3. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  4. package/plugins/lisa/rules/eager/automation-runbook-contract.md +4 -1
  5. package/plugins/lisa/rules/reference/automation-runbook-contract.md +16 -7
  6. package/plugins/lisa/scripts/automation-run-record.mjs +262 -0
  7. package/plugins/lisa-agy/plugin.json +1 -1
  8. package/plugins/lisa-agy/scripts/automation-run-record.mjs +262 -0
  9. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  12. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-copilot/rules/eager/automation-runbook-contract.md +4 -1
  16. package/plugins/lisa-copilot/rules/reference/automation-runbook-contract.md +16 -7
  17. package/plugins/lisa-copilot/scripts/automation-run-record.mjs +262 -0
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cursor/rules/automation-runbook-contract-reference.mdc +16 -7
  20. package/plugins/lisa-cursor/rules/automation-runbook-contract.mdc +4 -1
  21. package/plugins/lisa-cursor/scripts/automation-run-record.mjs +262 -0
  22. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-expo-agy/plugin.json +1 -1
  25. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  45. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-rails-agy/plugin.json +1 -1
  50. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  55. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  60. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/src/base/rules/eager/automation-runbook-contract.md +4 -1
  63. package/plugins/src/base/rules/reference/automation-runbook-contract.md +16 -7
  64. package/plugins/src/base/scripts/automation-run-record.mjs +262 -0
package/package.json CHANGED
@@ -102,7 +102,7 @@
102
102
  "form-data": ">=4.0.6"
103
103
  },
104
104
  "name": "@codyswann/lisa",
105
- "version": "2.253.0",
105
+ "version": "2.254.0",
106
106
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
107
107
  "main": "dist/index.js",
108
108
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -50,7 +50,10 @@ loop itself is broken, not that a work item was blocked.
50
50
 
51
51
  Every run — including a trivial early termination — ends by naming exactly one run outcome plus a
52
52
  one-line operator summary of what happened, recorded where the status surface can read it —
53
- there is no silent exit. Silence and health must never look identical to an operator.
53
+ there is no silent exit. The shared local substrate is
54
+ `plugins/src/base/scripts/automation-run-record.mjs`, which writes bounded JSONL records under
55
+ `.lisa/automations/runs/<loop-id>.jsonl`. Silence and health must never look identical to an
56
+ operator.
54
57
 
55
58
  ## Never block, always degrade
56
59
 
@@ -121,8 +121,17 @@ there is no silent exit for an operator to misread as health. A run that ends wi
121
121
  recorded outcome is indistinguishable from a crashed scheduler, which is exactly the ambiguity this
122
122
  contract removes. This generalizes `lisa-improve-harness`'s result-record discipline — every
123
123
  terminal step posts its result record before stopping, so there is no silent exit — from one flow to
124
- every registered loop. (The run-record substrate that stores outcomes and summaries is named here
125
- only; it ships with that ticket (#1797), do not assume its file is present in this branch.)
124
+ every registered loop.
125
+
126
+ The local storage substrate is `plugins/src/base/scripts/automation-run-record.mjs`. It writes one
127
+ bounded JSONL file per loop at `.lisa/automations/runs/<loop-id>.jsonl`, with one object per line:
128
+ `{ ts, loop_id, outcome, summary, runbook, refs[], run_id }`. The helper rejects any outcome outside
129
+ the closed six-value vocabulary, requires a non-empty operator-readable summary, suppresses duplicate
130
+ re-appends for the same `run_id`, skips corrupt/truncated lines on read, and trims to the newest
131
+ configured records on write. The default bound is 50 records per loop, overridable via
132
+ `.lisa.config.json` / `.lisa.config.local.json` `automations.runHistory.maxEntries`. These records
133
+ are local scheduler observations, not project knowledge, so `.lisa/automations/runs/` is ignored;
134
+ the runbooks under `.lisa/automations/*.runbook.md` remain checked-in knowledge.
126
135
 
127
136
  ## A run outcome is not a work-item lifecycle terminal state
128
137
 
@@ -209,8 +218,8 @@ packet above. A degraded run never blocks other work and never leaves the run un
209
218
 
210
219
  ## Citing adjacent work
211
220
 
212
- Cite these by name; each ships with its own ticket, do not assume its file is present in this
213
- branch: the runbook scaffolding that instantiates this template (#1796), the run-record substrate
214
- that stores outcomes and summaries (#1797), and the evidence packet defined by the evidence PRD
215
- (#1738) — named here, with no evidence format defined by this contract. Rejection memory
216
- extends `rejection-detection`; this contract neither restates nor overrides it.
221
+ Cite these by name. The run-record substrate ships with that ticket (#1797), do not assume its file
222
+ is present in older branches. The evidence packet ships with that ticket, do not assume its file is present in this branch. Other adjacent artifacts keep their own lifecycle: the runbook scaffolding
223
+ that instantiates this template (#1796), and the evidence packet defined by the evidence PRD (#1738)
224
+ — named here, with no evidence format defined by this contract. Rejection memory extends
225
+ `rejection-detection`; this contract neither restates nor overrides it.
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dependency-free run outcome recorder for registered Lisa automation loops.
4
+ *
5
+ * The file is local scheduler state, not project knowledge: one bounded JSONL
6
+ * file per loop under `.lisa/automations/runs/`.
7
+ */
8
+
9
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+
12
+ export const AUTOMATION_RUN_OUTCOMES = [
13
+ "nothing-needed",
14
+ "candidate-proposed",
15
+ "change-proved",
16
+ "approval-requested",
17
+ "recovery-required",
18
+ "policy-obsolete",
19
+ ];
20
+
21
+ export const DEFAULT_AUTOMATION_RUN_HISTORY_MAX_ENTRIES = 50;
22
+
23
+ /**
24
+ * @typedef {{
25
+ * readonly ts: string
26
+ * readonly loop_id: string
27
+ * readonly outcome: string
28
+ * readonly summary: string
29
+ * readonly runbook: string
30
+ * readonly refs: readonly string[]
31
+ * readonly run_id: string
32
+ * }} AutomationRunRecord
33
+ *
34
+ * @typedef {{
35
+ * readonly projectRoot?: string
36
+ * readonly loopId: string
37
+ * readonly outcome: string
38
+ * readonly summary: string
39
+ * readonly runbook: string
40
+ * readonly refs?: readonly string[]
41
+ * readonly runId?: string
42
+ * readonly ts?: string | Date
43
+ * readonly maxEntries?: number
44
+ * }} RecordAutomationRunInput
45
+ */
46
+
47
+ /**
48
+ * Record one automation-loop outcome, suppressing duplicate re-appends for the
49
+ * same `run_id` and trimming the file to the configured history bound.
50
+ *
51
+ * @param {RecordAutomationRunInput} input
52
+ * @returns {Promise<{ readonly path: string, readonly record: AutomationRunRecord, readonly records: readonly AutomationRunRecord[], readonly appended: boolean, readonly skippedCorruptLines: number, readonly maxEntries: number }>}
53
+ */
54
+ export async function recordAutomationRun(input) {
55
+ const projectRoot = path.resolve(input.projectRoot ?? process.cwd());
56
+ const maxEntries =
57
+ input.maxEntries ??
58
+ (await resolveAutomationRunHistoryMaxEntries(projectRoot));
59
+ const record = buildAutomationRunRecord(input);
60
+ const filePath = automationRunRecordPath(projectRoot, record.loop_id);
61
+ const readResult = await readAutomationRunRecords(filePath);
62
+
63
+ if (readResult.records.some(existing => existing.run_id === record.run_id)) {
64
+ return {
65
+ path: filePath,
66
+ record,
67
+ records: readResult.records,
68
+ appended: false,
69
+ skippedCorruptLines: readResult.skippedCorruptLines,
70
+ maxEntries,
71
+ };
72
+ }
73
+
74
+ const nextRecords = [...readResult.records, record].slice(-maxEntries);
75
+ await mkdir(path.dirname(filePath), { recursive: true });
76
+ await writeJsonlAtomically(filePath, nextRecords);
77
+
78
+ return {
79
+ path: filePath,
80
+ record,
81
+ records: nextRecords,
82
+ appended: true,
83
+ skippedCorruptLines: readResult.skippedCorruptLines,
84
+ maxEntries,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * @param {string} projectRoot
90
+ * @returns {Promise<number>}
91
+ */
92
+ export async function resolveAutomationRunHistoryMaxEntries(projectRoot) {
93
+ const globalConfig = await readJsonIfPresent(
94
+ path.join(projectRoot, ".lisa.config.json")
95
+ );
96
+ const localConfig = await readJsonIfPresent(
97
+ path.join(projectRoot, ".lisa.config.local.json")
98
+ );
99
+ const configured =
100
+ localConfig?.automations?.runHistory?.maxEntries ??
101
+ globalConfig?.automations?.runHistory?.maxEntries;
102
+
103
+ if (Number.isInteger(configured) && configured > 0) {
104
+ return configured;
105
+ }
106
+
107
+ return DEFAULT_AUTOMATION_RUN_HISTORY_MAX_ENTRIES;
108
+ }
109
+
110
+ /**
111
+ * @param {string} projectRoot
112
+ * @param {string} loopId
113
+ * @returns {string}
114
+ */
115
+ export function automationRunRecordPath(projectRoot, loopId) {
116
+ return path.join(
117
+ path.resolve(projectRoot),
118
+ ".lisa",
119
+ "automations",
120
+ "runs",
121
+ `${normalizeLoopId(loopId)}.jsonl`
122
+ );
123
+ }
124
+
125
+ /**
126
+ * @param {string} filePath
127
+ * @returns {Promise<{ readonly records: readonly AutomationRunRecord[], readonly skippedCorruptLines: number }>}
128
+ */
129
+ export async function readAutomationRunRecords(filePath) {
130
+ let content = "";
131
+ try {
132
+ content = await readFile(filePath, "utf8");
133
+ } catch (error) {
134
+ if (error?.code === "ENOENT") {
135
+ return { records: [], skippedCorruptLines: 0 };
136
+ }
137
+ throw error;
138
+ }
139
+
140
+ const records = [];
141
+ let skippedCorruptLines = 0;
142
+ for (const line of content.split(/\n/)) {
143
+ if (!line.trim()) {
144
+ continue;
145
+ }
146
+ try {
147
+ const parsed = JSON.parse(line);
148
+ records.push(validateStoredRecord(parsed));
149
+ } catch {
150
+ skippedCorruptLines += 1;
151
+ }
152
+ }
153
+
154
+ return { records, skippedCorruptLines };
155
+ }
156
+
157
+ /**
158
+ * @param {RecordAutomationRunInput} input
159
+ * @returns {AutomationRunRecord}
160
+ */
161
+ function buildAutomationRunRecord(input) {
162
+ const loopId = normalizeLoopId(input.loopId);
163
+ const summary = String(input.summary ?? "").trim();
164
+ const runbook = String(input.runbook ?? "").trim();
165
+ const refs = Array.isArray(input.refs)
166
+ ? input.refs.map(ref => String(ref))
167
+ : [];
168
+ const ts =
169
+ input.ts instanceof Date
170
+ ? input.ts.toISOString()
171
+ : input.ts
172
+ ? new Date(input.ts).toISOString()
173
+ : new Date().toISOString();
174
+ const runId = String(input.runId ?? `${loopId}:${ts}`).trim();
175
+
176
+ if (!AUTOMATION_RUN_OUTCOMES.includes(input.outcome)) {
177
+ throw new Error(
178
+ `Invalid automation run outcome "${input.outcome}". Valid outcomes: ${AUTOMATION_RUN_OUTCOMES.join(", ")}.`
179
+ );
180
+ }
181
+ if (!summary) {
182
+ throw new Error("Automation run summary is required.");
183
+ }
184
+ if (!runbook) {
185
+ throw new Error("Automation runbook path is required.");
186
+ }
187
+ if (!runId) {
188
+ throw new Error("Automation run_id is required.");
189
+ }
190
+
191
+ return {
192
+ ts,
193
+ loop_id: loopId,
194
+ outcome: input.outcome,
195
+ summary,
196
+ runbook,
197
+ refs,
198
+ run_id: runId,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * @param {unknown} value
204
+ * @returns {AutomationRunRecord}
205
+ */
206
+ function validateStoredRecord(value) {
207
+ if (!value || typeof value !== "object") {
208
+ throw new Error("Automation run record must be an object.");
209
+ }
210
+ return buildAutomationRunRecord({
211
+ ts: String(value.ts ?? ""),
212
+ loopId: String(value.loop_id ?? ""),
213
+ outcome: String(value.outcome ?? ""),
214
+ summary: String(value.summary ?? ""),
215
+ runbook: String(value.runbook ?? ""),
216
+ refs: Array.isArray(value.refs) ? value.refs.map(ref => String(ref)) : [],
217
+ runId: String(value.run_id ?? ""),
218
+ });
219
+ }
220
+
221
+ /**
222
+ * @param {string} loopId
223
+ * @returns {string}
224
+ */
225
+ function normalizeLoopId(loopId) {
226
+ const normalized = String(loopId ?? "").trim();
227
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(normalized)) {
228
+ throw new Error(
229
+ "Automation loop_id must be a non-empty slug containing only letters, numbers, dots, underscores, and hyphens."
230
+ );
231
+ }
232
+ return normalized;
233
+ }
234
+
235
+ /**
236
+ * @param {string} filePath
237
+ * @returns {Promise<unknown | undefined>}
238
+ */
239
+ async function readJsonIfPresent(filePath) {
240
+ try {
241
+ return JSON.parse(await readFile(filePath, "utf8"));
242
+ } catch (error) {
243
+ if (error?.code === "ENOENT") {
244
+ return undefined;
245
+ }
246
+ if (error instanceof SyntaxError) {
247
+ return undefined;
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * @param {string} filePath
255
+ * @param {readonly AutomationRunRecord[]} records
256
+ */
257
+ async function writeJsonlAtomically(filePath, records) {
258
+ const content = `${records.map(record => JSON.stringify(record)).join("\n")}\n`;
259
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
260
+ await writeFile(tempPath, content, "utf8");
261
+ await rename(tempPath, filePath);
262
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dependency-free run outcome recorder for registered Lisa automation loops.
4
+ *
5
+ * The file is local scheduler state, not project knowledge: one bounded JSONL
6
+ * file per loop under `.lisa/automations/runs/`.
7
+ */
8
+
9
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+
12
+ export const AUTOMATION_RUN_OUTCOMES = [
13
+ "nothing-needed",
14
+ "candidate-proposed",
15
+ "change-proved",
16
+ "approval-requested",
17
+ "recovery-required",
18
+ "policy-obsolete",
19
+ ];
20
+
21
+ export const DEFAULT_AUTOMATION_RUN_HISTORY_MAX_ENTRIES = 50;
22
+
23
+ /**
24
+ * @typedef {{
25
+ * readonly ts: string
26
+ * readonly loop_id: string
27
+ * readonly outcome: string
28
+ * readonly summary: string
29
+ * readonly runbook: string
30
+ * readonly refs: readonly string[]
31
+ * readonly run_id: string
32
+ * }} AutomationRunRecord
33
+ *
34
+ * @typedef {{
35
+ * readonly projectRoot?: string
36
+ * readonly loopId: string
37
+ * readonly outcome: string
38
+ * readonly summary: string
39
+ * readonly runbook: string
40
+ * readonly refs?: readonly string[]
41
+ * readonly runId?: string
42
+ * readonly ts?: string | Date
43
+ * readonly maxEntries?: number
44
+ * }} RecordAutomationRunInput
45
+ */
46
+
47
+ /**
48
+ * Record one automation-loop outcome, suppressing duplicate re-appends for the
49
+ * same `run_id` and trimming the file to the configured history bound.
50
+ *
51
+ * @param {RecordAutomationRunInput} input
52
+ * @returns {Promise<{ readonly path: string, readonly record: AutomationRunRecord, readonly records: readonly AutomationRunRecord[], readonly appended: boolean, readonly skippedCorruptLines: number, readonly maxEntries: number }>}
53
+ */
54
+ export async function recordAutomationRun(input) {
55
+ const projectRoot = path.resolve(input.projectRoot ?? process.cwd());
56
+ const maxEntries =
57
+ input.maxEntries ??
58
+ (await resolveAutomationRunHistoryMaxEntries(projectRoot));
59
+ const record = buildAutomationRunRecord(input);
60
+ const filePath = automationRunRecordPath(projectRoot, record.loop_id);
61
+ const readResult = await readAutomationRunRecords(filePath);
62
+
63
+ if (readResult.records.some(existing => existing.run_id === record.run_id)) {
64
+ return {
65
+ path: filePath,
66
+ record,
67
+ records: readResult.records,
68
+ appended: false,
69
+ skippedCorruptLines: readResult.skippedCorruptLines,
70
+ maxEntries,
71
+ };
72
+ }
73
+
74
+ const nextRecords = [...readResult.records, record].slice(-maxEntries);
75
+ await mkdir(path.dirname(filePath), { recursive: true });
76
+ await writeJsonlAtomically(filePath, nextRecords);
77
+
78
+ return {
79
+ path: filePath,
80
+ record,
81
+ records: nextRecords,
82
+ appended: true,
83
+ skippedCorruptLines: readResult.skippedCorruptLines,
84
+ maxEntries,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * @param {string} projectRoot
90
+ * @returns {Promise<number>}
91
+ */
92
+ export async function resolveAutomationRunHistoryMaxEntries(projectRoot) {
93
+ const globalConfig = await readJsonIfPresent(
94
+ path.join(projectRoot, ".lisa.config.json")
95
+ );
96
+ const localConfig = await readJsonIfPresent(
97
+ path.join(projectRoot, ".lisa.config.local.json")
98
+ );
99
+ const configured =
100
+ localConfig?.automations?.runHistory?.maxEntries ??
101
+ globalConfig?.automations?.runHistory?.maxEntries;
102
+
103
+ if (Number.isInteger(configured) && configured > 0) {
104
+ return configured;
105
+ }
106
+
107
+ return DEFAULT_AUTOMATION_RUN_HISTORY_MAX_ENTRIES;
108
+ }
109
+
110
+ /**
111
+ * @param {string} projectRoot
112
+ * @param {string} loopId
113
+ * @returns {string}
114
+ */
115
+ export function automationRunRecordPath(projectRoot, loopId) {
116
+ return path.join(
117
+ path.resolve(projectRoot),
118
+ ".lisa",
119
+ "automations",
120
+ "runs",
121
+ `${normalizeLoopId(loopId)}.jsonl`
122
+ );
123
+ }
124
+
125
+ /**
126
+ * @param {string} filePath
127
+ * @returns {Promise<{ readonly records: readonly AutomationRunRecord[], readonly skippedCorruptLines: number }>}
128
+ */
129
+ export async function readAutomationRunRecords(filePath) {
130
+ let content = "";
131
+ try {
132
+ content = await readFile(filePath, "utf8");
133
+ } catch (error) {
134
+ if (error?.code === "ENOENT") {
135
+ return { records: [], skippedCorruptLines: 0 };
136
+ }
137
+ throw error;
138
+ }
139
+
140
+ const records = [];
141
+ let skippedCorruptLines = 0;
142
+ for (const line of content.split(/\n/)) {
143
+ if (!line.trim()) {
144
+ continue;
145
+ }
146
+ try {
147
+ const parsed = JSON.parse(line);
148
+ records.push(validateStoredRecord(parsed));
149
+ } catch {
150
+ skippedCorruptLines += 1;
151
+ }
152
+ }
153
+
154
+ return { records, skippedCorruptLines };
155
+ }
156
+
157
+ /**
158
+ * @param {RecordAutomationRunInput} input
159
+ * @returns {AutomationRunRecord}
160
+ */
161
+ function buildAutomationRunRecord(input) {
162
+ const loopId = normalizeLoopId(input.loopId);
163
+ const summary = String(input.summary ?? "").trim();
164
+ const runbook = String(input.runbook ?? "").trim();
165
+ const refs = Array.isArray(input.refs)
166
+ ? input.refs.map(ref => String(ref))
167
+ : [];
168
+ const ts =
169
+ input.ts instanceof Date
170
+ ? input.ts.toISOString()
171
+ : input.ts
172
+ ? new Date(input.ts).toISOString()
173
+ : new Date().toISOString();
174
+ const runId = String(input.runId ?? `${loopId}:${ts}`).trim();
175
+
176
+ if (!AUTOMATION_RUN_OUTCOMES.includes(input.outcome)) {
177
+ throw new Error(
178
+ `Invalid automation run outcome "${input.outcome}". Valid outcomes: ${AUTOMATION_RUN_OUTCOMES.join(", ")}.`
179
+ );
180
+ }
181
+ if (!summary) {
182
+ throw new Error("Automation run summary is required.");
183
+ }
184
+ if (!runbook) {
185
+ throw new Error("Automation runbook path is required.");
186
+ }
187
+ if (!runId) {
188
+ throw new Error("Automation run_id is required.");
189
+ }
190
+
191
+ return {
192
+ ts,
193
+ loop_id: loopId,
194
+ outcome: input.outcome,
195
+ summary,
196
+ runbook,
197
+ refs,
198
+ run_id: runId,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * @param {unknown} value
204
+ * @returns {AutomationRunRecord}
205
+ */
206
+ function validateStoredRecord(value) {
207
+ if (!value || typeof value !== "object") {
208
+ throw new Error("Automation run record must be an object.");
209
+ }
210
+ return buildAutomationRunRecord({
211
+ ts: String(value.ts ?? ""),
212
+ loopId: String(value.loop_id ?? ""),
213
+ outcome: String(value.outcome ?? ""),
214
+ summary: String(value.summary ?? ""),
215
+ runbook: String(value.runbook ?? ""),
216
+ refs: Array.isArray(value.refs) ? value.refs.map(ref => String(ref)) : [],
217
+ runId: String(value.run_id ?? ""),
218
+ });
219
+ }
220
+
221
+ /**
222
+ * @param {string} loopId
223
+ * @returns {string}
224
+ */
225
+ function normalizeLoopId(loopId) {
226
+ const normalized = String(loopId ?? "").trim();
227
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(normalized)) {
228
+ throw new Error(
229
+ "Automation loop_id must be a non-empty slug containing only letters, numbers, dots, underscores, and hyphens."
230
+ );
231
+ }
232
+ return normalized;
233
+ }
234
+
235
+ /**
236
+ * @param {string} filePath
237
+ * @returns {Promise<unknown | undefined>}
238
+ */
239
+ async function readJsonIfPresent(filePath) {
240
+ try {
241
+ return JSON.parse(await readFile(filePath, "utf8"));
242
+ } catch (error) {
243
+ if (error?.code === "ENOENT") {
244
+ return undefined;
245
+ }
246
+ if (error instanceof SyntaxError) {
247
+ return undefined;
248
+ }
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * @param {string} filePath
255
+ * @param {readonly AutomationRunRecord[]} records
256
+ */
257
+ async function writeJsonlAtomically(filePath, records) {
258
+ const content = `${records.map(record => JSON.stringify(record)).join("\n")}\n`;
259
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
260
+ await writeFile(tempPath, content, "utf8");
261
+ await rename(tempPath, filePath);
262
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "AWS CDK-specific Lisa plugin.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.253.0",
3
+ "version": "2.254.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -50,7 +50,10 @@ loop itself is broken, not that a work item was blocked.
50
50
 
51
51
  Every run — including a trivial early termination — ends by naming exactly one run outcome plus a
52
52
  one-line operator summary of what happened, recorded where the status surface can read it —
53
- there is no silent exit. Silence and health must never look identical to an operator.
53
+ there is no silent exit. The shared local substrate is
54
+ `plugins/src/base/scripts/automation-run-record.mjs`, which writes bounded JSONL records under
55
+ `.lisa/automations/runs/<loop-id>.jsonl`. Silence and health must never look identical to an
56
+ operator.
54
57
 
55
58
  ## Never block, always degrade
56
59