@akagilnc/pi-workflow-roles 0.1.4062 → 0.1.4196

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/acp-host/production-host.js +2075 -1088
  2. package/dist/archivist-record-entry.js +204 -20
  3. package/dist/archivist-record-topology.js +36 -16
  4. package/dist/auditor-dossier-tool.js +2 -1
  5. package/dist/diarist-contracts.js +96 -0
  6. package/dist/doctor-evidence.js +3 -2
  7. package/dist/headless-host/production-host.js +2215 -1228
  8. package/dist/migrate-book-topology.js +278917 -0
  9. package/dist/navigator-public-session.js +22 -25
  10. package/dist/notary-source-run.js +7 -4
  11. package/dist/pi/durable-principal.js +9 -6
  12. package/dist/public-cli/auto-resume.js +2 -5
  13. package/dist/public-cli/diarist-run.js +27 -25
  14. package/dist/public-cli/instruction-seat-run.js +8 -11
  15. package/dist/public-cli/invocation.js +127 -63
  16. package/dist/public-cli/main.js +1507 -645
  17. package/dist/public-cli/option-definitions.js +2 -2
  18. package/dist/public-cli/post-admission.js +47 -12
  19. package/dist/public-cli/run-lifecycle.js +206 -58
  20. package/dist/public-cli/seat-ticket-binding.js +3 -6
  21. package/dist/public-cli/settlement.js +5 -3
  22. package/dist/role-run-placement.js +84 -0
  23. package/dist/role-run-relocation.js +358 -0
  24. package/dist/run-terminal-artifacts.js +2 -1
  25. package/dist/run-ticket-number.js +51 -32
  26. package/dist/sitian-appender.js +11 -26
  27. package/dist/submission-ledger.js +78 -19
  28. package/dist/ticket-provenance.js +3 -1
  29. package/package.json +3 -2
  30. package/scripts/build-package.mjs +34 -2
  31. package/souls/countersign.md +2 -2
  32. package/souls/fixer.md +3 -2
  33. package/src/analyst-ledger.ts +14 -15
  34. package/src/archivist-record-entry.ts +285 -38
  35. package/src/archivist-record-topology.ts +43 -18
  36. package/src/auditor-dossier-tool.ts +2 -1
  37. package/src/book-topology-migration-placement.ts +493 -0
  38. package/src/book-topology-migration.ts +220 -0
  39. package/src/book-topology-migrators-t11.ts +856 -0
  40. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  41. package/src/book-topology-partition-migrators.ts +25 -0
  42. package/src/book-topology-record-class-migrators.ts +779 -0
  43. package/src/book-topology-runs-migrator.ts +244 -0
  44. package/src/diarist-contracts.ts +116 -0
  45. package/src/diarist-role.ts +6 -0
  46. package/src/diarist.ts +2 -0
  47. package/src/doctor-evidence.ts +4 -1
  48. package/src/doctor-role.ts +1 -1
  49. package/src/navigator-public-session.ts +33 -30
  50. package/src/notary-source-run.ts +9 -4
  51. package/src/pi/durable-principal.ts +9 -13
  52. package/src/public-cli/auto-resume.ts +2 -6
  53. package/src/public-cli/countersign-run.ts +182 -32
  54. package/src/public-cli/diarist-run.ts +34 -26
  55. package/src/public-cli/instruction-seat-run.ts +7 -11
  56. package/src/public-cli/invocation.ts +192 -58
  57. package/src/public-cli/option-definitions.ts +2 -2
  58. package/src/public-cli/post-admission.ts +73 -13
  59. package/src/public-cli/run-lifecycle.ts +255 -65
  60. package/src/public-cli/seat-ticket-binding.ts +3 -6
  61. package/src/public-cli/settlement.ts +6 -4
  62. package/src/role-run-placement.ts +128 -0
  63. package/src/role-run-relocation.ts +491 -0
  64. package/src/role-runtime.ts +11 -5
  65. package/src/run-terminal-artifacts.ts +3 -1
  66. package/src/run-ticket-number.ts +80 -20
  67. package/src/sitian-appender.ts +25 -28
  68. package/src/submission-ledger.ts +102 -19
  69. package/src/ticket-provenance.ts +4 -1
  70. package/src/ticket-trajectory.ts +22 -35
  71. package/src/worker-role.ts +4 -4
  72. package/src/worker-submission-gates.ts +6 -6
