@codyswann/lisa 4.59.0 → 4.59.2

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 (82) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +2 -2
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +6 -6
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +6 -5
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +4 -4
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/.codex-plugin/skills/lisa-github-build-intake/SKILL.md +9 -5
  14. package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +26 -24
  15. package/plugins/lisa/rules/reference/leaf-only-lifecycle.md +1 -1
  16. package/plugins/lisa/scripts/lifecycle-label-trust.mjs +23 -20
  17. package/plugins/lisa/skills/lisa-github-build-intake/SKILL.md +9 -5
  18. package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +26 -24
  19. package/plugins/lisa-agy/plugin.json +1 -1
  20. package/plugins/lisa-agy/scripts/lifecycle-label-trust.mjs +23 -20
  21. package/plugins/lisa-agy/skills/lisa-github-build-intake/SKILL.md +9 -5
  22. package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +26 -24
  23. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  26. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/rules/reference/leaf-only-lifecycle.md +1 -1
  30. package/plugins/lisa-copilot/scripts/lifecycle-label-trust.mjs +23 -20
  31. package/plugins/lisa-copilot/skills/lisa-github-build-intake/SKILL.md +9 -5
  32. package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +26 -24
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/rules/leaf-only-lifecycle-reference.mdc +1 -1
  35. package/plugins/lisa-cursor/scripts/lifecycle-label-trust.mjs +23 -20
  36. package/plugins/lisa-cursor/skills/lisa-github-build-intake/SKILL.md +9 -5
  37. package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +26 -24
  38. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-agy/plugin.json +1 -1
  41. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  61. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-agy/plugin.json +1 -1
  66. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  71. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  76. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/src/base/rules/reference/leaf-only-lifecycle.md +1 -1
  79. package/plugins/src/base/scripts/lifecycle-label-trust.mjs +23 -20
  80. package/plugins/src/base/skills/lisa-github-build-intake/SKILL.md +9 -5
  81. package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +26 -24
  82. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +137 -9
@@ -491,7 +491,11 @@ Before the claim mutation, apply `claim-time-guards` — **Value before claim**,
491
491
  1. **`two-failed-attempts` valve.** Count `[lisa-build-attempt]` markers on the issue from the read bundle's comments (match on the marker, never the title), applying both filters from `claim-time-guards`: count a marker only when it carries `measures=work` (a marker with no `measures=` counts as `work`), and only when its `createdAt` is **after the issue most recently gained the ready label**. The ready-lane entry comes from the read bundle's `LabeledEvent` stream, which is already fetched — no extra call. If that history is `unknown`, count every `measures=work` marker regardless of age and say so in the comment. With two or more surviving markers, do **not** claim: relabel to the configured blocked role (`gh issue edit <number> --repo <org>/<repo> --remove-label "$READY" --add-label "$BLOCKED"`, resolved from `github.labels.build.blocked` per `config-resolution`), post the operator-readable comment naming both attempts, and **stop the cycle** at Phase 3e. Every non-success terminal outcome recorded in 3c/3d also appends a fresh `<!-- [lisa-build-attempt] n=<N> outcome=<outcome> measures=<work|machine> -->` marker so the next cycle can count it — `measures=machine` when the run was terminated by a signal or its outcome was `recovery-required`, `measures=work` when the build ran and did not satisfy the issue.
492
492
  2. **`already-implemented` check.** Probe for this issue's own key — `git log --all --grep "<org>/<repo>#<number>"` and `gh pr list --repo <org>/<repo> --state all --search "<org>/<repo>#<number>" --json number,state,mergedAt,url`. On a hit, claim as normal but route 3c to **verify-and-close** instead of `lisa-implement`: verify what shipped against the issue's acceptance criteria, post evidence via `lisa-github-evidence` naming the shipping PR/commit, then run the ordinary 3d transition and 3d.1 rollup. A partial hit implements only the remaining gap. An unreadable history degrades to "no hit" and the ordinary path proceeds — the guard never blocks the claim. This is not `DUPLICATE_ALREADY_FIXED` (a *different* canonical issue) and not `claim-archaeology` (a *different* ancestor issue); it is this issue's own work already having shipped without a transition.
