@codyswann/lisa 2.88.0 → 2.90.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/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "lodash": ">=4.18.1"
83
83
  },
84
84
  "name": "@codyswann/lisa",
85
- "version": "2.88.0",
85
+ "version": "2.90.0",
86
86
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
87
87
  "main": "dist/index.js",
88
88
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.88.0",
3
+ "version": "2.90.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.88.0",
3
+ "version": "2.90.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,510 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared Codex runtime adapter for `/lisa:automation-status`.
4
+ *
5
+ * This adapter inspects the local Codex automation backing store read-only:
6
+ * the per-automation `automation.toml` contract plus the automation memory file
7
+ * used by recurring runs. It scopes the scan to the current repo's expected
8
+ * Lisa automation prefix, derives normalized command/cadence metadata, and
9
+ * overlays available recency/failure signals onto the shared fleet report
10
+ * contract.
11
+ */
12
+
13
+ import fs from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+
17
+ import { compareAutomationContract } from "./automation-status-contract-drift.mjs";
18
+
19
+ const CODEx_RUNTIME_LABEL = "Codex automations";
20
+ const RUN_FAILURE_PATTERN =
21
+ /\b(failed|failure|errored|error|exception|crash(?:ed)?)\b/i;
22
+ const NEGATED_FAILURE_PATTERN =
23
+ /\b(no|without)\s+(?:recent\s+)?fail(?:ure|ed)\b/i;
24
+
25
+ /**
26
+ * @typedef {import("./automation-status-expected-fleet.mjs").resolveExpectedAutomationFleet extends (...args: any[]) => infer T ? T : never} ExpectedFleet
27
+ *
28
+ * @typedef {{
29
+ * readonly automationId: string
30
+ * readonly status?: string
31
+ * readonly prompt?: string
32
+ * readonly observedCadence?: string
33
+ * readonly observedRRule?: string
34
+ * readonly observedCommand?: string
35
+ * readonly cwd?: string | null
36
+ * readonly createdAt?: number | null
37
+ * readonly updatedAt?: number | null
38
+ * readonly lastRunAt?: string | null
39
+ * readonly lastRunSummary?: string | null
40
+ * readonly lastRunFailed?: boolean
41
+ * }} ObservedCodexAutomation
42
+ */
43
+
44
+ /**
45
+ * Inspect the current repo's Codex automation fleet and map it to the shared
46
+ * automation-status report contract.
47
+ *
48
+ * @param {{
49
+ * readonly expectedFleet: ExpectedFleet
50
+ * readonly automationsDir?: string
51
+ * readonly now?: string | Date
52
+ * }} input
53
+ * @returns {Promise<{
54
+ * readonly runtime: string
55
+ * readonly generatedAt: string
56
+ * readonly groups: readonly {
57
+ * readonly id: string
58
+ * readonly title: string
59
+ * readonly items: readonly {
60
+ * readonly id: string
61
+ * readonly status: "HEALTHY" | "MISSING" | "UNSUPPORTED" | "DRIFTED" | "STALE" | "FAILING"
62
+ * readonly summary: string
63
+ * readonly expectedCadence?: string
64
+ * readonly expectedCommand?: string
65
+ * readonly observed?: string
66
+ * readonly remediation?: string
67
+ * }[]
68
+ * }[]
69
+ * readonly observedAutomations: readonly ObservedCodexAutomation[]
70
+ * }>}
71
+ */
72
+ export async function inspectCodexAutomationFleet(input) {
73
+ const expectedFleet = input.expectedFleet;
74
+ const now = normalizeDate(input.now);
75
+ const observedAutomations = await listCodexAutomations({
76
+ automationsDir: input.automationsDir,
77
+ automationPrefix: expectedFleet.automationPrefix,
78
+ });
79
+
80
+ const expectedGroups = new Map([
81
+ ["core", []],
82
+ ["exploratory", []],
83
+ ]);
84
+
85
+ for (const expected of expectedFleet.expected) {
86
+ const comparison = compareAutomationContract({
87
+ expected,
88
+ observedAutomations,
89
+ });
90
+ expectedGroups.get(expected.group)?.push(
91
+ createObservedStatusItem({
92
+ expected,
93
+ comparison,
94
+ now,
95
+ })
96
+ );
97
+ }
98
+
99
+ for (const unsupported of expectedFleet.unsupported) {
100
+ expectedGroups.get(unsupported.group)?.push({
101
+ id: unsupported.automationId,
102
+ status: "UNSUPPORTED",
103
+ summary: unsupported.reason,
104
+ expectedCadence: unsupported.expectedCadence,
105
+ observed: "No automation is expected for this repo/runtime combination.",
106
+ });
107
+ }
108
+
109
+ return {
110
+ runtime: `${CODEx_RUNTIME_LABEL} (backing-store metadata)`,
111
+ generatedAt: now.toISOString(),
112
+ groups: [
113
+ {
114
+ id: "1",
115
+ title: "Core automations",
116
+ items: expectedGroups.get("core") ?? [],
117
+ },
118
+ {
119
+ id: "2",
120
+ title: "Exploratory automations",
121
+ items: expectedGroups.get("exploratory") ?? [],
122
+ },
123
+ ],
124
+ observedAutomations,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Read every Codex automation whose id matches the current repo's Lisa prefix.
130
+ *
131
+ * @param {{
132
+ * readonly automationsDir?: string
133
+ * readonly automationPrefix: string
134
+ * }} input
135
+ * @returns {Promise<readonly ObservedCodexAutomation[]>}
136
+ */
137
+ export async function listCodexAutomations(input) {
138
+ const automationsDir =
139
+ input.automationsDir ?? resolveDefaultCodexAutomationsDir();
140
+ const dirEntries = await fs.readdir(automationsDir, {
141
+ withFileTypes: true,
142
+ });
143
+
144
+ const automationDirs = dirEntries
145
+ .filter(
146
+ entry =>
147
+ entry.isDirectory() && entry.name.startsWith(input.automationPrefix)
148
+ )
149
+ .map(entry => path.join(automationsDir, entry.name))
150
+ .sort((left, right) => left.localeCompare(right));
151
+
152
+ const automations = await Promise.all(
153
+ automationDirs.map(dir => readCodexAutomation(dir))
154
+ );
155
+
156
+ return automations.filter(Boolean);
157
+ }
158
+
159
+ /**
160
+ * Normalize a Lisa Codex automation prompt back into the slash-command surface
161
+ * expected by the shared drift classifier.
162
+ *
163
+ * @param {string | undefined} prompt
164
+ * @returns {string | undefined}
165
+ */
166
+ export function deriveCodexObservedCommand(prompt) {
167
+ if (!prompt) {
168
+ return undefined;
169
+ }
170
+
171
+ const lisaSkillMatch = prompt.match(
172
+ /Use the Lisa ([a-z0-9:-]+) skill with arguments `([^`]+)`/i
173
+ );
174
+ if (lisaSkillMatch?.[1] && lisaSkillMatch[2]) {
175
+ return `/lisa:${lisaSkillMatch[1]} ${lisaSkillMatch[2]}`.trim();
176
+ }
177
+
178
+ const aliasSkillMatch = prompt.match(
179
+ /Use the `\$([a-z0-9:-]+)` skill with arguments `([^`]+)`/i
180
+ );
181
+ if (aliasSkillMatch?.[1] && aliasSkillMatch[2]) {
182
+ return `/${aliasSkillMatch[1]} ${aliasSkillMatch[2]}`.trim();
183
+ }
184
+
185
+ return undefined;
186
+ }
187
+
188
+ /**
189
+ * Parse the latest run metadata from an automation memory file.
190
+ *
191
+ * @param {string | undefined} memoryContent
192
+ * @returns {{
193
+ * readonly lastRunAt: string | null
194
+ * readonly lastRunSummary: string | null
195
+ * readonly lastRunFailed: boolean
196
+ * }}
197
+ */
198
+ export function parseCodexAutomationMemory(memoryContent) {
199
+ if (!memoryContent) {
200
+ return {
201
+ lastRunAt: null,
202
+ lastRunSummary: null,
203
+ lastRunFailed: false,
204
+ };
205
+ }
206
+
207
+ const timestampMatch = memoryContent.match(
208
+ /20\d{2}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z/
209
+ );
210
+ const lines = memoryContent.split(/\r?\n/);
211
+ const summaryLine =
212
+ lines
213
+ .find(line => line.startsWith("- "))
214
+ ?.replace(/^- /, "")
215
+ .trim() ?? null;
216
+
217
+ const latestBlock = lines.slice(0, 20).join("\n");
218
+ const lastRunFailed =
219
+ RUN_FAILURE_PATTERN.test(latestBlock) &&
220
+ !NEGATED_FAILURE_PATTERN.test(latestBlock);
221
+
222
+ return {
223
+ lastRunAt: timestampMatch?.[0] ?? null,
224
+ lastRunSummary: summaryLine,
225
+ lastRunFailed,
226
+ };
227
+ }
228
+
229
+ async function readCodexAutomation(automationDir) {
230
+ const tomlPath = path.join(automationDir, "automation.toml");
231
+ const memoryPath = path.join(automationDir, "memory.md");
232
+ const tomlContent = await fs.readFile(tomlPath, "utf8");
233
+ const automation = parseAutomationToml(tomlContent);
234
+ const memoryContent = await fs.readFile(memoryPath, "utf8").catch(() => "");
235
+ const memory = parseCodexAutomationMemory(memoryContent);
236
+ const cwd = Array.isArray(automation.cwds) ? automation.cwds[0] : null;
237
+
238
+ return {
239
+ automationId:
240
+ stringOrUndefined(automation.id) ?? path.basename(automationDir),
241
+ status: stringOrUndefined(automation.status),
242
+ prompt: stringOrUndefined(automation.prompt),
243
+ observedCadence: humanizeAutomationCadence(
244
+ stringOrUndefined(automation.rrule)
245
+ ),
246
+ observedRRule: stringOrUndefined(automation.rrule),
247
+ observedCommand: deriveCodexObservedCommand(
248
+ stringOrUndefined(automation.prompt)
249
+ ),
250
+ cwd: typeof cwd === "string" ? cwd : null,
251
+ createdAt: numberOrNull(automation.created_at),
252
+ updatedAt: numberOrNull(automation.updated_at),
253
+ ...memory,
254
+ };
255
+ }
256
+
257
+ function createObservedStatusItem(input) {
258
+ const expected = input.expected;
259
+ const comparison = input.comparison;
260
+ const observed = comparison.observedAutomation;
261
+ const runSignal = classifyAutomationRunSignal({
262
+ expected,
263
+ observedAutomation: observed,
264
+ now: input.now,
265
+ });
266
+
267
+ const observedDetails = [comparison.observed];
268
+ if (observed?.status) {
269
+ observedDetails.push(`Scheduler status: ${observed.status}`);
270
+ }
271
+ if (observed?.lastRunAt) {
272
+ observedDetails.push(`Last run: ${observed.lastRunAt}`);
273
+ } else if (observed) {
274
+ observedDetails.push("Last run metadata unavailable.");
275
+ }
276
+ if (observed?.lastRunSummary) {
277
+ observedDetails.push(`Latest summary: ${observed.lastRunSummary}`);
278
+ }
279
+
280
+ const status =
281
+ runSignal?.status ??
282
+ /** @type {"HEALTHY" | "MISSING" | "DRIFTED"} */ (comparison.status);
283
+
284
+ return {
285
+ id: expected.automationId,
286
+ status,
287
+ summary: composeAutomationSummary({
288
+ comparison,
289
+ runSignal,
290
+ }),
291
+ expectedCadence: expected.expectedCadence,
292
+ expectedCommand: expected.expectedCommand,
293
+ observed: observedDetails.join(" "),
294
+ remediation: runSignal?.remediation ?? comparison.remediation,
295
+ };
296
+ }
297
+
298
+ function composeAutomationSummary(input) {
299
+ const comparisonSummary = input.comparison.summary;
300
+ if (!input.runSignal) {
301
+ return comparisonSummary;
302
+ }
303
+ if (input.comparison.status === "HEALTHY") {
304
+ return input.runSignal.summary;
305
+ }
306
+ return `${input.runSignal.summary}; ${comparisonSummary}`;
307
+ }
308
+
309
+ function classifyAutomationRunSignal(input) {
310
+ const observed = input.observedAutomation;
311
+ if (!observed) {
312
+ return null;
313
+ }
314
+
315
+ if (observed.status && observed.status !== "ACTIVE") {
316
+ return {
317
+ status: "FAILING",
318
+ summary: `scheduler entry is ${observed.status.toLowerCase()}`,
319
+ remediation: "Resume or re-enable the automation in Codex.",
320
+ };
321
+ }
322
+
323
+ if (observed.lastRunFailed) {
324
+ return {
325
+ status: "FAILING",
326
+ summary: "latest recorded run failed",
327
+ remediation:
328
+ "Inspect the latest automation run output and fix the failing job before re-running setup.",
329
+ };
330
+ }
331
+
332
+ if (!observed.lastRunAt) {
333
+ return null;
334
+ }
335
+
336
+ const cadenceMs =
337
+ rruleToIntervalMs(observed.observedRRule) ??
338
+ cadenceLabelToIntervalMs(input.expected.expectedCadence);
339
+ if (!cadenceMs) {
340
+ return null;
341
+ }
342
+
343
+ const lastRunAt = Date.parse(observed.lastRunAt);
344
+ if (Number.isNaN(lastRunAt)) {
345
+ return null;
346
+ }
347
+
348
+ const staleAfterMs = cadenceMs * 3;
349
+ if (input.now.getTime() - lastRunAt > staleAfterMs) {
350
+ return {
351
+ status: "STALE",
352
+ summary: `last recorded run is stale for the expected cadence`,
353
+ remediation:
354
+ "Inspect why the automation has not run recently, then resume normal scheduling or recreate it with `/lisa:setup-automations`.",
355
+ };
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ function resolveDefaultCodexAutomationsDir() {
362
+ return path.join(
363
+ process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"),
364
+ "automations"
365
+ );
366
+ }
367
+
368
+ function humanizeAutomationCadence(rrule) {
369
+ if (!rrule) {
370
+ return undefined;
371
+ }
372
+ if (rrule === "FREQ=HOURLY;INTERVAL=1") {
373
+ return "every 60 minutes";
374
+ }
375
+ if (rrule === "FREQ=MINUTELY;INTERVAL=10") {
376
+ return "every 10 minutes";
377
+ }
378
+ if (rrule === "FREQ=DAILY;INTERVAL=1") {
379
+ return "once a day";
380
+ }
381
+
382
+ const everyMinutes = rrule.match(/^FREQ=MINUTELY;INTERVAL=(\d+)$/);
383
+ if (everyMinutes?.[1]) {
384
+ return `every ${everyMinutes[1]} minutes`;
385
+ }
386
+
387
+ const everyHours = rrule.match(/^FREQ=HOURLY;INTERVAL=(\d+)$/);
388
+ if (everyHours?.[1]) {
389
+ return `every ${Number(everyHours[1]) * 60} minutes`;
390
+ }
391
+
392
+ const everyDays = rrule.match(/^FREQ=DAILY;INTERVAL=(\d+)$/);
393
+ if (everyDays?.[1]) {
394
+ return Number(everyDays[1]) === 1
395
+ ? "once a day"
396
+ : `every ${everyDays[1]} days`;
397
+ }
398
+
399
+ return rrule;
400
+ }
401
+
402
+ function rruleToIntervalMs(rrule) {
403
+ if (!rrule) {
404
+ return null;
405
+ }
406
+
407
+ const minutely = rrule.match(/^FREQ=MINUTELY;INTERVAL=(\d+)$/);
408
+ if (minutely?.[1]) {
409
+ return Number(minutely[1]) * 60_000;
410
+ }
411
+
412
+ const hourly = rrule.match(/^FREQ=HOURLY;INTERVAL=(\d+)$/);
413
+ if (hourly?.[1]) {
414
+ return Number(hourly[1]) * 60 * 60_000;
415
+ }
416
+
417
+ const daily = rrule.match(/^FREQ=DAILY;INTERVAL=(\d+)$/);
418
+ if (daily?.[1]) {
419
+ return Number(daily[1]) * 24 * 60 * 60_000;
420
+ }
421
+
422
+ return null;
423
+ }
424
+
425
+ function cadenceLabelToIntervalMs(label) {
426
+ if (!label) {
427
+ return null;
428
+ }
429
+
430
+ const everyMinutes = label.match(/^every (\d+) minutes$/);
431
+ if (everyMinutes?.[1]) {
432
+ return Number(everyMinutes[1]) * 60_000;
433
+ }
434
+
435
+ if (label === "once a day") {
436
+ return 24 * 60 * 60_000;
437
+ }
438
+
439
+ return null;
440
+ }
441
+
442
+ function normalizeDate(value) {
443
+ if (value instanceof Date) {
444
+ return value;
445
+ }
446
+ if (typeof value === "string") {
447
+ return new Date(value);
448
+ }
449
+ return new Date();
450
+ }
451
+
452
+ function stringOrUndefined(value) {
453
+ return typeof value === "string" ? value : undefined;
454
+ }
455
+
456
+ function numberOrNull(value) {
457
+ return typeof value === "number" ? value : null;
458
+ }
459
+
460
+ function parseAutomationToml(tomlContent) {
461
+ return tomlContent
462
+ .split(/\r?\n/)
463
+ .map(line => line.trim())
464
+ .filter(line => line && !line.startsWith("#"))
465
+ .reduce((parsed, line) => {
466
+ const separatorIndex = line.indexOf("=");
467
+ if (separatorIndex === -1) {
468
+ return parsed;
469
+ }
470
+
471
+ const key = line.slice(0, separatorIndex).trim();
472
+ const rawValue = line.slice(separatorIndex + 1).trim();
473
+
474
+ return {
475
+ ...parsed,
476
+ [key]: parseTomlValue(rawValue),
477
+ };
478
+ }, {});
479
+ }
480
+
481
+ function parseTomlValue(rawValue) {
482
+ if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
483
+ return rawValue.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
484
+ }
485
+
486
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
487
+ const inner = rawValue.slice(1, -1).trim();
488
+ if (!inner) {
489
+ return [];
490
+ }
491
+ return inner
492
+ .split(",")
493
+ .map(value => parseTomlValue(value.trim()))
494
+ .filter(value => value !== undefined);
495
+ }
496
+
497
+ if (/^-?\d+$/.test(rawValue)) {
498
+ return Number(rawValue);
499
+ }
500
+
501
+ if (rawValue === "true") {
502
+ return true;
503
+ }
504
+
505
+ if (rawValue === "false") {
506
+ return false;
507
+ }
508
+
509
+ return rawValue;
510
+ }