@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
@@ -3,23 +3,40 @@
3
3
  * 调用方只声明自己是谁的什么;落点由候簿拓扑算出,签名不含任何落点/路径参数。
4
4
  * 「谁调了谁」复用 Pi parentSession + ADR 0047 correlation,不新增 caller 字段。
5
5
  */
6
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
6
+ import {
7
+ closeSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ readSync,
14
+ realpathSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from "node:fs";
7
18
  import { dirname, resolve, join } from "node:path";
19
+ import { StringDecoder } from "node:string_decoder";
8
20
  import { SessionManager } from "@earendil-works/pi-coding-agent";
9
21
 
10
- import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
11
22
  import {
12
23
  ActivationLedgerError,
13
- activationBookDirectory,
14
24
  ensureRealDirectoryTree,
25
+ errnoCode,
15
26
  errorText,
16
27
  pathContainedIn,
17
28
  physicallyContainedIn,
18
29
  resolveActivationLedgerHomeForPath,
19
30
  } from "./activation-ledger-topology.ts";
20
- import { subjectKeyedRecordDirectory } from "./archivist-record-topology.ts";
31
+ import {
32
+ NAVIGATOR_RECORD_KIND,
33
+ resolveNavigatorWorkSubjectPlacement,
34
+ } from "./archivist-record-topology.ts";
21
35
 
22
- export { subjectKeyedRecordDirectory } from "./archivist-record-topology.ts";
36
+ export {
37
+ NAVIGATOR_RECORD_KIND,
38
+ resolveNavigatorWorkSubjectPlacement,
39
+ } from "./archivist-record-topology.ts";
23
40
 
24
41
  const CURRENT_SESSION_LEDGER = "current-session.json";
25
42
 
@@ -58,20 +75,234 @@ function writeCurrentSession(sessionDir: string, sessionFile: string): void {
58
75
  }
59
76
  }
60
77
 