493
493
 
494
+ Immediately before claiming, re-read native state. If it is closed, skip it without
495
+ relabeling or dispatching; an unreadable state holds the candidate.
496
+
494
497
  ```bash
498
+ gh issue view <number> --repo <org>/<repo> --json state -q '.state' # must be OPEN
495
499
  gh issue edit <number> --repo <org>/<repo> --remove-label "$READY" --add-label "$CLAIMED"
496
500
  # Assign to the authenticated user ONLY when the issue is currently unassigned (attributable claim;
497
501
  # do not pile a second assignee onto an issue that already has an owner):
@@ -536,7 +540,7 @@ The lifecycle run returns one of the following outcomes; resume this scanner wit
536
540
 
537
541
  - **Success** — the build flow completed and a PR exists; evidence posted. The PR may already be **merged** or still **open** (auto-merge enabled, awaiting checks/merge). "Success" means the build work is sound — it does **not** assert the change reached an environment. The env transition in 3d gates on the PR actually being merged; an open PR does not advance the issue to a `done` env status.
538
542
  - **Blocked by github-verify pre-flight gate** — the pre-flight gate (github-agent workflow step 2) relabels the issue to `status:blocked` (or removes `$CLAIMED` and reassigns to the original author). This is correct and expected — let it stand. Record and move on.
539
- - **Duplicate already fixed** — `lisa-ticket-triage` returned `DUPLICATE_ALREADY_FIXED` with a canonical issue reference and empirical base-branch evidence. Post the triage finding, ensure the native `duplicates <canonical>` relationship exists when GitHub exposes it (otherwise leave an explicit cross-reference comment/body link), remove `$CLAIMED`, add the terminal `$DONE` label, close the issue with `gh issue close --reason "not planned"`, and do not open a PR. If the canonical fix is merged but not yet on the production branch, the close comment must say the production error can recur until the canonical issue promotes and that recurrence is tracked by the canonical issue; do not reopen this duplicate for that recurrence.
543
+ - **Duplicate already fixed** — `lisa-ticket-triage` returned `DUPLICATE_ALREADY_FIXED` with a canonical issue reference and empirical base-branch evidence. Follow 3c.1 to link the duplicate, remove lifecycle roles, and close it as not planned without adding a done label or opening a PR. If the canonical fix is merged but not yet on the production branch, the close comment must say the production error can recur until the canonical issue promotes and that recurrence is tracked by the canonical issue; do not reopen this duplicate for that recurrence.
540
544
  - **Blocked by ticket-triage ambiguities** — triage posts findings and the lifecycle stops. The issue stays in `$CLAIMED`. Surface to human; do not auto-relabel. Record under "Errors".
541
545
  - **Errored** — exception, missing config, etc. Leave the issue in `$CLAIMED` for human investigation. Record under "Errors".
542
546
 
@@ -547,12 +551,12 @@ Run this only when the returned triage verdict is exactly `DUPLICATE_ALREADY_FIX
547
551
  1. Verify the structured result includes a canonical issue reference, the canonical PR/commit, and empirical evidence that the canonical fix is present on the base branch. If any piece is missing, treat the outcome as Held instead of closing.
548
552
  2. Post or preserve the triage-finding comment that explains why this issue is a duplicate and names the canonical issue.
549
553
  3. Ensure a native `duplicates <canonical>` link exists when GitHub exposes issue relationships; if this installation cannot create that relationship, leave an explicit issue cross-reference comment/body link and record the limitation in the summary.
550
- 4. Resolve terminal `$DONE` exactly as in Phase 3d. For a single-env repo, `$DONE` is terminal; for env-keyed config, only the production/final value is terminal.
551
- 5. Apply `$DONE` and retire every competing lifecycle role the issue currently carries — not just `$CLAIMED` — per the `leaf-only-lifecycle` rule's "Exactly one lifecycle role survives closure". Then close the issue as duplicate/not-planned:
554
+ 4. Resolve the configured lifecycle role set, including every environment's done value.
555
+ 5. Remove every lifecycle role the issue currently carries. Add no done label: this duplicate is being declined, and the canonical issue records delivery. Close as duplicate/not-planned:
552
556
 
