@codyswann/lisa 2.95.0 → 2.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "lodash": ">=4.18.1"
83
83
  },
84
84
  "name": "@codyswann/lisa",
85
- "version": "2.95.0",
85
+ "version": "2.97.0",
86
86
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
87
87
  "main": "dist/index.js",
88
88
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.95.0",
3
+ "version": "2.97.0",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "2.95.0",
3
+ "version": "2.97.0",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared build-side queue readers for `/lisa:queue-status`.
4
+ *
5
+ * These helpers normalize vendor-specific build lifecycle items into a common
6
+ * snapshot shape so queue-status can report lifecycle counts, actionable
7
+ * highlights, and repair-intake signals without drifting from Lisa's build
8
+ * lifecycle contract.
9
+ */
10
+
11
+ import { classifyQueueHealth } from "./queue-health-classification.mjs";
12
+
13
+ export const BUILD_LIFECYCLE_ORDER = [
14
+ "ready",
15
+ "claimed",
16
+ "review",
17
+ "blocked",
18
+ "done",
19
+ ];
20
+
21
+ const ACTIONABLE_ROLE_ORDER = ["blocked", "ready", "claimed", "review"];
22
+
23
+ const HIGHLIGHT_COPY = {
24
+ blocked: {
25
+ summary: "Oldest blocked build item",
26
+ nextStep: "Run /lisa:repair-intake <queue> after clearing the blocker.",
27
+ },
28
+ stalled: {
29
+ summary: "Oldest stalled build item likely actionable for repair-intake",
30
+ nextStep:
31
+ "Run /lisa:repair-intake <queue> to re-evaluate the stalled build item.",
32
+ },
33
+ ready: {
34
+ summary: "Oldest ready build item awaiting intake",
35
+ nextStep: "Run /lisa:intake <queue> to claim the next build issue.",
36
+ },
37
+ claimed: {
38
+ summary: "Oldest claimed build item still in flight",
39
+ nextStep:
40
+ "Inspect the active implementation path before escalating to /lisa:repair-intake <queue>.",
41
+ },
42
+ review: {
43
+ summary: "Oldest build item waiting in review",
44
+ nextStep:
45
+ "Check the linked PR or review handoff before re-running /lisa:intake <queue>.",
46
+ },
47
+ };
48
+
49
+ /**
50
+ * Read a GitHub-backed build queue snapshot from issue payloads.
51
+ *
52
+ * @param {{
53
+ * readonly issues?: readonly Record<string, any>[]
54
+ * readonly roles?: Record<string, any>
55
+ * readonly namespaceAdopted?: boolean
56
+ * readonly queueResolved?: boolean
57
+ * readonly queueArgument?: string
58
+ * readonly resolutionError?: string | null
59
+ * }} input
60
+ */
61
+ export function readGithubBuildQueueSnapshot(input = {}) {
62
+ const roles = input.roles ?? {};
63
+ const normalizedItems = (input.issues ?? [])
64
+ .map(issue => normalizeGithubBuildIssue(issue, roles))
65
+ .filter(Boolean);
66
+
67
+ return createBuildQueueSnapshot({
68
+ tracker: "github",
69
+ items: normalizedItems,
70
+ roles,
71
+ namespaceAdopted: input.namespaceAdopted,
72
+ queueResolved: input.queueResolved,
73
+ queueArgument: input.queueArgument,
74
+ resolutionError: input.resolutionError,
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Build a vendor-agnostic build queue snapshot from normalized lifecycle items.
80
+ *
81
+ * @param {{
82
+ * readonly tracker?: string
83
+ * readonly items?: readonly Record<string, any>[]
84
+ * readonly roles?: Record<string, any>
85
+ * readonly namespaceAdopted?: boolean
86
+ * readonly queueResolved?: boolean
87
+ * readonly queueArgument?: string
88
+ * readonly resolutionError?: string | null
89
+ * }} input
90
+ */
91
+ export function createBuildQueueSnapshot(input = {}) {
92
+ const roles = normalizeRoles(input.roles);
93
+ const items = normalizeItems(input.items);
94
+ const counts = buildLifecycleCounts(items);
95
+ const repairSignals = buildRepairSignals(items, input.queueArgument);
96
+ const highlights = buildActionableHighlights(
97
+ items,
98
+ repairSignals,
99
+ input.queueArgument
100
+ );
101
+ const queueResolved =
102
+ input.queueResolved ?? typeof input.resolutionError !== "string";
103
+ const namespaceAdopted =
104
+ input.namespaceAdopted ?? inferNamespaceAdopted(items, roles);
105
+
106
+ const health = classifyQueueHealth({
107
+ queueResolved,
108
+ namespaceAdopted,
109
+ readyCount: counts.ready,
110
+ activeCount: counts.claimed + counts.review,
111
+ blockedCount: counts.blocked,
112
+ stalledCount: repairSignals.stalled.length,
113
+ resolutionError: input.resolutionError ?? null,
114
+ });
115
+
116
+ return {
117
+ tracker: input.tracker ?? "unknown",
118
+ queueResolved,
119
+ namespaceAdopted,
120
+ roles,
121
+ counts,
122
+ highlights,
123
+ repairSignals,
124
+ health,
125
+ resolutionError: input.resolutionError ?? null,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * @param {Record<string, any>} issue
131
+ * @param {Record<string, any>} roles
132
+ * @returns {Record<string, any> | null}
133
+ */
134
+ function normalizeGithubBuildIssue(issue, roles) {
135
+ if (!issue || typeof issue !== "object") {
136
+ return null;
137
+ }
138
+
139
+ const role = resolveGithubBuildRole(issue.labels, roles);
140
+
141
+ return normalizeItem({
142
+ id: String(issue.id ?? issue.number ?? issue.url ?? issue.title ?? ""),
143
+ ref:
144
+ issue.number !== undefined && issue.number !== null
145
+ ? `#${issue.number}`
146
+ : String(issue.url ?? issue.title ?? ""),
147
+ title: String(issue.title ?? "").trim(),
148
+ url: typeof issue.url === "string" ? issue.url : null,
149
+ createdAt: issue.createdAt ?? null,
150
+ updatedAt: issue.updatedAt ?? null,
151
+ role,
152
+ stalled: Boolean(issue.stalled),
153
+ });
154
+ }
155
+
156
+ /**
157
+ * @param {readonly any[] | undefined} labels
158
+ * @param {Record<string, any>} roles
159
+ * @returns {string | null}
160
+ */
161
+ function resolveGithubBuildRole(labels, roles) {
162
+ if (!Array.isArray(labels)) {
163
+ return null;
164
+ }
165
+
166
+ const labelNames = new Set(
167
+ labels
168
+ .map(label =>
169
+ typeof label === "string"
170
+ ? label
171
+ : typeof label?.name === "string"
172
+ ? label.name
173
+ : null
174
+ )
175
+ .filter(Boolean)
176
+ );
177
+
178
+ for (const role of BUILD_LIFECYCLE_ORDER) {
179
+ if (role === "done") {
180
+ if (resolveDoneRoleNames(roles).some(name => labelNames.has(name))) {
181
+ return "done";
182
+ }
183
+ continue;
184
+ }
185
+
186
+ const configuredName = roles[role];
187
+ if (configuredName && labelNames.has(configuredName)) {
188
+ return role;
189
+ }
190
+ }
191
+
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * @param {Record<string, any> | undefined} roles
197
+ * @returns {Record<string, any>}
198
+ */
199
+ function normalizeRoles(roles) {
200
+ return {
201
+ ready:
202
+ typeof roles?.ready === "string" && roles.ready.trim().length > 0
203
+ ? roles.ready.trim()
204
+ : "ready",
205
+ claimed:
206
+ typeof roles?.claimed === "string" && roles.claimed.trim().length > 0
207
+ ? roles.claimed.trim()
208
+ : "claimed",
209
+ review:
210
+ typeof roles?.review === "string" && roles.review.trim().length > 0
211
+ ? roles.review.trim()
212
+ : "review",
213
+ blocked:
214
+ typeof roles?.blocked === "string" && roles.blocked.trim().length > 0
215
+ ? roles.blocked.trim()
216
+ : "blocked",
217
+ done: normalizeDoneRoles(roles?.done),
218
+ };
219
+ }
220
+
221
+ /**
222
+ * @param {Record<string, any> | undefined} done
223
+ * @returns {Record<string, string>}
224
+ */
225
+ function normalizeDoneRoles(done) {
226
+ if (typeof done === "string" && done.trim().length > 0) {
227
+ return { default: done.trim() };
228
+ }
229
+
230
+ const normalized = {};
231
+ for (const [key, value] of Object.entries(done ?? {})) {
232
+ if (typeof value === "string" && value.trim().length > 0) {
233
+ normalized[key] = value.trim();
234
+ }
235
+ }
236
+
237
+ return Object.keys(normalized).length > 0 ? normalized : { default: "done" };
238
+ }
239
+
240
+ /**
241
+ * @param {Record<string, any>} roles
242
+ * @returns {readonly string[]}
243
+ */
244
+ function resolveDoneRoleNames(roles) {
245
+ return Object.values(normalizeDoneRoles(roles?.done));
246
+ }
247
+
248
+ /**
249
+ * @param {readonly Record<string, any>[] | undefined} items
250
+ * @returns {readonly Record<string, any>[]}
251
+ */
252
+ function normalizeItems(items) {
253
+ return (items ?? []).map(normalizeItem).filter(Boolean);
254
+ }
255
+
256
+ /**
257
+ * @param {Record<string, any>} item
258
+ * @returns {Record<string, any> | null}
259
+ */
260
+ function normalizeItem(item) {
261
+ if (!item || typeof item !== "object") {
262
+ return null;
263
+ }
264
+
265
+ const role =
266
+ typeof item.role === "string" && BUILD_LIFECYCLE_ORDER.includes(item.role)
267
+ ? item.role
268
+ : null;
269
+
270
+ return {
271
+ id: String(item.id ?? item.ref ?? item.title ?? ""),
272
+ ref: String(item.ref ?? item.id ?? item.title ?? ""),
273
+ title: String(item.title ?? "").trim(),
274
+ url: typeof item.url === "string" ? item.url : null,
275
+ role,
276
+ createdAt: normalizeTimestamp(item.createdAt),
277
+ updatedAt: normalizeTimestamp(item.updatedAt),
278
+ stalled: Boolean(item.stalled),
279
+ };
280
+ }
281
+
282
+ /**
283
+ * @param {readonly Record<string, any>[]} items
284
+ */
285
+ function buildLifecycleCounts(items) {
286
+ const counts = Object.fromEntries(
287
+ BUILD_LIFECYCLE_ORDER.map(role => [role, 0])
288
+ );
289
+
290
+ for (const item of items) {
291
+ if (item.role && counts[item.role] !== undefined) {
292
+ counts[item.role] += 1;
293
+ }
294
+ }
295
+
296
+ return counts;
297
+ }
298
+
299
+ /**
300
+ * @param {readonly Record<string, any>[]} items
301
+ * @param {string | undefined} queueArgument
302
+ */
303
+ function buildRepairSignals(items, queueArgument) {
304
+ const blocked = items
305
+ .filter(item => item.role === "blocked")
306
+ .sort(compareQueueItemsByCreatedAt)
307
+ .map(toRepairSignalItem);
308
+ const stalled = items
309
+ .filter(item => item.stalled)
310
+ .sort(compareQueueItemsByCreatedAt)
311
+ .map(toRepairSignalItem);
312
+
313
+ return {
314
+ actionable: blocked.length > 0 || stalled.length > 0,
315
+ blocked,
316
+ stalled,
317
+ suggestedCommand:
318
+ blocked.length > 0 || stalled.length > 0
319
+ ? expandHighlightNextStep(
320
+ "Run /lisa:repair-intake <queue> to inspect the most actionable stuck build work.",
321
+ queueArgument
322
+ )
323
+ : null,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * @param {readonly Record<string, any>[]} items
329
+ * @param {Record<string, any>} repairSignals
330
+ * @param {string | undefined} queueArgument
331
+ */
332
+ function buildActionableHighlights(items, repairSignals, queueArgument) {
333
+ const highlights = [];
334
+
335
+ const oldestStalled = repairSignals.stalled[0];
336
+ if (oldestStalled) {
337
+ highlights.push({
338
+ role: "stalled",
339
+ ref: oldestStalled.ref,
340
+ title: oldestStalled.title,
341
+ url: oldestStalled.url,
342
+ createdAt: oldestStalled.createdAt,
343
+ summary: HIGHLIGHT_COPY.stalled.summary,
344
+ nextStep: expandHighlightNextStep(
345
+ HIGHLIGHT_COPY.stalled.nextStep,
346
+ queueArgument
347
+ ),
348
+ });
349
+ }
350
+
351
+ for (const role of ACTIONABLE_ROLE_ORDER) {
352
+ const oldest = findOldestItemForRole(items, role);
353
+ if (!oldest) {
354
+ continue;
355
+ }
356
+
357
+ const copy = HIGHLIGHT_COPY[role];
358
+ highlights.push({
359
+ role,
360
+ ref: oldest.ref,
361
+ title: oldest.title,
362
+ url: oldest.url,
363
+ createdAt: oldest.createdAt,
364
+ summary: copy.summary,
365
+ nextStep: expandHighlightNextStep(copy.nextStep, queueArgument),
366
+ });
367
+ }
368
+
369
+ return highlights;
370
+ }
371
+
372
+ /**
373
+ * @param {readonly Record<string, any>[]} items
374
+ * @param {string} role
375
+ */
376
+ function findOldestItemForRole(items, role) {
377
+ return (
378
+ items
379
+ .filter(item => item.role === role)
380
+ .sort(compareQueueItemsByCreatedAt)[0] ?? null
381
+ );
382
+ }
383
+
384
+ /**
385
+ * @param {readonly Record<string, any>[]} items
386
+ * @param {Record<string, any>} roles
387
+ * @returns {boolean}
388
+ */
389
+ function inferNamespaceAdopted(items, roles) {
390
+ if (items.some(item => item.role)) {
391
+ return true;
392
+ }
393
+
394
+ return (
395
+ ["ready", "claimed", "review", "blocked"].some(
396
+ role => typeof roles[role] === "string" && roles[role].trim().length > 0
397
+ ) || resolveDoneRoleNames(roles).length > 0
398
+ );
399
+ }
400
+
401
+ /**
402
+ * @param {Record<string, any>} item
403
+ * @returns {Record<string, any>}
404
+ */
405
+ function toRepairSignalItem(item) {
406
+ return {
407
+ ref: item.ref,
408
+ title: item.title,
409
+ url: item.url,
410
+ createdAt: item.createdAt,
411
+ };
412
+ }
413
+
414
+ /**
415
+ * @param {string} template
416
+ * @param {string | undefined} queueArgument
417
+ * @returns {string}
418
+ */
419
+ function expandHighlightNextStep(template, queueArgument) {
420
+ return template.replace("<queue>", queueArgument ?? "queue");
421
+ }
422
+
423
+ /**
424
+ * @param {Record<string, any>} left
425
+ * @param {Record<string, any>} right
426
+ * @returns {number}
427
+ */
428
+ function compareQueueItemsByCreatedAt(left, right) {
429
+ const leftMs = left.createdAt
430
+ ? Date.parse(left.createdAt)
431
+ : Number.POSITIVE_INFINITY;
432
+ const rightMs = right.createdAt
433
+ ? Date.parse(right.createdAt)
434
+ : Number.POSITIVE_INFINITY;
435
+
436
+ if (leftMs !== rightMs) {
437
+ return leftMs - rightMs;
438
+ }
439
+
440
+ return String(left.ref).localeCompare(String(right.ref));
441
+ }
442
+
443
+ /**
444
+ * @param {string | null | undefined} value
445
+ * @returns {string | null}
446
+ */
447
+ function normalizeTimestamp(value) {
448
+ if (typeof value !== "string") {
449
+ return null;
450
+ }
451
+
452
+ const trimmed = value.trim();
453
+ return trimmed.length > 0 ? trimmed : null;
454
+ }