61
- /** Parent session surface needed to link and (when already under home) nest the record. */
78
+ /** True when wx lost to a peer creator — native EEXIST on the write itself. */
79
+ function isCurrentSessionClaimRace(error: unknown): boolean {
80
+ if (!(error instanceof ActivationLedgerError)) return errnoCode(error) === "EEXIST";
81
+ return errnoCode(error.cause) === "EEXIST";
82
+ }
83
+
84
+ function currentSessionLedgerPath(sessionDir: string): string {
85
+ return join(sessionDir, CURRENT_SESSION_LEDGER);
86
+ }
87
+
88
+ /** Same bounds as Pi readSessionHeader — do not invent a second scan ceiling. */
89
+ const SESSION_HEADER_CHUNK_BYTES = 4096;
90
+ const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
91
+
92
+ type NavigatorSessionHeader = {
93
+ readonly type: "session";
94
+ readonly id: string;
95
+ readonly cwd?: string;
96
+ };
97
+
98
+ /**
99
+ * Bounded session-header discovery matching Pi readSessionHeader:
100
+ * 4KiB chunks, ≤1MiB total, StringDecoder across reads + EOF flush, skip leading
101
+ * blank lines, first non-blank JSON line only. Accepted session header → value;
102
+ * complete non-session / malformed first line → rejected (non-candidate, stop);
103
+ * no complete line yet → incomplete (keep reading). Oversize-without-header →
104
+ * undefined. Real open/read I/O failures propagate — never washed into non-candidate.
105
+ */
106
+ type SessionHeaderRead =
107
+ | { readonly kind: "accepted"; readonly header: NavigatorSessionHeader }
108
+ | { readonly kind: "rejected" }
109
+ | { readonly kind: "incomplete" };
110
+
111
+ function readBoundedSessionHeader(filePath: string): NavigatorSessionHeader | undefined {
112
+ const fd = openSync(filePath, "r");
113
+ try {
114
+ const buffer = Buffer.allocUnsafe(SESSION_HEADER_CHUNK_BYTES);
115
+ const decoder = new StringDecoder("utf8");
116
+ let pending = "";
117
+ let scanned = 0;
118
+ const consume = (chunk: string): SessionHeaderRead => {
119
+ pending += chunk;
120
+ let lineStart = 0;
121
+ let newlineIndex = pending.indexOf("\n", lineStart);
122
+ while (newlineIndex !== -1) {
123
+ const line = pending.slice(lineStart, newlineIndex);
124
+ lineStart = newlineIndex + 1;
125
+ newlineIndex = pending.indexOf("\n", lineStart);
126
+ if (line.trim() === "") continue;
127
+ // First non-blank complete line decides — never scan past a rejection.
128
+ pending = pending.slice(lineStart);
129
+ const header = parseSessionHeaderLine(line);
130
+ return header === undefined
131
+ ? { kind: "rejected" }
132
+ : { kind: "accepted", header };
133
+ }
134
+ pending = pending.slice(lineStart);
135
+ return { kind: "incomplete" };
136
+ };
137
+ const finishPending = (tail: string): NavigatorSessionHeader | undefined => {
138
+ const result = consume(tail);
139
+ if (result.kind === "accepted") return result.header;
140
+ if (result.kind === "rejected") return undefined;
141
+ // EOF with residual non-blank bytes and no newline: treat as one final line.
142
+ if (pending.trim() === "") return undefined;
143
+ const header = parseSessionHeaderLine(pending);
144
+ pending = "";
145
+ return header;
146
+ };
147
+ while (scanned < MAX_SESSION_HEADER_SCAN_BYTES) {
148
+ const toRead = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scanned);
149
+ const bytesRead = readSync(fd, buffer, 0, toRead, null);
150
+ if (bytesRead === 0) {
151
+ return finishPending(decoder.end());
152
+ }
153
+ scanned += bytesRead;
154
+ const found = consume(decoder.write(buffer.subarray(0, bytesRead)));
155
+ if (found.kind === "accepted") return found.header;
156
+ if (found.kind === "rejected") return undefined;
157
+ }
158
+ // Exactly at the scan limit with a complete final line is still accepted;
159
+ // any further unread byte means the header never arrived in bounds.
160
+ const probe = Buffer.allocUnsafe(1);
161
+ if (readSync(fd, probe, 0, 1, null) === 0) {
162
+ return finishPending(decoder.end());
163
+ }
164
+ return undefined;
165
+ } finally {
166
+ closeSync(fd);
167
+ }
168
+ }
169
+
170
+ function parseSessionHeaderLine(line: string): NavigatorSessionHeader | undefined {
171
+ if (line.trim() === "") return undefined;
172
+ let parsed: unknown;
173
+ try {
174
+ parsed = JSON.parse(line);
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ if (
179
+ typeof parsed !== "object"
180
+ || parsed === null
181
+ || (parsed as { type?: unknown }).type !== "session"
182
+ || typeof (parsed as { id?: unknown }).id !== "string"
183
+ || (parsed as { id: string }).id.length === 0
184
+ ) {
185
+ return undefined;
186
+ }
187
+ const cwd = (parsed as { cwd?: unknown }).cwd;
188
+ return {
189
+ type: "session",
190
+ id: (parsed as { id: string }).id,
191
+ ...(typeof cwd === "string" ? { cwd } : {}),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Pre-sidecar navigator continuation (historical findMostRecentSession selection):
197
+ * bounded valid session header, header.cwd resolves equal to the calling cwd,
198
+ * newest file mtime wins. Nest-local only — not a generic kind fallback.
199
+ * Returns undefined when no candidate matches (caller mints fresh, old null path).
200
+ * Directory/stat/read I/O failures propagate as ActivationLedgerError (failure honesty);
201
+ * format non-candidates stay skippable.
202
+ */
203
+ function mostRecentNavigatorSessionFile(sessionDir: string, cwd: string): string | undefined {
204
+ const absoluteSessionDir = resolve(sessionDir);
205
+ const absoluteCwd = resolve(cwd);
206
+ let names: string[];
207
+ try {
208
+ names = readdirSync(absoluteSessionDir);
209
+ } catch (error) {
210
+ throw new ActivationLedgerError(
211
+ `archivist navigator nest is not readable (${sessionDir}): ${errorText(error)}`,
212
+ { cause: error },
213
+ );
214
+ }
215
+ const matched: { path: string; mtimeMs: number }[] = [];
216
+ for (const name of names) {
217
+ if (!name.endsWith(".jsonl")) continue;
218
+ const filePath = join(absoluteSessionDir, name);
219
+ let st;
220
+ try {
221
+ st = statSync(filePath);
222
+ } catch (error) {
223
+ throw new ActivationLedgerError(
224
+ `archivist navigator session file is not stat-able (${filePath}): ${errorText(error)}`,
225
+ { cause: error },
226
+ );
227
+ }
228
+ if (!st.isFile()) continue;
229
+ let header: NavigatorSessionHeader | undefined;
230
+ try {
231
+ header = readBoundedSessionHeader(filePath);
232
+ } catch (error) {
233
+ throw new ActivationLedgerError(
234
+ `archivist navigator session header is not readable (${filePath}): ${errorText(error)}`,
235
+ { cause: error },
236
+ );
237
+ }
238
+ if (header === undefined) continue;
239
+ if (typeof header.cwd !== "string" || header.cwd.length === 0) continue;
240
+ if (resolve(header.cwd) !== absoluteCwd) continue;
241
+ matched.push({ path: filePath, mtimeMs: st.mtimeMs });
242
+ }
243
+ if (matched.length === 0) return undefined;
244
+ matched.sort((a, b) => b.mtimeMs - a.mtimeMs);
245
+ return matched[0]!.path;
246
+ }
247
+
248
+ /**
249
+ * Resume target for an existing navigator work-subject nest.
250
+ * Prefer the AK current-session sidecar. When absent (legal pre-sidecar / migrated
251
+ * shape), adopt by historical cwd + mtime recency and write the sidecar once.
252
+ * No match → undefined so the sole entry mints fresh (old null path).
253
+ * Ordinary kinds and worker-submission-gate do not use this path.
254
+ */
255
+ function resolveNavigatorNestContinuation(
256
+ sessionDir: string,
257
+ cwd: string,
258
+ ): string | undefined {
259
+ const ledgerPath = currentSessionLedgerPath(sessionDir);
260
+ if (existsSync(ledgerPath)) {
261
+ const recentFile = readCurrentSession(sessionDir);
262
+ assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
263
+ return recentFile;
264
+ }
265
+ const adopted = mostRecentNavigatorSessionFile(sessionDir, cwd);
266
+ if (adopted === undefined) return undefined;
267
+ assertRecentFinalFileUnderSessionDir(sessionDir, adopted);
268
+ try {
269
+ writeCurrentSession(sessionDir, adopted);
270
+ return adopted;
271
+ } catch (error) {
272
+ // Concurrent first adopter lost wx — read the winner through the same
273
+ // containment gate. Other write failures keep their typed cause.
274
+ if (isCurrentSessionClaimRace(error)) {
275
+ const recentFile = readCurrentSession(sessionDir);
276
+ assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
277
+ return recentFile;
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+
283
+ /** Durable parent session surface that links and nests the record. */
62
284
  export type RecordSessionParent = {
63
285
  getSessionFile(): string | undefined;
64
286
  };
65
287
 
66
288
  export type CreateRecordSessionOptions = {
67
- /** Role working directory — used for git book-key discovery when the parent is not already under home. Not a record destination. */
289
+ /** Role working directory passed to the record session; not a placement input. */
68
290
  readonly cwd: string;
69
291
  /** What kind of record this is (e.g. "auditor-roles"). Single path segment; not a destination path. */
70
292
  readonly kind: string;
71
- /** Optional parent session — supplies parentSession link; nest under parent only when that parent already lives under the ledger home. */
293
+ /** Parent session — nesting authority for ordinary kinds; optional parentSession link for navigator. */
72
294
  readonly parent?: RecordSessionParent;
73
- /** Stable work identity for book-level records which continue across role runs. */
295
+ /**
296
+ * Durable work-subject intent. For navigator only, selects the book-top
297
+ * `navigator/<work-subject>` nest (dossier-topology / #852); does not restore
298
+ * generic kind partitions.
299
+ */
74
300
  readonly subject?: string;
301
+ /**
302
+ * Process home when no parent path can derive the ledger home. Not a record
303
+ * destination — same identity surface as resolveActivationLedgerHome(home).
304
+ */
305
+ readonly home?: string;
75
306
  };
76
307
 
77
308
  /** Authorized no-subject kind that may resume the most recent same-nest peer (ADR 0066). Sole string true source for gate resume identity. */
@@ -122,10 +353,10 @@ function assertRecentFinalFileUnderSessionDir(
122
353
  }
123
354
  }
124
355
 
125
- /** Open result including the sole resumed fact (nest existed before this open). */
356
+ /** Open result including the sole continuation fact. */
126
357
  export type RecordSessionOpen = {
127
358
  readonly session: SessionManager;
128
- /** True only when an existing same-nest volume was reopened (subject/gate path). */
359
+ /** True when an existing navigator work-subject or worker-submission-gate volume was reopened. */
129
360
  readonly resumed: boolean;
130
361
  };
131
362
 
@@ -136,8 +367,8 @@ export type RecordSessionOpen = {
136
367
  * identity is checked once before SessionManager.open (directory walk cannot see a
137
368
  * trailing .jsonl symlink). New principals mint under the already-validated sessionDir
138
369
  * via destination-free SessionManager.create — no derived postcondition.
139
- * Resume via the AK-owned current-session ledger is limited to subject-keyed identity
140
- * and the authorized worker-submission-gate durable path (ADR 0066).
370
+ * Resume via the AK-owned current-session ledger is limited to navigator work-subject
371
+ * nests and the authorized worker-submission-gate durable path (ADR 0066 / #852).
141
372
  * Other ordinary no-subject children (auditor-roles, evidence-children, …) always mint fresh.
142
373
  * New persisted principals materialize their deferred session header before return so
143
374
  * custom-entry-only writers do not need a parallel delayed-header helper.
@@ -147,52 +378,68 @@ export type RecordSessionOpen = {
147
378
  export function createRecordSessionOpen(options: CreateRecordSessionOptions): RecordSessionOpen {
148
379
  const cwd = options.cwd;
149
380
  const parentFile = options.parent?.getSessionFile();
150
- // Path → ledger home is owned by topology (explicit env.home nests via parent path).
151
- const ledgerHome = resolveActivationLedgerHomeForPath(parentFile);
152
381
 
153
382
  let sessionDir: string;
154
383
  let parentSession: string | undefined;
384
+ let mayResumeSameNest: boolean;
385
+ let ledgerHome: string;
155
386
 
156
- if (options.subject !== undefined) {
157
- sessionDir = subjectKeyedRecordDirectory({
387
+ // #852 sole book-top exception: navigator/<work-subject> via one topology authority.
388
+ // Holds for no parent / unmaterialized parent / materialized parent — never silent in-memory.
389
+ if (options.kind === NAVIGATOR_RECORD_KIND && options.subject !== undefined) {
390
+ const placement = resolveNavigatorWorkSubjectPlacement({
158
391
  cwd,
159
- kind: options.kind,
160
392
  subject: options.subject,
161
393
  ...(parentFile === undefined || parentFile.length === 0
162
394
  ? {}
163
395
  : { parentSessionFile: parentFile }),
396
+ ...(options.home === undefined ? {} : { home: options.home }),
164
397
  });
398
+ sessionDir = placement.sessionDir;
399
+ ledgerHome = placement.ledgerHome;
165
400
  parentSession = parentFile && parentFile.length > 0 ? parentFile : undefined;
401
+ mayResumeSameNest = true;
166
402
  } else if (parentFile === undefined || parentFile.length === 0) {
167
- // No durable parent principal — preserve prior in-memory child behavior.
168
- return { session: SessionManager.inMemory(cwd), resumed: false };
403
+ if (options.subject === undefined) {
404
+ // No durable principal requested: preserve prior in-memory child behavior.
405
+ return { session: SessionManager.inMemory(cwd), resumed: false };
406
+ }
407
+ throw new Error("Durable record ownership requires a parent session inside the ledger home");
169
408
  } else {
409
+ ledgerHome = resolveActivationLedgerHomeForPath(parentFile);
170
410
  const parentResolved = resolve(parentFile);
171
- // Nest under parent only when the parent record already lives under the package home.
172
- // Nest base is dirname(parent file) — the durable principal's directory — never a
173
- // separate getSessionDir() that can diverge (empty in-memory dir + durable file).
174
- // Otherwise the book is resolved from cwd (ADR 0048) and the kind sits under that book —
175
- // workspace / foreign parents cannot drag records out of home.
176
- sessionDir = physicallyContainedIn(ledgerHome, parentResolved)
177
- ? join(dirname(parentResolved), options.kind)
178
- : join(activationBookDirectory(ledgerHome, resolveBookKeyFromGit(cwd)), options.kind);
411
+ if (!physicallyContainedIn(ledgerHome, parentResolved)) {
412
+ throw new Error("Durable record ownership requires a parent session inside the ledger home");
413
+ }
414
+ // Ordinary kinds: durable parent's file is the sole nesting authority. A divergent
415
+ // SessionManager directory must not create a second placement route.
416
+ // Do not restore generic bookDir/<kind> fallback for foreign/unrooted parents.
417
+ sessionDir = join(dirname(parentResolved), options.kind);
179
418
  parentSession = parentFile;
419
+ mayResumeSameNest = options.kind === WORKER_SUBMISSION_GATE_KIND;
180
420
  }
181
421
 
182
422
  const nestAlreadyExists = existsSync(sessionDir);
183
423
  // Directory-chain ownership: containment + physical components (no parallel assert).
184
424
  ensureRealDirectoryTree(ledgerHome, sessionDir);
185
- // Subject-keyed nests continue by subject digest; worker-submission-gate is the
186
- // sole authorized no-subject same-nest continuation. All other kinds mint fresh.
187
- const mayResumeSameNest =
188
- options.subject !== undefined || options.kind === WORKER_SUBMISSION_GATE_KIND;
189
425
  if (mayResumeSameNest && nestAlreadyExists) {
190
- const recentFile = readCurrentSession(sessionDir);
191
- assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
192
- return {
193
- session: SessionManager.open(recentFile, sessionDir, cwd),
194
- resumed: true,
195
- };
426
+ if (options.kind === NAVIGATOR_RECORD_KIND) {
427
+ const recentFile = resolveNavigatorNestContinuation(sessionDir, cwd);
428
+ if (recentFile !== undefined) {
429
+ return {
430
+ session: SessionManager.open(recentFile, sessionDir, cwd),
431
+ resumed: true,
432
+ };
433
+ }
434
+ // No sidecar and no cwd-matching session — mint fresh in the existing nest.
435
+ } else {
436
+ const recentFile = readCurrentSession(sessionDir);
437
+ assertRecentFinalFileUnderSessionDir(sessionDir, recentFile);
438
+ return {
439
+ session: SessionManager.open(recentFile, sessionDir, cwd),
440
+ resumed: true,
441
+ };
442
+ }
196
443
  }
197
444
 
198
445
  const session = SessionManager.create(
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Pure archivist record placement topology (ADR 0048 / 0065).
2
+ * Pure archivist record placement topology (ADR 0048 / 0065 / dossier-topology).
3
3
  * No SessionManager / pi-coding-agent — cold public-bin and ledger discovery
4
4
  * may import this without pulling open-session runtime into the graph.
5
+ *
6
+ * Navigator work-subject is the sole book-top subject partition (#852):
7
+ * books/<book>/navigator/<work-subject-digest>/. Callers must not re-hash
8
+ * subject or re-join book/navigator/digest themselves.
5
9
  */
6
10
  import { createHash } from "node:crypto";
7
11
  import { join } from "node:path";
@@ -10,30 +14,51 @@ import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
10
14
  import {
11
15
  activationBookDirectory,
12
16
  resolveActivationLedgerHome,
13
- resolveActivationLedgerHomeForPath,
17
+ tryHomeFromAkRolesPath,
14
18
  } from "./activation-ledger-topology.ts";
15
19
 
20
+ /** Book-top partition for cross-ticket navigator route-memory (dossier-topology). */
21
+ export const NAVIGATOR_RECORD_KIND = "navigator" as const;
22
+
23
+ /** One resolved navigator work-subject placement — ledger root and nest together. */
24
+ export type NavigatorWorkSubjectPlacement = {
25
+ readonly ledgerHome: string;
26
+ readonly sessionDir: string;
27
+ };
28
+
16
29
  /**
17
- * Sole subject-keyed nest path under the ledger book (ADR 0048 / 0065).
18
- * Callers must not re-hash subject or re-join book/kind/digest themselves.
30
+ * Sole navigator work-subject placement under the ledger book.
31
+ * Returns ledgerHome and nest once so createRecordSessionOpen does not re-branch
32
+ * parent/home. Work-subject key shape is sha256(subject) hex truncated to 32
33
+ * (#852 out of scope — shape unchanged).
34
+ *
35
+ * parentSessionFile contributes home only when tryHomeFromAkRolesPath succeeds;
36
+ * otherwise the explicit home (from HostContext.runDirectory) is kept. Never
37
+ * invents a second home via passwd/env fallback from a foreign parent path.
19
38
  */
20
- export function subjectKeyedRecordDirectory(input: {
39
+ export function resolveNavigatorWorkSubjectPlacement(input: {
21
40
  readonly cwd: string;
22
- readonly kind: string;
23
41
  readonly subject: string;
24
- /** Parent session file — home path-derives from it when present. */
42
+ /** Parent session file — home path-derives from it only under .ak-roles. */
25
43
  readonly parentSessionFile?: string;
26
- /** Explicit process home when no parent file is available (discovery-only). */
44
+ /** Explicit process home when no ledger parent path is available. */
27
45
  readonly home?: string;
28
- }): string {
29
- const ledgerHome =
30
- input.parentSessionFile !== undefined && input.parentSessionFile.length > 0
31
- ? resolveActivationLedgerHomeForPath(input.parentSessionFile)
32
- : resolveActivationLedgerHome(input.home);
46
+ }): NavigatorWorkSubjectPlacement {
47
+ let ledgerHome: string | undefined;
48
+ if (input.parentSessionFile !== undefined && input.parentSessionFile.length > 0) {
49
+ const fromParent = tryHomeFromAkRolesPath(input.parentSessionFile);
50
+ if (fromParent !== undefined && fromParent.length > 0) {
51
+ ledgerHome = resolveActivationLedgerHome(fromParent);
52
+ }
53
+ }
54
+ ledgerHome ??= resolveActivationLedgerHome(input.home);
33
55
  const digest = createHash("sha256").update(input.subject).digest("hex").slice(0, 32);
34
- return join(
35
- activationBookDirectory(ledgerHome, resolveBookKeyFromGit(input.cwd)),
36
- input.kind,
37
- digest,
38
- );
56
+ return {
57
+ ledgerHome,
58
+ sessionDir: join(
59
+ activationBookDirectory(ledgerHome, resolveBookKeyFromGit(input.cwd)),
60
+ NAVIGATOR_RECORD_KIND,
61
+ digest,
62
+ ),
63
+ };
39
64
  }
@@ -2,6 +2,7 @@ import { dirname, join, resolve } from "node:path";
2
2
 
3
3
  import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import type { HostContext } from "./host-contracts.ts";
5
+ import { roleRunArtifactsDirectory } from "./role-run-placement.ts";
5
6
  import { Type } from "typebox";
6
7
 
7
8
  export const AUDITOR_DOSSIER_TOOL_NAME = "ak_get_run_dossier" as const;
@@ -45,7 +46,7 @@ export function createAuditorDossierTool(
45
46
  admittedRequest: join(runDirectory, "admitted-request.json"),
46
47
  parentSessionCandidate: join(runDirectory, "session", "session.jsonl"),
47
48
  attachments: join(runDirectory, "attachments"),
48
- artifacts: join(runDirectory, "artifacts"),
49
+ artifacts: roleRunArtifactsDirectory(runDirectory),
49
50
  };
50
51
  return {
51
52
  content: [{ type: "text", text: JSON.stringify(details) }],