553
557
  ```bash
554
558
  gh issue view <number> --repo <org>/<repo> --json labels -q '[.labels[].name] | join(" ")'
555
- gh issue edit <number> --repo <org>/<repo> --add-label "$DONE" <one --remove-label "<role>" per competing role the issue CURRENTLY carries>
559
+ gh issue edit <number> --repo <org>/<repo> <one --remove-label "<role>" per lifecycle role the issue CURRENTLY carries; skip when none>
556
560
  gh issue close <number> --repo <org>/<repo> --reason "not planned"
557
561
  ```
558
562
 
@@ -562,7 +566,7 @@ gh issue close <number> --repo <org>/<repo> --reason "not planned"
562
566
  gh issue view <number> --repo <org>/<repo> --json state,stateReason,labels
563
567
  ```
564
568
 
565
- It must read `state: CLOSED` with `stateReason: NOT_PLANNED`, carry `$DONE`, and carry **no** other lifecycle role. Anything else is an Error naming what is still present. Post the closeout comment only after that passes:
569
+ It must read `state: CLOSED` with `stateReason: NOT_PLANNED` and carry **no** lifecycle role, including `$DONE`. Anything else is an Error naming what is still present. Post the closeout comment only after that passes:
566
570
 
567
571
  ```bash
568
572
  gh issue comment <number> --repo <org>/<repo> --body "[claude-build-intake] Closed as duplicate of <canonical>. Canonical fix: <PR-or-commit>. Evidence: <base-branch-proof>."
@@ -683,7 +683,8 @@ LISA_BLOCKER_NODE
683
683
  invoke `resolveBlockerEdges` as shown above; never create a second resolver in the host.
684
684
  It returns `dissolve` / `keep` / `escalate` per edge.
685
685
  **Dissolve the edge in the SAME write that records the ruling, then read the relation back and
686
- confirm it is gone.** Only when every edge dissolves → move `blocked → claimed`, then run the
686
+ confirm it is gone.** Only when the parsed edge set is non-empty and the resolver returns
687
+ `proceed: true` with every edge dissolved → move `blocked → claimed`, then run the
687
688
  same agent-dispatch + post-agent `claimed → done` sequence as the stalled-`claimed` path above
688
689
  (one-cycle recovery). If the agent re-blocks, move back to `blocked` — a valid outcome.
689
690
 
@@ -773,22 +774,16 @@ native-open / active / unresolved:
773
774
  4. Post a compact `[lisa-repair-intake]` note only when the native close-out changed state or when
774
775
  an actionable setup error must be surfaced. Do not spam already-closed terminal items.
775
776
 
776
- ### Lifecycle label contradicts native state — walk BOTH directions
777
+ ### Lifecycle label contradicts native state — walk every direction
777
778
 
778
- A lifecycle label can lie in two ways, and historically only one of them had an owner:
779
+ Use the existing detector to check native state and closure reason together.
780
+ `directionsWalked` reports all three repair directions:
779
781
 
780
- | label | native state | owned by |
782
+ | Label | Native state | Repair |
781
783
  |---|---|---|
782
- | terminal `done` role | still open / active | the `Build terminal-open` section above |
783
- | non-terminal role (`ready`, `claimed`, `blocked`, or any other `status:` member) | closed / completed | **nothing, until now** |
784
-
785
- A pass that walks one direction leaves the other accumulating silently. Measured on the Linear TUN
786
- board: **TUN-556 and TUN-503 both carry a terminal `status:done` while still natively open in
787
- `On Dev`** — invisible to intake and indistinguishable from handled, exactly like a bot-applied
788
- `status:in-progress` on GitHub.
789
-
790
- Resolve both directions in one pass with the shipped detector, which always reports
791
- `directionsWalked` covering both, so a "clean" verdict is an assertion that both were examined.
784
+ | Terminal `done` role | Open / active | Follow `Build terminal-open` above |
785
+ | Non-terminal lifecycle role | Closed as `COMPLETED` or reason absent | Advance to the configured terminal role |
786
+ | Non-terminal lifecycle role | Closed as `NOT_PLANNED` | Remove the stale role and add nothing |
792
787
 
