@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.
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/rules/repo-scope-split.md +1 -1
- package/plugins/lisa/scripts/automation-status-claude-adapter.mjs +34 -7
- package/plugins/lisa/scripts/automation-status-codex-adapter.mjs +39 -14
- package/plugins/lisa/scripts/automation-status-contract-drift.mjs +65 -1
- package/plugins/lisa/scripts/queue-status-build-readers.mjs +2 -1
- package/plugins/lisa/scripts/queue-status-prd-readers.mjs +2 -1
- package/plugins/lisa/skills/github-build-intake/SKILL.md +1 -0
- package/plugins/lisa/skills/intake-explain/SKILL.md +28 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/repo-scope-split.md +1 -1
- package/plugins/src/base/scripts/automation-status-claude-adapter.mjs +34 -7
- package/plugins/src/base/scripts/automation-status-codex-adapter.mjs +39 -14
- package/plugins/src/base/scripts/automation-status-contract-drift.mjs +65 -1
- package/plugins/src/base/scripts/queue-status-build-readers.mjs +2 -1
- package/plugins/src/base/scripts/queue-status-prd-readers.mjs +2 -1
- package/plugins/src/base/skills/github-build-intake/SKILL.md +1 -0
- 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.
|
|
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": {
|
|
@@ -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 {
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
225
|
+
const latestBlockText = latestBlock.lines.join("\n");
|
|
225
226
|
const lastRunFailed =
|
|
226
|
-
RUN_FAILURE_PATTERN.test(
|
|
227
|
-
!NEGATED_FAILURE_PATTERN.test(
|
|
227
|
+
RUN_FAILURE_PATTERN.test(latestBlockText) &&
|
|
228
|
+
!NEGATED_FAILURE_PATTERN.test(latestBlockText);
|
|
228
229
|
|
|
229
230
|
return {
|
|
230
|
-
lastRunAt:
|
|
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-openclaw",
|
|
3
|
-
"version": "2.104.
|
|
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.
|
|
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"
|
|
@@ -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 {
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
225
|
+
const latestBlockText = latestBlock.lines.join("\n");
|
|
225
226
|
const lastRunFailed =
|
|
226
|
-
RUN_FAILURE_PATTERN.test(
|
|
227
|
-
!NEGATED_FAILURE_PATTERN.test(
|
|
227
|
+
RUN_FAILURE_PATTERN.test(latestBlockText) &&
|
|
228
|
+
!NEGATED_FAILURE_PATTERN.test(latestBlockText);
|
|
228
229
|
|
|
229
230
|
return {
|
|
230
|
-
lastRunAt:
|
|
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:
|