@akagilnc/pi-workflow-roles 0.1.4929 → 0.1.4949

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.
@@ -39,6 +39,36 @@ function sealTornTail(recordFile) {
39
39
  appendFileSync(recordFile, "\n", "utf8");
40
40
  }
41
41
  }
42
+ function appendSitianRecordBlock(input, block) {
43
+ if (block === "") return;
44
+ try {
45
+ const { sessionDir, recordFile, ledgerHome } = resolveSitianRecordPath(input);
46
+ ensureRealDirectoryTree(ledgerHome, sessionDir);
47
+ appendFileSync(recordFile, "", "utf8");
48
+ sealTornTail(recordFile);
49
+ const identityOf = (line) => {
50
+ try {
51
+ const row = JSON.parse(line);
52
+ return isRecord(row) && typeof row.identity === "string" ? row.identity : void 0;
53
+ } catch {
54
+ return void 0;
55
+ }
56
+ };
57
+ const identities = new Set(readFileSync(recordFile, "utf8").split("\n").map(identityOf).filter((id) => id !== void 0));
58
+ for (const line of block.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
59
+ const identity = identityOf(line);
60
+ if (identity !== void 0 && identities.has(identity)) continue;
61
+ appendFileSync(recordFile, line, "utf8");
62
+ if (identity !== void 0) identities.add(identity);
63
+ }
64
+ } catch (error) {
65
+ if (error instanceof SitianInfrastructureError) throw error;
66
+ throw new SitianInfrastructureError(
67
+ `Sitian appender persistence failure: ${errorText(error)}`,
68
+ { cause: error }
69
+ );
70
+ }
71
+ }
42
72
  function findIdentityPointer(recordFile, identity, kind, level) {
43
73
  if (!existsSync(recordFile)) return void 0;
44
74
  const text = readFileSync(recordFile, "utf8");
@@ -170,6 +200,9 @@ function resolveSitianRecordPathInLedger(input, ledgerHome) {
170
200
  );
171
201
  sessionDir = paths.sessionDir;
172
202
  recordFile = paths.recordFile;
203
+ } else if (category === "ticket-provenance" && input.runDirectory !== void 0) {
204
+ sessionDir = input.runDirectory;
205
+ recordFile = join(sessionDir, SITIAN_RECORDS_LEAF);
173
206
  } else {
174
207
  if (input.sessionParent === void 0 || input.sessionParent.length === 0 || !physicallyContainedIn(ledgerHome, input.sessionParent)) {
175
208
  throw new Error("Sitian record ownership requires a parent session inside the ledger home");
@@ -177,6 +210,9 @@ function resolveSitianRecordPathInLedger(input, ledgerHome) {
177
210
  sessionDir = join(dirname(input.sessionParent), category);
178
211
  recordFile = join(sessionDir, SITIAN_RECORDS_LEAF);
179
212
  }
213
+ if (!physicallyContainedIn(ledgerHome, sessionDir)) {
214
+ throw new Error("Sitian record ownership requires a directory inside the ledger home");
215
+ }
180
216
  return { sessionDir, recordFile, ledgerHome };
181
217
  }
182
218
  function projectTicketRecordsPathShape() {
@@ -188,7 +224,7 @@ function projectTicketRecordsPathShape() {
188
224
  return recordFile.replace(/\\/g, "/");
189
225
  }
190
226
  function resolveSitianRecordPath(input) {
191
- const ledgerHome = input.home !== void 0 && input.home.length > 0 ? resolveActivationLedgerHome(input.home) : resolveActivationLedgerHomeForPath(input.sessionParent);
227
+ const ledgerHome = input.home !== void 0 && input.home.length > 0 ? resolveActivationLedgerHome(input.home) : resolveActivationLedgerHomeForPath(input.runDirectory ?? input.sessionParent);
192
228
  return resolveSitianRecordPathInLedger(input, ledgerHome);
193
229
  }
194
230
  function appendSitianRecord(input) {
@@ -226,6 +262,7 @@ function appendSitianRecord(input) {
226
262
  export {
227
263
  S4_SUBMISSION_LEDGER_KINDS,
228
264
  appendSitianRecord,
265
+ appendSitianRecordBlock,
229
266
  projectTicketRecordsPathShape,
230
267
  resolveSitianRecordPath,
231
268
  resolveSitianRecordPathInLedger,
@@ -82,7 +82,7 @@ export function projectTicketProvenanceSessions(value) {
82
82
  export function projectTicketProvenanceHeader(value) {
83
83
  if (!isRecord(value))
84
84
  return undefined;
85
- const ticket = positiveInteger(value.ticket);
85
+ const ticket = value.ticket === null ? null : positiveInteger(value.ticket);
86
86
  if (ticket === undefined)
87
87
  return undefined;
88
88
  if (typeof value.repo !== "string")
@@ -6,14 +6,14 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import { appendFileSync } from "node:fs";
9
- import { readFile } from "node:fs/promises";
9
+ import { readFile, unlink } from "node:fs/promises";
10
10
  import { basename, dirname, join, resolve } from "node:path";
11
11
  import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
12
12
  import { ensureRealDirectoryTree, errnoCode, packageMachineHome, physicalPathIdentity, physicallyContainedIn, resolveActivationLedgerHome, } from "./activation-ledger-topology.js";
13
13
  import { readLedgerSessionJsonlLines, } from "./ledger-session-read.js";
14
14
  import { isSafePositiveTicketNumber } from "./run-ticket-number.js";
15
15
  import { adaptSessionDialogue, nativeEventId } from "./session-dialogue.js";
16
- import { appendSitianRecord, resolveSitianRecordPath, } from "./sitian-facade.js";
16
+ import { appendSitianRecordBlock, appendSitianRecord, resolveSitianRecordPath, } from "./sitian-facade.js";
17
17
  import { TICKET_PROVENANCE_KIND, projectTicketProvenanceHeader, projectTicketProvenanceLine, projectTicketProvenanceSessions, } from "./ticket-provenance-contracts.js";
18
18
  /**
19
19
  * Typed input failure for diarist bounds/session path (reask, not infrastructure).
@@ -73,11 +73,12 @@ export function ticketProvenanceSubject(ticketNumber) {
73
73
  return String(ticketNumber);
74
74
  }
75
75
  /** Topology input only — no destination parameter (ADR 0065 record-entry). */
76
- function ticketProvenanceRecordInput(ticketNumber, cwd, home) {
76
+ function ticketProvenanceRecordInput(ticketNumber, cwd, home, runDirectory) {
77
77
  return {
78
78
  level: "event",
79
79
  kind: TICKET_PROVENANCE_KIND,
80
- subject: ticketProvenanceSubject(ticketNumber),
80
+ ...(ticketNumber === null ? {} : { subject: ticketProvenanceSubject(ticketNumber) }),
81
+ ...(runDirectory === undefined ? {} : { runDirectory }),
81
82
  cwd,
82
83
  ...(home === undefined ? {} : { home }),
83
84
  };
@@ -140,8 +141,10 @@ function projectTicketProvenanceCommit(value) {
140
141
  return { timestamp: record.timestamp, sessions, lines };
141
142
  }
142
143
  /** Read the unique diary file (empty/absent → no header, no lines). */
143
- export async function readTicketProvenance(ticketNumber, cwd, home) {
144
- const { recordFile } = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
144
+ export async function readTicketProvenance(ticketNumber, cwd, home, runDirectory) {
145
+ const { recordFile } = ticketNumber === null
146
+ ? resolveSitianRecordPath(ticketProvenanceRecordInput(null, cwd, home, runDirectory))
147
+ : resolveTicketProvenanceVolume(ticketNumber, cwd, home);
145
148
  let text;
146
149
  try {
147
150
  text = await readFile(recordFile, "utf8");
@@ -252,7 +255,7 @@ export async function readTicketProvenance(ticketNumber, cwd, home) {
252
255
  repo: header?.repo ?? resolveBookKeyFromGit(cwd),
253
256
  ticket: ticketNumber,
254
257
  createdAt: header?.createdAt ?? now,
255
- updatedAt: now,
258
+ updatedAt: header?.updatedAt !== undefined && header.updatedAt > now ? header.updatedAt : now,
256
259
  sessions: merged.sessions,
257
260
  };
258
261
  const carried = lines.map((entry) => ({
@@ -293,6 +296,21 @@ export function ensureTicketProvenanceVolume(ticketNumber, cwd, home) {
293
296
  appendFileSync(path.recordFile, "", "utf8");
294
297
  return { recordFile: path.recordFile, volumeDir: path.sessionDir };
295
298
  }
299
+ /** Assign a run-owned unbound diary as its original block through Sitian. */
300
+ export async function rehomeUnboundTicketProvenance(runDirectory, ticketNumber, cwd, home) {
301
+ const source = join(runDirectory, "records.jsonl");
302
+ let content;
303
+ try {
304
+ content = await readFile(source, "utf8");
305
+ }
306
+ catch (error) {
307
+ if (errnoCode(error) === "ENOENT")
308
+ return;
309
+ throw error;
310
+ }
311
+ appendSitianRecordBlock(ticketProvenanceRecordInput(ticketNumber, cwd, home), content);
312
+ await unlink(source);
313
+ }
296
314
  /**
297
315
  * Resolve a bound endpoint against this round's session lines.
298
316
  * id → first physical line carrying that native id; line → that 1-based line.
@@ -583,7 +601,7 @@ async function projectSessionRanges(input) {
583
601
  * Persistence appends one immutable commit through the Sitian appender seam.
584
602
  */
585
603
  export async function reprojectTicketProvenance(input) {
586
- const prior = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
604
+ const prior = await readTicketProvenance(input.ticketNumber, input.cwd, input.home, input.runDirectory);
587
605
  if (input.sessions.length === 0) {
588
606
  const now = new Date().toISOString();
589
607
  return {
@@ -663,7 +681,7 @@ export async function reprojectTicketProvenance(input) {
663
681
  });
664
682
  const identity = `ticket-provenance:${createHash("sha256").update(identityMaterial).digest("hex")}`;
665
683
  const pointer = appendSitianRecord({
666
- ...ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home),
684
+ ...ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home, input.runDirectory),
667
685
  identity,
668
686
  payload: {
669
687
  type: "ticket-provenance-append",
@@ -671,7 +689,7 @@ export async function reprojectTicketProvenance(input) {
671
689
  lines: [...fresh, ...raw],
672
690
  },
673
691
  });
674
- const folded = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
692
+ const folded = await readTicketProvenance(input.ticketNumber, input.cwd, input.home, input.runDirectory);
675
693
  return {
676
694
  recordFile: pointer.recordFile,
677
695
  header: folded.header ?? header,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4929",
3
+ "version": "0.1.4949",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -2,3 +2,4 @@
2
2
 
3
3
  提交:标题冠 ak-roles: 在最前;forward commit,严禁 amend——改错就再提交一笔,不改历史。
4
4
  交卷回执:改动清单与票面逐项对得上;验证证据附命令与结果原文。
5
+ 新造通用件:回执写明仓内、语言标准库、包生态各搜了什么、为什么不用。
@@ -2,3 +2,4 @@
2
2
 
3
3
  提交:标题冠 ak-roles: 在最前;forward commit,严禁 amend——改错就再提交一笔,不改历史。
4
4
  交卷回执:逐 finding 报处置;验证证据按仓级 CLAUDE.md 证据条款:指针(runId/toolCallId)或摘抄结论皆合格,以读者能直接核为准。
5
+ 新造通用件:回执写明仓内、语言标准库、包生态各搜了什么、为什么不用。
@@ -8,6 +8,8 @@
8
8
 
9
9
  取舍位次:过测试 → 显意图 → 无重复 → 最少元素。
10
10
 
11
+ 生态里已有的不自己造:仓内、语言标准库、包生态有现成件而另写一份,即复杂度缺陷。新造的通用件(序列化、重试、锁、diff、路径处理之类)须答得出搜了这三处各是什么、为什么不用;答不出即封驳。
12
+
11
13
  ## 先搜,再想简单的(owner 2026-09-20)
12
14
 
13
15
  > 逐字:「请你们思考实现方式的时候。多搜搜互联网,多想简单的方式,不是在那加加加,加一堆烂机制最终还要烧我的token来删!」
@@ -522,7 +522,7 @@ function tryBareTicketProvenanceVolume(
522
522
  // Legacy SitianRecord rows carry a typed kind — leave them to line placement.
523
523
  if (recordClassOfKind(first.value.kind) !== undefined) return undefined;
524
524
  const header = projectTicketProvenanceHeader(first.value);
525
- if (header === undefined) return undefined;
525
+ if (header === undefined || header.ticket === null) return undefined;
526
526
  return { ticket: header.ticket, body };
527
527
  }
528
528
 
@@ -23,7 +23,7 @@ export type DiaristOutput =
23
23
  readonly status: "completed";
24
24
  /**
25
25
  * Typed court-target assertion (ADR 0075 `diarist-resolves-ticket-llm-layer`).
26
- * Positive integer = 本庭对象=票N; null/absent = true-unbound (真无票→无录).
26
+ * Positive integer = 本庭对象=票N; null/absent = true-unbound (先录入 unbound).
27
27
  * LLM owns recognition; mechanical layer does not re-judge the number.
28
28
  */
29
29
  readonly ticketNumber?: number | null;
package/src/diarist.ts CHANGED
@@ -12,7 +12,7 @@ import type { TicketProvenanceSession } from "./ticket-provenance-contracts.ts";
12
12
  * Populated by the envelope accept hook — never from model self-report.
13
13
  */
14
14
  export type DiaristCommitFacts = {
15
- readonly ticketNumber: number;
15
+ readonly ticketNumber: number | null;
16
16
  readonly volumeRecordFile: string;
17
17
  /** Dialogue lines written by this reproject. */
18
18
  readonly lineCount: number;
@@ -24,9 +24,10 @@ export type DiaristCommitFacts = {
24
24
  * this seam only copies source facts into the volume.
25
25
  */
26
26
  export async function commitDiaristProjection(input: {
27
- readonly ticketNumber: number;
27
+ readonly ticketNumber: number | null;
28
28
  readonly cwd: string;
29
29
  readonly home?: string;
30
+ readonly runDirectory?: string;
30
31
  readonly sessions: readonly TicketProvenanceSession[];
31
32
  }): Promise<DiaristCommitFacts> {
32
33
  const result = await reprojectTicketProvenance({
@@ -34,6 +35,7 @@ export async function commitDiaristProjection(input: {
34
35
  cwd: input.cwd,
35
36
  ...(input.home === undefined ? {} : { home: input.home }),
36
37
  sessions: input.sessions,
38
+ ...(input.runDirectory === undefined ? {} : { runDirectory: input.runDirectory }),
37
39
  });
38
40
  return {
39
41
  ticketNumber: input.ticketNumber,
@@ -78,6 +78,7 @@ export type CourtDiaristIdentity =
78
78
 
79
79
  export type CourtDiaristInvocationResult = {
80
80
  readonly identity: CourtDiaristIdentity;
81
+ readonly admitted?: import("./invocation.ts").AdmittedRoleInvocation;
81
82
  readonly failedWithoutEscalate?: { readonly diagnostic: string };
82
83
  };
83
84
 
@@ -278,6 +279,7 @@ export async function invokeCourtDiarist(
278
279
  kind: "escalate",
279
280
  diagnostic: courtDiaristEscalateDiagnostic(roleOutcome, submissions),
280
281
  },
282
+ ...(result.admitted === undefined ? {} : { admitted: result.admitted }),
281
283
  };
282
284
  }
283
285
 
@@ -288,6 +290,7 @@ export async function invokeCourtDiarist(
288
290
  : result.stderr?.trim() || `exit ${result.exitCode}`;
289
291
  return {
290
292
  identity: { kind: "unbound" },
293
+ ...(result.admitted === undefined ? {} : { admitted: result.admitted }),
291
294
  failedWithoutEscalate: {
292
295
  diagnostic: `court diarist station failed for ${input.failureLabel}: ${diagnostic}`,
293
296
  },
@@ -308,10 +311,12 @@ export async function invokeCourtDiarist(
308
311
  ticketNumber: asserted,
309
312
  ...(courtTicketNumbers === undefined ? {} : { courtTicketNumbers }),
310
313
  },
314
+ ...(result.admitted === undefined ? {} : { admitted: result.admitted }),
311
315
  };
312
316
  }
313
317
  return {
314
318
  identity: { kind: "unbound" },
319
+ ...(result.admitted === undefined ? {} : { admitted: result.admitted }),
315
320
  };
316
321
  }
317
322
 
@@ -4,7 +4,7 @@
4
4
  * starts two ordinary single-axis runs here. Countersign keeps deferred
5
5
  * identity on this entry; the court diarist station stays in countersign-run.
6
6
  */
7
- import { resolve } from "node:path";
7
+ import { resolve, sep } from "node:path";
8
8
 
9
9
  import type { DurablePrincipalAuthority, RoleTurnRequest } from "../host-contracts.ts";
10
10
  import { isSafePositiveTicketNumber, readBoardTicketNumber } from "../run-ticket-number.ts";
@@ -32,6 +32,7 @@ import {
32
32
  materializeCountersignInvocation,
33
33
  persistAdmittedSourceRunPath,
34
34
  recordAdmittedCorrelation,
35
+ recordChildDiaristRun,
35
36
  relocateAdmittedRunToTicket,
36
37
  withPreparedAttachments,
37
38
  type AdmittedCountersignInvocation,
@@ -429,6 +430,7 @@ async function runCountersignBody(
429
430
  let typedTicket: number | undefined;
430
431
  let typedCourtTicketNumbers: readonly number[] | undefined;
431
432
  let identityDiaristRan = false;
433
+ let unboundDiaristRunId: string | undefined;
432
434
 
433
435
  if (env.runCourtDiaristStation === undefined) {
434
436
  let outcome: Awaited<ReturnType<typeof invokeCourtDiarist>>;
@@ -452,6 +454,9 @@ async function runCountersignBody(
452
454
  }, seatAdapters(admitted, env), env.principalAuthority, io) as SeatRunResult;
453
455
  }
454
456
  identityDiaristRan = true;
457
+ if (outcome.admitted?.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) {
458
+ unboundDiaristRunId = outcome.admitted.runId;
459
+ }
455
460
  if (outcome.identity.kind === "escalate" || outcome.failedWithoutEscalate !== undefined) {
456
461
  const diagnostic = outcome.identity.kind === "escalate"
457
462
  ? outcome.identity.diagnostic
@@ -459,6 +464,7 @@ async function runCountersignBody(
459
464
  await materializeAdmission(
460
465
  isSafePositiveTicketNumber(env.boundTicketNumber) ? env.boundTicketNumber : undefined,
461
466
  );
467
+ if (unboundDiaristRunId !== undefined) await recordChildDiaristRun(admitted, unboundDiaristRunId);
462
468
  await markRunAdmitted(admitted, env.principalAuthority);
463
469
  return await presentControlledFailure(admitted, {
464
470
  timedOut: false,
@@ -479,6 +485,9 @@ async function runCountersignBody(
479
485
 
480
486
  await materializeAdmission(typedTicket);
481
487
  await markRunAdmitted(admitted, env.principalAuthority);
488
+ if (unboundDiaristRunId !== undefined) {
489
+ await recordChildDiaristRun(admitted, unboundDiaristRunId);
490
+ }
482
491
  if (gateParentRunPath !== undefined) {
483
492
  await persistAdmittedSourceRunPath(admitted, gateParentRunPath);
484
493
  admitted = { ...admitted, sourceRunPath: gateParentRunPath };
@@ -745,6 +754,9 @@ export async function runPublicInstructionSeat(
745
754
  ...(env.signal === undefined ? {} : { signal: env.signal }),
746
755
  ...(env.hostAdapters === undefined ? {} : { hostAdapters: env.hostAdapters }),
747
756
  }, io);
757
+ if (outcome.admitted?.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) {
758
+ await recordChildDiaristRun(admitted, outcome.admitted.runId);
759
+ }
748
760
  if (outcome.identity.kind === "escalate" || outcome.failedWithoutEscalate !== undefined) {
749
761
  const diagnostic = outcome.identity.kind === "escalate"
750
762
  ? outcome.identity.diagnostic
@@ -758,6 +770,9 @@ export async function runPublicInstructionSeat(
758
770
  }
759
771
  if (outcome.identity.kind === "ticket") {
760
772
  try {
773
+ if (outcome.admitted === undefined) throw new Error("court diarist ticket has no admitted run");
774
+ await bindAdmittedTicketNumber(outcome.admitted, outcome.identity.ticketNumber);
775
+ await relocateAdmittedRunToTicket(outcome.admitted, env.principalAuthority);
761
776
  await bindAdmittedTicketNumber(admitted, outcome.identity.ticketNumber);
762
777
  await relocateAdmittedRunToTicket(admitted, env.principalAuthority);
763
778
  } catch (error) {
@@ -83,6 +83,7 @@ import {
83
83
  type MergerInput,
84
84
  } from "../merger-contracts.ts";
85
85
  import { sha256Hex } from "../sha256.ts";
86
+ import { rehomeUnboundTicketProvenance } from "../ticket-provenance.ts";
86
87
  import { uuidv7 } from "../uuidv7.ts";
87
88
  import {
88
89
  NOTARY_FIXED_KICKOFF,
@@ -661,9 +662,36 @@ export async function relocateAdmittedRunToTicket(
661
662
  // Keep that failure before the filesystem commit point.
662
663
  const principal = authority.seal(target);
663
664
 
664
- // Rename is the only durable commit. Persisted paths are resolved from typed
665
- // run identity on read, so online relocation never writes unleased peers or
666
- // pretends a directory rename plus page rewrites form one transaction.
665
+ if (admitted.role !== "diarist") {
666
+ const parentPage = JSON.parse(await readFile(admitted.admittedRequestPath, "utf8")) as Record<string, unknown>;
667
+ const childRunIds = parentPage.childDiaristRunIds;
668
+ for (const childRunId of Array.isArray(childRunIds) ? childRunIds : []) {
669
+ if (typeof childRunId !== "string") continue;
670
+ const childDirectory = join(dirname(oldRunDirectory), `${childRunId}@diarist`);
671
+ const childTarget = roleRunPlacement(ledgerHome, {
672
+ bookKey: admitted.bookKey,
673
+ subject: { ticketNumber: admitted.ticketNumber },
674
+ runId: childRunId,
675
+ role: "diarist",
676
+ });
677
+ authority.seal(childTarget);
678
+ if (!existsSync(childDirectory) && existsSync(childTarget.runDirectory)) continue;
679
+ await bindTicketNumberOnRunDirectory(childDirectory, admitted.ticketNumber);
680
+ await rehomeUnboundTicketProvenance(childDirectory, admitted.ticketNumber, admitted.projectRoot, homeFromRunDirectory(oldRunDirectory));
681
+ ensureRoleRunDirectory(ledgerHome, dirname(childTarget.runDirectory));
682
+ await rename(childDirectory, childTarget.runDirectory);
683
+ }
684
+ }
685
+
686
+ // An identity diarist can file before its caller obtains a ticket. Both its
687
+ // own bind and the caller's later bind use this existing relocation seam.
688
+ if (admitted.role === "diarist") {
689
+ await rehomeUnboundTicketProvenance(oldRunDirectory, admitted.ticketNumber, admitted.projectRoot, homeFromRunDirectory(oldRunDirectory));
690
+ }
691
+
692
+ // Rename commits the run placement. Diary assignment above is an idempotent
693
+ // Sitian append, not part of an atomic transaction with this directory move.
694
+ // Persisted paths are resolved from typed run identity on read.
667
695
  await rename(oldRunDirectory, target.runDirectory);
668
696
 
669
697
  // rename moved the open lock inode with the directory. Transfer cleanup
@@ -697,6 +725,19 @@ export async function relocateAdmittedRunToTicket(
697
725
  return { oldRunDirectory, newRunDirectory: target.runDirectory };
698
726
  }
699
727
 
728
+ /** Persist the exact child identity; later parent binding never inspects siblings. */
729
+ export async function recordChildDiaristRun(
730
+ parent: AdmittedRoleInvocation,
731
+ childRunId: string,
732
+ ): Promise<void> {
733
+ const page = JSON.parse(await readFile(parent.admittedRequestPath, "utf8")) as Record<string, unknown>;
734
+ const existing = Array.isArray(page.childDiaristRunIds)
735
+ ? page.childDiaristRunIds.filter((runId): runId is string => typeof runId === "string")
736
+ : [];
737
+ const childDiaristRunIds = existing.includes(childRunId) ? existing : [...existing, childRunId];
738
+ await writeFile(parent.admittedRequestPath, `${JSON.stringify({ ...page, childDiaristRunIds }, null, 2)}\n`, "utf8");
739
+ }
740
+
700
741
  /**
701
742
  * Bind ticket identity onto a run directory's durable pages when the admitted
702
743
  * object is not in hand (起居郎 accept hook after LLM assertion, #771).
@@ -905,29 +905,30 @@ export function createDiaristRoleRuntime(
905
905
  if (coords.boundTicketNumber === undefined) {
906
906
  await bindTicketNumberOnRunDirectory(coords.runDirectory, ticketNumber);
907
907
  }
908
- const sessions = projectDiaristSessions(parameters);
909
- if (sessions === undefined) {
910
- throw new ParentQueueReaskError(DIARIST_BOUNDS_REASK);
911
- }
912
- let facts;
913
- try {
914
- facts = await commitDiaristProjection({
915
- ticketNumber,
916
- cwd: coords.projectRoot,
917
- home: coords.home,
918
- sessions,
919
- });
920
- } catch (error) {
921
- // Bound/session input failures → reask via typed identity (not message prefix).
922
- // Unexpected infrastructure keeps its own identity (do not wash).
923
- if (error instanceof ParentQueueReaskError) throw error;
924
- if (error instanceof TicketProvenanceInputError) {
925
- throw new ParentQueueReaskError(
926
- `${DIARIST_BOUNDS_REASK}\n${error.message}`,
927
- );
928
- }
929
- throw error;
908
+ }
909
+ const sessions = projectDiaristSessions(parameters);
910
+ if (sessions === undefined) {
911
+ throw new ParentQueueReaskError(DIARIST_BOUNDS_REASK);
912
+ }
913
+ let facts;
914
+ try {
915
+ facts = await commitDiaristProjection({
916
+ ticketNumber: ticketNumber ?? null,
917
+ cwd: coords.projectRoot,
918
+ home: coords.home,
919
+ runDirectory: coords.runDirectory,
920
+ sessions,
921
+ });
922
+ } catch (error) {
923
+ // Bound/session input failures → reask via typed identity (not message prefix).
924
+ // Unexpected infrastructure keeps its own identity (do not wash).
925
+ if (error instanceof ParentQueueReaskError) throw error;
926
+ if (error instanceof TicketProvenanceInputError) {
927
+ throw new ParentQueueReaskError(
928
+ `${DIARIST_BOUNDS_REASK}\n${error.message}`,
929
+ );
930
930
  }
931
+ throw error;
931
932
  }
932
933
  return parameters;
933
934
  },
@@ -59,6 +59,38 @@ function sealTornTail(recordFile: string): void {
59
59
  }
60
60
  }
61
61
 
62
+ /** Assign already-recorded lines, preserving their original text and malformed lines. */
63
+ export function appendSitianRecordBlock(input: SitianRecordInput, block: string): void {
64
+ if (block === "") return;
65
+ try {
66
+ const { sessionDir, recordFile, ledgerHome } = resolveSitianRecordPath(input);
67
+ ensureRealDirectoryTree(ledgerHome, sessionDir);
68
+ appendFileSync(recordFile, "", "utf8");
69
+ sealTornTail(recordFile);
70
+ const identityOf = (line: string): string | undefined => {
71
+ try {
72
+ const row: unknown = JSON.parse(line);
73
+ return isRecord(row) && typeof row.identity === "string" ? row.identity : undefined;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ };
78
+ const identities = new Set(readFileSync(recordFile, "utf8").split("\n").map(identityOf).filter((id): id is string => id !== undefined));
79
+ for (const line of block.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
80
+ const identity = identityOf(line);
81
+ if (identity !== undefined && identities.has(identity)) continue;
82
+ appendFileSync(recordFile, line, "utf8");
83
+ if (identity !== undefined) identities.add(identity);
84
+ }
85
+ } catch (error) {
86
+ if (error instanceof SitianInfrastructureError) throw error;
87
+ throw new SitianInfrastructureError(
88
+ `Sitian appender persistence failure: ${errorText(error)}`,
89
+ { cause: error },
90
+ );
91
+ }
92
+ }
93
+
62
94
  function findIdentityPointer(
63
95
  recordFile: string,
64
96
  identity: string,
@@ -254,6 +286,9 @@ export function resolveSitianRecordPathInLedger(
254
286
  );
255
287
  sessionDir = paths.sessionDir;
256
288
  recordFile = paths.recordFile;
289
+ } else if (category === "ticket-provenance" && input.runDirectory !== undefined) {
290
+ sessionDir = input.runDirectory;
291
+ recordFile = join(sessionDir, SITIAN_RECORDS_LEAF);
257
292
  } else {
258
293
  if (
259
294
  input.sessionParent === undefined
@@ -265,6 +300,9 @@ export function resolveSitianRecordPathInLedger(
265
300
  sessionDir = join(dirname(input.sessionParent), category);
266
301
  recordFile = join(sessionDir, SITIAN_RECORDS_LEAF);
267
302
  }
303
+ if (!physicallyContainedIn(ledgerHome, sessionDir)) {
304
+ throw new Error("Sitian record ownership requires a directory inside the ledger home");
305
+ }
268
306
 
269
307
  return { sessionDir, recordFile, ledgerHome };
270
308
  }
@@ -288,7 +326,7 @@ export function resolveSitianRecordPath(input: SitianRecordInput): SitianRecordP
288
326
  const ledgerHome =
289
327
  input.home !== undefined && input.home.length > 0
290
328
  ? resolveActivationLedgerHome(input.home)
291
- : resolveActivationLedgerHomeForPath(input.sessionParent);
329
+ : resolveActivationLedgerHomeForPath(input.runDirectory ?? input.sessionParent);
292
330
  return resolveSitianRecordPathInLedger(input, ledgerHome);
293
331
  }
294
332
 
@@ -63,6 +63,8 @@ export type SitianRecordInput = {
63
63
  readonly identity?: string | undefined;
64
64
  readonly subject?: SitianSubject | undefined;
65
65
  readonly sessionParent?: string | undefined;
66
+ /** Run owner for ticket provenance before a ticket is known. */
67
+ readonly runDirectory?: string | undefined;
66
68
  readonly priorEventId?: string | undefined;
67
69
  readonly timestamp?: string | undefined;
68
70
  readonly host?: string | undefined;
@@ -40,7 +40,7 @@ export type TicketProvenanceSession = {
40
40
  /** 读取时折叠出的逻辑册子头。sessions 为累计区间;reopen 与跨宿主并入,不新建册。 */
41
41
  export type TicketProvenanceHeader = {
42
42
  readonly repo: string;
43
- readonly ticket: number;
43
+ readonly ticket: number | null;
44
44
  readonly createdAt: string;
45
45
  readonly updatedAt: string;
46
46
  readonly sessions: readonly TicketProvenanceSession[];
@@ -132,7 +132,7 @@ export function projectTicketProvenanceHeader(
132
132
  value: unknown,
133
133
  ): TicketProvenanceHeader | undefined {
134
134
  if (!isRecord(value)) return undefined;
135
- const ticket = positiveInteger(value.ticket);
135
+ const ticket = value.ticket === null ? null : positiveInteger(value.ticket);
136
136
  if (ticket === undefined) return undefined;
137
137
  if (typeof value.repo !== "string") return undefined;
138
138
  if (typeof value.createdAt !== "string" || typeof value.updatedAt !== "string") {