@codyswann/lisa 2.104.2 → 2.104.4

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 (35) 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/repo-scope-split.md +1 -1
  5. package/plugins/lisa/scripts/automation-status-claude-adapter.mjs +34 -7
  6. package/plugins/lisa/scripts/automation-status-codex-adapter.mjs +39 -14
  7. package/plugins/lisa/scripts/automation-status-contract-drift.mjs +65 -1
  8. package/plugins/lisa/scripts/queue-status-build-readers.mjs +2 -1
  9. package/plugins/lisa/scripts/queue-status-prd-readers.mjs +2 -1
  10. package/plugins/lisa/skills/github-build-intake/SKILL.md +1 -0
  11. package/plugins/lisa/skills/intake-explain/SKILL.md +28 -0
  12. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  20. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  28. package/plugins/src/base/rules/repo-scope-split.md +1 -1
  29. package/plugins/src/base/scripts/automation-status-claude-adapter.mjs +34 -7
  30. package/plugins/src/base/scripts/automation-status-codex-adapter.mjs +39 -14
  31. package/plugins/src/base/scripts/automation-status-contract-drift.mjs +65 -1
  32. package/plugins/src/base/scripts/queue-status-build-readers.mjs +2 -1
  33. package/plugins/src/base/scripts/queue-status-prd-readers.mjs +2 -1
  34. package/plugins/src/base/skills/github-build-intake/SKILL.md +1 -0
  35. package/plugins/src/base/skills/intake-explain/SKILL.md +28 -0
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.2",
85
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -47,7 +47,7 @@ Resolve the current repo per the `config-resolution` "Repo scoping" section (con
47
47
 
48
48
  **Cost.** Only **unlabeled** candidates need content determination; once stamped, wrong-repo candidates are skipped by label alone. Prefer candidates already labeled `repo:<current>` first (cheap claim), falling through to unlabeled candidates (determine + stamp) only when no pre-labeled current-repo leaf is ready.
49
49
 
50
- A container (Epic/Story/Spike) is handled by the leaf-only gate, not here — containers may span repos and are never claimed/built directly.
50
+ A container (Epic/Story/Spike) is handled by the leaf-only gate, not here — containers may span repos, may keep multiple `repo:<name>` labels for visibility, and are never claimed/built directly. Only a leaf work unit is split or skipped by repo scope.
51
51
 
52
52
  ## Vendor mechanics
53
53
 
@@ -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);
@@ -170,6 +170,7 @@ GitHub Issues live in one repo by definition, so the scanned repo's issues are u
170
170
  3. **Per candidate, apply the repo-scope decision (`repo-scope-split`):**