@@ -1,8 +1,10 @@
1
+ import { join } from "node:path";
1
2
  import { resolveActivationLedgerHome, tryHomeFromAkRolesPath, } from "./activation-ledger-topology.js";
2
3
  import { courtAttemptIdFromHostContext, runDirectoryFromHostContext, } from "./host-contracts.js";
3
4
  import { isAuditEscalationProjection } from "./audit-escalation.js";
4
5
  import { runIdFromRunDirectory } from "./run-terminal-artifacts.js";
5
6
  import { readSitianRecords, resolveSitianRecordPathInLedger, sitianReport } from "./sitian-facade.js";
7
+ import { findRunDirectoryById } from "./public-cli/run-lifecycle.js";
6
8
  import { isCorrectableExecuteError } from "./submission-correctable-error.js";
7
9
  import { failOnInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.js";
8
10
  /**
@@ -33,21 +35,40 @@ function attemptIdentity(context, runId) {
33
35
  return courtAttempt;
34
36
  return context.sessionManager.getHeader?.()?.id ?? context.sessionManager.getLeafId?.() ?? `${runId}:initial`;
35
37
  }
36
- function submissionRecordFile(cwd, runId, home) {
37
- const ledgerHome = resolveActivationLedgerHome(home);
38
+ /**
39
+ * Resolve the submission-ledger record file for a run.
40
+ * Unknown run (no sessionParent and no discoverable directory) → undefined so
41
+ * read APIs return their empty set at the read boundary. Write paths always
42
+ * supply sessionParent and keep the ownership gate.
43
+ */
44
+ async function submissionRecordFile(cwd, runId, scope) {
45
+ const ledgerHome = resolveActivationLedgerHome(scope.home);
46
+ let sessionParent = scope.sessionParent;
47
+ if (sessionParent === undefined) {
48
+ const discoveredRun = await findRunDirectoryById(scope.home, runId);
49
+ if (discoveredRun === undefined)
50
+ return undefined;
51
+ sessionParent = join(discoveredRun, "session", "session.jsonl");
52
+ }
38
53
  return resolveSitianRecordPathInLedger({
39
54
  level: "event",
40
55
  kind: "candidate",
41
56
  subject: { runId },
42
57
  cwd,
58
+ sessionParent,
43
59
  }, ledgerHome).recordFile;
44
60
  }
45
- async function readOwnedSubmissionRecords(cwd, runId, home) {
46
- const file = submissionRecordFile(cwd, runId, home);
61
+ async function readOwnedSubmissionRecords(cwd, runId, scope = {}) {
62
+ const file = await submissionRecordFile(cwd, runId, scope);
63
+ if (file === undefined) {
64
+ return { file: undefined, owned: [] };
65
+ }
47
66
  const { records } = await readSitianRecords(file);
48
67
  return {
49
68
  file,
50
- owned: records.filter((record) => typeof record.subject === "object" && record.subject !== null && record.subject.runId === runId),
69
+ owned: records.filter((record) => typeof record.subject === "object"
70
+ && record.subject !== null
71
+ && record.subject.runId === runId),
51
72
  };
52
73
  }
53
74
  function resolveReadScope(homeOrScope) {
@@ -86,8 +107,8 @@ function recordAttemptId(record) {
86
107
  * outranking the current attempt's own real host-turn failure signal
87
108
  * (#637 original intent, restored narrowly).
88
109
  */
89
- export async function hasFreshAttemptSubmission(cwd, runId, attemptId, home) {
90
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, home);
110
+ export async function hasFreshAttemptSubmission(cwd, runId, attemptId, homeOrScope) {
111
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, resolveReadScope(homeOrScope));
91
112
  return owned.some((record) => {
92
113
  if (recordAttemptId(record) !== attemptId)
93
114
  return false;
@@ -245,7 +266,7 @@ function mapOwnedToSubmissionRows(owned) {
245
266
  */
246
267
  export async function readRecordedSubmissionRows(cwd, runId, homeOrScope) {
247
268
  const scope = resolveReadScope(homeOrScope);
248
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
269
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope);
249
270
  const scoped = recordsForAttempt(owned, scope.attemptId);
250
271
  return mapOwnedToSubmissionRows(scoped);
251
272
  }
@@ -257,7 +278,7 @@ export async function readRecordedSubmissionRows(cwd, runId, homeOrScope) {
257
278
  export async function readAttemptScopedSubmissionRows(cwd, runId, attemptId, home) {
258
279
  if (attemptId.length === 0)
259
280
  return [];
260
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, home);
281
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, home === undefined ? {} : { home });
261
282
  return mapOwnedToSubmissionRows(owned.filter((record) => recordAttemptId(record) === attemptId));
262
283
  }
263
284
  /**
@@ -274,7 +295,7 @@ export async function hasRecordedSubmission(cwd, runId, homeOrScope) {
274
295
  /** Latest non-final outcome on the run ledger (settlement residual precedence). */
275
296
  export async function readLatestSubmissionOutcome(cwd, runId, homeOrScope) {
276
297
  const scope = resolveReadScope(homeOrScope);
277
- const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope.home);
298
+ const { owned } = await readOwnedSubmissionRecords(cwd, runId, scope);
278
299
  const scoped = recordsForAttempt(owned, scope.attemptId);
279
300
  for (let index = scoped.length - 1; index >= 0; index -= 1) {
280
301
  const record = scoped[index];
@@ -287,14 +308,33 @@ export async function readLatestSubmissionOutcome(cwd, runId, homeOrScope) {
287
308
  }
288
309
  return undefined;
289
310
  }
290
- async function restoreState(cwd, runId, home) {
291
- const { file, owned } = await readOwnedSubmissionRecords(cwd, runId, home);
311
+ async function restoreState(cwd, runId, scope) {
312
+ const located = await readOwnedSubmissionRecords(cwd, runId, scope);
313
+ // Unknown-run empty set: no prior chain. Owned rows without a file are an internal bug.
314
+ if (located.file === undefined) {
315
+ if (located.owned.length !== 0) {
316
+ throw new Error(`submission ledger invariant: owned rows present without recordFile for run ${runId}`);
317
+ }
318
+ return { sequence: 0 };
319
+ }
320
+ const { file, owned } = located;
292
321
  const last = owned.at(-1);
293
322
  return {
294
- ...(last === undefined ? {} : { prior: { identity: last.identity, recordFile: file, kind: last.kind, level: last.level } }),
323
+ ...(last === undefined
324
+ ? {}
325
+ : {
326
+ prior: {
327
+ identity: last.identity,
328
+ recordFile: file,
329
+ kind: last.kind,
330
+ level: last.level,
331
+ },
332
+ }),
295
333
  sequence: owned.reduce((maximum, record) => {
296
334
  const payload = record.payload;
297
- return payload?.type === "candidate" && typeof payload.sequence === "number" ? Math.max(maximum, payload.sequence) : maximum;
335
+ return payload?.type === "candidate" && typeof payload.sequence === "number"
336
+ ? Math.max(maximum, payload.sequence)
337
+ : maximum;
298
338
  }, 0),
299
339
  };
300
340
  }
@@ -306,22 +346,40 @@ async function restoreState(cwd, runId, home) {
306
346
  */
307
347
  export function createSubmissionLedgerHost(host, outputTools, failInfrastructure = (error) => { throw error; }, projectClosure = () => undefined, options) {
308
348
  const states = new Map();
349
+ /** Sole HostContext-derived run coordinate for restore and append (never process.env). */
350
+ const sessionParentFromContext = (context) => {
351
+ const runDirectory = runDirectoryFromHostContext(context);
352
+ if (runDirectory !== undefined) {
353
+ return join(runDirectory, "session", "session.jsonl");
354
+ }
355
+ const sessionFile = context.sessionManager.getSessionFile?.();
356
+ if (typeof sessionFile === "string" && sessionFile.length > 0)
357
+ return sessionFile;
358
+ const sessionDir = context.sessionManager.getSessionDir?.();
359
+ if (typeof sessionDir === "string" && sessionDir.length > 0) {
360
+ return join(sessionDir, "session.jsonl");
361
+ }
362
+ return undefined;
363
+ };
309
364
  const resolveHomeFromContext = (context) => {
310
365
  if (options?.home !== undefined)
311
366
  return options.home;
312
- const sessionFile = context.sessionManager.getSessionFile?.() || context.sessionManager.getSessionDir?.();
313
- return typeof sessionFile === "string" && sessionFile.length > 0
314
- ? tryHomeFromAkRolesPath(sessionFile)
315
- : undefined;
367
+ const sessionParent = sessionParentFromContext(context);
368
+ return sessionParent !== undefined ? tryHomeFromAkRolesPath(sessionParent) : undefined;
316
369
  };
317
370
  const stateFor = (context, runId) => states.get(runId) ?? (() => {
318
371
  const home = resolveHomeFromContext(context);
319
- const pending = restoreState(context.cwd, runId, home);
372
+ const sessionParent = sessionParentFromContext(context);
373
+ const pending = restoreState(context.cwd, runId, {
374
+ ...(home === undefined ? {} : { home }),
375
+ ...(sessionParent === undefined ? {} : { sessionParent }),
376
+ });
320
377
  states.set(runId, pending);
321
378
  return pending;
322
379
  })();
323
380
  const appendFor = (state, context, runId, attemptId, event) => {
324
381
  const home = resolveHomeFromContext(context);
382
+ const sessionParent = sessionParentFromContext(context);
325
383
  const pointer = sitianReport({
326
384
  level: "event",
327
385
  kind: event.type,
@@ -331,6 +389,7 @@ export function createSubmissionLedgerHost(host, outputTools, failInfrastructure
331
389
  source: "role-runtime",
332
390
  cwd: context.cwd,
333
391
  ...(home !== undefined ? { home } : {}),
392
+ ...(sessionParent === undefined ? {} : { sessionParent }),
334
393
  });
335
394
  state.prior = pointer;
336
395
  return pointer;
@@ -6,10 +6,11 @@ import { createHash } from "node:crypto";
6
6
  import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { resolveSitianRecordPath, sitianReport, readSitianRecords, } from "./sitian-facade.js";
9
+ import { isSafePositiveTicketNumber } from "./run-ticket-number.js";
9
10
  import { TICKET_PROVENANCE_HUMAN_VIEW, TICKET_PROVENANCE_KIND, projectTicketProvenanceDiagnostic, projectTicketProvenanceEntry, } from "./ticket-provenance-contracts.js";
10
11
  /** Subject string for ticket-keyed volumes — history follows the ticket. */
11
12
  export function ticketProvenanceSubject(ticketNumber) {
12
- if (!Number.isSafeInteger(ticketNumber) || ticketNumber < 1) {
13
+ if (!isSafePositiveTicketNumber(ticketNumber)) {
13
14
  throw new Error(`ticket-provenance subject requires a positive ticket number, got ${String(ticketNumber)}`);
14
15
  }
15
16
  return String(ticketNumber);
@@ -71,6 +72,7 @@ export function appendTicketProvenanceEntry(input) {
71
72
  identity,
72
73
  subject,
73
74
  cwd: input.cwd,
75
+ sessionParent: input.sessionParent,
74
76
  ...(input.home === undefined ? {} : { home: input.home }),
75
77
  host: input.host ?? "diarist",
76
78
  source: input.source ?? "diarist",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4062",
3
+ "version": "0.1.4196",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -47,7 +47,8 @@
47
47
  "test:integration": "node --import tsx --import ./scripts/test-process-env-preload.mjs --test test/unit/**/*.test.ts test/contract/**/*.test.ts test/integration/**/*.test.ts",
48
48
  "test:all": "node scripts/run-test-all.mjs",
49
49
  "test:adjudication": "node --import tsx --import ./scripts/test-process-env-preload.mjs --test test/adjudication/**/*.test.ts",
50
- "typecheck": "tsc --noEmit"
50
+ "typecheck": "tsc --noEmit",
51
+ "migrate:book-topology": "node dist/migrate-book-topology.js"
51
52
  },
52
53
  "pi": {
53
54
  "extensions": []
@@ -22,12 +22,14 @@ const entries = [
22
22
  "activation-ledger-topology",
23
23
  "activation-reconciliation",
24
24
  "archivist-record-entry",
25
- // Pure subject nest topology — cold discovery without SessionManager (#636).
26
- "archivist-record-topology",
27
25
  // Session material loaders used by published non-bundle roots.
28
26
  "session-opening-materials",
29
27
  // Value-import closure of published non-bundle roots (build-package-only loadable).
30
28
  "auditor-dossier-tool",
29
+ // Static import of auditor-dossier-tool / placement path authority (#859).
30
+ "role-run-placement",
31
+ // Static import of role-run-placement (safe-positive ticket invariant).
32
+ "run-ticket-number",
31
33
  "canonical-json",
32
34
  "compliance-transport",
33
35
  "countersign-contracts",
@@ -141,6 +143,35 @@ export async function buildHeadlessProductionHost(
141
143
  );
142
144
  }
143
145
 
146
+ /**
147
+ * Published one-shot book-topology migration CLI (#852).
148
+ * Node-executable ESM bundle — consumers must not need dev-only tsx.
149
+ * createRequire banner lets transitive CJS deps reach node builtins.
150
+ */
151
+ export async function buildMigrateBookTopology(
152
+ outfile = "dist/migrate-book-topology.js",
153
+ ) {
154
+ await mkdir(dirname(outfile), { recursive: true });
155
+ await build({
156
+ entryPoints: ["scripts/migrate-book-topology.ts"],
157
+ outfile,
158
+ format: "esm",
159
+ platform: "node",
160
+ target: "node20",
161
+ bundle: true,
162
+ banner: {
163
+ js: [
164
+ "#!/usr/bin/env node",
165
+ "import { createRequire as __akCreateRequire } from 'node:module';",
166
+ "const require = __akCreateRequire(import.meta.url);",
167
+ "",
168
+ ].join("\n"),
169
+ },
170
+ logLevel: "silent",
171
+ });
172
+ await chmod(outfile, 0o755);
173
+ }
174
+
144
175
  export async function buildPackageArtifacts() {
145
176
  await build({
146
177
  entryPoints: entries.map((name) => `src/${name}.ts`),
@@ -167,6 +198,7 @@ export async function buildPackageArtifacts() {
167
198
  await buildPublicAkRoleBin();
168
199
  await buildAcpProductionHost();
169
200
  await buildHeadlessProductionHost();
201
+ await buildMigrateBookTopology();
170
202
  }
171
203
 
172
204
  const isMain =
@@ -9,13 +9,13 @@
9
9
  1 第一法源立法(最高法):该票起居录,全局宪法,仓内治理文件;
10
10
 
11
11
  2 第二法源立法:
12
- 适用 ADR——由你自行检索,按票面触及的席位、接缝、术语在 docs/adr 与 CONTEXT.md 查全,票面引没引都算——并逐键列出其决策键;质量法、审刑院法典;
12
+ 适用 ADR——由你自行检索,按票面触及的席位、接缝、术语在 docs/adr 与 CONTEXT.md 查全,票面引没引都算——并逐条列出其决定;质量法、审刑院法典;
13
13
 
14
14
  3 第二法源合法性检查:判断第二法源 以及 输入的 prompt等 对比第一法源是否有超出,不足,曲解。如有,除非有陛下原话批准,否则直接封驳 要求改正或陛下批准。
15
15
 
16
16
  **二、相抵** 票面,相关票与在飞分支与法源有无抵触。
17
17
 
18
- **三、缺失** 法源要求的,票面有没有漏:适用 ADR 未被引用即缺失,引用须写 `docs/adr/...md` 路径;ADR 每个决策键须有落点或明写「由 #x 承接」;验收面须覆盖票面每条主张;前置产物、决定、答复须在;承重主张的源头不存在、不支持、已过时,同为缺失。缺失列表在能力范围内一次扫完,尽量避免因缺失导致多轮往返。
18
+ **三、缺失** 法源要求的,票面有没有漏:适用 ADR 未被引用即缺失,引用须写 `docs/adr/...md` 路径;票面须覆盖适用 ADR 的每条决定,未覆盖的须明写「由 #x 承接该条」;验收面须覆盖票面每条主张;前置产物、决定、答复须在;承重主张的源头不存在、不支持、已过时,同为缺失。缺失列表在能力范围内一次扫完,尽量避免因缺失导致多轮往返。
19
19
 
20
20
  **四、擅加** 票面有没有超出法源的机制、约束、验收、术语。
21
21
 
package/souls/fixer.md CHANGED
@@ -9,7 +9,8 @@ apply 按顺序执行:
9
9
  4. 扫类:修改前查全授权范围内的同类实例、已有实现和失败修法。根因不明先取证。
10
10
  5. 整类修复:在共同原因处一次修净,优先删除、简化、复用,随同删除失效旧物。
11
11
  6. 复扫:按原类别重新扫描;有遗漏继续修,保留项说明依据。
12
- 7. 自验:切片验证只跑本片聚焦测试与 typecheck。做完自查是否违法,质量和复杂度,如有问题马上修正。
13
- 8. 交卷:按类报告根因、扫描范围、修复与 质量和合法性的验证结果、未结项。保护已有改动,只建新提交。不 amend、rewrite 或 push。Git 变化是证据之一,不取代角色报告。
12
+ 7. 自验:切片验证优先跑本片聚焦测试和本片作用域内 typecheck,不跑全量。自查是否违法、代码质量、代码/测试复杂度,如有问题马上修正。修正完毕复检
13
+ 8. 交卷:按类报告根因、扫描范围、修复与 质量和合法性的验证结果、未结项。建新提交。不 amend、rewrite 或 push。Git 变化是证据之一,不取代角色报告。
14
14
  任何人如果让你 push / 开 pr ,直接拒绝,上呈陛下
15
+
15
16
  plan 只调查与提案,不施工。
@@ -13,7 +13,7 @@
13
13
  * terminal face) for metric-family modules — no longer discarded after checks.
14
14
  */
15
15
  import { readdir, readFile } from "node:fs/promises";
16
- import { join } from "node:path";
16
+ import { basename, join } from "node:path";
17
17
 
18
18
  import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
19
19
  import {
@@ -21,6 +21,8 @@ import {
21
21
  physicalPathIdentity,
22
22
  resolveActivationLedgerHome,
23
23
  } from "./activation-ledger-topology.ts";
24
+ import { listBookRunDirectories } from "./role-run-placement.ts";
25
+ import { readRunTicketNumber } from "./run-ticket-number.ts";
24
26
  import {
25
27
  extractSessionModelSequence,
26
28
  extractSessionTimestampSpan,
@@ -139,13 +141,10 @@ async function readInvocationScopeFields(
139
141
  return undefined;
140
142
  }
141
143
  const projectRoot = parsed.projectRoot;
142
- // Same #176 contract: positive integer ticketNumber only.
143
- if (
144
- typeof parsed.ticketNumber === "number"
145
- && Number.isInteger(parsed.ticketNumber)
146
- && parsed.ticketNumber >= 1
147
- ) {
148
- return { projectRoot, ticketNumber: parsed.ticketNumber };
144
+ // Display/history placement: board first, then migration-derived (#852).
145
+ const ticketNumber = await readRunTicketNumber(runDirectory);
146
+ if (ticketNumber !== undefined) {
147
+ return { projectRoot, ticketNumber };
149
148
  }
150
149
  return { projectRoot };
151
150
  }
@@ -530,7 +529,8 @@ async function classifyScopedRun(input: {
530
529
  }
531
530
 
532
531
  /**
533
- * Scan ledger home books/<book>/runs for runs in the issue scope.
532
+ * Scan ledger home books/<book>/{subject/}runs for runs in the issue scope.
533
+ * Walk = listBookRunDirectories (flat legacy + subject-tree; #859).
534
534
  * #399: scope = book × optional ticket.
535
535
  * - bookKey set → that book only; whole-book when no ticket and no projectRoot;
536
536
  * ticket filters alone; bookKey + projectRoot (cohort ensure, #412) narrows
@@ -598,20 +598,19 @@ export async function scanAnalystIssueRuns(input: {
598
598
  const scopeConflicts: AnalystScopeConflict[] = [];
599
599
 
600
600
  for (const book of bookNames) {
601
- const runsDir = join(booksRoot, book, "runs");
602
- let runNames: string[];
601
+ const bookDir = join(booksRoot, book);
602
+ let runDirectories: string[];
603
603
  try {
604
- const entries = await readdir(runsDir, { withFileTypes: true });
605
- runNames = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
604
+ runDirectories = await listBookRunDirectories(bookDir);
606
605
  } catch (error) {
607
606
  if (isMissingPathError(error)) continue;
608
607
  throw error;
609
608
  }
610
609
 
611
- for (const runName of runNames) {
610
+ for (const runDirectory of runDirectories) {
611
+ const runName = basename(runDirectory);
612
612
  const parsed = parseRunDirectoryName(runName);
613
613
  if (parsed === undefined) continue;
614
- const runDirectory = join(runsDir, runName);
615
614
 
616
615
  let scopeFields: InvocationScopeFields | undefined;
617
616
  try {