@akagilnc/pi-workflow-roles 0.1.4800 → 0.1.4870

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 (143) hide show
  1. package/dist/acp-host/production-host.js +19434 -18218
  2. package/dist/analyst-book-key.js +63 -0
  3. package/dist/analyst-cohort.js +167 -0
  4. package/dist/analyst-entry.js +320 -0
  5. package/dist/analyst-index.js +247 -0
  6. package/dist/analyst-ledger.js +561 -0
  7. package/dist/analyst-median.js +16 -0
  8. package/dist/analyst-metric-families/acceptance-success-rework.js +249 -0
  9. package/dist/analyst-metric-families/b2-frame-buckets-actions.js +189 -0
  10. package/dist/analyst-metric-families/gate-cycles.js +77 -0
  11. package/dist/analyst-metric-families/leg-wall-clock.js +56 -0
  12. package/dist/analyst-metric-families/round-timeline.js +126 -0
  13. package/dist/analyst-metric-families.js +23 -0
  14. package/dist/analyst-metric-family.js +11 -0
  15. package/dist/analyst-model-groups.js +135 -0
  16. package/dist/analyst-page.js +182 -0
  17. package/dist/atomic-write.js +23 -0
  18. package/dist/auditor-soul.js +1 -1
  19. package/dist/countersign-contracts.js +0 -2
  20. package/dist/diarist-contracts.js +0 -1
  21. package/dist/doctor-contracts.js +2 -2
  22. package/dist/gatekeeper-role.js +15 -16
  23. package/dist/gleaner-left-contracts.js +0 -2
  24. package/dist/headless-host/production-host.js +16967 -15749
  25. package/dist/host-contracts.js +0 -4
  26. package/dist/inspector-contracts.js +0 -2
  27. package/dist/institutional-resolution.js +4 -2
  28. package/dist/merger-contracts.js +0 -2
  29. package/dist/migrate-book-topology.js +3908 -3089
  30. package/dist/navigator-public-session.js +4 -3
  31. package/dist/notary-contracts.js +0 -2
  32. package/dist/package-contracts/auditor-output.js +0 -1
  33. package/dist/package-contracts/collector-output.js +0 -2
  34. package/dist/package-contracts/fixer-output.js +0 -2
  35. package/dist/package-contracts/gatekeeper-output.js +0 -2
  36. package/dist/package-contracts/judge-output.js +0 -2
  37. package/dist/package-contracts/navigator-output.js +0 -2
  38. package/dist/package-contracts/reviewer-output.js +0 -2
  39. package/dist/package-contracts/terminating-tools.js +32 -92
  40. package/dist/package-contracts/worker-output.js +0 -4
  41. package/dist/packaged-role-registry.js +518 -36
  42. package/dist/pi/role-turn-host.js +2 -1
  43. package/dist/public-cli/analyst-run.js +185 -0
  44. package/dist/public-cli/cli.js +885 -0
  45. package/dist/public-cli/config.js +12 -9
  46. package/dist/public-cli/countersign-run.js +4 -311
  47. package/dist/public-cli/instruction-seat-run.js +628 -158
  48. package/dist/public-cli/invocation.js +806 -1550
  49. package/dist/public-cli/main.js +6962 -9882
  50. package/dist/public-cli/option-definitions.js +51 -99
  51. package/dist/public-cli/post-admission.js +30 -2
  52. package/dist/public-cli/run-lifecycle.js +234 -353
  53. package/dist/public-cli/seat-ticket-binding.js +6 -0
  54. package/dist/public-cli/settlement.js +261 -675
  55. package/dist/public-cli/turn-request.js +76 -0
  56. package/dist/public-role-summons.js +38 -112
  57. package/dist/role-activation-flags.js +23 -45
  58. package/dist/secretariat-contracts.js +0 -2
  59. package/dist/session-identity.js +17 -8
  60. package/dist/session-opening-materials.js +3 -2
  61. package/extensions/role-runtime.ts +6 -80
  62. package/package.json +1 -1
  63. package/souls/coder.md +1 -2
  64. package/src/acp-host/role-turn-host.ts +4 -1
  65. package/src/analyst-metric-families/acceptance-success-rework.ts +3 -2
  66. package/src/auditor-role.ts +1 -2
  67. package/src/auditor-soul.ts +1 -1
  68. package/src/collector-role.ts +2 -2
  69. package/src/countersign-contracts.ts +0 -1
  70. package/src/countersign-role.ts +1 -1
  71. package/src/diarist-contracts.ts +0 -1
  72. package/src/diarist-role.ts +0 -1
  73. package/src/doctor-auditor.ts +1 -2
  74. package/src/doctor-contracts.ts +1 -1
  75. package/src/doctor-role.ts +3 -2
  76. package/src/external-host-turn-loop.ts +1 -1
  77. package/src/factory-board.ts +10 -7
  78. package/src/gatekeeper-role.ts +16 -10
  79. package/src/gleaner-left-contracts.ts +0 -1
  80. package/src/gleaner-left-role.ts +0 -1
  81. package/src/headless-host/role-turn-host.ts +7 -1
  82. package/src/host-contracts.ts +10 -6
  83. package/src/inspector-contracts.ts +0 -1
  84. package/src/inspector-role.ts +0 -2
  85. package/src/institutional-resolution.ts +6 -4
  86. package/src/judge-role.ts +3 -3
  87. package/src/merger-contracts.ts +0 -1
  88. package/src/merger-role.ts +2 -2
  89. package/src/navigator-public-session.ts +7 -6
  90. package/src/navigator-work-context.ts +6 -8
  91. package/src/notary-contracts.ts +0 -1
  92. package/src/notary-role.ts +2 -3
  93. package/src/package-contracts/auditor-output.ts +0 -1
  94. package/src/package-contracts/collector-output.ts +0 -1
  95. package/src/package-contracts/fixer-output.ts +0 -1
  96. package/src/package-contracts/gatekeeper-output.ts +0 -1
  97. package/src/package-contracts/judge-output.ts +0 -1
  98. package/src/package-contracts/navigator-output.ts +0 -1
  99. package/src/package-contracts/reviewer-output.ts +0 -1
  100. package/src/package-contracts/terminating-tools.ts +27 -99
  101. package/src/package-contracts/worker-output.ts +0 -2
  102. package/src/packaged-role-registry.ts +604 -35
  103. package/src/pi/role-turn-host.ts +2 -1
  104. package/src/public-cli/cli.ts +64 -259
  105. package/src/public-cli/config.ts +18 -10
  106. package/src/public-cli/countersign-run.ts +14 -449
  107. package/src/public-cli/instruction-seat-run.ts +758 -277
  108. package/src/public-cli/invocation.ts +1097 -2051
  109. package/src/public-cli/option-definitions.ts +62 -127
  110. package/src/public-cli/post-admission.ts +47 -2
  111. package/src/public-cli/run-lifecycle.ts +303 -613
  112. package/src/public-cli/seat-ticket-binding.ts +6 -0
  113. package/src/public-cli/settlement.ts +399 -1185
  114. package/src/public-cli/turn-request.ts +92 -1
  115. package/src/public-role-summons.ts +44 -128
  116. package/src/reviewer-role.ts +3 -2
  117. package/src/role-activation-flags.ts +20 -41
  118. package/src/role-runtime-dependencies.ts +25 -26
  119. package/src/role-runtime.ts +121 -232
  120. package/src/secretariat-contracts.ts +0 -1
  121. package/src/secretariat-role.ts +0 -2
  122. package/src/session-identity.ts +23 -8
  123. package/src/session-opening-materials.ts +2 -1
  124. package/src/worker-role.ts +3 -4
  125. package/dist/doctor-auditor.js +0 -25
  126. package/dist/public-cli/diarist-run.js +0 -130
  127. package/dist/public-cli/doctor-run.js +0 -87
  128. package/dist/public-cli/inspector-run.js +0 -151
  129. package/dist/public-cli/judge-run.js +0 -114
  130. package/dist/public-cli/notary-run.js +0 -174
  131. package/dist/public-cli/reviewer-run.js +0 -339
  132. package/src/public-cli/coder-run.ts +0 -255
  133. package/src/public-cli/collector-run.ts +0 -189
  134. package/src/public-cli/diarist-run.ts +0 -233
  135. package/src/public-cli/doctor-run.ts +0 -167
  136. package/src/public-cli/fixer-run.ts +0 -265
  137. package/src/public-cli/gleaner-left-run.ts +0 -167
  138. package/src/public-cli/inspector-run.ts +0 -261
  139. package/src/public-cli/judge-run.ts +0 -196
  140. package/src/public-cli/merger-run.ts +0 -294
  141. package/src/public-cli/notary-run.ts +0 -279
  142. package/src/public-cli/reviewer-run.ts +0 -480
  143. package/src/public-cli/secretariat-run.ts +0 -265
