@codyswann/lisa 2.252.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 (98) 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/.codex-plugin/skills/lisa-automation-status/SKILL.md +14 -8
  5. package/plugins/lisa/.codex-plugin/skills/lisa-setup-automations/SKILL.md +116 -1
  6. package/plugins/lisa/.codex-plugin/skills/lisa-tear-down-automations/SKILL.md +25 -5
  7. package/plugins/lisa/rules/eager/automation-runbook-contract.md +4 -1
  8. package/plugins/lisa/rules/reference/automation-runbook-contract.md +16 -7
  9. package/plugins/lisa/scripts/automation-run-record.mjs +262 -0
  10. package/plugins/lisa/scripts/automation-status-claude-adapter.mjs +36 -18
  11. package/plugins/lisa/scripts/automation-status-codex-adapter.mjs +50 -27
  12. package/plugins/lisa/scripts/automation-status-expected-fleet.mjs +149 -6
  13. package/plugins/lisa/skills/lisa-automation-status/SKILL.md +14 -8
  14. package/plugins/lisa/skills/lisa-setup-automations/SKILL.md +116 -1
  15. package/plugins/lisa/skills/lisa-tear-down-automations/SKILL.md +26 -6
  16. package/plugins/lisa/skills/lisa-tear-down-automations/agents/openai.yaml +2 -2
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-agy/scripts/automation-run-record.mjs +262 -0
  19. package/plugins/lisa-agy/scripts/automation-status-claude-adapter.mjs +36 -18
  20. package/plugins/lisa-agy/scripts/automation-status-codex-adapter.mjs +50 -27
  21. package/plugins/lisa-agy/scripts/automation-status-expected-fleet.mjs +149 -6
  22. package/plugins/lisa-agy/skills/lisa-automation-status/SKILL.md +14 -8
  23. package/plugins/lisa-agy/skills/lisa-setup-automations/SKILL.md +116 -1
  24. package/plugins/lisa-agy/skills/lisa-tear-down-automations/SKILL.md +26 -6
  25. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-copilot/rules/eager/automation-runbook-contract.md +4 -1
  32. package/plugins/lisa-copilot/rules/reference/automation-runbook-contract.md +16 -7
  33. package/plugins/lisa-copilot/scripts/automation-run-record.mjs +262 -0
  34. package/plugins/lisa-copilot/scripts/automation-status-claude-adapter.mjs +36 -18
  35. package/plugins/lisa-copilot/scripts/automation-status-codex-adapter.mjs +50 -27
  36. package/plugins/lisa-copilot/scripts/automation-status-expected-fleet.mjs +149 -6
  37. package/plugins/lisa-copilot/skills/lisa-automation-status/SKILL.md +14 -8
  38. package/plugins/lisa-copilot/skills/lisa-setup-automations/SKILL.md +116 -1
  39. package/plugins/lisa-copilot/skills/lisa-tear-down-automations/SKILL.md +26 -6
  40. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cursor/rules/automation-runbook-contract-reference.mdc +16 -7
  42. package/plugins/lisa-cursor/rules/automation-runbook-contract.mdc +4 -1
  43. package/plugins/lisa-cursor/scripts/automation-run-record.mjs +262 -0
  44. package/plugins/lisa-cursor/scripts/automation-status-claude-adapter.mjs +36 -18
  45. package/plugins/lisa-cursor/scripts/automation-status-codex-adapter.mjs +50 -27
  46. package/plugins/lisa-cursor/scripts/automation-status-expected-fleet.mjs +149 -6
  47. package/plugins/lisa-cursor/skills/lisa-automation-status/SKILL.md +14 -8
  48. package/plugins/lisa-cursor/skills/lisa-setup-automations/SKILL.md +116 -1
  49. package/plugins/lisa-cursor/skills/lisa-tear-down-automations/SKILL.md +26 -6
  50. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-expo-agy/plugin.json +1 -1
  53. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  63. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  68. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  73. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails-agy/plugin.json +1 -1
  78. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  83. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  88. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/src/base/rules/eager/automation-runbook-contract.md +4 -1
  91. package/plugins/src/base/rules/reference/automation-runbook-contract.md +16 -7
  92. package/plugins/src/base/scripts/automation-run-record.mjs +262 -0
  93. package/plugins/src/base/scripts/automation-status-claude-adapter.mjs +36 -18
  94. package/plugins/src/base/scripts/automation-status-codex-adapter.mjs +50 -27
  95. package/plugins/src/base/scripts/automation-status-expected-fleet.mjs +149 -6
  96. package/plugins/src/base/skills/lisa-automation-status/SKILL.md +14 -8
  97. package/plugins/src/base/skills/lisa-setup-automations/SKILL.md +116 -1
  98. package/plugins/src/base/skills/lisa-tear-down-automations/SKILL.md +26 -6