793
788
  **Build the classifier input inside this cycle. Never consume a temp file another skill wrote.**
794
789
  repair-intake runs as its own cycle, so borrowing `lisa-github-build-intake`'s scratch file fails
@@ -801,6 +796,9 @@ silent-clean verdict this section promises cannot happen.
801
796
  TRUST_DIR=$(mktemp -d)
802
797
  trap 'rm -rf "$TRUST_DIR"' EXIT
803
798
 
799
+ # REST, not `gh issue view`: this payload carries `state_reason`, and the reason is
800
+ # what separates the two closed directions. A read that drops it makes a NOT_PLANNED
801
+ # closure indistinguishable from a completion at the exact moment one of them WRITES.
804
802
  gh api "repos/<org>/<repo>/issues/<n>" > "$TRUST_DIR/issue.json"
805
803
 
806
804
  # --paginate emits ONE ARRAY PER PAGE; --slurp + `add` flattens all of them so a
@@ -835,14 +833,18 @@ The `excluded` array reports what was held back; surface it, do not repair it.
835
833
  1. **`terminal-label-open-state`** → hand to the `Build terminal-open → native close` section above.
836
834
  Do not restate its rules; the terminality test lives there (intermediate env rungs like
837
835
  `status:on-dev` / `status:on-stg` are **not** terminal).
838
- 2. **`open-label-closed-state`** → the previously unowned direction. The item is natively closed or
839
- completed while still wearing a non-terminal lifecycle role. Advance the label to the env-resolved
840
- terminal `done` role and post one idempotent `[lisa-repair-intake]` note. This is a write on
841
- **Lisa's own** lifecycle surface, not a contest with another writer.
836
+ 2. **`open-label-closed-state`** → the previously unowned direction. The item is natively closed as
837
+ **completed** while still wearing a non-terminal lifecycle role. Advance the label to the
838
+ env-resolved terminal `done` role and post one idempotent `[lisa-repair-intake]` note. This is a
839
+ write on **Lisa's own** lifecycle surface, not a contest with another writer.
842
840
  Apply the leaf/container check from the **Lifecycle ownership guard** section *before* this
843
841
  branch: repair-intake owns container repair, so a natively-closed `ready` **leaf** is skipped
844
842
  here and left to the build lane rather than claimed by this pass.
845
- 3. **Vendor caveat — Linear.** On Linear the lifecycle surface is the native workflow **state**, not a
843
+ 3. **`open-label-abandoned-state`** → closed as **`NOT_PLANNED`**. Remove the stale
844
+ non-terminal role and add nothing: declined work has not shipped. Post one idempotent
845
+ `[lisa-repair-intake]` note naming the closure reason. This removal also applies to closed
846
+ ready leaves, before the ordinary ready-leaf skip; it never claims or dispatches them.
847
+ 4. **Vendor caveat — Linear.** On Linear the lifecycle surface is the native workflow **state**, not a
846
848
  `status:*` label (see `config-resolution`). A `status:*` label there is leftover cruft that no
847
849
  repair direction reads, which is precisely why TUN-556 and TUN-503 rotted. Treat Linear's native
848
850
  state as authoritative and remove the contradicting stale label; never move the state to match a
@@ -1417,8 +1419,9 @@ It MAY:
1417
1419
  terminal `done`.
1418
1420
  - Reconcile a **container** wrongly carrying the build-ready `ready` role (a leaf-only-invariant
1419
1421
  violation) by rolling it up from its children and removing the `ready`, with a
1420
- `[lisa-repair-intake]` audit note. This is the one `ready`-touching exception (see MUST NOT) and
1421
- applies only to containers, never to leaves.
1422
+ `[lisa-repair-intake]` audit note.
1423
+ - Remove stale lifecycle labels from a natively closed `NOT_PLANNED` item, including a ready
1424
+ leaf. Add no done label and do not claim or dispatch it.
1422
1425
  - Move a PRD with fully terminal generated work to `shipped` and close/archive the source artifact
