@akagilnc/pi-workflow-roles 0.1.4291 → 0.1.4321

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.
@@ -1,15 +1,20 @@
1
1
  /**
2
- * 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901。
3
- * 一册=一个文件:首行册子头,其后裸对话行。每轮按当前区间重投影是唯一机制;
4
- * append 水位、无 SitianRecord 外壳。目的地解析与读写经司天台唯一入口
5
- * (ADR 0065 records-owner / record-entry;ADR 0081 入录经司天台)。
2
+ * 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901 / #918
3
+ * 一册=一个追加式 records.jsonl。每轮新投影经司天台 appender 追加为不可变提交;
4
+ * 读取时折叠全部提交得到累计 sessions 与对话视图。既有首行册子头+裸对话行的
5
+ * snapshot 继续可读,但后续不为升级回写旧卷。
6
6
  */
7
+ import { createHash } from "node:crypto";
8
+ import { appendFileSync } from "node:fs";
9
+ import { readFile } from "node:fs/promises";
7
10
  import { basename, dirname, join, resolve } from "node:path";
8
11
 
9
12
  import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
10
13
  import {
14
+ ensureRealDirectoryTree,
11
15
  errnoCode,
12
16
  packageMachineHome,
17
+ physicalPathIdentity,
13
18
  physicallyContainedIn,
14
19
  resolveActivationLedgerHome,
15
20
  } from "./activation-ledger-topology.ts";