@@ -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
+ }
@@ -10,6 +10,11 @@
10
10
  */
11
11
 
12
12
  import { compareAutomationFleet } from "./automation-status-contract-drift.mjs";
13
+ import {
14
+ assignToAutomationGroup,
15
+ createAutomationGroupBins,
16
+ renderAutomationGroups,
17
+ } from "./automation-status-expected-fleet.mjs";
13
18
 
14
19
  const CLAUDE_RUNTIME_LABEL = "Claude /schedule";
15
20
  const CLAUDE_ACTIVE_STATUSES = new Set([
@@ -75,10 +80,7 @@ export function inspectClaudeAutomationFleet(input) {
75
80
  automationPrefix: expectedFleet.automationPrefix,
76
81
  });
77
82
 
78
- const expectedGroups = new Map([
79
- ["core", []],
80
- ["exploratory", []],
81
- ]);
83
+ const expectedGroups = createAutomationGroupBins();
82
84
 
83
85
  const comparisons = compareAutomationFleet({
84
86
  expectedAutomations: expectedFleet.expected,
@@ -87,7 +89,9 @@ export function inspectClaudeAutomationFleet(input) {
87
89
 
88
90
  for (const [index, expected] of expectedFleet.expected.entries()) {
89
91
  const comparison = comparisons[index];
90
- expectedGroups.get(expected.group)?.push(
92
+ assignToAutomationGroup(
93
+ expectedGroups,
94
+ expected.group,
91
95
  createObservedStatusItem({
92
96
  expected,
93
97
  comparison,
@@ -97,7 +101,7 @@ export function inspectClaudeAutomationFleet(input) {
97
101
  }
98
102
 
99
103
  for (const unsupported of expectedFleet.unsupported) {
100
- expectedGroups.get(unsupported.group)?.push({
104
+ assignToAutomationGroup(expectedGroups, unsupported.group, {
101
105
  id: unsupported.automationId,
102
106
  status: "UNSUPPORTED",
103
107
  summary: unsupported.reason,
@@ -109,18 +113,7 @@ export function inspectClaudeAutomationFleet(input) {
109
113
  return {
110
114
  runtime: `${CLAUDE_RUNTIME_LABEL} listing`,
111
115
  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
- ],
116
+ groups: renderAutomationGroups(expectedGroups),
124
117
  observedAutomations,
125
118
  };
126
119
  }
@@ -487,6 +480,13 @@ function normalizeClaudeRRule(value) {
487
480
  ) {
488
481
  return "FREQ=DAILY;INTERVAL=1";
489
482
  }
483
+ if (
484
+ cadence === "once a week" ||
485
+ cadence === "every week" ||
486
+ cadence === "weekly"
487
+ ) {
488
+ return "FREQ=WEEKLY;INTERVAL=1";
489
+ }
490
490
 
491
491
  const everyMinutes = cadence.match(/every (\d+) minutes?/);
492
492
  if (everyMinutes?.[1]) {
@@ -503,6 +503,11 @@ function normalizeClaudeRRule(value) {
503
503
  return `FREQ=DAILY;INTERVAL=${everyDays[1]}`;
504
504
  }
505
505
 
506
+ const everyWeeks = cadence.match(/every (\d+) weeks?/);
507
+ if (everyWeeks?.[1]) {
508
+ return `FREQ=WEEKLY;INTERVAL=${everyWeeks[1]}`;
509
+ }
510
+
506
511
  return undefined;
507
512
  }
508
513
 
@@ -527,6 +532,9 @@ function humanizeClaudeCadence(value) {
527
532
  if (normalized === "every day") {
528
533
  return "once a day";
529
534
  }
535
+ if (normalized === "weekly" || normalized === "every week") {
536
+ return "once a week";
537
+ }
530
538
  return normalized;
531
539
  }
532
540
 
@@ -556,6 +564,11 @@ function humanizeRRule(rrule) {
556
564
  return Number(daily[1]) === 1 ? "once a day" : `every ${daily[1]} days`;
557
565
  }
558
566
 
567
+ const weekly = rrule.match(/^FREQ=WEEKLY;INTERVAL=(\d+)$/);
568
+ if (weekly?.[1]) {
569
+ return Number(weekly[1]) === 1 ? "once a week" : `every ${weekly[1]} weeks`;
570
+ }
571
+
559
572
  return rrule;
560
573
  }
561
574
 
@@ -574,6 +587,11 @@ function cadenceLabelToIntervalMs(label) {
574
587
  return 24 * 60 * 60_000;
575
588
  }
576
589
 
590
+ const oncePerWeek = new Set(["once a week", "weekly"]);
591
+ if (oncePerWeek.has(label)) {
592
+ return 7 * 24 * 60 * 60_000;
593
+ }
594
+
577
595
  return null;
578
596
  }
579
597
 
@@ -17,6 +17,11 @@ import { execFile } from "node:child_process";
17
17
  import { promisify } from "node:util";
18
18
 
19
19
  import { compareAutomationFleet } from "./automation-status-contract-drift.mjs";
20
+ import {
21
+ assignToAutomationGroup,
22
+ createAutomationGroupBins,
23
+ renderAutomationGroups,
24
+ } from "./automation-status-expected-fleet.mjs";
20
25
 
21
26
  const CODEx_RUNTIME_LABEL = "Codex automations";
22
27
  const RUN_TIMESTAMP_PATTERN = /20\d{2}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z/;
@@ -156,10 +161,7 @@ export async function inspectCodexAutomationFleet(input) {
156
161
  automationPrefix: expectedFleet.automationPrefix,
157
162
  });
158
163
 
159
- const expectedGroups = new Map([
160
- ["core", []],
161
- ["exploratory", []],
162
- ]);
164
+ const expectedGroups = createAutomationGroupBins();
163
165
 
164
166
  const comparisons = compareAutomationFleet({
165
167
  expectedAutomations: expectedFleet.expected,
@@ -168,7 +170,9 @@ export async function inspectCodexAutomationFleet(input) {
168
170
 
169
171
  for (const [index, expected] of expectedFleet.expected.entries()) {
170
172
  const comparison = comparisons[index];
171
- expectedGroups.get(expected.group)?.push(
173
+ assignToAutomationGroup(
174
+ expectedGroups,
175
+ expected.group,
172
176
  createObservedStatusItem({
173
177
  expected,
174
178
  comparison,
@@ -178,7 +182,7 @@ export async function inspectCodexAutomationFleet(input) {
178
182
  }
179
183
 
180
184
  for (const unsupported of expectedFleet.unsupported) {
181
- expectedGroups.get(unsupported.group)?.push({
185
+ assignToAutomationGroup(expectedGroups, unsupported.group, {
182
186
  id: unsupported.automationId,
183
187
  status: "UNSUPPORTED",
184
188
  summary: unsupported.reason,
@@ -190,18 +194,7 @@ export async function inspectCodexAutomationFleet(input) {
190
194
  return {
191
195
  runtime: `${CODEx_RUNTIME_LABEL} (backing-store metadata)`,
192
196
  generatedAt: now.toISOString(),
193
- groups: [
194
- {
195
- id: "1",
196
- title: "Core automations",
197
- items: expectedGroups.get("core") ?? [],
198
- },
199
- {
200
- id: "2",
201
- title: "Exploratory automations",
202
- items: expectedGroups.get("exploratory") ?? [],
203
- },
204
- ],
197
+ groups: renderAutomationGroups(expectedGroups),
205
198
  observedAutomations,
206
199
  };
207
200
  }
@@ -249,21 +242,35 @@ export function deriveCodexObservedCommand(prompt) {
249
242
  return undefined;
250
243
  }
251
244
 
245
+ // The "with arguments" clause is optional: no-argument loops (monitor, the
246
+ // gardener) register without one. Requiring it made every such loop derive an
247
+ // undefined command and report DRIFTED forever, un-fixable by re-running setup.
252
248
  const lisaSkillMatch = prompt.match(
253
- /Use the Lisa ([a-z0-9:-]+) skill with arguments `([^`]+)`/i
249
+ /Use the Lisa ([a-z0-9:-]+) skill(?: with arguments `([^`]*)`)?/i
254
250
  );
255
- if (lisaSkillMatch?.[1] && lisaSkillMatch[2]) {
256
- return `/lisa:${lisaSkillMatch[1]} ${lisaSkillMatch[2]}`.trim();
251
+ if (lisaSkillMatch?.[1]) {
252
+ return `/lisa:${lisaSkillMatch[1]} ${lisaSkillMatch[2] ?? ""}`.trim();
257
253
  }
258
254
 
259
255
  const aliasSkillMatch = prompt.match(
260
- /Use the `\$([a-z0-9:-]+)` skill with arguments `([^`]+)`/i
256
+ /Use the `\$([a-z0-9:-]+)` skill(?: with arguments `([^`]*)`)?/i
261
257
  );
262
- if (aliasSkillMatch?.[1] && aliasSkillMatch[2]) {
263
- return `${canonicalizeCodexSkillAlias(aliasSkillMatch[1])} ${aliasSkillMatch[2]}`.trim();
264
- }
265
-
266
- return undefined;
258
+ if (aliasSkillMatch?.[1]) {
259
+ return `${canonicalizeCodexSkillAlias(aliasSkillMatch[1])} ${aliasSkillMatch[2] ?? ""}`.trim();
260
+ }
261
+
262
+ // A registration whose prompt carries the literal Lisa command on its own
263
+ // line — the shape `/lisa:setup-automations` bakes so a command that is not a
264
+ // plain skill name (`/lisa:learnings:audit`) round-trips exactly. Mirrors the
265
+ // Claude adapter, which has always accepted a bare `/lisa:` command. Codex
266
+ // stores prompts as single-line TOML, so a line break arrives either real or
267
+ // as the two-character escape `\n`; both delimit a line here.
268
+ const literalCommand = prompt
269
+ .split(/\r?\n|\\n/)
270
+ .map(segment => segment.trim())
271
+ .find(segment => /^\/lisa[:-]\S/.test(segment));
272
+
273
+ return literalCommand;
267
274
  }
268
275
 
269
276
  function canonicalizeCodexSkillAlias(alias) {
@@ -583,6 +590,13 @@ function humanizeAutomationCadence(rrule) {
583
590
  : `every ${everyDays[1]} days`;
584
591
  }
585
592
 
593
+ const everyWeeks = rrule.match(/^FREQ=WEEKLY;INTERVAL=(\d+)$/);
594
+ if (everyWeeks?.[1]) {
595
+ return Number(everyWeeks[1]) === 1
596
+ ? "once a week"
597
+ : `every ${everyWeeks[1]} weeks`;
598
+ }
599
+
586
600
  return rrule;
587
601
  }
588
602
 
@@ -606,6 +620,11 @@ function rruleToIntervalMs(rrule) {
606
620
  return Number(daily[1]) * 24 * 60 * 60_000;
607
621
  }
608
622
 
623
+ const weekly = rrule.match(/^FREQ=WEEKLY;INTERVAL=(\d+)$/);
624
+ if (weekly?.[1]) {
625
+ return Number(weekly[1]) * 7 * 24 * 60 * 60_000;
626
+ }
627
+
609
628
  return null;
610
629
  }
611
630
 
@@ -623,6 +642,10 @@ function cadenceLabelToIntervalMs(label) {
623
642
  return 24 * 60 * 60_000;
624
643
  }
625
644
 
645
+ if (label === "once a week") {
646
+ return 7 * 24 * 60 * 60_000;
647
+ }
648
+
626
649
  return null;
627
650
  }
628
651