1423
1426
  where the source vendor supports native close-out, per `prd-lifecycle-rollup`.
1424
1427
  - Repair missing native GitHub child links by replaying the same-repo, idempotent `addSubIssue`
@@ -1439,9 +1442,8 @@ It MUST NOT:
1439
1442
  (empty parent token / `Top-level work:` entries) may become PRD children.
1440
1443
  - Apply a build `done` value other than via the env-resolution rules, or close a native item at
1441
1444
  any value other than the true terminal `done` (see `leaf-only-lifecycle`).
1442
- - Touch `ready` **leaves** (that is `lisa-intake`'s lane). A container carrying `ready` is the
1443
- documented exception above — repair-intake reconciles it because `ready` on a parent is an
1444
- invariant violation, not the human "claim this leaf" signal intake owns.
1445
+ - Touch `ready` **leaves** except to remove stale labels from a natively closed `NOT_PLANNED`
1446
+ item as above. Open ready leaves remain `lisa-intake`'s lane.
1445
1447
  - Move a GitHub issue that already carries a configured lifecycle label back to `ready` merely
1446
1448
  because some other label looks stale. Official lifecycle labels remain authoritative.
1447
1449
 
@@ -997,6 +997,84 @@ export function countDecisiveJobs(jobs) {
997
997
  return jobs.filter(job => DECISIVE_CONCLUSIONS.has(job?.conclusion)).length;
998
998
  }
999
999
 
1000
+ /** GitHub's explicit job-ceiling diagnostic; unrecognized wording stays unknown. */
1001
+ const TIMEOUT_ANNOTATION = /exceeded the maximum execution time/iu;
1002
+
1003
+ /** Bound diagnostic requests; larger matrices remain unattributed. */
1004
+ const TIMEOUT_PROBE_JOB_CAP = 20;
1005
+
1006
+ /**
1007
+ * Restrict authenticated annotation reads to this repository's check runs.
1008
+ * Preserve Enterprise API prefixes when deriving the relative request path.
1009
+ * @param {object} api - Configured API root and repository.
1010
+ * @param {object} job - Job returned by the Actions API.
1011
+ * @returns {string|null} Repository-scoped API path, or null.
1012
+ */
1013
+ function checkRunPath(api, job) {
1014
+ const url = job?.check_run_url;
1015
+ if (typeof url !== "string") return null;
1016
+ if (typeof api?.apiUrl !== "string") return null;
1017
+ const prefix = `${api.apiUrl}/repos/${api.repo}/check-runs/`;
1018
+ if (!url.startsWith(prefix) || !/^\d+$/u.test(url.slice(prefix.length)))
1019
+ return null;
1020
+ return url.slice(api.apiUrl.length);
1021
+ }
1022
+
1023
+ /**
1024
+ * Read explicit timeout evidence without changing the scored verdict.
1025
+ * Missing, failed or partial reads remain unknown; absence does not prove
1026
+ * that an operator cancelled the run.
1027
+ * @param {object} api - API coordinates.
1028
+ * @param {ReadonlyArray<object>|null|undefined} jobs - The scored run's jobs.
1029
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep.
1030
+ * @returns {Promise<boolean|null>} Timeout found, absent, or unknown.
1031
+ */
1032
+ export async function readTimeoutEvidence(api, jobs, wait) {
1033
+ if (!Array.isArray(jobs)) return null;
1034
+ const candidates = jobs.filter(
1035
+ job => !DECISIVE_CONCLUSIONS.has(job?.conclusion)
1036
+ );
1037
+ // No indecisive job means nothing was killed part-way, which is a different
1038
+ // statement from "we looked and found nothing" — the caller only asks this
1039
+ // about a run that did not finish.
1040
+ if (candidates.length === 0 || candidates.length > TIMEOUT_PROBE_JOB_CAP)
1041
+ return null;
1042
+ let readEverything = true;
1043
+ for (const job of candidates) {
1044
+ const path = checkRunPath(api, job);
1045
+ if (path === null) {
1046
+ readEverything = false;
1047
+ continue;
1048
+ }
1049
+ let result;
1050
+ try {
1051
+ result = await apiGet(api, `${path}/annotations?per_page=100`, wait);
1052
+ } catch {
1053
+ readEverything = false;
1054
+ continue;
1055
+ }
1056
+ // A 404 is `null`, and a body that is not the documented array is a shape
1057
+ // this cannot read. Both are unread, not empty — counting either as "no
1058
+ // annotations" would manufacture the absence of evidence.
1059
+ if (result === null || !Array.isArray(result.body)) {
1060
+ readEverything = false;
1061
+ continue;
1062
+ }
1063
+ if (
1064
+ result.body.some(entry =>
1065
+ TIMEOUT_ANNOTATION.test(String(entry?.message ?? ""))
1066
+ )
1067
+ )
1068
+ return true;
1069
+ if (
1070
+ result.body.length >= 100 ||
1071
+ result.headers.get("link")?.includes('rel="next"')
1072
+ )
1073
+ readEverything = false;
1074
+ }
1075
+ return readEverything ? false : null;
1076
+ }
1077
+
1000
1078
  /**
1001
1079
  * Reads the scope markers a run published, from its artifact NAMES.
1002
1080
  *
@@ -2039,13 +2117,13 @@ export function formatFinding(finding) {
2039
2117
  * which run it scores is the same defect one layer down, so the selection is not
2040
2118
  * conditional on anything having gone wrong.
2041
2119
  *
2042
- * A scored run that reached NO verdict also names its cause, from the job
2043
- * counts the walk already read. `cancelled` is overloaded across a displaced
2044
- * duplicate, a job killed at its own `timeout-minutes` ceiling, and an operator
2045
- * cancel; "0 of 1 job(s) reached a verdict" and "7 of 8" are different enough
2046
- * that a reader can tell "it never started" from "it ran out of time" without
2047
- * opening the run. That costs nothing — the counts come from jobs already
2048
- * fetched, and no workflow job configuration is read to produce them.
2120
+ * A scored run that reached NO verdict also names its cause. The job counts
2121
+ * come free from the walk and separate "it never started" from "it started and
2122
+ * stopped"; what they cannot separate is why it stopped, and the earlier
2123
+ * version of this comment claimed they could — it read "7 of 8" as "it ran out
2124
+ * of time", which is true of a ceiling kill and false of an operator cancel.
2125
+ * That guess is now a read: `readTimeoutEvidence` asks GitHub whether it killed
2126
+ * a job at its ceiling, and `describeShortfall` says only what came back.
2049
2127
  *
2050
2128
  * A DECISIVE conclusion gets no such suffix. `success` and `failure` already say
2051
2129
  * what happened, and appending job arithmetic to every green is noise.
@@ -2053,6 +2131,43 @@ export function formatFinding(finding) {
2053
2131
  * @param {object|null|undefined} selection - The finding's selection record
2054
2132
  * @returns {string|null} One trailing line, or null when there is nothing to say
2055
2133
  */
2134
+ /**
2135
+ * Names why an inconclusive run stopped, to whatever resolution is evidenced.
2136
+ *
2137
+ * Four answers, and the boundary between the last two is the point. A run that
2138
+ * reached no verdict of its own is one of: a duplicate the concurrency group
2139
+ * displaced before it scored anything, a job killed at its `timeout-minutes`
2140
+ * ceiling, an operator pressing cancel, a newer run displacing this one
2141
+ * mid-flight, a runner evicted underneath it, or a `needs:` dependency failing
2142
+ * and taking its dependents with it.
2143
+ *
2144
+ * Only the ceiling kill is a statement about the software or its budget. An
2145
+ * operator cancel is a human decision and says nothing about health, so
2146
+ * reporting the two alike cries wolf: it presents a decision somebody made as a
2147
+ * suite that exhausted its time. It cannot hide a regression in the other
2148
+ * direction — the run is scored and blocks either way, and only the diagnosis
2149
+ * differed — which is why this is a reporting-resolution fix and changes
2150
+ * nothing about which runs are scoreable.
2151
+ *
2152
+ * The residual is named as unattributed rather than as "an operator cancel",
2153
+ * because nothing here proves a human did anything. The evidence available says
2154
+ * a ceiling was hit or does not say so; claiming the negative as a positive
2155
+ * would be the same category error one layer along.
2156
+ * @param {object} selection - The finding's selection record
2157
+ * @returns {string} A clause completing "so it …"
2158
+ */
2159
+ function describeShortfall(selection) {
2160
+ if (selection.decisiveJobs === 0) return "tested nothing";
2161
+ if (selection.timedOut === true) return "was killed at a time limit";
2162
+ if (selection.timedOut === false)
2163
+ return (
2164
+ "stopped before finishing, with no time-limit evidence on any " +
2165
+ "unfinished job — an operator cancel, a displaced duplicate, an evicted " +
2166
+ "runner and a failed dependency all land here"
2167
+ );
2168
+ return "ran but did not finish, for a reason this gate could not read";
2169
+ }
2170
+
2056
2171
  export function formatSelection(selection) {
2057
2172
  if (!selection) return null;
2058
2173
  const conclusion = selection.conclusion ?? null;
@@ -2061,7 +2176,7 @@ export function formatSelection(selection) {
2061
2176
  typeof selection.decisiveJobs === "number" &&
2062
2177
  typeof selection.totalJobs === "number";
2063
2178
  const cause = counted
2064
- ? ` — ${selection.decisiveJobs} of ${selection.totalJobs} job(s) reached a verdict, so it ${selection.decisiveJobs === 0 ? "tested nothing" : "ran but did not finish"}`
2179
+ ? ` — ${selection.decisiveJobs} of ${selection.totalJobs} job(s) reached a verdict, so it ${describeShortfall(selection)}`
2065
2180
  : "";
2066
2181
  const scored = `↳ scored run ${selection.runId ?? "?"} (${conclusion ?? "no conclusion"}${cause}, ${selection.createdAt ?? "unknown time"})`;
2067
2182
  const fallback = selection.fellBack
@@ -3350,6 +3465,18 @@ async function selectScoredRun(api, candidates, freshnessHours, now, wait) {
3350
3465
  // honest report is "this was scored because nothing better existed", not a
3351
3466
  // line contradicting itself.
3352
3467
  const skipped = walkedPast.filter(entry => entry.runId !== run.id);
3468
+ const decisiveJobs = countDecisiveJobs(jobs);
3469
+ // Asked only of the one shape that is ambiguous: a run that reached no
3470
+ // verdict of its own yet scored some of its jobs. A decisive run has already
3471
+ // said what happened, and a run with nothing decisive tested nothing — both
3472
+ // are named without this, so neither pays for it. That keeps the common path
3473
+ // at exactly the request count it had before.
3474
+ const timedOut =
3475
+ jobsComplete &&
3476
+ !DECISIVE_CONCLUSIONS.has(run.conclusion ?? null) &&
3477
+ decisiveJobs > 0
3478
+ ? await readTimeoutEvidence(api, jobs, wait)
3479
+ : null;
3353
3480
  return {
3354
3481
  run,
3355
3482
  jobs,
@@ -3360,7 +3487,8 @@ async function selectScoredRun(api, candidates, freshnessHours, now, wait) {
3360
3487
  createdAt: run.created_at ?? null,
3361
3488
  // Carried for the SCORED run too, not only the skipped ones, so an
3362
3489
  // inconclusive verdict can name its cause. See `formatSelection`.
3363
- decisiveJobs: countDecisiveJobs(jobs),
3490
+ decisiveJobs,
3491
+ timedOut,
3364
3492
  totalJobs: jobs.length,
3365
3493
  fellBack: chosen === null && walkedPast.length > 0,
3366
3494
  skipped: Object.freeze(skipped.map(entry => Object.freeze(entry))),