@codyswann/lisa 2.104.3 → 2.104.5

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 (29) 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/scripts/automation-status-claude-adapter.mjs +34 -7
  5. package/plugins/lisa/scripts/automation-status-codex-adapter.mjs +39 -14
  6. package/plugins/lisa/scripts/automation-status-contract-drift.mjs +65 -1
  7. package/plugins/lisa/scripts/queue-status-build-readers.mjs +2 -1
  8. package/plugins/lisa/scripts/queue-status-prd-readers.mjs +2 -1
  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-expo/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  25. package/plugins/src/base/scripts/automation-status-claude-adapter.mjs +34 -7
  26. package/plugins/src/base/scripts/automation-status-codex-adapter.mjs +39 -14
  27. package/plugins/src/base/scripts/automation-status-contract-drift.mjs +65 -1
  28. package/plugins/src/base/scripts/queue-status-build-readers.mjs +2 -1
  29. package/plugins/src/base/scripts/queue-status-prd-readers.mjs +2 -1
package/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "lodash": ">=4.18.1"
83
83
  },
84
84
  "name": "@codyswann/lisa",
85
- "version": "2.104.3",
85
+ "version": "2.104.5",
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.104.3",
3
+ "version": "2.104.5",
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.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -9,7 +9,7 @@
9
9
  * Claude does not expose last-run or failure metadata.
10
10
  */
11
11
 
12
- import { compareAutomationContract } from "./automation-status-contract-drift.mjs";
12
+ import { compareAutomationFleet } from "./automation-status-contract-drift.mjs";
13
13
 
14
14
  const CLAUDE_RUNTIME_LABEL = "Claude /schedule";