@@ -20,16 +25,15 @@ import {
20
25
  import { isSafePositiveTicketNumber } from "./run-ticket-number.ts";
21
26
  import { adaptSessionDialogue, nativeEventId } from "./session-dialogue.ts";
22
27
  import {
23
- ensureSitianVolume,
24
- readSitianVolumeText,
25
- resolveSitianVolume,
26
- rewriteSitianVolume,
28
+ appendSitianRecord,
29
+ resolveSitianRecordPath,
27
30
  type SitianRecordInput,
28
31
  } from "./sitian-facade.ts";
29
32
  import {
30
33
  TICKET_PROVENANCE_KIND,
31
34
  projectTicketProvenanceHeader,
32
35
  projectTicketProvenanceLine,
36
+ projectTicketProvenanceSessions,
33
37
  type TicketProvenanceAmendment,
34
38
  type TicketProvenanceBound,
35
39
  type TicketProvenanceHeader,
@@ -59,7 +63,9 @@ export class TicketProvenanceInputError extends Error {
59
63
  */
60
64
  export function dialogueSessionSourceRoots(home?: string): readonly string[] {
61
65
  const machineHome =
62
- typeof home === "string" && home.trim() !== "" ? home : packageMachineHome();
66
+ typeof home === "string" && home.trim() !== ""
67
+ ? home
68
+ : packageMachineHome();
63
69
  return [
64
70
  join(machineHome, ".claude", "projects"),
65
71
  join(machineHome, ".codex", "sessions"),
@@ -73,10 +79,15 @@ export function dialogueSessionSourceRoots(home?: string): readonly string[] {
73
79
  */
74
80
  function isLedgerRoleSessionFile(absolute: string, home?: string): boolean {
75
81
  const machineHome =
76
- typeof home === "string" && home.trim() !== "" ? home : packageMachineHome();
82
+ typeof home === "string" && home.trim() !== ""
83
+ ? home
84
+ : packageMachineHome();
77
85
  const ledgerHome = resolveActivationLedgerHome(machineHome);
78
86
  if (!physicallyContainedIn(ledgerHome, absolute)) return false;
79
- return basename(absolute) === "session.jsonl" && basename(dirname(absolute)) === "session";
87
+ return (
88
+ basename(absolute) === "session.jsonl" &&
89
+ basename(dirname(absolute)) === "session"
90
+ );
80
91
  }
81
92
 
82
93
  /** Real I/O seam gate: only host session stores or sitian role-run session.jsonl. */
@@ -127,12 +138,47 @@ export function resolveTicketProvenanceVolume(
127
138
  cwd: string,
128
139
  home?: string,
129
140
  ): TicketProvenanceVolumePath {
130
- return resolveSitianVolume(ticketProvenanceRecordInput(ticketNumber, cwd, home));
141
+ const path = resolveSitianRecordPath(
142
+ ticketProvenanceRecordInput(ticketNumber, cwd, home),
143
+ );
144
+ return { recordFile: path.recordFile, volumeDir: path.sessionDir };
145
+ }
146
+
147
+ type TicketProvenanceCommit = {
148
+ readonly timestamp: string;
149
+ readonly sessions: readonly TicketProvenanceSession[];
150
+ readonly lines: readonly TicketProvenanceLine[];
151
+ };
152
+
153
+ function projectTicketProvenanceCommit(value: unknown): TicketProvenanceCommit | undefined {
154
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
155
+ const record = value as Record<string, unknown>;
156
+ if (record.kind !== TICKET_PROVENANCE_KIND || typeof record.timestamp !== "string") {
157
+ return undefined;
158
+ }
159
+ const payload = record.payload;
160
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
161
+ const body = payload as Record<string, unknown>;
162
+ if (body.type !== "ticket-provenance-append") return undefined;
163
+ const sessions = projectTicketProvenanceSessions(body.sessions);
164
+ if (sessions === undefined || !Array.isArray(body.lines)) return undefined;
165
+ const lines: TicketProvenanceLine[] = [];
166
+ for (const raw of body.lines) {
167
+ const line = projectTicketProvenanceLine(raw);
168
+ if (line === undefined) return undefined;
169
+ lines.push(line);
170
+ }
171
+ return { timestamp: record.timestamp, sessions, lines };
131
172
  }
132
173
 
133
174
  export type ReadTicketProvenanceResult = {
134
175
  readonly header: TicketProvenanceHeader | undefined;
135
176
  readonly lines: readonly TicketProvenanceLine[];
177
+ /**
178
+ * Body rows that could not be projected (bad JSON or unusable shape).
179
+ * Kept byte-stable across lawful rewrites — 证不出的原样留存 (#918 / G8).
180
+ */
181
+ readonly unprojectedRaw: readonly string[];
136
182
  readonly recordFile: string;
137
183
  };
138
184
 
@@ -142,15 +188,20 @@ export async function readTicketProvenance(
142
188
  cwd: string,
143
189
  home?: string,
144
190
  ): Promise<ReadTicketProvenanceResult> {
145
- const { recordFile, text } = await readSitianVolumeText(
146
- ticketProvenanceRecordInput(ticketNumber, cwd, home),
147
- );
148
- if (text === undefined) {
149
- return { header: undefined, lines: [], recordFile };
191
+ const { recordFile } = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
192
+ let text: string;
193
+ try {
194
+ text = await readFile(recordFile, "utf8");
195
+ } catch (error) {
196
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
197
+ return { header: undefined, lines: [], unprojectedRaw: [], recordFile };
198
+ }
199
+ throw error;
150
200
  }
151
201
  const physical = text.split("\n");
152
202
  let header: TicketProvenanceHeader | undefined;
153
203
  const lines: TicketProvenanceLine[] = [];
204
+ const unprojectedRaw: string[] = [];
154
205
  let sawFirst = false;
155
206
  for (let index = 0; index < physical.length; index += 1) {
156
207
  const raw = physical[index]!;
@@ -159,19 +210,47 @@ export async function readTicketProvenance(
159
210
  try {
160
211
  parsed = JSON.parse(raw);
161
212
  } catch {
162
- // Stock / damaged rows stay unprojected — no backfill, no guess.
213
+ // Stock / damaged rows stay unprojected — keep raw bytes on rewrite.
214
+ unprojectedRaw.push(raw);
163
215
  continue;
164
216
  }
165
217
  if (!sawFirst) {
166
218
  sawFirst = true;
167
219
  header = projectTicketProvenanceHeader(parsed);
168
- // First line that is not a header is treated as a body line (stock shapes).
220
+ // Legacy snapshot: first line header followed by bare dialogue rows.
169
221
  if (header !== undefined) continue;
170
222
  }
171
223
  const line = projectTicketProvenanceLine(parsed);
172
- if (line !== undefined) lines.push(line);
224
+ if (line !== undefined) {
225
+ lines.push(line);
226
+ continue;
227
+ }
228
+ const commit = projectTicketProvenanceCommit(parsed);
229
+ if (commit !== undefined) {
230
+ const merged = mergeSessionBounds(header?.sessions, commit.sessions);
231
+ const remapped = commit.lines.map((entry) => ({
232
+ ...entry,
233
+ s: merged.incomingIndexes[entry.s] ?? entry.s,
234
+ }));
235
+ const now = commit.timestamp;
236
+ header = {
237
+ repo: header?.repo ?? resolveBookKeyFromGit(cwd),
238
+ ticket: ticketNumber,
239
+ createdAt: header?.createdAt ?? now,
240
+ updatedAt: now,
241
+ sessions: merged.sessions,
242
+ };
243
+ const carried = lines.map((entry) => ({
244
+ ...entry,
245
+ s: merged.priorIndexes[entry.s] ?? entry.s,
246
+ }));
247
+ lines.splice(0, lines.length, ...mergeFreshIntoCarried(carried, remapped));
248
+ continue;
249
+ }
250
+ // Unknown stock / damaged rows remain readable and are never upgraded in place.
251
+ unprojectedRaw.push(raw);
173
252
  }
174
- return { header, lines, recordFile };
253
+ return { header, lines, unprojectedRaw, recordFile };
175
254
  }
176
255
 
177
256
  /**
@@ -183,7 +262,11 @@ export function ensureTicketProvenanceVolume(
183
262
  cwd: string,
184
263
  home?: string,
185
264
  ): TicketProvenanceVolumePath {
186
- return ensureSitianVolume(ticketProvenanceRecordInput(ticketNumber, cwd, home));
265
+ const input = ticketProvenanceRecordInput(ticketNumber, cwd, home);
266
+ const path = resolveSitianRecordPath(input);
267
+ ensureRealDirectoryTree(path.ledgerHome, path.sessionDir);
268
+ appendFileSync(path.recordFile, "", "utf8");
269
+ return { recordFile: path.recordFile, volumeDir: path.sessionDir };
187
270
  }
188
271
 
189
272
  /** One session line the mechanical projector could not enter. */
@@ -231,6 +314,149 @@ function amendmentKey(s: number, line: number): string {
231
314
  return `${s}:${line}`;
232
315
  }
233
316
 
317
+ /** Exact range identity for cumulative header dedupe (idempotent resubmit). */
318
+ function rangeDeclarationKey(range: TicketProvenanceRange): string {
319
+ return JSON.stringify({
320
+ from: range.from,
321
+ to: range.to,
322
+ });
323
+ }
324
+
325
+ /**
326
+ * #918 甲案:prior ranges ∪ 本轮 ranges。按 path 合并;先保 prior 序,再追加新 path。
327
+ * path identity 与 I/O 接缝同用 `physicalPathIdentity`(symlink-stable),故
328
+ * `p` / `p/./` / 经 symlink 祖先的别名合为一条;保留首见 path 字面与累计 ranges。
329
+ * 即使 prior 缺失 / sessions=[],incoming 自身也必须按 identity 归并——首轮同一
330
+ * 物理卷不得铸出多个 s(#918 C5)。遗漏不代表删除。
331
+ */
332
+ function mergeSessionBounds(
333
+ prior: readonly TicketProvenanceSession[] | undefined,
334
+ incoming: readonly TicketProvenanceSession[],
335
+ ): {
336
+ readonly sessions: readonly TicketProvenanceSession[];
337
+ readonly priorIndexes: readonly number[];
338
+ readonly incomingIndexes: readonly number[];
339
+ } {
340
+ const merged: { path: string; ranges: TicketProvenanceRange[] }[] = [];
341
+ const indexByIdentity = new Map<string, number>();
342
+
343
+ const absorb = (sessions: readonly TicketProvenanceSession[]): number[] => {
344
+ const indexes: number[] = [];
345
+ for (const session of sessions) {
346
+ const identity = physicalPathIdentity(session.path);
347
+ let targetIndex = indexByIdentity.get(identity);
348
+ if (targetIndex === undefined) {
349
+ targetIndex = merged.length;
350
+ indexByIdentity.set(identity, targetIndex);
351
+ merged.push({ path: session.path, ranges: [] });
352
+ }
353
+ indexes.push(targetIndex);
354
+ const target = merged[targetIndex]!;
355
+ const seen = new Set(target.ranges.map(rangeDeclarationKey));
356
+ for (const range of session.ranges) {
357
+ const key = rangeDeclarationKey(range);
358
+ if (seen.has(key)) continue;
359
+ seen.add(key);
360
+ target.ranges.push({ from: { ...range.from }, to: { ...range.to } });
361
+ }
362
+ }
363
+ return indexes;
364
+ };
365
+
366
+ const priorIndexes = absorb(prior ?? []);
367
+ const incomingIndexes = absorb(incoming);
368
+ return { sessions: merged, priorIndexes, incomingIndexes };
369
+ }
370
+
371
+ /**
372
+ * #918 第一节:已投影行即卷宗。只对 prior 未声明的 range(按 path identity + 精确
373
+ * from/to)读源;已声明的按 s,line 结转,不重读历史源。
374
+ */
375
+ function undeclaredSessionRanges(
376
+ prior: readonly TicketProvenanceSession[] | undefined,
377
+ merged: readonly TicketProvenanceSession[],
378
+ ): readonly {
379
+ readonly s: number;
380
+ readonly session: TicketProvenanceSession;
381
+ }[] {
382
+ const priorKeys = new Map<string, Set<string>>();
383
+ for (const session of prior ?? []) {
384
+ const identity = physicalPathIdentity(session.path);
385
+ let keys = priorKeys.get(identity);
386
+ if (keys === undefined) {
387
+ keys = new Set();
388
+ priorKeys.set(identity, keys);
389
+ }
390
+ for (const range of session.ranges) keys.add(rangeDeclarationKey(range));
391
+ }
392
+ const out: { s: number; session: TicketProvenanceSession }[] = [];
393
+ for (let s = 0; s < merged.length; s += 1) {
394
+ const session = merged[s]!;
395
+ const declared =
396
+ priorKeys.get(physicalPathIdentity(session.path)) ?? new Set();
397
+ const fresh = session.ranges.filter(
398
+ (range) => !declared.has(rangeDeclarationKey(range)),
399
+ );
400
+ if (fresh.length === 0) continue;
401
+ out.push({
402
+ s,
403
+ session: {
404
+ path: session.path,
405
+ ranges: fresh.map((range) => ({
406
+ from: { ...range.from },
407
+ to: { ...range.to },
408
+ })),
409
+ },
410
+ });
411
+ }
412
+ return out;
413
+ }
414
+
415
+ /** Sort archive rows by session index then source line (stable within equal keys). */
416
+ function compareLinePosition(
417
+ left: TicketProvenanceLine,
418
+ right: TicketProvenanceLine,
419
+ ): number {
420
+ if (left.s !== right.s) return left.s - right.s;
421
+ const leftLine = left.line ?? Number.MAX_SAFE_INTEGER;
422
+ const rightLine = right.line ?? Number.MAX_SAFE_INTEGER;
423
+ return leftLine - rightLine;
424
+ }
425
+
426
+ /**
427
+ * Merge newly projected rows into the carried archive.
428
+ * 既有卷宗按 (s,line) 优先;fresh 不得复制无 id 行(#918 C2/G1)。
429
+ * id 去重仍保留(跨 path 同 id 首见胜)。
430
+ */
431
+ function mergeFreshIntoCarried(
432
+ carried: readonly TicketProvenanceLine[],
433
+ fresh: readonly TicketProvenanceLine[],
434
+ ): TicketProvenanceLine[] {
435
+ const seenIds = new Set<string>();
436
+ const seenPositions = new Set<string>();
437
+ const out: TicketProvenanceLine[] = [];
438
+ for (const line of carried) {
439
+ if (line.id !== undefined) seenIds.add(line.id);
440
+ if (line.line !== undefined)
441
+ seenPositions.add(amendmentKey(line.s, line.line));
442
+ out.push(line);
443
+ }
444
+ for (const line of fresh) {
445
+ if (line.id !== undefined) {
446
+ if (seenIds.has(line.id)) continue;
447
+ seenIds.add(line.id);
448
+ }
449
+ if (line.line !== undefined) {
450
+ const position = amendmentKey(line.s, line.line);
451
+ if (seenPositions.has(position)) continue;
452
+ seenPositions.add(position);
453
+ }
454
+ out.push(line);
455
+ }
456
+ out.sort(compareLinePosition);
457
+ return out;
458
+ }
459
+
234
460
  /**
235
461
  * Resolve submitted ranges against the session, then sort by source position and
236
462
  * merge overlaps so each physical row is visited once (#901 source order).
@@ -354,10 +580,12 @@ async function projectSessionRanges(input: {
354
580
  }
355
581
 
356
582
  /**
357
- * Reproject the unique diary file from the submitted bounds + optional amendments.
358
- * Header + locating fields may change; dialogue text is taken from the source
359
- * (or from a typed amendment). Does not append; the whole file is the projection.
360
- * Persistence goes through the Sitian volume seam (rewriteSitianVolume).
583
+ * Reproject the unique diary from cumulative bounds + optional amendments.
584
+ * #918:已投影行即卷宗——按 s,line 结转;本轮只读 prior 未声明的 range(或新
585
+ * session 区间)。精确重复提交=幂等 no-op,不因历史源不可读而失败。
586
+ * amendments 只在本轮读取的新范围确有对应坏行时生效;空 sessions 保持 no-op。
587
+ * 证不出的 body 原字节经 unprojectedRaw 原样留存。
588
+ * Persistence appends one immutable commit through the Sitian appender seam.
361
589
  */
362
590
  export async function reprojectTicketProvenance(input: {
363
591
  readonly ticketNumber: number;
@@ -366,96 +594,107 @@ export async function reprojectTicketProvenance(input: {
366
594
  readonly sessions: readonly TicketProvenanceSession[];
367
595
  readonly amendments?: readonly TicketProvenanceAmendment[];
368
596
  }): Promise<ReprojectTicketProvenanceResult> {
369
- const recordInput = ticketProvenanceRecordInput(
370
- input.ticketNumber,
371
- input.cwd,
372
- input.home,
373
- );
374
597
  const prior = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
375
- const priorRaw = await readSitianVolumeText(recordInput);
376
- const priorNonEmpty =
377
- priorRaw.text !== undefined && priorRaw.text.trim() !== "";
378
-
379
- const amendments = input.amendments ?? [];
380
- // Empty sessions: pure empty selection preserves non-empty volume. Amendments-only
381
- // continuation reuses prior header sessions — never wash into accepted no-op.
382
- let sessions = input.sessions;
383
- if (sessions.length === 0) {
384
- if (amendments.length > 0) {
385
- const priorSessions = prior.header?.sessions;
386
- if (priorSessions === undefined || priorSessions.length === 0) {
387
- throw new TicketProvenanceInputError(
388
- "amendments require sessions bounds (none submitted and no prior header sessions)",
389
- );
390
- }
391
- sessions = priorSessions;
392
- } else if (priorNonEmpty) {
393
- const now = new Date().toISOString();
394
- const header: TicketProvenanceHeader =
395
- prior.header ??
396
- ({
397
- repo: resolveBookKeyFromGit(input.cwd),
398
- ticket: input.ticketNumber,
399
- createdAt: now,
400
- updatedAt: now,
401
- sessions: [],
402
- } satisfies TicketProvenanceHeader);
403
- return {
404
- recordFile: prior.recordFile,
405
- header,
406
- lines: prior.lines,
407
- unparsable: [],
408
- };
409
- }
598
+ if (input.sessions.length === 0) {
599
+ const now = new Date().toISOString();
600
+ return {
601
+ recordFile: prior.recordFile,
602
+ header: prior.header ?? {
603
+ repo: resolveBookKeyFromGit(input.cwd),
604
+ ticket: input.ticketNumber,
605
+ createdAt: now,
606
+ updatedAt: now,
607
+ sessions: [],
608
+ },
609
+ lines: prior.lines,
610
+ unparsable: [],
611
+ };
612
+ }
613
+
614
+ const merged = mergeSessionBounds(prior.header?.sessions, input.sessions);
615
+ const deltas = undeclaredSessionRanges(prior.header?.sessions, merged.sessions);
616
+ if (deltas.length === 0) {
617
+ const now = new Date().toISOString();
618
+ return {
619
+ recordFile: prior.recordFile,
620
+ header: prior.header ?? {
621
+ repo: resolveBookKeyFromGit(input.cwd),
622
+ ticket: input.ticketNumber,
623
+ createdAt: now,
624
+ updatedAt: now,
625
+ sessions: merged.sessions,
626
+ },
627
+ lines: prior.lines,
628
+ unparsable: [],
629
+ };
410
630
  }
411
631
 
412
632
  const amendmentsByKey = new Map<string, TicketProvenanceAmendment>();
413
- for (const amendment of amendments) {
414
- amendmentsByKey.set(amendmentKey(amendment.s, amendment.line), amendment);
633
+ for (const amendment of input.amendments ?? []) {
634
+ const cumulativeIndex = merged.incomingIndexes[amendment.s];
635
+ if (cumulativeIndex === undefined) continue;
636
+ amendmentsByKey.set(amendmentKey(cumulativeIndex, amendment.line), {
637
+ ...amendment,
638
+ s: cumulativeIndex,
639
+ });
415
640
  }
416
641
 
417
- const lines: TicketProvenanceLine[] = [];
642
+ const seenIds = new Set(
643
+ prior.lines.flatMap((line) => line.id === undefined ? [] : [line.id]),
644
+ );
645
+ const fresh: TicketProvenanceLine[] = [];
418
646
  const unparsable: UnparsableSessionLine[] = [];
419
- // First-seen native id across the whole reproject (rewritten session copies).
420
- const seenIds = new Set<string>();
421
- for (let s = 0; s < sessions.length; s += 1) {
647
+ for (const delta of deltas) {
422
648
  const projected = await projectSessionRanges({
423
- s,
424
- session: sessions[s]!,
649
+ s: delta.s,
650
+ session: delta.session,
425
651
  amendmentsByKey,
426
652
  seenIds,
427
653
  ...(input.home === undefined ? {} : { home: input.home }),
428
654
  });
429
- lines.push(...projected.lines);
655
+ fresh.push(...projected.lines);
430
656
  unparsable.push(...projected.unparsable);
431
657
  }
432
-
658
+ const lines = mergeFreshIntoCarried(prior.lines, fresh);
433
659
  const now = new Date().toISOString();
434
660
  const header: TicketProvenanceHeader = {
435
- repo: resolveBookKeyFromGit(input.cwd),
661
+ repo: prior.header?.repo ?? resolveBookKeyFromGit(input.cwd),
436
662
  ticket: input.ticketNumber,
437
663
  createdAt: prior.header?.createdAt ?? now,
438
664
  updatedAt: now,
439
- sessions,
665
+ sessions: merged.sessions,
440
666
  };
441
-
442
- // Still-open gaps → reask without publishing a partial/rejected projection.
443
667
  if (unparsable.length > 0) {
444
- return {
445
- recordFile: prior.recordFile,
446
- header,
447
- lines,
448
- unparsable,
449
- };
668
+ return { recordFile: prior.recordFile, header, lines, unparsable };
450
669
  }
451
670
 
452
- const body = `${[JSON.stringify(header), ...lines.map((line) => JSON.stringify(line))].join("\n")}\n`;
453
- const volume = await rewriteSitianVolume({ ...recordInput, body });
454
-
671
+ // One immutable projection commit. Its deterministic identity makes retries of
672
+ // the same logical increment converge, while unrelated concurrent increments
673
+ // append independently and are folded by readTicketProvenance.
674
+ const identityMaterial = JSON.stringify({
675
+ ticket: input.ticketNumber,
676
+ deltas: deltas.map(({ s, session }) => ({
677
+ s,
678
+ path: physicalPathIdentity(session.path),
679
+ ranges: session.ranges,
680
+ })),
681
+ lines: fresh,
682
+ });
683
+ const identity = `ticket-provenance:${createHash("sha256").update(identityMaterial).digest("hex")}`;
684
+ const pointer = appendSitianRecord({
685
+ ...ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home),
686
+ identity,
687
+ payload: {
688
+ type: "ticket-provenance-append",
689
+ sessions: merged.sessions,
690
+ lines: fresh,
691
+ },
692
+ });
693
+ const folded = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
455
694
  return {
456
- recordFile: volume.recordFile,
457
- header,
458
- lines,
459
- unparsable,
695
+ recordFile: pointer.recordFile,
696
+ header: folded.header ?? header,
697
+ lines: folded.lines,
698
+ unparsable: [],
460
699
  };
461
700
  }
@@ -1,23 +0,0 @@
1
- /**
2
- * Same-directory temp + rename atomic file replace.
3
- * Shared primitive for ledger-adjacent typed pages (analyst metrics, etc.).
4
- * Does not open/truncate an existing destination inode, so hard-linked twins
5
- * keep prior bytes until the directory entry is swapped.
6
- * Parent directory must already exist — callers that write under the package
7
- * ledger home own confinement via ensureRealDirectoryTree (ADR 0038).
8
- */
9
- import { randomUUID } from "node:crypto";
10
- import { rename, rm, writeFile } from "node:fs/promises";
11
- import { dirname, join } from "node:path";
12
- export async function writeFileAtomically(destination, contents) {
13
- const parent = dirname(destination);
14
- const temporary = join(parent, `.atomic-write-${randomUUID()}.tmp`);
15
- try {
16
- await writeFile(temporary, contents);
17
- await rename(temporary, destination);
18
- }
19
- catch (error) {
20
- await rm(temporary, { force: true }).catch(() => undefined);
21
- throw error;
22
- }
23
- }
@@ -1,67 +0,0 @@
1
- /**
2
- * Sitian whole-file volume I/O (ADR 0065 records-owner / record-entry).
3
- *
4
- * Owns destination resolution, create, raw read, and whole-file rewrite for
5
- * kinds whose persistence shape is not the appender SitianRecord row
6
- * (ticket-provenance header + bare dialogue lines, #901). Appender kernel
7
- * stays append-only; this module does not restore append watermarks.
8
- */
9
- import { appendFileSync } from "node:fs";
10
- import { readFile } from "node:fs/promises";
11
- import { ensureRealDirectoryTree, errorText, } from "./activation-ledger-topology.js";
12
- import { writeFileAtomically } from "./atomic-write.js";
13
- import { resolveSitianRecordPath } from "./sitian-appender.js";
14
- import { SitianInfrastructureError, } from "./sitian-contracts.js";
15
- /** Resolve a volume path from ambient topology — callers never pick the destination. */
16
- export function resolveSitianVolume(input) {
17
- const path = resolveSitianRecordPath(input);
18
- return { recordFile: path.recordFile, volumeDir: path.sessionDir };
19
- }
20
- /**
21
- * Ensure the volume directory + file exist.
22
- * Append-open creates an absent volume without truncating concurrent first writers.
23
- */
24
- export function ensureSitianVolume(input) {
25
- try {
26
- const { sessionDir, recordFile, ledgerHome } = resolveSitianRecordPath(input);
27
- ensureRealDirectoryTree(ledgerHome, sessionDir);
28
- appendFileSync(recordFile, "", "utf8");
29
- return { recordFile, volumeDir: sessionDir };
30
- }
31
- catch (error) {
32
- if (error instanceof SitianInfrastructureError)
33
- throw error;
34
- throw new SitianInfrastructureError(`Sitian volume ensure failure: ${errorText(error)}`, { cause: error });
35
- }
36
- }
37
- /** Raw volume text. Absent file → text undefined (not an error). */
38
- export async function readSitianVolumeText(input) {
39
- const { recordFile } = resolveSitianVolume(input);
40
- try {
41
- return { recordFile, text: await readFile(recordFile, "utf8") };
42
- }
43
- catch (error) {
44
- if (error.code === "ENOENT") {
45
- return { recordFile, text: undefined };
46
- }
47
- throw new SitianInfrastructureError(`Sitian volume read failure: ${errorText(error)}`, { cause: error });
48
- }
49
- }
50
- /**
51
- * Replace the entire volume body (whole-file reproject).
52
- * Same-directory temp + rename — never truncate the live inode in place
53
- * (#901 sole diary; reuse atomic-write, no parallel persist mechanism).
54
- * Not append; no identity claim / torn-tail watermark — those stay on appender.
55
- */
56
- export async function rewriteSitianVolume(input) {
57
- try {
58
- const volume = ensureSitianVolume(input);
59
- await writeFileAtomically(volume.recordFile, input.body);
60
- return volume;
61
- }
62
- catch (error) {
63
- if (error instanceof SitianInfrastructureError)
64
- throw error;
65
- throw new SitianInfrastructureError(`Sitian volume rewrite failure: ${errorText(error)}`, { cause: error });
66
- }
67
- }