@@ -0,0 +1,561 @@
1
+ /**
2
+ * Analyst ledger scan: S-family book/runs topology, book × ticket issue scope
3
+ * (#399), and loud unreadable exclusion for required sources.
4
+ * Reuses canonical session/artifact readers — does not parse session JSONL itself.
5
+ *
6
+ * Scope unit = ledger book (git common-dir key) × optional typed ticket filter.
7
+ * CLI issue query never path-filters by projectRoot (owner #399: that face deleted).
8
+ * Sweep/legacy library may still pass projectRoot as a path-narrow when bookKey
9
+ * is absent — recording-side field, not the public query mechanical key.
10
+ * Typed ticketNumber, when requested, decides alone (no silent projectRoot fallback).
11
+ *
12
+ * A2: classifyScopedRun retains typed per-run facts (frame span, tool intervals,
13
+ * terminal face) for metric-family modules — no longer discarded after checks.
14
+ */
15
+ import { readdir, readFile } from "node:fs/promises";
16
+ import { basename, join } from "node:path";
17
+ import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
18
+ import { pathContainedIn, physicalPathIdentity, resolveActivationLedgerHome, } from "./activation-ledger-topology.js";
19
+ import { listBookRunDirectories } from "./role-run-placement.js";
20
+ import { autopsyWriterLock } from "./public-cli/run-lifecycle.js";
21
+ import { readRunTicketNumber } from "./run-ticket-number.js";
22
+ import { extractSessionModelSequence, extractSessionTimestampSpan, extractSessionToolIntervals, LedgerSessionJsonlError, readLedgerSessionJsonl, } from "./ledger-session-read.js";
23
+ import { readRunTerminalArtifact, } from "./run-terminal-artifacts.js";
24
+ import { readAnalystGateCyclesFromAuditorRoles, } from "./analyst-gate-cycles-read.js";
25
+ function isMissingPathError(error) {
26
+ return (error instanceof Error
27
+ && "code" in error
28
+ && (error.code === "ENOENT" || error.code === "ENOTDIR"));
29
+ }
30
+ function errorText(error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ function isRecord(value) {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+ /** Existing public-cli run-state live phases — not a new state machine. */
37
+ const LIVE_RUN_STATES = new Set(["admitted", "running", "resumable"]);
38
+ /**
39
+ * Read lifecycle state from the existing run-state.json face.
40
+ * Used only to distinguish live in-flight runs from terminal no-receipt.
41
+ */
42
+ async function readExistingRunLifecycleState(runDirectory) {
43
+ try {
44
+ const raw = JSON.parse(await readFile(join(runDirectory, "run-state.json"), "utf8"));
45
+ if (!isRecord(raw) || typeof raw.state !== "string")
46
+ return undefined;
47
+ return raw.state;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ function ghostLeaseCheckFromAutopsy(autopsy) {
54
+ switch (autopsy.verdict) {
55
+ case "alive":
56
+ return undefined;
57
+ case "dead":
58
+ return { kind: "holder-dead", pid: autopsy.pid };
59
+ case "absent":
60
+ return { kind: "no-lease" };
61
+ case "unknown":
62
+ return {
63
+ kind: "unverifiable",
64
+ reason: autopsy.reason === "unreadable" ? "unreadable" : "unparseable",
65
+ };
66
+ }
67
+ }
68
+ /**
69
+ * Classify one admitted|running run against writer lease liveness (#855).
70
+ * Alive holder → still in-flight (omit). Otherwise → ghost report fact only.
71
+ */
72
+ async function classifyGhostCandidate(input) {
73
+ const autopsy = await autopsyWriterLock(join(input.runDirectory, "writer.lock"));
74
+ const leaseCheck = ghostLeaseCheckFromAutopsy(autopsy);
75
+ if (leaseCheck === undefined)
76
+ return { kind: "live" };
77
+ return {
78
+ kind: "ghost",
79
+ leg: {
80
+ runId: input.runId,
81
+ runState: input.runState,
82
+ leaseCheck,
83
+ },
84
+ };
85
+ }
86
+ function parseRunDirectoryName(name) {
87
+ const at = name.lastIndexOf("@");
88
+ if (at <= 0 || at === name.length - 1)
89
+ return undefined;
90
+ return { runId: name.slice(0, at), role: name.slice(at + 1) };
91
+ }
92
+ /** Best-effort book key from a projectRoot git common-dir; undefined when not a git tree. */
93
+ function tryResolveBookKeyFromProjectRoot(projectRoot) {
94
+ try {
95
+ return resolveBookKeyFromGit(projectRoot);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ async function listLedgerBookNames(booksRoot) {
102
+ try {
103
+ const entries = await readdir(booksRoot, { withFileTypes: true });
104
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
105
+ }
106
+ catch (error) {
107
+ if (isMissingPathError(error))
108
+ return [];
109
+ throw error;
110
+ }
111
+ }
112
+ async function readInvocationScopeFields(runDirectory) {
113
+ let raw;
114
+ try {
115
+ raw = await readFile(join(runDirectory, "invocation.json"), "utf8");
116
+ }
117
+ catch (error) {
118
+ if (isMissingPathError(error))
119
+ return undefined;
120
+ throw error;
121
+ }
122
+ const parsed = JSON.parse(raw);
123
+ if (!isRecord(parsed))
124
+ return undefined;
125
+ if (typeof parsed.projectRoot !== "string" || parsed.projectRoot.trim() === "") {
126
+ return undefined;
127
+ }
128
+ const projectRoot = parsed.projectRoot;
129
+ // Display/history placement: board first, then migration-derived (#852).
130
+ const ticketNumber = await readRunTicketNumber(runDirectory);
131
+ if (ticketNumber !== undefined) {
132
+ return { projectRoot, ticketNumber };
133
+ }
134
+ return { projectRoot };
135
+ }
136
+ /**
137
+ * Issue scope decision for one run (#399 / C4).
138
+ * - Scope ticket set → typed ticket alone decides (match in; else out).
139
+ * No projectRoot fallback — that silent path labeled full-project pages as ticket N.
140
+ * - Whole-book scope (CLI bare / bookKey without ticket) → every run in the book.
141
+ * - Path-narrow (sweep/legacy only): no ticket + scopeRootIdentity → path match.
142
+ */
143
+ function decideIssueScope(input) {
144
+ if (input.scopeTicketNumber !== undefined) {
145
+ // Strict (book, N): typed ticket alone; never fall back to path match.
146
+ return { inScope: input.runTicketNumber === input.scopeTicketNumber };
147
+ }
148
+ if (input.wholeBook) {
149
+ return { inScope: true };
150
+ }
151
+ if (input.scopeRootIdentity !== undefined) {
152
+ return {
153
+ inScope: input.runProjectRootIdentity === input.scopeRootIdentity,
154
+ };
155
+ }
156
+ return { inScope: true };
157
+ }
158
+ async function resolveSessionFile(runDirectory) {
159
+ // Prefer invocation.sessionFile when present; fall back to S-family principal.
160
+ try {
161
+ const raw = await readFile(join(runDirectory, "invocation.json"), "utf8");
162
+ const parsed = JSON.parse(raw);
163
+ if (isRecord(parsed)
164
+ && typeof parsed.sessionFile === "string"
165
+ && parsed.sessionFile.trim() !== "") {
166
+ return parsed.sessionFile;
167
+ }
168
+ }
169
+ catch (error) {
170
+ if (!isMissingPathError(error))
171
+ throw error;
172
+ }
173
+ return join(runDirectory, "session", "session.jsonl");
174
+ }
175
+ /**
176
+ * Attribute session rows to one run.
177
+ * Private run volumes (session under runDirectory) keep the whole file.
178
+ * External volumes stay honest missing — never silent full ownership.
179
+ */
180
+ function attributeSessionRowsForRun(input) {
181
+ if (pathContainedIn(input.runDirectory, input.sessionFile)) {
182
+ return { kind: "attributed", rows: input.rows, closed: false };
183
+ }
184
+ return {
185
+ kind: "missing-boundary",
186
+ reason: "session volume is outside the run directory",
187
+ };
188
+ }
189
+ /**
190
+ * Admit frame span (and partial edges) from already-attributed session rows.
191
+ * Shared by the clean parse path and closed-interval damage recovery.
192
+ */
193
+ function admitFrameSpanFromRows(rows) {
194
+ const models = extractSessionModelSequence(rows);
195
+ const span = extractSessionTimestampSpan(rows);
196
+ let partialFirstFrameAt = { status: "absent" };
197
+ let partialLastFrameAt = { status: "absent" };
198
+ if (span.startedAt === undefined || span.endedAt === undefined) {
199
+ if (span.startedAt !== undefined) {
200
+ partialFirstFrameAt = { status: "present", at: span.startedAt };
201
+ }
202
+ if (span.endedAt !== undefined) {
203
+ partialLastFrameAt = { status: "present", at: span.endedAt };
204
+ }
205
+ return {
206
+ models,
207
+ partialFirstFrameAt,
208
+ partialLastFrameAt,
209
+ missingReason: "session timeline has no usable timestamps",
210
+ };
211
+ }
212
+ const startedMs = Date.parse(span.startedAt);
213
+ const endedMs = Date.parse(span.endedAt);
214
+ if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs)) {
215
+ return {
216
+ models,
217
+ partialFirstFrameAt: { status: "present", at: span.startedAt },
218
+ partialLastFrameAt: { status: "present", at: span.endedAt },
219
+ missingReason: "session timeline timestamps are not parseable instants",
220
+ };
221
+ }
222
+ if (endedMs < startedMs) {
223
+ return {
224
+ models,
225
+ partialFirstFrameAt: { status: "present", at: span.startedAt },
226
+ partialLastFrameAt: { status: "present", at: span.endedAt },
227
+ missingReason: "session timeline end is earlier than start",
228
+ };
229
+ }
230
+ return {
231
+ models,
232
+ frameSpan: { startedAt: span.startedAt, endedAt: span.endedAt },
233
+ partialFirstFrameAt: { status: "present", at: span.startedAt },
234
+ partialLastFrameAt: { status: "present", at: span.endedAt },
235
+ };
236
+ }
237
+ async function classifyScopedRun(input) {
238
+ const missingSources = [];
239
+ const reasons = [];
240
+ let frameSpan;
241
+ let toolIntervals;
242
+ let terminal;
243
+ /** Ordered-unique models retained whenever session rows were readable. */
244
+ let models = [];
245
+ /** Partial first/last frame retained when full session span cannot be admitted. */
246
+ let partialFirstFrameAt = { status: "absent" };
247
+ let partialLastFrameAt = { status: "absent" };
248
+ // 1) session timeline from the run-private volume.
249
+ const sessionFile = await resolveSessionFile(input.runDirectory);
250
+ let rows;
251
+ const admitAttributed = (attributedRows) => {
252
+ rows = attributedRows;
253
+ const admitted = admitFrameSpanFromRows(attributedRows);
254
+ models = admitted.models;
255
+ partialFirstFrameAt = admitted.partialFirstFrameAt;
256
+ partialLastFrameAt = admitted.partialLastFrameAt;
257
+ if (admitted.frameSpan !== undefined) {
258
+ frameSpan = admitted.frameSpan;
259
+ }
260
+ else if (admitted.missingReason !== undefined) {
261
+ missingSources.push("session-timeline");
262
+ reasons.push(admitted.missingReason);
263
+ }
264
+ };
265
+ try {
266
+ const rawRows = await readLedgerSessionJsonl(sessionFile);
267
+ const attributed = attributeSessionRowsForRun({
268
+ rows: rawRows,
269
+ sessionFile,
270
+ runDirectory: input.runDirectory,
271
+ runId: input.runId,
272
+ });
273
+ if (attributed.kind === "missing-boundary") {
274
+ missingSources.push("session-timeline");
275
+ reasons.push(attributed.reason);
276
+ }
277
+ else {
278
+ admitAttributed(attributed.rows);
279
+ }
280
+ }
281
+ catch (error) {
282
+ // Single parse kernel: attribute the prefix, then decide by binding closure.
283
+ // Closed shared interval → full frame/model/tool (damage belongs to a later run).
284
+ // Open interval / private volume / missing boundary → honest missing + partial edges.
285
+ if (error instanceof LedgerSessionJsonlError) {
286
+ const attributed = attributeSessionRowsForRun({
287
+ rows: error.prefixRows,
288
+ sessionFile,
289
+ runDirectory: input.runDirectory,
290
+ runId: input.runId,
291
+ });
292
+ if (attributed.kind === "attributed" && attributed.closed) {
293
+ admitAttributed(attributed.rows);
294
+ }
295
+ else if (attributed.kind === "attributed") {
296
+ missingSources.push("session-timeline");
297
+ reasons.push(errorText(error));
298
+ const admitted = admitFrameSpanFromRows(attributed.rows);
299
+ models = admitted.models;
300
+ partialFirstFrameAt = admitted.partialFirstFrameAt;
301
+ partialLastFrameAt = admitted.partialLastFrameAt;
302
+ }
303
+ else {
304
+ missingSources.push("session-timeline");
305
+ reasons.push(errorText(error));
306
+ // missing-boundary on damaged prefix: no partial ownership of sibling runs.
307
+ }
308
+ }
309
+ else {
310
+ missingSources.push("session-timeline");
311
+ reasons.push(errorText(error));
312
+ }
313
+ }
314
+ // 2) tool association (only when session rows are available)
315
+ if (rows !== undefined && !missingSources.includes("session-timeline")) {
316
+ try {
317
+ toolIntervals = extractSessionToolIntervals(rows);
318
+ }
319
+ catch (error) {
320
+ missingSources.push("tool-association");
321
+ reasons.push(errorText(error));
322
+ }
323
+ }
324
+ // 3) typed terminal artifact — absence is no-receipt only for terminal runs.
325
+ // Live admitted/running/resumable runs (existing run-state) are not dead legs.
326
+ // #855: writer-lease ghost check is independent of terminal artifact status.
327
+ // present → still emit metrics; unreadable → still enter missingSources;
328
+ // admitted|running + non-live lease → still list in ghostLegs (ticket #4).
329
+ const lifecycle = await readExistingRunLifecycleState(input.runDirectory);
330
+ let ghostLeg;
331
+ if (lifecycle === "admitted" || lifecycle === "running") {
332
+ const ghost = await classifyGhostCandidate({
333
+ runId: input.runId,
334
+ runState: lifecycle,
335
+ runDirectory: input.runDirectory,
336
+ });
337
+ if (ghost.kind === "ghost")
338
+ ghostLeg = ghost.leg;
339
+ }
340
+ try {
341
+ const artifact = await readRunTerminalArtifact(input.runDirectory);
342
+ if (artifact.status === "unreadable") {
343
+ missingSources.push("terminal-artifact");
344
+ reasons.push(`${artifact.file}: ${artifact.reason}`);
345
+ }
346
+ else if (artifact.status === "absent") {
347
+ if (lifecycle === "admitted" || lifecycle === "running") {
348
+ // No terminal yet: ghost report only, or live in-flight omit.
349
+ if (ghostLeg !== undefined)
350
+ return { kind: "ghost", leg: ghostLeg };
351
+ return { kind: "live" };
352
+ }
353
+ if (lifecycle !== undefined && LIVE_RUN_STATES.has(lifecycle)) {
354
+ return { kind: "live" };
355
+ }
356
+ terminal = { status: "absent" };
357
+ }
358
+ else {
359
+ // role already required nonblank by readRunTerminalArtifact (single owner).
360
+ terminal = {
361
+ status: "present",
362
+ file: artifact.file,
363
+ body: artifact.body,
364
+ role: artifact.body.role,
365
+ };
366
+ }
367
+ }
368
+ catch (error) {
369
+ missingSources.push("terminal-artifact");
370
+ reasons.push(errorText(error));
371
+ }
372
+ if (missingSources.length > 0) {
373
+ // Prefer full-span edges when session was admitted; else partial prefix facts.
374
+ const firstFrameAt = frameSpan !== undefined
375
+ ? { status: "present", at: frameSpan.startedAt }
376
+ : partialFirstFrameAt;
377
+ const lastFrameAt = frameSpan !== undefined
378
+ ? { status: "present", at: frameSpan.endedAt }
379
+ : partialLastFrameAt;
380
+ return {
381
+ kind: "unreadable",
382
+ entry: {
383
+ runId: input.runId,
384
+ book: input.book,
385
+ missingSources,
386
+ reason: reasons.join("; "),
387
+ firstFrameAt,
388
+ lastFrameAt,
389
+ },
390
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
391
+ };
392
+ }
393
+ // All three faces present after the gate above (TypeScript cannot see the link).
394
+ if (frameSpan === undefined || toolIntervals === undefined || terminal === undefined) {
395
+ throw new Error(`classifyScopedRun internal invariant: missing retained facts for ${input.runId}`);
396
+ }
397
+ // Nested auditor-roles gate pairs stay inside the sole scan (families must
398
+ // not readdir this tree again). Missing directory → []. Damaged discovered
399
+ // nested JSONL is page-local unreadable — never silently under-count rounds.
400
+ let gateCycles;
401
+ try {
402
+ const parentSessionFile = join(input.runDirectory, "session", "session.jsonl");
403
+ gateCycles = await readAnalystGateCyclesFromAuditorRoles(join(input.runDirectory, "session", "auditor-roles"), { parentSessionFile });
404
+ }
405
+ catch (error) {
406
+ return {
407
+ kind: "unreadable",
408
+ entry: {
409
+ runId: input.runId,
410
+ book: input.book,
411
+ missingSources: ["auditor-roles"],
412
+ reason: errorText(error),
413
+ firstFrameAt: { status: "present", at: frameSpan.startedAt },
414
+ lastFrameAt: { status: "present", at: frameSpan.endedAt },
415
+ },
416
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
417
+ };
418
+ }
419
+ return {
420
+ kind: "readable",
421
+ facts: {
422
+ runId: input.runId,
423
+ book: input.book,
424
+ role: input.role,
425
+ frameSpan,
426
+ toolIntervals,
427
+ terminal,
428
+ models,
429
+ gateCycles,
430
+ },
431
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
432
+ };
433
+ }
434
+ /**
435
+ * Scan ledger home books/<book>/{subject/}runs for runs in the issue scope.
436
+ * Walk = listBookRunDirectories (flat legacy + subject-tree; #859).
437
+ * #399: scope = book × optional ticket.
438
+ * - bookKey set → that book only; whole-book when no ticket and no projectRoot;
439
+ * ticket filters alone; bookKey + projectRoot (cohort ensure, #412) narrows
440
+ * to that root inside the book.
441
+ * - projectRoot without bookKey (sweep/legacy): git-resolved → whole that book;
442
+ * non-git → path-narrow across books (fixture isolation).
443
+ * Damaged required sources become unreadable exclusions.
444
+ * Readable runs retain typed facts for metric-family composition.
445
+ */
446
+ export async function scanAnalystIssueRuns(input) {
447
+ // Package-owned machine home only (ADR 0048) — caller may supply explicit home for isolation.
448
+ const ledgerHome = resolveActivationLedgerHome(input.home);
449
+ const scopeTicketNumber = input.ticketNumber;
450
+ const booksRoot = join(ledgerHome, "books");
451
+ let wholeBook = false;
452
+ let scopeRootIdentity;
453
+ let bookNames;
454
+ if (input.bookKey !== undefined && input.bookKey.trim() !== "") {
455
+ bookNames = [input.bookKey];
456
+ if (input.projectRoot !== undefined) {
457
+ // Cohort ensure conjunction (#412): the index join already selected the
458
+ // row — a cache-miss recompute must stay inside this root of this book,
459
+ // never inhale sibling roots' runs into one issue page.
460
+ wholeBook = false;
461
+ scopeRootIdentity = physicalPathIdentity(input.projectRoot);
462
+ }
463
+ else {
464
+ // CLI book scope: whole book unless ticket filters. Never path-narrow.
465
+ wholeBook = true;
466
+ }
467
+ }
468
+ else if (input.projectRoot !== undefined) {
469
+ const resolved = tryResolveBookKeyFromProjectRoot(input.projectRoot);
470
+ if (resolved !== undefined) {
471
+ bookNames = [resolved];
472
+ wholeBook = true;
473
+ }
474
+ else {
475
+ bookNames = await listLedgerBookNames(booksRoot);
476
+ scopeRootIdentity = physicalPathIdentity(input.projectRoot);
477
+ }
478
+ }
479
+ else {
480
+ bookNames = await listLedgerBookNames(booksRoot);
481
+ wholeBook = true;
482
+ }
483
+ if (bookNames.length === 0) {
484
+ return { runs: [], unreadable: [], scopeConflicts: [], ghostLegs: [] };
485
+ }
486
+ const runs = [];
487
+ const unreadable = [];
488
+ const ghostLegs = [];
489
+ // scopeConflicts retained on the scan face for page envelope compat; book×ticket
490
+ // scope no longer emits projectRoot dual-key conflicts on the CLI path.
491
+ const scopeConflicts = [];
492
+ for (const book of bookNames) {
493
+ const bookDir = join(booksRoot, book);
494
+ let runDirectories;
495
+ try {
496
+ runDirectories = await listBookRunDirectories(bookDir);
497
+ }
498
+ catch (error) {
499
+ if (isMissingPathError(error))
500
+ continue;
501
+ throw error;
502
+ }
503
+ for (const runDirectory of runDirectories) {
504
+ const runName = basename(runDirectory);
505
+ const parsed = parseRunDirectoryName(runName);
506
+ if (parsed === undefined)
507
+ continue;
508
+ let scopeFields;
509
+ try {
510
+ scopeFields = await readInvocationScopeFields(runDirectory);
511
+ }
512
+ catch (error) {
513
+ // Corrupt invocation cannot be scoped to this issue — skip.
514
+ if (error instanceof SyntaxError)
515
+ continue;
516
+ throw error;
517
+ }
518
+ if (scopeFields === undefined)
519
+ continue;
520
+ const runProjectRootIdentity = physicalPathIdentity(scopeFields.projectRoot);
521
+ const decision = decideIssueScope({
522
+ scopeTicketNumber,
523
+ wholeBook,
524
+ scopeRootIdentity,
525
+ runProjectRootIdentity,
526
+ runTicketNumber: scopeFields.ticketNumber,
527
+ });
528
+ if (!decision.inScope)
529
+ continue;
530
+ const classified = await classifyScopedRun({
531
+ book,
532
+ runId: parsed.runId,
533
+ role: parsed.role,
534
+ runDirectory,
535
+ });
536
+ if (classified.kind === "readable") {
537
+ runs.push(classified.facts);
538
+ if (classified.ghostLeg !== undefined)
539
+ ghostLegs.push(classified.ghostLeg);
540
+ }
541
+ else if (classified.kind === "unreadable") {
542
+ unreadable.push(classified.entry);
543
+ if (classified.ghostLeg !== undefined)
544
+ ghostLegs.push(classified.ghostLeg);
545
+ }
546
+ else if (classified.kind === "ghost") {
547
+ ghostLegs.push(classified.leg);
548
+ }
549
+ // live in-flight runs are omitted from legs and unreadable (not failure/death).
550
+ // #855 ghost legs are reported separately — not counted as in-flight.
551
+ // Lease check is independent of terminal artifact: present/unreadable may
552
+ // still carry a ghostLeg alongside metrics or missingSources.
553
+ }
554
+ }
555
+ return {
556
+ runs,
557
+ unreadable,
558
+ scopeConflicts,
559
+ ghostLegs,
560
+ };
561
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared analyst median primitive (family-wide convention).
3
+ * Odd sample count → single middle value after ascending sort.
4
+ * Even sample count → arithmetic mean of the two middle values.
5
+ * Empty input → undefined (callers record typed vacancy; no invented zero).
6
+ */
7
+ export function medianNumber(values) {
8
+ if (values.length === 0)
9
+ return undefined;
10
+ const sorted = [...values].sort((a, b) => a - b);
11
+ const mid = Math.floor(sorted.length / 2);
12
+ if (sorted.length % 2 === 1) {
13
+ return sorted[mid];
14
+ }
15
+ return (sorted[mid - 1] + sorted[mid]) / 2;
16
+ }