@codyswann/lisa 3.14.3 → 3.14.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 (72) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +13 -3
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/expo/create-only/.github/workflows/maestro-e2e.yml +21 -8
  5. package/package.json +1 -1
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +54 -0
  9. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +95 -0
  10. package/plugins/lisa/scripts/lifecycle-label-trust.mjs +494 -0
  11. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +54 -0
  12. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +95 -0
  13. package/plugins/lisa-agy/plugin.json +1 -1
  14. package/plugins/lisa-agy/scripts/lifecycle-label-trust.mjs +494 -0
  15. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +54 -0
  16. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +95 -0
  17. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-copilot/scripts/lifecycle-label-trust.mjs +494 -0
  24. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +54 -0
  25. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +95 -0
  26. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cursor/scripts/lifecycle-label-trust.mjs +494 -0
  28. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +54 -0
  29. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +95 -0
  30. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo-agy/plugin.json +1 -1
  33. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  38. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  43. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  48. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  53. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-rails-agy/plugin.json +1 -1
  58. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  63. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  68. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/src/base/scripts/lifecycle-label-trust.mjs +494 -0
  71. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +54 -0
  72. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +95 -0
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lifecycle-label trust resolution — the read-side guard for #2539.
4
+ *
5
+ * ## The defect
6
+ *
7
+ * `coderabbitai[bot]` stamps a `status:*` label on issues seconds after they are
8
+ * filed. Measured across CodySwannGT/lisa#2460–#2540, seven of eight bot-applied
9
+ * lifecycle labels landed 26–118s after creation — far too fast to be a real
10
+ * claim. An issue wearing a bot-applied `status:in-progress` is invisible to
11
+ * build-intake (it is not the `ready` role) AND looks handled to a human, so
12
+ * nothing re-examines it. A MISSING lifecycle label is recoverable; a WRONG one
13
+ * is silent in both directions at once.
14
+ *
15
+ * ## Why this guard reads instead of writes
16
+ *
17
+ * The label is applied by a webhook after the fact, so no pre-tool check on this
18
+ * machine has anything to intercept — the write never passes through us. The two
19
+ * remaining options were a reconciler that reverts the label, and this: intake
20
+ * refusing to BELIEVE the label. The reconciler was rejected because the bot
21
+ * re-applies on every subsequent review event, so reverting produces a label-flap
22
+ * loop that is worse than the disease. Distrust is idempotent, cannot race, and
23
+ * writes nothing — nothing in this module mutates a tracker.
24
+ *
25
+ * ## Why actor alone is not enough
26
+ *
27
+ * #2470 (false claim, 27s) and #2494 (real, 3001s) were BOTH applied by
28
+ * `coderabbitai[bot]`. Rejecting every bot-authored lifecycle label would
29
+ * discard the true one; rejecting every fast label would discard the human
30
+ * `status:ready` applied at filing time, which is the queue's entry signal.
31
+ * Distrust therefore requires BOTH conditions: a bot actor AND a latency below
32
+ * the plausibility window.
33
+ *
34
+ * ## Why the member set is never hardcoded
35
+ *
36
+ * The live `status:*` family has drifted 7 → 6 members. A pinned literal breaks
37
+ * silently — an unrecognised member simply fails to match, so the guard reports
38
+ * clean on exactly the case it stopped covering. Membership is decided by the
39
+ * `status:` PREFIX and terminality is resolved from live config.
40
+ */
41
+
42
+ /** Lifecycle labels are identified by this prefix, never by a pinned set. */
43
+ export const LIFECYCLE_LABEL_PREFIX = "status:";
44
+
45
+ /**
46
+ * Fallback terminal label used only when config supplies no `done` role.
47
+ *
48
+ * This module ships standalone into host projects and cannot import Lisa's
49
+ * TypeScript `BUILD_LABEL_DEFAULTS`, so the literal is repeated here — but it is
50
+ * NOT free to drift: `lifecycle-label-trust.test.ts` asserts it stays equal to
51
+ * `BUILD_LABEL_DEFAULTS.done.production` in `src/sync/lifecycle-defaults.ts`.
52
+ * This is a fallback only; a configured project resolves the value live.
53
+ */
54
+ export const DEFAULT_TERMINAL_LIFECYCLE_LABEL = "status:done";
55
+
56
+ /**
57
+ * Seconds below which a BOT-applied lifecycle label is not believable as a claim.
58
+ *
59
+ * Calibrated against measurement, not taste: the observed false bot claims
60
+ * clustered at 26–118s, while the one corroborated bot label landed at 3001s.
61
+ * 300s sits an order of magnitude above the noise and an order of magnitude
62
+ * below the real signal, so neither edge is close to the boundary.
63
+ */
64
+ export const IMPLAUSIBLE_CLAIM_WINDOW_SECONDS = 300;
65
+
66
+ /**
67
+ * Both directions a lifecycle label can contradict native state.
68
+ *
69
+ * Emitted on every drift result so a repair pass cannot walk one direction and
70
+ * silently leave the other accumulating — the asymmetry that let TUN-556 and
71
+ * TUN-503 sit on a terminal `status:done` while still natively open.
72
+ */
73
+ export const LIFECYCLE_DRIFT_DIRECTIONS = [
74
+ "terminal-label-open-state",
75
+ "open-label-closed-state",
76
+ ];
77
+
78
+ /**
79
+ * @typedef {{ readonly login?: string, readonly type?: string }} Actor
80
+ *
81
+ * @typedef {{
82
+ * readonly event?: string
83
+ * readonly label?: { readonly name?: string }
84
+ * readonly actor?: Actor | null
85
+ * readonly created_at?: string
86
+ * }} TimelineEvent
87
+ *
88
+ * @typedef {{
89
+ * readonly label: string
90
+ * readonly actor: string | null
91
+ * readonly latencySeconds: number | null
92
+ * readonly reason: string
93
+ * readonly trusted: boolean
94
+ * }} EvaluatedLabel
95
+ */
96
+
97
+ /**
98
+ * Is this label part of the lifecycle namespace?
99
+ *
100
+ * Prefix-only by design — see the module preamble on set drift. `status:` with
101
+ * no member is still lifecycle-namespaced and must not fall through as taxonomy.
102
+ *
103
+ * @param {unknown} name
104
+ * @returns {boolean}
105
+ */
106
+ export function isLifecycleLabel(name) {
107
+ if (typeof name !== "string") {
108
+ return false;
109
+ }
110
+ return name.trim().toLowerCase().startsWith(LIFECYCLE_LABEL_PREFIX);
111
+ }
112
+
113
+ /**
114
+ * Is this timeline actor a bot?
115
+ *
116
+ * Checks GitHub's first-class `type` discriminator first, then falls back to the
117
+ * `[bot]` login suffix so an actor payload lacking `type` is still classified.
118
+ * A missing actor is NOT a bot: unattributable is handled separately, and
119
+ * guessing "bot" there would silently drop legitimate labels.
120
+ *
121
+ * @param {Actor | null | undefined} actor
122
+ * @returns {boolean}
123
+ */
124
+ export function isBotActor(actor) {
125
+ if (!actor || typeof actor !== "object") {
126
+ return false;
127
+ }
128
+ if (typeof actor.type === "string" && actor.type.toLowerCase() === "bot") {
129
+ return true;
130
+ }
131
+ return (
132
+ typeof actor.login === "string" &&
133
+ actor.login.trim().toLowerCase().endsWith("[bot]")
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Resolve which of an item's lifecycle labels intake is entitled to believe.
139
+ *
140
+ * Read-only and total: every lifecycle label on the item lands in exactly one of
141
+ * `trusted` or `untrusted`, and `evaluated` carries the reasoning for all of
142
+ * them so a distrust decision is auditable rather than invisible.
143
+ *
144
+ * Provenance that cannot be established (a label applied at creation, which
145
+ * GitHub records no `labeled` event for) is TRUSTED but reported in
146
+ * `unknownProvenance`. Failing closed there would make intake ignore the
147
+ * human `status:ready` that opens the queue, stalling every project — so the
148
+ * blind spot is surfaced rather than guessed at. Supplying `issueAuthor` closes
149
+ * it, by attributing creation-time labels to whoever filed the item.
150
+ *
151
+ * @param {{
152
+ * readonly labels?: readonly (string | { readonly name?: string })[]
153
+ * readonly timeline?: readonly TimelineEvent[]
154
+ * readonly issueCreatedAt?: string
155
+ * readonly issueAuthor?: Actor | null
156
+ * readonly windowSeconds?: number
157
+ * }} input
158
+ * @returns {{
159
+ * readonly trusted: readonly string[]
160
+ * readonly untrusted: readonly Omit<EvaluatedLabel, "trusted">[]
161
+ * readonly unknownProvenance: readonly string[]
162
+ * readonly evaluated: readonly EvaluatedLabel[]
163
+ * readonly hasUntrustedLifecycleLabels: boolean
164
+ * }}
165
+ */
166
+ export function resolveTrustedLifecycleLabels(input = {}) {
167
+ const windowSeconds = normalizeWindow(input.windowSeconds);
168
+ const createdAtMs = parseTimestamp(input.issueCreatedAt);
169
+ const lifecycleLabels = normalizeLabelNames(input.labels).filter(
170
+ isLifecycleLabel
171
+ );
172
+ const latestApplication = indexLatestLabelApplications(input.timeline);
173
+
174
+ const evaluated = lifecycleLabels.map(label =>
175
+ evaluateLabel({
176
+ label,
177
+ application: latestApplication.get(label.trim().toLowerCase()),
178
+ createdAtMs,
179
+ issueAuthor: input.issueAuthor,
180
+ windowSeconds,
181
+ })
182
+ );
183
+
184
+ return {
185
+ trusted: evaluated.filter(entry => entry.trusted).map(entry => entry.label),
186
+ untrusted: evaluated
187
+ .filter(entry => !entry.trusted)
188
+ .map(({ trusted: _trusted, ...rest }) => rest),
189
+ unknownProvenance: evaluated
190
+ .filter(entry => entry.reason === "provenance-unknown")
191
+ .map(entry => entry.label),
192
+ evaluated,
193
+ hasUntrustedLifecycleLabels: evaluated.some(entry => !entry.trusted),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * The lifecycle labels that mean "this work is finished", read from live config.
199
+ *
200
+ * Only the PRODUCTION rung of the `done` role is terminal. `status:on-dev` and
201
+ * `status:on-stg` are shipped-somewhere, not finished, and closing on them would
202
+ * retire work that still has environments to clear.
203
+ *
204
+ * @param {unknown} config a parsed `.lisa.config.json`-shaped object
205
+ * @returns {readonly string[]}
206
+ */
207
+ export function terminalLifecycleLabels(config) {
208
+ const done = readPath(config, ["github", "labels", "build", "done"]);
209
+
210
+ if (typeof done === "string" && done.trim().length > 0) {
211
+ return [done.trim()];
212
+ }
213
+ const production = readPath(done, ["production"]);
214
+ if (typeof production === "string" && production.trim().length > 0) {
215
+ return [production.trim()];
216
+ }
217
+ return [DEFAULT_TERMINAL_LIFECYCLE_LABEL];
218
+ }
219
+
220
+ /**
221
+ * Detect lifecycle labels that contradict native state, in BOTH directions.
222
+ *
223
+ * `directionsWalked` is always the full direction list, so a caller reporting
224
+ * "clean" is asserting it looked at both — the failure mode refinement #1 of
225
+ * issue #2539 describes, where only the auto-complete direction was ever
226
+ * repaired and the reverse rotted unobserved.
227
+ *
228
+ * `excludeLabels` removes labels from consideration entirely. Callers MUST pass
229
+ * the untrusted set here: `open-label-closed-state` is a repair direction that
230
+ * WRITES (it advances the label to the terminal `done` role), so without this a
231
+ * bot-applied label the classifier just refused to believe would still drive a
232
+ * real label write — the guard would launder the very input it rejected.
233
+ *
234
+ * @param {{
235
+ * readonly labels?: readonly (string | { readonly name?: string })[]
236
+ * readonly state?: string
237
+ * readonly terminalLabels?: readonly string[]
238
+ * readonly excludeLabels?: readonly string[]
239
+ * }} input
240
+ * @returns {{
241
+ * readonly drifts: readonly { direction: string, label: string }[]
242
+ * readonly directionsWalked: readonly string[]
243
+ * readonly excluded: readonly string[]
244
+ * }}
245
+ */
246
+ export function detectLifecycleDrift(input = {}) {
247
+ const terminal = new Set(
248
+ (input.terminalLabels ?? terminalLifecycleLabels(undefined)).map(label =>
249
+ String(label).trim().toLowerCase()
250
+ )
251
+ );
252
+ const excluded = new Set(
253
+ (Array.isArray(input.excludeLabels) ? input.excludeLabels : []).map(label =>
254
+ String(label).trim().toLowerCase()
255
+ )
256
+ );
257
+ const closed =
258
+ String(input.state ?? "")
259
+ .trim()
260
+ .toLowerCase() === "closed";
261
+ const allLifecycleLabels = normalizeLabelNames(input.labels).filter(
262
+ isLifecycleLabel
263
+ );
264
+ const skipped = allLifecycleLabels.filter(label =>
265
+ excluded.has(label.trim().toLowerCase())
266
+ );
267
+ const lifecycleLabels = allLifecycleLabels.filter(
268
+ label => !excluded.has(label.trim().toLowerCase())
269
+ );
270
+
271
+ const drifts = lifecycleLabels.flatMap(label => {
272
+ const isTerminal = terminal.has(label.trim().toLowerCase());
273
+ if (isTerminal && !closed) {
274
+ return [{ direction: "terminal-label-open-state", label }];
275
+ }
276
+ if (!isTerminal && closed) {
277
+ return [{ direction: "open-label-closed-state", label }];
278
+ }
279
+ return [];
280
+ });
281
+
282
+ return {
283
+ drifts,
284
+ directionsWalked: LIFECYCLE_DRIFT_DIRECTIONS,
285
+ excluded: skipped,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Classify one lifecycle label against its most recent application.
291
+ *
292
+ * @param {{
293
+ * label: string
294
+ * application?: { actor?: Actor | null, createdAtMs: number | null }
295
+ * createdAtMs: number | null
296
+ * issueAuthor?: Actor | null
297
+ * windowSeconds: number
298
+ * }} params
299
+ * @returns {EvaluatedLabel}
300
+ */
301
+ function evaluateLabel(params) {
302
+ const { label, application, createdAtMs, issueAuthor, windowSeconds } =
303
+ params;
304
+
305
+ const actor = application ? application.actor : issueAuthor;
306
+ const hasProvenance = Boolean(application) || Boolean(issueAuthor);
307
+
308
+ if (!hasProvenance) {
309
+ return {
310
+ label,
311
+ actor: null,
312
+ latencySeconds: null,
313
+ reason: "provenance-unknown",
314
+ trusted: true,
315
+ };
316
+ }
317
+
318
+ const login = typeof actor?.login === "string" ? actor.login : null;
319
+
320
+ if (!isBotActor(actor)) {
321
+ return {
322
+ label,
323
+ actor: login,
324
+ latencySeconds: latencySeconds(createdAtMs, application),
325
+ reason: "human-actor",
326
+ trusted: true,
327
+ };
328
+ }
329
+
330
+ const latency = latencySeconds(createdAtMs, application);
331
+ const implausible = latency === null || latency < windowSeconds;
332
+
333
+ return {
334
+ label,
335
+ actor: login,
336
+ latencySeconds: latency,
337
+ reason: implausible
338
+ ? "bot-actor-implausible-latency"
339
+ : "bot-actor-plausible-latency",
340
+ trusted: !implausible,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Seconds between item creation and the label application.
346
+ *
347
+ * A creation-time label (no timeline event) is latency 0 by construction.
348
+ *
349
+ * @param {number | null} createdAtMs
350
+ * @param {{ createdAtMs: number | null } | undefined} application
351
+ * @returns {number | null}
352
+ */
353
+ function latencySeconds(createdAtMs, application) {
354
+ if (createdAtMs === null) {
355
+ return null;
356
+ }
357
+ if (!application) {
358
+ return 0;
359
+ }
360
+ if (application.createdAtMs === null) {
361
+ return null;
362
+ }
363
+ return Math.max(
364
+ 0,
365
+ Math.round((application.createdAtMs - createdAtMs) / 1000)
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Map each lifecycle label to its MOST RECENT `labeled` event.
371
+ *
372
+ * Most-recent, not first: a human who removes the bot's label and re-applies it
373
+ * has made a genuine claim, and keying on the first application would keep
374
+ * distrusting a label the human now owns.
375
+ *
376
+ * @param {readonly TimelineEvent[] | undefined} timeline
377
+ * @returns {Map<string, { actor?: Actor | null, createdAtMs: number | null }>}
378
+ */
379
+ function indexLatestLabelApplications(timeline) {
380
+ const index = new Map();
381
+
382
+ for (const event of Array.isArray(timeline) ? timeline : []) {
383
+ if (event?.event !== "labeled") {
384
+ continue;
385
+ }
386
+ const name = event.label?.name;
387
+ if (typeof name !== "string" || name.trim().length === 0) {
388
+ continue;
389
+ }
390
+ const key = name.trim().toLowerCase();
391
+ const createdAtMs = parseTimestamp(event.created_at);
392
+ const existing = index.get(key);
393
+ if (
394
+ existing &&
395
+ existing.createdAtMs !== null &&
396
+ createdAtMs !== null &&
397
+ existing.createdAtMs > createdAtMs
398
+ ) {
399
+ continue;
400
+ }
401
+ index.set(key, { actor: event.actor ?? null, createdAtMs });
402
+ }
403
+
404
+ return index;
405
+ }
406
+
407
+ /**
408
+ * @param {readonly (string | { readonly name?: string })[] | undefined} labels
409
+ * @returns {readonly string[]}
410
+ */
411
+ function normalizeLabelNames(labels) {
412
+ return (Array.isArray(labels) ? labels : [])
413
+ .map(label => (typeof label === "string" ? label : label?.name))
414
+ .filter(name => typeof name === "string" && name.trim().length > 0);
415
+ }
416
+
417
+ /**
418
+ * @param {unknown} value
419
+ * @returns {number | null}
420
+ */
421
+ function parseTimestamp(value) {
422
+ if (typeof value !== "string") {
423
+ return null;
424
+ }
425
+ const parsed = Date.parse(value);
426
+ return Number.isNaN(parsed) ? null : parsed;
427
+ }
428
+
429
+ /**
430
+ * @param {unknown} value
431
+ * @returns {number}
432
+ */
433
+ function normalizeWindow(value) {
434
+ return Number.isFinite(value) && value >= 0
435
+ ? Number(value)
436
+ : IMPLAUSIBLE_CLAIM_WINDOW_SECONDS;
437
+ }
438
+
439
+ /**
440
+ * @param {unknown} source
441
+ * @param {readonly string[]} keys
442
+ * @returns {unknown}
443
+ */
444
+ function readPath(source, keys) {
445
+ return keys.reduce(
446
+ (current, key) =>
447
+ current && typeof current === "object" ? current[key] : undefined,
448
+ source
449
+ );
450
+ }
451
+
452
+ /**
453
+ * CLI: read a `{ issue, timeline, config }` payload on stdin, print the verdict.
454
+ *
455
+ * Exits 0 even when untrusted labels are found. This is a classifier, not a
456
+ * gate: callers run it inside `set -e` intake bash and branch on the JSON, and a
457
+ * non-zero exit would abort the scan on the very issues it exists to rescue.
458
+ *
459
+ * @returns {Promise<void>}
460
+ */
461
+ async function main() {
462
+ const chunks = [];
463
+ for await (const chunk of process.stdin) {
464
+ chunks.push(chunk);
465
+ }
466
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
467
+ const issue = payload.issue ?? {};
468
+
469
+ const trust = resolveTrustedLifecycleLabels({
470
+ labels: issue.labels,
471
+ timeline: payload.timeline,
472
+ issueCreatedAt: issue.created_at ?? issue.createdAt,
473
+ issueAuthor: issue.user ?? issue.author,
474
+ windowSeconds: payload.windowSeconds,
475
+ });
476
+ // Untrusted labels are excluded here rather than at the call site: the
477
+ // `open-label-closed-state` direction WRITES, so a caller that forgot to pass
478
+ // them would launder a label the classifier just rejected into a real write.
479
+ const drift = detectLifecycleDrift({
480
+ labels: issue.labels,
481
+ state: issue.state,
482
+ terminalLabels: terminalLifecycleLabels(payload.config),
483
+ excludeLabels: trust.untrusted.map(entry => entry.label),
484
+ });
485
+
486
+ process.stdout.write(`${JSON.stringify({ ...trust, ...drift }, null, 2)}\n`);
487
+ }
488
+
489
+ if (import.meta.url === `file://${process.argv[1]}`) {
490
+ main().catch(error => {
491
+ process.stderr.write(`${error.message}\n`);
492
+ process.exitCode = 1;
493
+ });
494
+ }
@@ -168,6 +168,60 @@ gh label list --repo <org>/<repo> --json name \
168
168
 
169
169
  If none of the configured role labels exist on the repo → label convention not adopted, surface a setup error and exit. If the role labels exist but none are `$READY` on any open issue matching the resolved assignee filter (or any open issue when the filter is empty) → genuinely empty queue, exit cleanly with `"No GitHub issues labeled $READY. Nothing to do."`
170
170
 
171
+ #### 2b. Lifecycle-label trust resolution (bot-authored labels are not signals)
172
+
173
+ **A `status:*` label is only a lifecycle signal if a trustworthy actor applied it.** Third-party GitHub Apps write labels through the same API humans do, and at least one — `coderabbitai[bot]` — stamps `status:*` on issues seconds after filing. Measured on CodySwannGT/lisa#2460–#2540: seven of eight bot-applied lifecycle labels landed 26–118s after creation. Those labels are guesses wearing the costume of the intake contract, and they break the queue in **both** directions:
174
+
175
+ - a bot-applied **`$CLAIMED`** makes an unworked issue look like somebody's active work, so no cycle picks it up and no human re-examines it (#2470 sat unworked for a day this way);
176
+ - a bot-applied **`$READY`** puts an issue into the build queue that no human ever flipped ready (#2538), inverting the gate the ready role exists to be.
177
+
178
+ Before treating any candidate's lifecycle labels as meaningful, resolve which ones are trustworthy:
179
+
180
+ ```bash
181
+ TRUST_DIR=$(mktemp -d)
182
+ trap 'rm -rf "$TRUST_DIR"' EXIT
183
+
184
+ gh api "repos/<org>/<repo>/issues/<n>" > "$TRUST_DIR/issue.json"
185
+
186
+ # --paginate emits ONE ARRAY PER PAGE. Reading `$t[0]` would pass only page one,
187
+ # silently dropping later label events — the newest of which is exactly the
188
+ # application this classifier keys on. --slurp + `add` flattens all pages.
189
+ gh api "repos/<org>/<repo>/issues/<n>/timeline?per_page=100" --paginate --slurp \
190
+ > "$TRUST_DIR/pages.json"
191
+ jq 'add // []' "$TRUST_DIR/pages.json" > "$TRUST_DIR/timeline.json"
192
+
193
+ # Resolve config the SAME way `read_role` does: the local file overrides the
194
+ # global one key by key. Reading only `.lisa.config.json` would resolve a stale
195
+ # terminal `done` label for any project that overrides it locally, and
196
+ # `lisa-repair-intake` WRITES on the drift direction that mistake produces.
197
+ # Both files are optional — a missing one must not abort the cycle.
198
+ jq -s '(.[0] // {}) * (.[1] // {})' \
199
+ <(cat .lisa.config.json 2>/dev/null || echo '{}') \
200
+ <(cat .lisa.config.local.json 2>/dev/null || echo '{}') \
201
+ > "$TRUST_DIR/config.json"
202
+
203
+ jq -n --slurpfile i "$TRUST_DIR/issue.json" \
204
+ --slurpfile t "$TRUST_DIR/timeline.json" \
205
+ --slurpfile c "$TRUST_DIR/config.json" \
206
+ '{issue: $i[0], timeline: $t[0], config: $c[0]}' \
207
+ > "$TRUST_DIR/input.json"
208
+
209
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/lifecycle-label-trust.mjs" < "$TRUST_DIR/input.json"
210
+ ```
211
+
212
+ The temp directory is **per invocation**. Fixed `/tmp/lisa-*.json` paths let two concurrent intake cycles interleave one issue's metadata with another's timeline, which yields a confident verdict about an item that was never examined.
213
+
214
+ The classifier returns `trusted`, `untrusted`, `unknownProvenance`, and a per-label `evaluated` list carrying the actor and the latency behind each decision. **Use `trusted` wherever this skill would otherwise read the raw label set**, and specifically:
215
+
216
+ - a candidate whose `$READY` is **untrusted** is **not claimable** — skip it and leave it for a human to flip genuinely ready; report it in the summary rather than dispatching it;
217
+ - a candidate whose `$CLAIMED` is **untrusted** is **not claimed** — if its `$READY` is trusted it stays a normal candidate, exactly as if the bot label were absent.
218
+
219
+ **Never unlabel to correct this.** The bot re-applies its label on each subsequent review event, so reverting produces a label-flap loop that is worse than the defect. The guard is that intake stops *believing* the label; it writes nothing. Distrust is idempotent and cannot race.
220
+
221
+ A label whose provenance cannot be established (applied at creation, which GitHub records no `labeled` event for) is trusted but listed in `unknownProvenance` — failing closed there would ignore the human `status:ready` that opens the queue. Report that list; do not silently drop it.
222
+
223
+ Lifecycle membership is decided by the **`status:` prefix**, never by a pinned member list. The live family has drifted 7 → 6 members, and a literal set breaks silently: an unrecognised member simply fails to match, so the guard would report clean on exactly the case it stopped covering.
224
+
171
225
  ### Phase 3 — Process the first eligible ready issue
172
226
 
173
227
  #### 3a.0 Repo-scope gate (claim only current-repo issues)
@@ -560,6 +560,101 @@ native-open / active / unresolved:
560
560
  4. Post a compact `[lisa-repair-intake]` note only when the native close-out changed state or when
561
561
  an actionable setup error must be surfaced. Do not spam already-closed terminal items.
562
562
 
563
+ ### Lifecycle label contradicts native state — walk BOTH directions
564
+
565
+ A lifecycle label can lie in two ways, and historically only one of them had an owner:
566
+
567
+ | label | native state | owned by |
568
+ |---|---|---|
569
+ | terminal `done` role | still open / active | the `Build terminal-open` section above |
570
+ | non-terminal role (`ready`, `claimed`, `blocked`, or any other `status:` member) | closed / completed | **nothing, until now** |
571
+
572
+ A pass that walks one direction leaves the other accumulating silently. Measured on the Linear TUN
573
+ board: **TUN-556 and TUN-503 both carry a terminal `status:done` while still natively open in
574
+ `On Dev`** — invisible to intake and indistinguishable from handled, exactly like a bot-applied
575
+ `status:in-progress` on GitHub.
576
+
577
+ Resolve both directions in one pass with the shipped detector, which always reports
578
+ `directionsWalked` covering both, so a "clean" verdict is an assertion that both were examined.
579
+
580
+ **Build the classifier input inside this cycle. Never consume a temp file another skill wrote.**
581
+ repair-intake runs as its own cycle, so borrowing `lisa-github-build-intake`'s scratch file fails
582
+ two ways: absent, the redirect fails and `set -e` aborts the whole cycle; stale from an earlier run
583
+ on a *different* item, the resolver classifies that other item and this section reports `drifts: []`
584
+ while `directionsWalked` still claims both directions were walked. That is precisely the
585
+ silent-clean verdict this section promises cannot happen.
586
+
587
+ ```bash
588
+ TRUST_DIR=$(mktemp -d)
589
+ trap 'rm -rf "$TRUST_DIR"' EXIT
590
+
591
+ gh api "repos/<org>/<repo>/issues/<n>" > "$TRUST_DIR/issue.json"
592
+
593
+ # --paginate emits ONE ARRAY PER PAGE; --slurp + `add` flattens all of them so a
594
+ # label event on page two is not silently dropped.
595
+ gh api "repos/<org>/<repo>/issues/<n>/timeline?per_page=100" --paginate --slurp \
596
+ > "$TRUST_DIR/pages.json"
597
+ jq 'add // []' "$TRUST_DIR/pages.json" > "$TRUST_DIR/timeline.json"
598
+
599
+ # Same resolution `read_role` uses: local overrides global, both optional.
600
+ # Resolving a stale terminal `done` here would make the true terminal label look
601
+ # like `open-label-closed-state` — and that direction WRITES.
602
+ jq -s '(.[0] // {}) * (.[1] // {})' \
603
+ <(cat .lisa.config.json 2>/dev/null || echo '{}') \
604
+ <(cat .lisa.config.local.json 2>/dev/null || echo '{}') \
605
+ > "$TRUST_DIR/config.json"
606
+
607
+ jq -n --slurpfile i "$TRUST_DIR/issue.json" \
608
+ --slurpfile t "$TRUST_DIR/timeline.json" \
609
+ --slurpfile c "$TRUST_DIR/config.json" \
610
+ '{issue: $i[0], timeline: $t[0], config: $c[0]}' \
611
+ > "$TRUST_DIR/input.json"
612
+
613
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/lifecycle-label-trust.mjs" < "$TRUST_DIR/input.json"
614
+ ```
615
+
616
+ **Untrusted labels are excluded from `drifts` before any repair runs** — the classifier does this
617
+ itself, so repair must **never act on a label the classifier refused to believe**. Without it, a
618
+ bot-applied label on a natively closed item would be advanced to the terminal `done` role by the
619
+ `open-label-closed-state` repair, laundering the exact input the guard rejected into a real write.
620
+ The `excluded` array reports what was held back; surface it, do not repair it.
621
+
622
+ 1. **`terminal-label-open-state`** → hand to the `Build terminal-open → native close` section above.
623
+ Do not restate its rules; the terminality test lives there (intermediate env rungs like
624
+ `status:on-dev` / `status:on-stg` are **not** terminal).
625
+ 2. **`open-label-closed-state`** → the previously unowned direction. The item is natively closed or
626
+ completed while still wearing a non-terminal lifecycle role. Advance the label to the env-resolved
627
+ terminal `done` role and post one idempotent `[lisa-repair-intake]` note. This is a write on
628
+ **Lisa's own** lifecycle surface, not a contest with another writer.
629
+ Apply the leaf/container check from the **Lifecycle ownership guard** section *before* this
630
+ branch: repair-intake owns container repair, so a natively-closed `ready` **leaf** is skipped
631
+ here and left to the build lane rather than claimed by this pass.
632
+ 3. **Vendor caveat — Linear.** On Linear the lifecycle surface is the native workflow **state**, not a
633
+ `status:*` label (see `config-resolution`). A `status:*` label there is leftover cruft that no
634
+ repair direction reads, which is precisely why TUN-556 and TUN-503 rotted. Treat Linear's native
635
+ state as authoritative and remove the contradicting stale label; never move the state to match a
636
+ label.
637
+
638
+ Membership in the lifecycle namespace is decided by the **`status:` prefix**, never a pinned member
639
+ list — the live family has drifted 7 → 6 members, and a literal set fails silently by simply not
640
+ matching the member it stopped covering.
641
+
642
+ ### Bot-authored lifecycle label → distrust, never revert (read-only)
643
+
644
+ `status:*` labels written by a **bot** actor within the plausibility window of the item's creation are
645
+ not lifecycle signals — see `lisa-github-build-intake` Phase 2b for the measurement and the
646
+ classifier. Report them here so an operator can see how much of the board is wearing a label nobody
647
+ chose:
648
+
649
+ - list each item alongside the untrusted label, the bot login, and the latency, from the classifier's
650
+ `untrusted` array;
651
+ - list `unknownProvenance` labels separately — trusted, but unattributable;
652
+ - **do not unlabel and do not relabel.** The bot re-applies on each subsequent review event, so a
653
+ reverting reconciler produces a label-flap loop that is worse than the defect. Repair happens by
654
+ intake declining to *believe* the label, which is idempotent and writes nothing.
655
+
656
+ This class is deliberately **read-only** and never counts toward the repair cap's actionable work.
657
+
563
658
  ### Build parent rollup reconciliation (intermediate-env or terminal close-out)
564
659
 
565
660
  For each parent/container item (an Epic, a Linear Project, or any item — of any type — with open child work),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "3.14.3",
3
+ "version": "3.14.5",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"