15
15
  const CLAUDE_ACTIVE_STATUSES = new Set([
@@ -80,11 +80,13 @@ export function inspectClaudeAutomationFleet(input) {
80
80
  ["exploratory", []],
81
81
  ]);
82
82
 
83
- for (const expected of expectedFleet.expected) {
84
- const comparison = compareAutomationContract({
85
- expected,
86
- observedAutomations,
87
- });
83
+ const comparisons = compareAutomationFleet({
84
+ expectedAutomations: expectedFleet.expected,
85
+ observedAutomations,
86
+ });
87
+
88
+ for (const [index, expected] of expectedFleet.expected.entries()) {
89
+ const comparison = comparisons[index];
88
90
  expectedGroups.get(expected.group)?.push(
89
91
  createObservedStatusItem({
90
92
  expected,
@@ -175,6 +177,31 @@ export function deriveClaudeObservedCommand(command) {
175
177
  return undefined;
176
178
  }
177
179
 
180
+ /**
181
+ * Extract the cadence argument from a Claude `/schedule` command string.
182
+ * Supports quoted (double-quote, single-quote, backtick) and unquoted cadence
183
+ * values, returning the first matched capture group via {@link firstString}.
184
+ *
185
+ * @param {string | undefined} command - The command string to parse
186
+ * @returns {string | undefined} The extracted cadence, or undefined if not found
187
+ */
188
+ function extractClaudeScheduleCadence(command) {
189
+ if (!command) {
190
+ return undefined;
191
+ }
192
+
193
+ const scheduleLine = command
194
+ .trim()
195
+ .match(/^\/schedule\s+(?:"([^"]+)"|'([^']+)'|`([^`]+)`|(\S+))/m);
196
+
197
+ return firstString(
198
+ scheduleLine?.[1],
199
+ scheduleLine?.[2],
200
+ scheduleLine?.[3],
201
+ scheduleLine?.[4]
202
+ );
203
+ }
204
+
178
205
  function createObservedStatusItem(input) {
179
206
  const expected = input.expected;
180
207
  const comparison = input.comparison;
@@ -405,7 +432,7 @@ function normalizeClaudeScheduleTextEntry(block) {
405
432
 
406
433
  const cadenceSource =
407
434
  extractField(block, /^(?:Cadence|Schedule):\s*(.+)$/im) ??
408
- block.match(/^\/schedule\s+(?:"[^"]+"|'[^']+'|`[^`]+`|\S+)/m)?.[0];
435
+ extractClaudeScheduleCadence(block);
409
436
  const commandSource =
410
437
  extractField(block, /^(?:Command|Prompt):\s*(.+)$/im) ??
411
438
  extractField(
@@ -14,9 +14,10 @@ import fs from "node:fs/promises";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
 
17
- import { compareAutomationContract } from "./automation-status-contract-drift.mjs";
17
+ import { compareAutomationFleet } from "./automation-status-contract-drift.mjs";
18
18
 
19
19
  const CODEx_RUNTIME_LABEL = "Codex automations";
20
+ const RUN_TIMESTAMP_PATTERN = /20\d{2}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z/;
20
21
  const RUN_FAILURE_PATTERN =
21
22
  /\b(failed|failure|errored|error|exception|crash(?:ed)?)\b/i;
22
23
  const NEGATED_FAILURE_PATTERN =
@@ -82,11 +83,13 @@ export async function inspectCodexAutomationFleet(input) {
82
83
  ["exploratory", []],
83
84
  ]);
84
85
 
85
- for (const expected of expectedFleet.expected) {
86
- const comparison = compareAutomationContract({
87
- expected,
88
- observedAutomations,
89
- });
86
+ const comparisons = compareAutomationFleet({
87
+ expectedAutomations: expectedFleet.expected,
88
+ observedAutomations,
89
+ });
90
+
91
+ for (const [index, expected] of expectedFleet.expected.entries()) {
92
+ const comparison = comparisons[index];
90
93
  expectedGroups.get(expected.group)?.push(
91
94
  createObservedStatusItem({
92
95
  expected,
@@ -211,28 +214,50 @@ export function parseCodexAutomationMemory(memoryContent) {
211
214
  };
212
215
  }
213
216
 
214
- const timestampMatch = memoryContent.match(
215
- /20\d{2}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z/
216
- );
217
217
  const lines = memoryContent.split(/\r?\n/);
218
+ const latestBlock = findLatestAutomationMemoryBlock(lines);
218
219
  const summaryLine =
219
- lines
220
+ latestBlock.lines
220
221
  .find(line => line.startsWith("- "))
221
222
  ?.replace(/^- /, "")
222
223
  .trim() ?? null;
223
224
 
224
- const latestBlock = lines.slice(0, 20).join("\n");
225
+ const latestBlockText = latestBlock.lines.join("\n");
225
226
  const lastRunFailed =
226
- RUN_FAILURE_PATTERN.test(latestBlock) &&
227
- !NEGATED_FAILURE_PATTERN.test(latestBlock);
227
+ RUN_FAILURE_PATTERN.test(latestBlockText) &&
228
+ !NEGATED_FAILURE_PATTERN.test(latestBlockText);
228
229
 
229
230
  return {
230
- lastRunAt: timestampMatch?.[0] ?? null,
231
+ lastRunAt: latestBlock.timestamp,
231
232
  lastRunSummary: summaryLine,
232
233
  lastRunFailed,
233
234
  };
234
235
  }
235
236
 
237
+ function findLatestAutomationMemoryBlock(lines) {
238
+ const timestampLines = lines
239
+ .map((line, index) => ({
240
+ index,
241
+ timestamp: line.match(RUN_TIMESTAMP_PATTERN)?.[0] ?? null,
242
+ }))
243
+ .filter(entry => entry.timestamp);
244
+
245
+ if (timestampLines.length === 0) {
246
+ return {
247
+ timestamp: null,
248
+ lines,
249
+ };
250
+ }
251
+
252
+ const latest = timestampLines.at(-1);
253
+ const next = timestampLines.find(entry => entry.index > latest.index);
254
+
255
+ return {
256
+ timestamp: latest.timestamp,
257
+ lines: lines.slice(latest.index, next?.index),
258
+ };
259
+ }
260
+
236
261
  async function readCodexAutomation(automationDir) {
237
262
  const tomlPath = path.join(automationDir, "automation.toml");
238
263
  const memoryPath = path.join(automationDir, "memory.md");
@@ -39,6 +39,41 @@ const DRIFT_LABELS = {
39
39
  * }} AutomationContractComparison
40
40
  */
41
41
 
42
+ /**
43
+ * Compare the expected automation fleet against observed scheduler entries,
44
+ * consuming each observed automation at most once.
45
+ *
46
+ * @param {{
47
+ * readonly expectedAutomations: readonly ExpectedAutomationContract[]
48
+ * readonly observedAutomations?: readonly ObservedAutomationContract[]
49
+ * }} input
50
+ * @returns {readonly AutomationContractComparison[]}
51
+ */
52
+ export function compareAutomationFleet(input) {
53
+ const remainingObserved = [...(input.observedAutomations ?? [])];
54
+
55
+ return input.expectedAutomations.map(expected => {
56
+ const observed = findObservedAutomationMatch(
57
+ expected,
58
+ remainingObserved,
59
+ input.expectedAutomations
60
+ );
61
+ const comparison = compareAutomationContract({
62
+ expected,
63
+ observedAutomation: observed,
64
+ });
65
+
66
+ if (observed) {
67
+ const index = remainingObserved.indexOf(observed);
68
+ if (index >= 0) {
69
+ remainingObserved.splice(index, 1);
70
+ }
71
+ }
72
+
73
+ return comparison;
74
+ });
75
+ }
76
+
42
77
  /**
43
78
  * Find the best observed scheduler entry for an expected automation contract.
44
79
  *
@@ -50,11 +85,13 @@ const DRIFT_LABELS = {
50
85
  *
51
86
  * @param {ExpectedAutomationContract} expected
52
87
  * @param {readonly ObservedAutomationContract[]} observedAutomations
88
+ * @param {readonly ExpectedAutomationContract[]} expectedAutomations
53
89
  * @returns {ObservedAutomationContract | null}
54
90
  */
55
91
  export function findObservedAutomationMatch(
56
92
  expected,
57
- observedAutomations = []
93
+ observedAutomations = [],
94
+ expectedAutomations = [expected]
58
95
  ) {
59
96
  const exactId = observedAutomations.find(
60
97
  observed => observed.automationId === expected.automationId
@@ -99,6 +136,15 @@ export function findObservedAutomationMatch(
99
136
  return exactCommand;
100
137
  }
101
138
 
139
+ if (
140
+ isSharedExpectedCommandToken(
141
+ expectedCommand.commandToken,
142
+ expectedAutomations
143
+ )
144
+ ) {
145
+ return null;
146
+ }
147
+
102
148
  return (
103
149
  observedAutomations.find(observed => {
104
150
  const observedCommand = normalizeAutomationCommand(
@@ -161,6 +207,24 @@ export function compareAutomationContract(input) {
161
207
  };
162
208
  }
163
209
 
210
+ /**
211
+ * @param {string} commandToken
212
+ * @param {readonly ExpectedAutomationContract[]} expectedAutomations
213
+ * @returns {boolean}
214
+ */
215
+ function isSharedExpectedCommandToken(commandToken, expectedAutomations) {
216
+ if (!commandToken) {
217
+ return false;
218
+ }
219
+
220
+ return (
221
+ expectedAutomations.filter(expected => {
222
+ const normalized = normalizeAutomationCommand(expected.expectedCommand);
223
+ return normalized.commandToken === commandToken;
224
+ }).length > 1
225
+ );
226
+ }
227
+
164
228
  /**
165
229
  * @param {ExpectedAutomationContract} expected
166
230
  * @param {ObservedAutomationContract} observed
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { classifyQueueHealth } from "./queue-health-classification.mjs";
12
+ import { resolveBuildLifecycleRoles } from "./queue-contract-resolution.mjs";
12
13
 
13
14
  export const BUILD_LIFECYCLE_ORDER = [
14
15
  "ready",
@@ -60,7 +61,7 @@ const HIGHLIGHT_COPY = {
60
61
  * }} input
61
62
  */
62
63
  export function readGithubBuildQueueSnapshot(input = {}) {
63
- const roles = input.roles ?? {};
64
+ const roles = input.roles ?? resolveBuildLifecycleRoles({}, "github").roles;
64
65
  const normalizedItems = (input.issues ?? [])
65
66
  .map(issue => normalizeGithubBuildIssue(issue, roles))
66
67
  .filter(Boolean);
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { classifyQueueHealth } from "./queue-health-classification.mjs";
12
+ import { resolvePrdLifecycleRoles } from "./queue-contract-resolution.mjs";
12
13
 
13
14
  export const PRD_LIFECYCLE_ORDER = [
14
15
  "draft",
@@ -67,7 +68,7 @@ const HIGHLIGHT_COPY = {
67
68
  * }} input
68
69
  */
69
70
  export function readGithubPrdQueueSnapshot(input = {}) {
70
- const roles = input.roles ?? {};
71
+ const roles = input.roles ?? resolvePrdLifecycleRoles({}, "github").roles;
71
72
  const normalizedItems = (input.issues ?? [])
72
73
  .map(issue => normalizeGithubPrdIssue(issue, roles))
73
74
  .filter(Boolean);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
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.104.3",
3
+ "version": "2.104.5",
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-expo",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Expo and React Native-specific skills, agents, rules, and MCP servers.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Harper/Fabric-specific rules for TypeScript component apps",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-harper-fabric",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Harper/Fabric-specific Lisa rules for TypeScript component apps.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM) and hooks (migration write-protection)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "NestJS-specific skills and migration write-protection hooks.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-openclaw",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Connect staff roles to Telegram or Slack via OpenClaw — facilitator/specialist hub-and-spoke routing and repo-coding topics, across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "2.104.3",
3
+ "version": "2.104.5",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -9,7 +9,7 @@
9
9
  * Claude does not expose last-run or failure metadata.
10
10
  */
11
11
 
12
- import { compareAutomationContract } from "./automation-status-contract-drift.mjs";
12
+ import { compareAutomationFleet } from "./automation-status-contract-drift.mjs";
13
13
 
14
14
  const CLAUDE_RUNTIME_LABEL = "Claude /schedule";
15
15
  const CLAUDE_ACTIVE_STATUSES = new Set([
@@ -80,11 +80,13 @@ export function inspectClaudeAutomationFleet(input) {
80
80
  ["exploratory", []],
81
81
  ]);
82
82
 
83
- for (const expected of expectedFleet.expected) {
84
- const comparison = compareAutomationContract({
85
- expected,
86
- observedAutomations,
87
- });
83
+ const comparisons = compareAutomationFleet({
84
+ expectedAutomations: expectedFleet.expected,
85
+ observedAutomations,
86
+ });
87
+
88
+ for (const [index, expected] of expectedFleet.expected.entries()) {
89
+ const comparison = comparisons[index];
88
90
  expectedGroups.get(expected.group)?.push(
89
91
  createObservedStatusItem({
90
92
  expected,
@@ -175,6 +177,31 @@ export function deriveClaudeObservedCommand(command) {
175
177
  return undefined;
176
178
  }
177
179
 
180
+ /**
181
+ * Extract the cadence argument from a Claude `/schedule` command string.
182
+ * Supports quoted (double-quote, single-quote, backtick) and unquoted cadence
183
+ * values, returning the first matched capture group via {@link firstString}.
184
+ *
185
+ * @param {string | undefined} command - The command string to parse
186
+ * @returns {string | undefined} The extracted cadence, or undefined if not found
187
+ */
188
+ function extractClaudeScheduleCadence(command) {
189
+ if (!command) {
190
+ return undefined;
191
+ }
192
+
193
+ const scheduleLine = command
194
+ .trim()
195
+ .match(/^\/schedule\s+(?:"([^"]+)"|'([^']+)'|`([^`]+)`|(\S+))/m);
196
+
197
+ return firstString(
198
+ scheduleLine?.[1],
199
+ scheduleLine?.[2],
200
+ scheduleLine?.[3],
201
+ scheduleLine?.[4]
202
+ );
203
+ }
204
+
178
205
  function createObservedStatusItem(input) {
179
206
  const expected = input.expected;
180
207
  const comparison = input.comparison;
@@ -405,7 +432,7 @@ function normalizeClaudeScheduleTextEntry(block) {
405
432
 
406
433
  const cadenceSource =
407
434
  extractField(block, /^(?:Cadence|Schedule):\s*(.+)$/im) ??
408
- block.match(/^\/schedule\s+(?:"[^"]+"|'[^']+'|`[^`]+`|\S+)/m)?.[0];
435
+ extractClaudeScheduleCadence(block);
409
436
  const commandSource =
410
437
  extractField(block, /^(?:Command|Prompt):\s*(.+)$/im) ??
411
438
  extractField(
@@ -14,9 +14,10 @@ import fs from "node:fs/promises";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
 
17
- import { compareAutomationContract } from "./automation-status-contract-drift.mjs";
17
+ import { compareAutomationFleet } from "./automation-status-contract-drift.mjs";
18
18
 
19
19
  const CODEx_RUNTIME_LABEL = "Codex automations";
20
+ const RUN_TIMESTAMP_PATTERN = /20\d{2}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z/;
20
21
  const RUN_FAILURE_PATTERN =
21
22
  /\b(failed|failure|errored|error|exception|crash(?:ed)?)\b/i;
22
23
  const NEGATED_FAILURE_PATTERN =
@@ -82,11 +83,13 @@ export async function inspectCodexAutomationFleet(input) {
82
83
  ["exploratory", []],
83
84
  ]);
84
85
 
85
- for (const expected of expectedFleet.expected) {
86
- const comparison = compareAutomationContract({
87
- expected,
88
- observedAutomations,
89
- });
86
+ const comparisons = compareAutomationFleet({
87
+ expectedAutomations: expectedFleet.expected,
88
+ observedAutomations,
89
+ });
90
+
91
+ for (const [index, expected] of expectedFleet.expected.entries()) {
92
+ const comparison = comparisons[index];
90
93
  expectedGroups.get(expected.group)?.push(
91
94
  createObservedStatusItem({
92
95
  expected,
@@ -211,28 +214,50 @@ export function parseCodexAutomationMemory(memoryContent) {
211
214
  };
212
215
  }
213
216
 
214
- const timestampMatch = memoryContent.match(
215
- /20\d{2}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z/
216
- );
217
217
  const lines = memoryContent.split(/\r?\n/);
218
+ const latestBlock = findLatestAutomationMemoryBlock(lines);
218
219
  const summaryLine =
219
- lines
220
+ latestBlock.lines
220
221
  .find(line => line.startsWith("- "))
221
222
  ?.replace(/^- /, "")
222
223
  .trim() ?? null;
223
224
 
224
- const latestBlock = lines.slice(0, 20).join("\n");
225
+ const latestBlockText = latestBlock.lines.join("\n");
225
226
  const lastRunFailed =
226
- RUN_FAILURE_PATTERN.test(latestBlock) &&
227
- !NEGATED_FAILURE_PATTERN.test(latestBlock);
227
+ RUN_FAILURE_PATTERN.test(latestBlockText) &&
228
+ !NEGATED_FAILURE_PATTERN.test(latestBlockText);
228
229
 
229
230
  return {
230
- lastRunAt: timestampMatch?.[0] ?? null,
231
+ lastRunAt: latestBlock.timestamp,
231
232
  lastRunSummary: summaryLine,
232
233
  lastRunFailed,
233
234
  };
234
235
  }
235
236
 
237
+ function findLatestAutomationMemoryBlock(lines) {
238
+ const timestampLines = lines
239
+ .map((line, index) => ({
240
+ index,
241
+ timestamp: line.match(RUN_TIMESTAMP_PATTERN)?.[0] ?? null,
242
+ }))
243
+ .filter(entry => entry.timestamp);
244
+
245
+ if (timestampLines.length === 0) {
246
+ return {
247
+ timestamp: null,
248
+ lines,
249
+ };
250
+ }
251
+
252
+ const latest = timestampLines.at(-1);
253
+ const next = timestampLines.find(entry => entry.index > latest.index);
254
+
255
+ return {
256
+ timestamp: latest.timestamp,
257
+ lines: lines.slice(latest.index, next?.index),
258
+ };
259
+ }
260
+
236
261
  async function readCodexAutomation(automationDir) {
237
262
  const tomlPath = path.join(automationDir, "automation.toml");
238
263
  const memoryPath = path.join(automationDir, "memory.md");
@@ -39,6 +39,41 @@ const DRIFT_LABELS = {
39
39
  * }} AutomationContractComparison
40
40
  */
41
41
 
42
+ /**
43
+ * Compare the expected automation fleet against observed scheduler entries,
44
+ * consuming each observed automation at most once.
45
+ *
46
+ * @param {{
47
+ * readonly expectedAutomations: readonly ExpectedAutomationContract[]
48
+ * readonly observedAutomations?: readonly ObservedAutomationContract[]
49
+ * }} input
50
+ * @returns {readonly AutomationContractComparison[]}
51
+ */
52
+ export function compareAutomationFleet(input) {
53
+ const remainingObserved = [...(input.observedAutomations ?? [])];
54
+
55
+ return input.expectedAutomations.map(expected => {
56
+ const observed = findObservedAutomationMatch(
57
+ expected,
58
+ remainingObserved,
59
+ input.expectedAutomations
60
+ );
61
+ const comparison = compareAutomationContract({
62
+ expected,
63
+ observedAutomation: observed,
64
+ });
65
+
66
+ if (observed) {
67
+ const index = remainingObserved.indexOf(observed);
68
+ if (index >= 0) {
69
+ remainingObserved.splice(index, 1);
70
+ }
71
+ }
72
+
73
+ return comparison;
74
+ });
75
+ }
76
+
42
77
  /**
43
78
  * Find the best observed scheduler entry for an expected automation contract.
44
79
  *
@@ -50,11 +85,13 @@ const DRIFT_LABELS = {
50
85
  *
51
86
  * @param {ExpectedAutomationContract} expected
52
87
  * @param {readonly ObservedAutomationContract[]} observedAutomations
88
+ * @param {readonly ExpectedAutomationContract[]} expectedAutomations
53
89
  * @returns {ObservedAutomationContract | null}
54
90
  */
55
91
  export function findObservedAutomationMatch(
56
92
  expected,
57
- observedAutomations = []
93
+ observedAutomations = [],
94
+ expectedAutomations = [expected]
58
95
  ) {
59
96
  const exactId = observedAutomations.find(
60
97
  observed => observed.automationId === expected.automationId
@@ -99,6 +136,15 @@ export function findObservedAutomationMatch(
99
136
  return exactCommand;
100
137
  }
101
138
 
139
+ if (
140
+ isSharedExpectedCommandToken(
141
+ expectedCommand.commandToken,
142
+ expectedAutomations
143
+ )
144
+ ) {
145
+ return null;
146
+ }
147
+
102
148
  return (
103
149
  observedAutomations.find(observed => {
104
150
  const observedCommand = normalizeAutomationCommand(
@@ -161,6 +207,24 @@ export function compareAutomationContract(input) {
161
207
  };
162
208
  }
163
209
 
210
+ /**
211
+ * @param {string} commandToken
212
+ * @param {readonly ExpectedAutomationContract[]} expectedAutomations
213
+ * @returns {boolean}
214
+ */
215
+ function isSharedExpectedCommandToken(commandToken, expectedAutomations) {
216
+ if (!commandToken) {
217
+ return false;
218
+ }
219
+
220
+ return (
221
+ expectedAutomations.filter(expected => {
222
+ const normalized = normalizeAutomationCommand(expected.expectedCommand);
223
+ return normalized.commandToken === commandToken;
224
+ }).length > 1
225
+ );
226
+ }
227
+
164
228
  /**
165
229
  * @param {ExpectedAutomationContract} expected
166
230
  * @param {ObservedAutomationContract} observed
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { classifyQueueHealth } from "./queue-health-classification.mjs";
12
+ import { resolveBuildLifecycleRoles } from "./queue-contract-resolution.mjs";
12
13
 
13
14
  export const BUILD_LIFECYCLE_ORDER = [
14
15
  "ready",
@@ -60,7 +61,7 @@ const HIGHLIGHT_COPY = {
60
61
  * }} input
61
62
  */
62
63
  export function readGithubBuildQueueSnapshot(input = {}) {
63
- const roles = input.roles ?? {};
64
+ const roles = input.roles ?? resolveBuildLifecycleRoles({}, "github").roles;
64
65
  const normalizedItems = (input.issues ?? [])
65
66
  .map(issue => normalizeGithubBuildIssue(issue, roles))
66
67
  .filter(Boolean);
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { classifyQueueHealth } from "./queue-health-classification.mjs";
12
+ import { resolvePrdLifecycleRoles } from "./queue-contract-resolution.mjs";
12
13
 
13
14
  export const PRD_LIFECYCLE_ORDER = [
14
15
  "draft",
@@ -67,7 +68,7 @@ const HIGHLIGHT_COPY = {
67
68
  * }} input
68
69
  */
69
70
  export function readGithubPrdQueueSnapshot(input = {}) {
70
- const roles = input.roles ?? {};
71
+ const roles = input.roles ?? resolvePrdLifecycleRoles({}, "github").roles;
71
72
  const normalizedItems = (input.issues ?? [])
72
73
  .map(issue => normalizeGithubPrdIssue(issue, roles))
73
74
  .filter(Boolean);