171
171
  - Carries `repo:<other>` → **skip** (leave it `ready` for that repo's own intake); next candidate.
172
172
  - **Unlabeled** → determine the target repo(s) from the issue + code surfaces, then **stamp** `repo:<name>` via `gh issue edit <n> --add-label "repo:<name>"` (create the label lazily) so later cycles filter cheaply; re-apply with the now-known repo. (An issue whose work is entirely in the scanned repo is simply labeled `repo:<current>`.)
173
+ - **Container visibility is allowed.** A multi-repo Epic / Story / Spike may legitimately carry multiple `repo:<name>` labels for operator visibility. Do not split or claim it here; leave the repo markers intact and fall through to the leaf-only gate, which repairs the stale build-ready label instead of dispatching the container.
173
174
  - **Multi-repo leaf → split, never claim.** Run the `repo-scope-split` work-time procedure into single-repo siblings, each created **build-ready** (`build_ready: true`) and stamped with its own `repo:<name>`; the current repo's sibling becomes a normal candidate.
174
175
  - **Single-repo leaf for the current repo** → fall through to 3a (leaf-only gate) and 3b (claim).
175
176
  4. Continue until a claimable current-repo leaf is found (claim it; one per cycle) or the ready set is exhausted — exit cleanly with `"No ready issues for repo <current>. Nothing to do."`.
@@ -215,6 +215,34 @@ The `Next action:` line should stay small and specific. Prefer one actionable fo
215
215
  - "manual product clarification" when Lisa is not the current owner
216
216
  - "fix `.lisa.config.json` or lifecycle labels" when the problem is misconfiguration
217
217
 
218
+ ## Smoke fixtures and read-only assertions
219
+
220
+ Intake-explain must keep representative smoke fixtures for both PRD and build lifecycles. These fixtures are contract examples for implementers and tests: they prove that lifecycle classification, dependency holds, staleness windows, and repair backoff map to the same verdict language operators see in real diagnosis output.
221
+
222
+ Minimum PRD smoke fixtures:
223
+
224
+ | Fixture | Decisive signals | Expected verdict |
225
+ |---|---|---|
226
+ | `prd-draft-product-owned` | PRD role `draft`; source lane resolved; no Lisa claim marker | `PRODUCT_OWNED_STATE` |
227
+ | `prd-ready-actionable` | PRD role `ready`; source lane resolved; validation-ready content present | `ELIGIBLE_FOR_INTAKE` |
228
+ | `prd-in-review-fresh` | PRD role `in_review`; newest Lisa or tracker activity is inside `stale_after` | `WAITING_ON_STALENESS` |
229
+ | `prd-blocked-backoff` | PRD role `blocked`; latest `[lisa-repair-intake]` fingerprint is unchanged and inside the backoff window | `WAITING_ON_STALENESS` |
230
+ | `prd-blocked-new-signal` | PRD role `blocked`; clarifying answer or blocker fingerprint changed after the last repair marker | `ELIGIBLE_FOR_REPAIR` |
231
+
232
+ Minimum build smoke fixtures:
233
+
234
+ | Fixture | Decisive signals | Expected verdict |
235
+ |---|---|---|
236
+ | `build-ready-leaf` | `status:ready`; `repo:<current>`; leaf type; no open children; no active blockers | `ELIGIBLE_FOR_INTAKE` |
237
+ | `build-active-dependency` | otherwise actionable ready leaf; `Blocked by:` points at an open blocker without a cleared status | `HELD_BY_BLOCKERS` |
238
+ | `build-cleared-dependency` | otherwise actionable ready leaf; blockers are closed or carry cleared build status | `ELIGIBLE_FOR_INTAKE` |
239
+ | `build-open-children` | build lifecycle role present; native sub-issues or body parentage include open child work | `NON_LEAF_CONTAINER` |
240
+ | `build-claimed-fresh` | `status:in-progress`; newest claim, PR, check, or issue activity is inside `stale_after` | `WAITING_ON_STALENESS` |
241
+ | `build-blocked-backoff` | `status:blocked`; blocker fingerprint unchanged and repair-backoff marker still suppresses retries | `WAITING_ON_STALENESS` |
242
+ | `build-blocked-cleared` | `status:blocked`; parsed blockers are now cleared or the blocker fingerprint changed | `ELIGIBLE_FOR_REPAIR` |
243
+
244
+ Every smoke fixture must assert read-only behavior. A diagnosis may call vendor read APIs, inspect config, and render a verdict, but it must not call write APIs such as `gh issue edit`, `gh issue comment`, label creation, issue creation, transition endpoints, PR mutation, or tracker comment/update calls. If execution intake would stamp repo labels, split a multi-repo leaf, repair a stale container label, add a dependency-hold comment, or retry stuck work, the smoke fixture should assert that intake-explain only reports that action as the next step.
245
+
218
246
  ## Output shape
219
247
 
220
248
  Use a stable grouped shape so one diagnosis is easy to scan:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "2.104.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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.2",
3
+ "version": "2.104.4",
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"
@@ -47,7 +47,7 @@ Resolve the current repo per the `config-resolution` "Repo scoping" section (con
47
47
 
48
48
  **Cost.** Only **unlabeled** candidates need content determination; once stamped, wrong-repo candidates are skipped by label alone. Prefer candidates already labeled `repo:<current>` first (cheap claim), falling through to unlabeled candidates (determine + stamp) only when no pre-labeled current-repo leaf is ready.
49
49
 
50
- A container (Epic/Story/Spike) is handled by the leaf-only gate, not here — containers may span repos and are never claimed/built directly.
50
+ A container (Epic/Story/Spike) is handled by the leaf-only gate, not here — containers may span repos, may keep multiple `repo:<name>` labels for visibility, and are never claimed/built directly. Only a leaf work unit is split or skipped by repo scope.
51
51
 
52
52
  ## Vendor mechanics
53
53
 
@@ -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);
@@ -170,6 +170,7 @@ GitHub Issues live in one repo by definition, so the scanned repo's issues are u
170
170
  3. **Per candidate, apply the repo-scope decision (`repo-scope-split`):**
171
171
  - Carries `repo:<other>` → **skip** (leave it `ready` for that repo's own intake); next candidate.
172
172
  - **Unlabeled** → determine the target repo(s) from the issue + code surfaces, then **stamp** `repo:<name>` via `gh issue edit <n> --add-label "repo:<name>"` (create the label lazily) so later cycles filter cheaply; re-apply with the now-known repo. (An issue whose work is entirely in the scanned repo is simply labeled `repo:<current>`.)
173
+ - **Container visibility is allowed.** A multi-repo Epic / Story / Spike may legitimately carry multiple `repo:<name>` labels for operator visibility. Do not split or claim it here; leave the repo markers intact and fall through to the leaf-only gate, which repairs the stale build-ready label instead of dispatching the container.
173
174
  - **Multi-repo leaf → split, never claim.** Run the `repo-scope-split` work-time procedure into single-repo siblings, each created **build-ready** (`build_ready: true`) and stamped with its own `repo:<name>`; the current repo's sibling becomes a normal candidate.
174
175
  - **Single-repo leaf for the current repo** → fall through to 3a (leaf-only gate) and 3b (claim).
175
176
  4. Continue until a claimable current-repo leaf is found (claim it; one per cycle) or the ready set is exhausted — exit cleanly with `"No ready issues for repo <current>. Nothing to do."`.
@@ -215,6 +215,34 @@ The `Next action:` line should stay small and specific. Prefer one actionable fo
215
215
  - "manual product clarification" when Lisa is not the current owner
216
216
  - "fix `.lisa.config.json` or lifecycle labels" when the problem is misconfiguration
217
217
 
218
+ ## Smoke fixtures and read-only assertions
219
+
220
+ Intake-explain must keep representative smoke fixtures for both PRD and build lifecycles. These fixtures are contract examples for implementers and tests: they prove that lifecycle classification, dependency holds, staleness windows, and repair backoff map to the same verdict language operators see in real diagnosis output.
221
+
222
+ Minimum PRD smoke fixtures:
223
+
224
+ | Fixture | Decisive signals | Expected verdict |
225
+ |---|---|---|
226
+ | `prd-draft-product-owned` | PRD role `draft`; source lane resolved; no Lisa claim marker | `PRODUCT_OWNED_STATE` |
227
+ | `prd-ready-actionable` | PRD role `ready`; source lane resolved; validation-ready content present | `ELIGIBLE_FOR_INTAKE` |
228
+ | `prd-in-review-fresh` | PRD role `in_review`; newest Lisa or tracker activity is inside `stale_after` | `WAITING_ON_STALENESS` |
229
+ | `prd-blocked-backoff` | PRD role `blocked`; latest `[lisa-repair-intake]` fingerprint is unchanged and inside the backoff window | `WAITING_ON_STALENESS` |
230
+ | `prd-blocked-new-signal` | PRD role `blocked`; clarifying answer or blocker fingerprint changed after the last repair marker | `ELIGIBLE_FOR_REPAIR` |
231
+
232
+ Minimum build smoke fixtures:
233
+
234
+ | Fixture | Decisive signals | Expected verdict |
235
+ |---|---|---|
236
+ | `build-ready-leaf` | `status:ready`; `repo:<current>`; leaf type; no open children; no active blockers | `ELIGIBLE_FOR_INTAKE` |
237
+ | `build-active-dependency` | otherwise actionable ready leaf; `Blocked by:` points at an open blocker without a cleared status | `HELD_BY_BLOCKERS` |
238
+ | `build-cleared-dependency` | otherwise actionable ready leaf; blockers are closed or carry cleared build status | `ELIGIBLE_FOR_INTAKE` |
239
+ | `build-open-children` | build lifecycle role present; native sub-issues or body parentage include open child work | `NON_LEAF_CONTAINER` |
240
+ | `build-claimed-fresh` | `status:in-progress`; newest claim, PR, check, or issue activity is inside `stale_after` | `WAITING_ON_STALENESS` |
241
+ | `build-blocked-backoff` | `status:blocked`; blocker fingerprint unchanged and repair-backoff marker still suppresses retries | `WAITING_ON_STALENESS` |
242
+ | `build-blocked-cleared` | `status:blocked`; parsed blockers are now cleared or the blocker fingerprint changed | `ELIGIBLE_FOR_REPAIR` |
243
+
244
+ Every smoke fixture must assert read-only behavior. A diagnosis may call vendor read APIs, inspect config, and render a verdict, but it must not call write APIs such as `gh issue edit`, `gh issue comment`, label creation, issue creation, transition endpoints, PR mutation, or tracker comment/update calls. If execution intake would stamp repo labels, split a multi-repo leaf, repair a stale container label, add a dependency-hold comment, or retry stuck work, the smoke fixture should assert that intake-explain only reports that action as the next step.
245
+
218
246
  ## Output shape
219
247
 
220
248
  Use a stable grouped shape so one diagnosis is easy to scan: