@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
@@ -0,0 +1,244 @@
1
+ /**
2
+ * #865 T9: migrate retained runs into ticket-scoped or unbound placement.
3
+ * Inventory covers legacy flat `runs/`, existing `<ticket>/runs/`, and
4
+ * `unbound/runs/`. Attribution reuses the shared migrating-run helper;
5
+ * already-canonical trees copy as-is.
6
+ */
7
+ import { cp, mkdir, stat, writeFile } from "node:fs/promises";
8
+ import { basename, dirname, join, relative, sep } from "node:path";
9
+
10
+ import {
11
+ destinationRunDirectory,
12
+ isMigrationEnoent,
13
+ isTicketNumberString,
14
+ listBackupRunLeaves,
15
+ listMigrationBookKeys,
16
+ resolveMigratingRunTicket,
17
+ } from "./book-topology-migration-placement.ts";
18
+ import {
19
+ reconcileMigrationPartition,
20
+ type BookTopologyMigrationContext,
21
+ type BookTopologyPartitionMigrator,
22
+ type MigrationItemOutcome,
23
+ } from "./book-topology-migration.ts";
24
+ import {
25
+ rewriteRoleRunDurablePages,
26
+ type RunDirectoryPathRewrite,
27
+ } from "./role-run-relocation.ts";
28
+ import { MIGRATION_TICKET_DERIVATION_PAGE } from "./run-ticket-number.ts";
29
+
30
+ const RUNS_PARTITION = "runs";
31
+
32
+ const RUN_LEAF =
33
+ /^([A-Za-z0-9][A-Za-z0-9._-]*)@([A-Za-z][A-Za-z0-9_-]*)$/;
34
+
35
+ type RunLeaf = { readonly runId: string; readonly role: string };
36
+
37
+ type PlannedRunMove = {
38
+ readonly sourcePath: string;
39
+ readonly sourceIdentity: string;
40
+ readonly isDirectory: boolean;
41
+ readonly disposition: "placed" | "unbound";
42
+ readonly targetPath: string;
43
+ readonly historicalRunDirectory: string | undefined;
44
+ readonly derivation:
45
+ | { readonly ticketNumber: number; readonly projectRoot: string; readonly sourcePage: string }
46
+ | undefined;
47
+ };
48
+
49
+ function parseRunLeaf(name: string): RunLeaf | undefined {
50
+ const match = RUN_LEAF.exec(name);
51
+ if (match === null) return undefined;
52
+ return { runId: match[1]!, role: match[2]! };
53
+ }
54
+
55
+ async function pathExists(path: string): Promise<boolean> {
56
+ try {
57
+ await stat(path);
58
+ return true;
59
+ } catch (error) {
60
+ if (isMigrationEnoent(error)) return false;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ async function writeDerivationPage(
66
+ targetRunDirectory: string,
67
+ derivation: {
68
+ readonly ticketNumber: number;
69
+ readonly projectRoot: string;
70
+ readonly sourcePage: string;
71
+ },
72
+ ): Promise<void> {
73
+ const page = {
74
+ ticketNumber: derivation.ticketNumber,
75
+ derivation: "worktree-path-basename" as const,
76
+ source: {
77
+ page: derivation.sourcePage,
78
+ field: "projectRoot",
79
+ path: derivation.projectRoot,
80
+ basename: basename(derivation.projectRoot),
81
+ },
82
+ };
83
+ await writeFile(
84
+ join(targetRunDirectory, MIGRATION_TICKET_DERIVATION_PAGE),
85
+ `${JSON.stringify(page, null, 2)}\n`,
86
+ "utf8",
87
+ );
88
+ }
89
+
90
+ async function copyRunTree(
91
+ source: string,
92
+ target: string,
93
+ isDirectory: boolean,
94
+ ): Promise<void> {
95
+ await mkdir(dirname(target), { recursive: true });
96
+ if (isDirectory) {
97
+ await cp(source, target, { recursive: true, preserveTimestamps: true });
98
+ return;
99
+ }
100
+ await cp(source, target, { preserveTimestamps: true });
101
+ }
102
+
103
+ async function planBookMoves(
104
+ backupBooksDirectory: string,
105
+ booksDirectory: string,
106
+ bookKey: string,
107
+ ): Promise<readonly PlannedRunMove[]> {
108
+ const backupBook = join(backupBooksDirectory, bookKey);
109
+ const claimed = new Map<string, string>();
110
+ const planned: PlannedRunMove[] = [];
111
+
112
+ for (const leaf of await listBackupRunLeaves(backupBook)) {
113
+ const sourceIdentity = relative(backupBooksDirectory, leaf.sourcePath)
114
+ .split(sep)
115
+ .join("/");
116
+ const parsed = leaf.isDirectory ? parseRunLeaf(leaf.leafName) : undefined;
117
+
118
+ let targetPath: string;
119
+ let disposition: "placed" | "unbound";
120
+ let historicalRunDirectory: string | undefined;
121
+ let derivation: PlannedRunMove["derivation"];
122
+
123
+ if (leaf.layout === "issues") {
124
+ // issues/<ticket>/runs/<leaf> → <ticket>/runs/<leaf>; conflict refuses below.
125
+ const parts = leaf.relativePath.replaceAll("\\", "/").split("/");
126
+ const ticketStr = parts[1];
127
+ if (ticketStr !== undefined && isTicketNumberString(ticketStr)) {
128
+ targetPath = join(booksDirectory, bookKey, ticketStr, "runs", leaf.leafName);
129
+ disposition = "placed";
130
+ historicalRunDirectory = join(
131
+ booksDirectory,
132
+ bookKey,
133
+ "issues",
134
+ ticketStr,
135
+ "runs",
136
+ leaf.leafName,
137
+ );
138
+ } else {
139
+ targetPath = join(booksDirectory, bookKey, "unbound", "runs", leaf.leafName);
140
+ disposition = "unbound";
141
+ historicalRunDirectory = join(booksDirectory, bookKey, leaf.relativePath);
142
+ }
143
+ derivation = undefined;
144
+ } else if (leaf.layout !== "flat") {
145
+ targetPath = join(booksDirectory, bookKey, leaf.relativePath);
146
+ disposition = leaf.layout === "unbound" ? "unbound" : "placed";
147
+ historicalRunDirectory = undefined;
148
+ derivation = undefined;
149
+ } else if (parsed === undefined) {
150
+ targetPath = join(booksDirectory, bookKey, "unbound", "runs", leaf.leafName);
151
+ disposition = "unbound";
152
+ historicalRunDirectory = join(booksDirectory, bookKey, "runs", leaf.leafName);
153
+ derivation = undefined;
154
+ } else {
155
+ const ticket = await resolveMigratingRunTicket(leaf.sourcePath);
156
+ targetPath = destinationRunDirectory(
157
+ booksDirectory,
158
+ bookKey,
159
+ ticket.ticketNumber,
160
+ parsed.runId,
161
+ parsed.role,
162
+ );
163
+ disposition = ticket.ticketNumber === undefined ? "unbound" : "placed";
164
+ historicalRunDirectory = join(booksDirectory, bookKey, "runs", leaf.leafName);
165
+ derivation =
166
+ ticket.derivation?.method === "project-root-basename"
167
+ && ticket.ticketNumber !== undefined
168
+ ? {
169
+ ticketNumber: ticket.ticketNumber,
170
+ projectRoot: ticket.derivation.source,
171
+ sourcePage: ticket.derivation.sourcePage,
172
+ }
173
+ : undefined;
174
+ }
175
+
176
+ const prior = claimed.get(targetPath);
177
+ if (prior !== undefined) {
178
+ throw new Error(
179
+ `book topology migration refuses to overwrite ${targetPath} (already claimed by ${prior}) with ${leaf.sourcePath}`,
180
+ );
181
+ }
182
+ claimed.set(targetPath, leaf.sourcePath);
183
+ planned.push({
184
+ sourcePath: leaf.sourcePath,
185
+ sourceIdentity,
186
+ isDirectory: leaf.isDirectory,
187
+ disposition,
188
+ targetPath,
189
+ historicalRunDirectory,
190
+ derivation,
191
+ });
192
+ }
193
+ return planned;
194
+ }
195
+
196
+ export const bookTopologyRunsMigrator: BookTopologyPartitionMigrator = {
197
+ partition: RUNS_PARTITION,
198
+ async migrate(context: BookTopologyMigrationContext) {
199
+ const outcomes: MigrationItemOutcome[] = [];
200
+ const { backupBooksDirectory, booksDirectory } = context;
201
+
202
+ for (const bookKey of await listMigrationBookKeys(backupBooksDirectory)) {
203
+ const planned = await planBookMoves(
204
+ backupBooksDirectory,
205
+ booksDirectory,
206
+ bookKey,
207
+ );
208
+
209
+ const crossRunRewrites: RunDirectoryPathRewrite[] = planned
210
+ .filter((move) => move.isDirectory && move.historicalRunDirectory !== undefined)
211
+ .map((move) => ({
212
+ oldRunDirectory: move.historicalRunDirectory!,
213
+ newRunDirectory: move.targetPath,
214
+ }));
215
+
216
+ for (const move of planned) {
217
+ if (await pathExists(move.targetPath)) {
218
+ throw new Error(
219
+ `book topology migration refuses to overwrite ${move.targetPath} with ${move.sourcePath}`,
220
+ );
221
+ }
222
+ await copyRunTree(move.sourcePath, move.targetPath, move.isDirectory);
223
+ if (move.isDirectory) {
224
+ await rewriteRoleRunDurablePages({
225
+ pagesDirectory: move.targetPath,
226
+ oldRunDirectory: move.historicalRunDirectory ?? move.targetPath,
227
+ newRunDirectory: move.targetPath,
228
+ crossRunRewrites,
229
+ });
230
+ }
231
+ if (move.isDirectory && move.derivation !== undefined) {
232
+ await writeDerivationPage(move.targetPath, move.derivation);
233
+ }
234
+
235
+ outcomes.push({
236
+ disposition: move.disposition,
237
+ source: move.sourceIdentity,
238
+ });
239
+ }
240
+ }
241
+
242
+ return reconcileMigrationPartition(RUNS_PARTITION, "entries", outcomes);
243
+ },
244
+ };
@@ -12,6 +12,10 @@ import type {
12
12
  TicketProvenanceSourceKind,
13
13
  TicketProvenanceSourceRef,
14
14
  } from "./ticket-provenance-contracts.ts";
15
+ import {
16
+ isSafePositiveTicketNumber,
17
+ requireSafePositiveTicketNumber,
18
+ } from "./run-ticket-number.ts";
15
19
 
16
20
  export const DIARIST_OUTPUT_TOOL_NAME = "ak_diarist_output";
17
21
  export const DIARIST_ACCEPTED_TEXT = "起居郎回执已接受";
@@ -39,6 +43,15 @@ export type DiaristOutput =
39
43
  * LLM owns recognition; mechanical layer does not re-judge the number.
40
44
  */
41
45
  readonly ticketNumber?: number | null;
46
+ /**
47
+ * #871 typed co-review set (ADR 0075 `diarist-resolves-ticket-llm-layer` narrow extension).
48
+ * Positive integers = tickets that should each receive a diary this court (includes main).
49
+ * Field absent → no new set this turn (resume keeps stored run fact; first court defaults to [main]).
50
+ * Explicit empty array → single-ticket face [main] whole-set replace.
51
+ * Non-empty with zero lawful members after type projection is not a new set (call site).
52
+ * Mechanical layer only type-projects/dedupes and guarantees principal membership when applying a set.
53
+ */
54
+ readonly courtTicketNumbers?: readonly number[] | null;
42
55
  /** Whole blocks to append under the asserted ticket. Empty list is lawful. */
43
56
  readonly entries?: readonly DiaristEntrySubmission[];
44
57
  }
@@ -48,6 +61,109 @@ export type DiaristOutput =
48
61
  readonly reason: string;
49
62
  };
50
63
 
64
+ /**
65
+ * Sole shape projection for a typed co-review ticket set (#871).
66
+ * Positive safe integers, first-seen order, duplicates dropped. Not content judgment.
67
+ * Returns null when `raw` is not an array (callers classify absent vs durable damage).
68
+ * Returns a possibly empty list when `raw` is an array (unlawful members skipped).
69
+ * When `principalTicket` is set, that ticket is guaranteed as a member (prepended if missing).
70
+ */
71
+ export function projectCourtTicketNumbers(
72
+ raw: unknown,
73
+ options?: { readonly principalTicket?: number },
74
+ ): readonly number[] | null {
75
+ if (!Array.isArray(raw)) return null;
76
+ const out: number[] = [];
77
+ const seen = new Set<number>();
78
+ for (const item of raw) {
79
+ if (!isSafePositiveTicketNumber(item)) continue;
80
+ if (seen.has(item)) continue;
81
+ seen.add(item);
82
+ out.push(item);
83
+ }
84
+ const principal = options?.principalTicket;
85
+ if (principal === undefined) return out;
86
+ requireSafePositiveTicketNumber(principal, "projectCourtTicketNumbers principalTicket");
87
+ if (seen.has(principal)) return out;
88
+ return [principal, ...out];
89
+ }
90
+
91
+ /**
92
+ * Durable-field interpreter for #871 co-review sets.
93
+ * Absent key (old-format run) is not damage. Present but empty / non-array /
94
+ * partially unlawful / missing principal is damage — callers must fail closed,
95
+ * never wash into main-only.
96
+ */
97
+ export function interpretDurableCourtTicketNumbers(
98
+ record: Record<string, unknown>,
99
+ options?: { readonly principalTicket?: number },
100
+ ):
101
+ | { readonly kind: "absent" }
102
+ | { readonly kind: "ok"; readonly tickets: readonly number[] }
103
+ | { readonly kind: "damage"; readonly reason: string } {
104
+ if (!Object.hasOwn(record, "courtTicketNumbers")) {
105
+ return { kind: "absent" };
106
+ }
107
+ const raw = record.courtTicketNumbers;
108
+ if (!Array.isArray(raw)) {
109
+ return {
110
+ kind: "damage",
111
+ reason: "courtTicketNumbers is present but not an array",
112
+ };
113
+ }
114
+ if (raw.length === 0) {
115
+ return {
116
+ kind: "damage",
117
+ reason: "courtTicketNumbers is present but empty",
118
+ };
119
+ }
120
+ for (let i = 0; i < raw.length; i += 1) {
121
+ const item = raw[i];
122
+ if (!isSafePositiveTicketNumber(item)) {
123
+ return {
124
+ kind: "damage",
125
+ reason: `courtTicketNumbers[${i}] is not a safe positive integer`,
126
+ };
127
+ }
128
+ }
129
+ // Members are all lawful numbers; sole projection owns dedupe/order.
130
+ const tickets = projectCourtTicketNumbers(raw);
131
+ if (tickets === null || tickets.length === 0) {
132
+ return {
133
+ kind: "damage",
134
+ reason: "courtTicketNumbers projected empty after lawful members",
135
+ };
136
+ }
137
+ const principal = options?.principalTicket;
138
+ if (principal !== undefined) {
139
+ if (!isSafePositiveTicketNumber(principal)) {
140
+ return {
141
+ kind: "damage",
142
+ reason: "courtTicketNumbers principal ticket is not a safe positive integer",
143
+ };
144
+ }
145
+ if (!tickets.includes(principal)) {
146
+ return {
147
+ kind: "damage",
148
+ reason: `courtTicketNumbers is missing principal ticket #${principal}`,
149
+ };
150
+ }
151
+ }
152
+ return { kind: "ok", tickets: Object.freeze([...tickets]) };
153
+ }
154
+
155
+ /** True when two durable #871 sets are byte-identical sequences. */
156
+ export function sameCourtTicketNumbers(
157
+ left: readonly number[],
158
+ right: readonly number[],
159
+ ): boolean {
160
+ if (left.length !== right.length) return false;
161
+ for (let i = 0; i < left.length; i += 1) {
162
+ if (left[i] !== right[i]) return false;
163
+ }
164
+ return true;
165
+ }
166
+
51
167
  export function validateRecordedDiaristOutput(value: unknown): DiaristOutput {
52
168
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
53
169
  throw new Error("Diarist output has no execution discriminator");
@@ -35,6 +35,12 @@ export const diaristOutputSchema = withInfrastructureFailureDeclaration(
35
35
  "本庭对象票号(正整数)或 null/省略=真无票;下游走 typed 键;认不出用 status=escalate,不洗成无录。机械层不重判。",
36
36
  }),
37
37
  ),
38
+ courtTicketNumbers: Type.Optional(
39
+ Type.Unknown({
40
+ description:
41
+ "#871 本次合审应各自成录的票号集合(正整数数组,含主票);单票可省略。机械层只做类型/去重投影,不从散文猜票。",
42
+ }),
43
+ ),
38
44
  reason: Type.Optional(
39
45
  Type.String({
40
46
  description: "status 为 escalate 时:认不出本庭对象的原因",
package/src/diarist.ts CHANGED
@@ -42,6 +42,7 @@ export type DiaristCommitFacts = {
42
42
  export async function commitDiaristEntries(input: {
43
43
  readonly ticketNumber: number;
44
44
  readonly cwd: string;
45
+ readonly sessionParent: string;
45
46
  readonly home?: string;
46
47
  readonly entries: readonly unknown[];
47
48
  }): Promise<DiaristCommitFacts> {
@@ -59,6 +60,7 @@ export async function commitDiaristEntries(input: {
59
60
  appendTicketProvenanceEntry({
60
61
  ticketNumber,
61
62
  cwd,
63
+ sessionParent: input.sessionParent,
62
64
  ...homeOpt,
63
65
  payload: submitted,
64
66
  source: "diarist",
@@ -37,7 +37,10 @@ function deriveSession(content: string, id: string): SessionDerivation {
37
37
 
38
38
  /** Read Pi's retained session directory as the sole raw material for one case. */
39
39
  export async function loadDoctorCase(runsPath: string): Promise<DoctorCase> {
40
- const root = await realpath(runsPath); const match = root.split(sep).join("/").match(/\/\.ak-roles\/books\/[^/]+\/issues\/([1-9]\d*)\/runs$/); if (!match) throw new Error("Doctor case must be an .ak-roles/books/<book>/issues/<n>/runs directory");
40
+ const root = await realpath(runsPath);
41
+ // Canonical <book>/<ticket>/runs; legacy issues/<n>/runs remains read-only compatible.
42
+ const match = root.split(sep).join("/").match(/\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/);
43
+ if (!match) throw new Error("Doctor case must be an .ak-roles/books/<book>/<n>/runs directory");
41
44
  const evidence: DoctorEvidenceEntry[] = [], sessions: DoctorCaseCost["sessions"] = [], statuses: DoctorCaseCost["statuses"] = [], commits: DoctorCaseCost["commits"] = [];
42
45
  const turns: DoctorCount = { count: 0, sources: [] }, calls: DoctorCount = { count: 0, sources: [] }, tokens: DoctorCount = { count: 0, sources: [] };
43
46
  for (const path of await discoverCaseFiles(root)) { const id = relative(root, path).split(sep).join("/"); const bytes = await readFile(path); const content = bytes.toString("utf8"); const kind = id.endsWith(".jsonl") ? "session" : "stderr"; evidence.push({ id, kind, byteLength: bytes.byteLength, contentLength: content.length, sha256: sha256Hex(bytes), content }); if (kind === "stderr") continue; const result = deriveSession(content, id); sessions.push(result.session); statuses.push(...result.statuses); commits.push(...result.commits); accumulate(turns, result.turns, id); accumulate(calls, result.calls, id); accumulate(tokens, result.tokens, id); }
@@ -8,7 +8,7 @@ import { DOCTOR_ACCEPTED_AUDIT_NO_RECEIPT_TEXT, DOCTOR_ACCEPTED_TEXT, DOCTOR_EVI
8
8
  import { sitianReport } from "./sitian-facade.ts";
9
9
 
10
10
  export { DOCTOR_EVIDENCE_TOOL_NAME, DOCTOR_OUTPUT_TOOL_NAME };
11
- export const DOCTOR_CASE_FLAG = { name: "ak-doctor-case", definition: { description: "Retained .ak-roles/books/<book>/issues/<n>/runs directory", type: "string" as const } } as const;
11
+ export const DOCTOR_CASE_FLAG = { name: "ak-doctor-case", definition: { description: "Retained .ak-roles/books/<book>/<n>/runs directory", type: "string" as const } } as const;
12
12
  export type DoctorRoleDependencies = { loadSoul(): Promise<string>; loadCase(path: string): Promise<DoctorCase>; auditCompliance(options: { context: HostContext; signal?: AbortSignal }): Promise<ComplianceDecision> };
13
13
  function appendCandidate(ctx: HostContext, data: unknown): void {
14
14
  try {
@@ -17,6 +17,29 @@ import {
17
17
  type NavigatorSessionFactory,
18
18
  } from "./navigator-session-contracts.ts";
19
19
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
20
+ import { runDirectoryFromHostContext, type HostContext } from "./host-contracts.ts";
21
+
22
+ /**
23
+ * Ledger process home for navigator attendance: admitted HostContext.runDirectory
24
+ * first, else a parent session file under .ak-roles. Never context.home / env HOME /
25
+ * passwd guesses — those split the nest from the owning run (#852).
26
+ */
27
+ async function resolveNavigatorLedgerHome(context: HostContext): Promise<string | undefined> {
28
+ const { tryHomeFromAkRolesPath } = await import(
29
+ "./activation-ledger-topology.ts"
30
+ );
31
+ const runDirectory = runDirectoryFromHostContext(context);
32
+ if (runDirectory !== undefined) {
33
+ const fromRun = tryHomeFromAkRolesPath(runDirectory);
34
+ if (fromRun !== undefined && fromRun.length > 0) return fromRun;
35
+ }
36
+ const parentFile = context.sessionManager?.getSessionFile?.();
37
+ if (typeof parentFile === "string" && parentFile.trim() !== "") {
38
+ const fromParent = tryHomeFromAkRolesPath(parentFile);
39
+ if (fromParent !== undefined && fromParent.length > 0) return fromParent;
40
+ }
41
+ return undefined;
42
+ }
20
43
 
21
44
  export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
22
45
  return async ({ context, subject, tool }) => {
@@ -24,12 +47,17 @@ export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
24
47
  let thinkingLevel = resolved.thinkingLevel;
25
48
 
26
49
  // Archivist nest for attendance route memory only (ADR 0018 / 0065) — not a session open.
27
- const { createRecordSession } = await import("./archivist-record-entry.ts");
50
+ // #852: navigator/<work-subject> is the sole book-top exception; always pass subject so
51
+ // unmaterialized/missing parent still gets a durable nest instead of silent in-memory.
52
+ // Home comes from HostContext.runDirectory (or ledger parent path) — never context.home.
53
+ const { createRecordSession, NAVIGATOR_RECORD_KIND } = await import("./archivist-record-entry.ts");
54
+ const home = await resolveNavigatorLedgerHome(context);
28
55
  const sessionManager = createRecordSession({
29
56
  cwd: context.cwd,
30
- kind: "navigator",
57
+ kind: NAVIGATOR_RECORD_KIND,
31
58
  subject,
32
- parent: context.sessionManager,
59
+ ...(context.sessionManager !== undefined ? { parent: context.sessionManager } : {}),
60
+ ...(home !== undefined ? { home } : {}),
33
61
  });
34
62
 
35
63
  let providerFailure: NavigatorProviderFailureFact | undefined;
@@ -37,31 +65,6 @@ export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
37
65
  let disposed = false;
38
66
  let inFlightPrompt: Promise<unknown> | undefined;
39
67
 
40
- const resolveHome = async (): Promise<string | undefined> => {
41
- const parentFile = context.sessionManager?.getSessionFile?.();
42
- if (typeof parentFile === "string" && parentFile.trim() !== "") {
43
- try {
44
- const { tryHomeFromAkRolesPath, homeFromRunDirectory } = await import(
45
- "./activation-ledger-topology.ts"
46
- );
47
- const fromParent = tryHomeFromAkRolesPath(parentFile);
48
- if (fromParent !== undefined && fromParent.length > 0) return fromParent;
49
- const runDir = parentFile.replace(/\/session\/session\.jsonl$/, "");
50
- if (runDir !== parentFile) {
51
- try {
52
- return homeFromRunDirectory(runDir);
53
- } catch {
54
- // fall through
55
- }
56
- }
57
- } catch {
58
- // fall through
59
- }
60
- }
61
- const envHome = process.env.HOME;
62
- return typeof envHome === "string" && envHome.trim() !== "" ? envHome : undefined;
63
- };
64
-
65
68
  return {
66
69
  prompt: async (text) => {
67
70
  if (disposed) {
@@ -72,13 +75,13 @@ export function createNativeNavigatorSessionFactory(): NavigatorSessionFactory {
72
75
  const run = (async () => {
73
76
  try {
74
77
  const { summonPublicRole } = await import("./public-role-summons.ts");
75
- const home = await resolveHome();
78
+ const summonHome = await resolveNavigatorLedgerHome(context);
76
79
  // Public activation — same face as `ak-role navigator <instruction>` (#675).
77
80
  const summoned = await summonPublicRole({
78
81
  role: "navigator",
79
82
  argv: [text],
80
83
  cwd: context.cwd,
81
- ...(home === undefined ? {} : { home }),
84
+ ...(summonHome === undefined ? {} : { home: summonHome }),
82
85
  });
83
86
 
84
87
  const outcome = summoned.terminal?.roleOutcome;
@@ -13,7 +13,7 @@ import {
13
13
  resolveActivationLedgerHome,
14
14
  } from "./activation-ledger-topology.ts";
15
15
  import type { NotarySourceRunLocator } from "./notary-contracts.ts";
16
- import { readRoleRunIdentity } from "./public-cli/run-lifecycle.ts";
16
+ import { findRunDirectoryById, readRoleRunIdentity } from "./public-cli/run-lifecycle.ts";
17
17
 
18
18
  // Run directory leaf: <runId>@<role>. Production uses uuidv7 runIds; offline tracers
19
19
  // may use deterministic non-uuid leaves. Role token stays identifier-shaped.
@@ -92,7 +92,8 @@ export async function resolveNotarySourceRunLocator(options: {
92
92
  let candidate: string;
93
93
  const bare = parseRunDirectoryName(raw);
94
94
  if (bare !== undefined && !raw.includes("/") && !raw.includes("\\")) {
95
- candidate = join(bookRunsRoot, `${bare.runId}@${bare.role}`);
95
+ candidate = (await findRunDirectoryById(options.home, bare.runId, bookKey, bare.role))
96
+ ?? join(bookRunsRoot, `${bare.runId}@${bare.role}`);
96
97
  } else {
97
98
  candidate = isAbsolute(raw) ? raw : resolve(options.projectRoot, raw);
98
99
  }
@@ -100,9 +101,13 @@ export async function resolveNotarySourceRunLocator(options: {
100
101
  const real = await requireRunDirectory(candidate, raw);
101
102
  const identity = parseRunDirectoryName(basename(real))!;
102
103
 
103
- const runsRootIdentity = physicalPathIdentity(bookRunsRoot);
104
+ const bookIdentity = physicalPathIdentity(activationBookDirectory(ledgerHome, bookKey));
104
105
  const parentIdentity = physicalPathIdentity(dirname(real));
105
- if (parentIdentity !== runsRootIdentity) {
106
+ const subjectBookIdentity = physicalPathIdentity(dirname(dirname(dirname(real))));
107
+ if (
108
+ parentIdentity !== physicalPathIdentity(bookRunsRoot) &&
109
+ !(basename(dirname(real)) === "runs" && subjectBookIdentity === bookIdentity)
110
+ ) {
106
111
  throw new NotarySourceRunError(
107
112
  "notary --source-run must resolve to a retained run under the project machine-ledger book",
108
113
  );
@@ -8,10 +8,8 @@ import type {
8
8
  NewDurablePrincipalRequest,
9
9
  } from "../host-contracts.ts";
10
10
  import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
11
- import {
12
- activationBookDirectory,
13
- resolveActivationLedgerHome,
14
- } from "../activation-ledger-topology.ts";
11
+ import { resolveActivationLedgerHome } from "../activation-ledger-topology.ts";
12
+ import { roleRunPlacement } from "../role-run-placement.ts";
15
13
 
16
14
  type PiDurablePrincipal = DurablePrincipal & {
17
15
  readonly sessionDirectory: string;
@@ -31,18 +29,16 @@ export function issuePiDurablePrincipalCoordinates(
31
29
  } {
32
30
  const ledgerHome = resolveActivationLedgerHome(request.home);
33
31
  const bookKey = resolveBookKeyFromGit(request.cwd);
34
- const runDirectory = join(
35
- activationBookDirectory(ledgerHome, bookKey),
36
- "runs",
37
- `${request.runId}@${request.role}`,
38
- );
39
- const sessionDirectory = join(runDirectory, "session");
32
+ const placement = roleRunPlacement(ledgerHome, {
33
+ bookKey,
34
+ subject: { unbound: true },
35
+ runId: request.runId,
36
+ role: request.role,
37
+ });
40
38
  return {
41
39
  ledgerHome,
42
40
  bookKey,
43
- runDirectory,
44
- sessionDirectory,
45
- sessionFile: join(sessionDirectory, "session.jsonl"),
41
+ ...placement,
46
42
  };
47
43
  }
48
44
 
@@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto";
16
16
  import { lstat, mkdir, open } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
 
19
+ import { roleRunArtifactsDirectory } from "../role-run-placement.ts";
19
20
  import type {
20
21
  DurablePrincipal,
21
22
  DurablePrincipalAuthority,
@@ -143,11 +144,6 @@ export class TurnDispatchedFailure extends Error {
143
144
  /** Session custom-entry type carrying the pointer to one dispatch error file. */
144
145
  export const DISPATCH_ERROR_RETENTION_ENTRY_TYPE = "ak_run_dispatch_error_retention" as const;
145
146
 
146
- /** Artifacts subdirectory of a run directory (established run-artifacts location). */
147
- function runArtifactsDirectory(runDirectory: string): string {
148
- return join(runDirectory, "artifacts");
149
- }
150
-
151
147
  /**
152
148
  * #182-A hardened path identity, mirrored from settlement.ts's
153
149
  * ensureAuditEvidenceDirectory: a planted symlink at the run directory or the
@@ -163,7 +159,7 @@ export async function ensureRealArtifactsDirectory(runDirectory: string): Promis
163
159
  if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
164
160
  throw new Error("run artifact retention: run directory is not a real directory");
165
161
  }
166
- const artifactsDir = runArtifactsDirectory(runDirectory);
162
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
167
163
  try {
168
164
  const existing = await lstat(artifactsDir);
169
165
  if (existing.isSymbolicLink() || !existing.isDirectory()) {