@akagilnc/pi-workflow-roles 0.1.5207 → 0.1.5216

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,20 +1,14 @@
1
1
  /**
2
- * 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901 / #918。
2
+ * 起居录 volume helpers — ADR 0075 / ADR 0081 / ADR 0086 / #1090.
3
3
  * 一册=一个追加式 records.jsonl。每轮新投影经司天台 appender 追加为不可变提交;
4
- * 读取时折叠全部提交得到累计 sessions 与对话视图。既有首行册子头+裸对话行的
5
- * snapshot 继续可读,但后续不为升级回写旧卷。
4
+ * 纯追加、不回读历史去重、不折叠。
6
5
  */
7
- import { createHash } from "node:crypto";
8
- import { appendFileSync } from "node:fs";
9
6
  import { readFile, unlink } from "node:fs/promises";
10
7
  import { basename, dirname, join, resolve } from "node:path";
11
8
 
12
- import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
13
9
  import {
14
- ensureRealDirectoryTree,
15
10
  errnoCode,
16
11
  packageMachineHome,
17
- physicalPathIdentity,
18
12
  physicallyContainedIn,
19
13
  resolveActivationLedgerHome,
20
14
  } from "./activation-ledger-topology.ts";
@@ -32,11 +26,7 @@ import {
32
26
  } from "./sitian-facade.ts";
33
27
  import {
34
28
  TICKET_PROVENANCE_KIND,
35
- projectTicketProvenanceHeader,
36
- projectTicketProvenanceLine,
37
- projectTicketProvenanceSessions,
38
29
  type TicketProvenanceBound,
39
- type TicketProvenanceHeader,
40
30
  type TicketProvenanceLine,
41
31
  type TicketProvenanceRange,
42
32
  type TicketProvenanceSession,
@@ -61,7 +51,7 @@ export class TicketProvenanceInputError extends Error {
61
51
  * Host-owned session source roots (ADR 0038 / ADR 0081 cross-host).
62
52
  * Narrow directory identities — not whole `.pi` / whole `.ak-roles`.
63
53
  */
64
- export function dialogueSessionSourceRoots(home?: string): readonly string[] {
54
+ function dialogueSessionSourceRoots(home?: string): readonly string[] {
65
55
  const machineHome =
66
56
  typeof home === "string" && home.trim() !== ""
67
57
  ? home
@@ -103,7 +93,7 @@ function assertDialogueSessionSourcePath(path: string, home?: string): void {
103
93
  }
104
94
 
105
95
  /** Subject string for ticket-keyed volumes — history follows the ticket. */
106
- export function ticketProvenanceSubject(ticketNumber: number): string {
96
+ function ticketProvenanceSubject(ticketNumber: number): string {
107
97
  if (!isSafePositiveTicketNumber(ticketNumber)) {
108
98
  throw new Error(
109
99
  `ticket-provenance subject requires a positive ticket number, got ${String(ticketNumber)}`,
@@ -153,223 +143,6 @@ type TicketProvenanceRaw = {
153
143
  readonly sourceIdentity?: string;
154
144
  };
155
145
 
156
- type TicketProvenanceCommit = {
157
- readonly timestamp: string;
158
- readonly sessions: readonly TicketProvenanceSession[];
159
- readonly lines: readonly (TicketProvenanceLine | TicketProvenanceRaw | string)[];
160
- };
161
-
162
- function projectTicketProvenanceRaw(value: unknown): TicketProvenanceRaw | undefined {
163
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
164
- const raw = value as Record<string, unknown>;
165
- if (typeof raw.raw !== "string" || !Number.isInteger(raw.s) || (raw.s as number) < 0 ||
166
- !Number.isInteger(raw.sourcePosition) || (raw.sourcePosition as number) < 0) {
167
- return undefined;
168
- }
169
- const sourceIdentity = typeof raw.sourceIdentity === "string" && raw.sourceIdentity !== ""
170
- ? raw.sourceIdentity
171
- : undefined;
172
- return {
173
- raw: raw.raw,
174
- s: raw.s as number,
175
- sourcePosition: raw.sourcePosition as number,
176
- ...(sourceIdentity === undefined ? {} : { sourceIdentity }),
177
- };
178
- }
179
-
180
- function projectTicketProvenanceCommit(value: unknown): TicketProvenanceCommit | undefined {
181
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
182
- const record = value as Record<string, unknown>;
183
- if (record.kind !== TICKET_PROVENANCE_KIND || typeof record.timestamp !== "string") {
184
- return undefined;
185
- }
186
- const payload = record.payload;
187
- if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
188
- const body = payload as Record<string, unknown>;
189
- if (body.type !== "ticket-provenance-append") return undefined;
190
- const sessions = projectTicketProvenanceSessions(body.sessions);
191
- if (sessions === undefined || !Array.isArray(body.lines)) return undefined;
192
- const lines: (TicketProvenanceLine | TicketProvenanceRaw | string)[] = [];
193
- for (const raw of body.lines) {
194
- if (typeof raw === "string") { lines.push(raw); continue; }
195
- const line = projectTicketProvenanceLine(raw);
196
- if (line !== undefined) { lines.push(line); continue; }
197
- const preserved = projectTicketProvenanceRaw(raw);
198
- if (preserved === undefined) return undefined;
199
- lines.push(preserved);
200
- }
201
- return { timestamp: record.timestamp, sessions, lines };
202
- }
203
-
204
- export type ReadTicketProvenanceResult = {
205
- readonly header: TicketProvenanceHeader | undefined;
206
- readonly lines: readonly TicketProvenanceLine[];
207
- /**
208
- * Body rows that could not be projected (bad JSON or unusable shape).
209
- * Kept byte-stable across lawful rewrites — 证不出的原样留存 (#918 / G8).
210
- */
211
- readonly unprojectedRaw: readonly string[];
212
- readonly recordFile: string;
213
- /** Internal cumulative coverage truth, keyed by normalized session index. */
214
- readonly sourceIdentities: ReadonlyMap<number, ReadonlySet<string>>;
215
- /** Upgrade fallback for records written before sourceIdentity existed. */
216
- readonly legacySourcePositions: ReadonlyMap<number, ReadonlySet<number>>;
217
- };
218
-
219
- /** Read the unique diary file (empty/absent → no header, no lines). */
220
- export async function readTicketProvenance(
221
- ticketNumber: number | null,
222
- cwd: string,
223
- home?: string,
224
- runDirectory?: string,
225
- ): Promise<ReadTicketProvenanceResult> {
226
- const { recordFile } = ticketNumber === null
227
- ? resolveSitianRecordPath(ticketProvenanceRecordInput(null, cwd, home, runDirectory))
228
- : resolveTicketProvenanceVolume(ticketNumber, cwd, home);
229
- let text: string;
230
- try {
231
- text = await readFile(recordFile, "utf8");
232
- } catch (error) {
233
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
234
- return {
235
- header: undefined,
236
- lines: [],
237
- unprojectedRaw: [],
238
- recordFile,
239
- sourceIdentities: new Map(),
240
- legacySourcePositions: new Map(),
241
- };
242
- }
243
- throw error;
244
- }
245
- const physical = text.split("\n");
246
- let header: TicketProvenanceHeader | undefined;
247
- const lines: TicketProvenanceLine[] = [];
248
- const unprojectedRaw: string[] = [];
249
- const sourceIdentities = new Map<number, Set<string>>();
250
- const legacySourcePositions = new Map<number, Set<number>>();
251
- const rememberIdentity = (s: number, identity: string): boolean => {
252
- const seen = sourceIdentities.get(s) ?? new Set<string>();
253
- const fresh = !seen.has(identity);
254
- seen.add(identity);
255
- sourceIdentities.set(s, seen);
256
- return fresh;
257
- };
258
- const rememberLegacyPosition = (s: number, position: number): void => {
259
- const seen = legacySourcePositions.get(s) ?? new Set<number>();
260
- seen.add(position);
261
- legacySourcePositions.set(s, seen);
262
- };
263
- const remapCoverage = <T>(
264
- coverage: Map<number, Set<T>>,
265
- priorIndexes: readonly number[],
266
- ): void => {
267
- const normalized = new Map<number, Set<T>>();
268
- for (const [s, values] of coverage) {
269
- const target = priorIndexes[s] ?? s;
270
- const union = normalized.get(target) ?? new Set<T>();
271
- for (const value of values) union.add(value);
272
- normalized.set(target, union);
273
- }
274
- coverage.clear();
275
- for (const [s, values] of normalized) coverage.set(s, values);
276
- };
277
- let sawFirst = false;
278
- for (let index = 0; index < physical.length; index += 1) {
279
- const raw = physical[index]!;
280
- if (!raw.trim()) continue;
281
- let parsed: unknown;
282
- try {
283
- parsed = JSON.parse(raw);
284
- } catch {
285
- // Stock / damaged rows stay unprojected — keep raw bytes on rewrite.
286
- unprojectedRaw.push(raw);
287
- continue;
288
- }
289
- if (!sawFirst) {
290
- sawFirst = true;
291
- header = projectTicketProvenanceHeader(parsed);
292
- // Legacy snapshot: first line header followed by bare dialogue rows.
293
- if (header !== undefined) continue;
294
- }
295
- const line = projectTicketProvenanceLine(parsed);
296
- if (line !== undefined) {
297
- lines.push(line);
298
- continue;
299
- }
300
- const commit = projectTicketProvenanceCommit(parsed);
301
- if (commit !== undefined) {
302
- const merged = mergeSessionBounds(header?.sessions, commit.sessions);
303
- remapCoverage(sourceIdentities, merged.priorIndexes);
304
- remapCoverage(legacySourcePositions, merged.priorIndexes);
305
- const remapped: TicketProvenanceLine[] = [];
306
- for (const entry of commit.lines) {
307
- if (typeof entry === "string") { unprojectedRaw.push(entry); continue; }
308
- const s = merged.incomingIndexes[entry.s] ?? merged.priorIndexes[entry.s] ?? entry.s;
309
- if ("raw" in entry) {
310
- if (entry.sourceIdentity !== undefined) {
311
- if (rememberIdentity(s, entry.sourceIdentity)) unprojectedRaw.push(entry.raw);
312
- } else {
313
- rememberLegacyPosition(s, entry.sourcePosition);
314
- unprojectedRaw.push(entry.raw);
315
- }
316
- continue;
317
- }
318
- remapped.push({ ...entry, s });
319
- if (entry.id === undefined) {
320
- if (entry.sourceIdentity !== undefined) rememberIdentity(s, entry.sourceIdentity);
321
- else if (entry.sourcePosition !== undefined) rememberLegacyPosition(s, entry.sourcePosition);
322
- }
323
- }
324
- const now = commit.timestamp;
325
- header = {
326
- repo: header?.repo ?? resolveBookKeyFromGit(cwd),
327
- ticket: ticketNumber,
328
- createdAt: header?.createdAt ?? now,
329
- updatedAt: header?.updatedAt !== undefined && header.updatedAt > now ? header.updatedAt : now,
330
- sessions: merged.sessions,
331
- };
332
- const carried = lines.map((entry) => ({
333
- ...entry,
334
- s: merged.priorIndexes[entry.s] ?? entry.s,
335
- }));
336
- lines.splice(0, lines.length, ...mergeFreshIntoCarried(carried, remapped));
337
- continue;
338
- }
339
- // Unknown stock / damaged rows remain readable and are never upgraded in place.
340
- unprojectedRaw.push(raw);
341
- }
342
- for (const line of lines) {
343
- if (line.id !== undefined) continue;
344
- if (line.sourceIdentity !== undefined) rememberIdentity(line.s, line.sourceIdentity);
345
- else if (line.sourcePosition !== undefined) rememberLegacyPosition(line.s, line.sourcePosition);
346
- }
347
- return {
348
- header,
349
- lines,
350
- unprojectedRaw,
351
- recordFile,
352
- sourceIdentities,
353
- legacySourcePositions,
354
- };
355
- }
356
-
357
- /**
358
- * Ensure the per-ticket directory + volume file exist (ADR 0075 ticket-provenance-file).
359
- * Empty file is lawful (bound court with no dialogue yet). Does not forge entries.
360
- */
361
- export function ensureTicketProvenanceVolume(
362
- ticketNumber: number,
363
- cwd: string,
364
- home?: string,
365
- ): TicketProvenanceVolumePath {
366
- const input = ticketProvenanceRecordInput(ticketNumber, cwd, home);
367
- const path = resolveSitianRecordPath(input);
368
- ensureRealDirectoryTree(path.ledgerHome, path.sessionDir);
369
- appendFileSync(path.recordFile, "", "utf8");
370
- return { recordFile: path.recordFile, volumeDir: path.sessionDir };
371
- }
372
-
373
146
  /** Assign a run-owned unbound diary as its original block through Sitian. */
374
147
  export async function rehomeUnboundTicketProvenance(
375
148
  runDirectory: string,
@@ -391,7 +164,6 @@ export async function rehomeUnboundTicketProvenance(
391
164
 
392
165
  export type ReprojectTicketProvenanceResult = {
393
166
  readonly recordFile: string;
394
- readonly header: TicketProvenanceHeader;
395
167
  readonly lines: readonly TicketProvenanceLine[];
396
168
  };
397
169
 
@@ -421,161 +193,6 @@ function resolveBoundIndex(
421
193
  return undefined;
422
194
  }
423
195
 
424
- /** Exact range identity for cumulative header dedupe (idempotent resubmit). */
425
- function dialogueIdentity(s: number, id: string): string {
426
- return `${s}\u0000${id}`;
427
- }
428
-
429
- function rangeDeclarationKey(range: TicketProvenanceRange): string {
430
- return JSON.stringify({
431
- from: range.from,
432
- to: range.to,
433
- });
434
- }
435
-
436
- /**
437
- * #918 甲案:prior ranges ∪ 本轮 ranges。按 path 合并;先保 prior 序,再追加新 path。
438
- * path identity 与 I/O 接缝同用 `physicalPathIdentity`(symlink-stable),故
439
- * `p` / `p/./` / 经 symlink 祖先的别名合为一条;保留首见 path 字面与累计 ranges。
440
- * 即使 prior 缺失 / sessions=[],incoming 自身也必须按 identity 归并——首轮同一
441
- * 物理卷不得铸出多个 s(#918 C5)。遗漏不代表删除。
442
- */
443
- function mergeSessionBounds(
444
- prior: readonly TicketProvenanceSession[] | undefined,
445
- incoming: readonly TicketProvenanceSession[],
446
- ): {
447
- readonly sessions: readonly TicketProvenanceSession[];
448
- readonly priorIndexes: readonly number[];
449
- readonly incomingIndexes: readonly number[];
450
- } {
451
- const merged: { path: string; ranges: TicketProvenanceRange[] }[] = [];
452
- const indexByIdentity = new Map<string, number>();
453
-
454
- const absorb = (sessions: readonly TicketProvenanceSession[]): number[] => {
455
- const indexes: number[] = [];
456
- for (const session of sessions) {
457
- const identity = physicalPathIdentity(session.path);
458
- let targetIndex = indexByIdentity.get(identity);
459
- if (targetIndex === undefined) {
460
- targetIndex = merged.length;
461
- indexByIdentity.set(identity, targetIndex);
462
- merged.push({ path: session.path, ranges: [] });
463
- }
464
- indexes.push(targetIndex);
465
- const target = merged[targetIndex]!;
466
- const seen = new Set(target.ranges.map(rangeDeclarationKey));
467
- for (const range of session.ranges) {
468
- const key = rangeDeclarationKey(range);
469
- if (seen.has(key)) continue;
470
- seen.add(key);
471
- target.ranges.push({ from: { ...range.from }, to: { ...range.to } });
472
- }
473
- }
474
- return indexes;
475
- };
476
-
477
- const priorIndexes = absorb(prior ?? []);
478
- const incomingIndexes = absorb(incoming);
479
- return { sessions: merged, priorIndexes, incomingIndexes };
480
- }
481
-
482
- /**
483
- * #918 第一节:已投影行即卷宗。只对 prior 未声明的 range(按 path identity + 精确
484
- * from/to)读源;已声明的投影原样结转,不重读历史源。
485
- */
486
- function undeclaredSessionRanges(
487
- prior: readonly TicketProvenanceSession[] | undefined,
488
- merged: readonly TicketProvenanceSession[],
489
- ): readonly {
490
- readonly s: number;
491
- readonly session: TicketProvenanceSession;
492
- }[] {
493
- const priorKeys = new Map<string, Set<string>>();
494
- for (const session of prior ?? []) {
495
- const identity = physicalPathIdentity(session.path);
496
- let keys = priorKeys.get(identity);
497
- if (keys === undefined) {
498
- keys = new Set();
499
- priorKeys.set(identity, keys);
500
- }
501
- for (const range of session.ranges) keys.add(rangeDeclarationKey(range));
502
- }
503
- const out: {
504
- s: number;
505
- session: TicketProvenanceSession;
506
- }[] = [];
507
- for (let s = 0; s < merged.length; s += 1) {
508
- const session = merged[s]!;
509
- const identity = physicalPathIdentity(session.path);
510
- const declared = priorKeys.get(identity) ?? new Set();
511
- const fresh = session.ranges.filter(
512
- (range) => !declared.has(rangeDeclarationKey(range)),
513
- );
514
- if (fresh.length === 0) continue;
515
- out.push({
516
- s,
517
- session: {
518
- path: session.path,
519
- ranges: fresh.map((range) => ({
520
- from: { ...range.from },
521
- to: { ...range.to },
522
- })),
523
- },
524
- });
525
- }
526
- return out;
527
- }
528
-
529
- /**
530
- * Merge newly projected rows into the carried archive.
531
- * Native id remains the typed first-seen identity; exact range resubmission is a no-op.
532
- */
533
- function mergeFreshIntoCarried(
534
- carried: readonly TicketProvenanceLine[],
535
- fresh: readonly TicketProvenanceLine[],
536
- ): TicketProvenanceLine[] {
537
- const seenSources = new Map<string, TicketProvenanceLine>();
538
- const bySession = new Map<number, TicketProvenanceLine[]>();
539
- const absorb = (lines: readonly TicketProvenanceLine[]): void => {
540
- for (const line of lines) {
541
- const sourceIdentity = line.id === undefined
542
- ? line.sourceIdentity
543
- : `id\u0000${line.id}`;
544
- const identity = sourceIdentity === undefined
545
- ? undefined
546
- : `${line.s}\u0000${sourceIdentity}`;
547
- const seen = identity === undefined ? undefined : seenSources.get(identity);
548
- if (seen !== undefined) {
549
- if (line.sourcePosition !== undefined) {
550
- Object.assign(seen, { sourcePosition: line.sourcePosition });
551
- }
552
- continue;
553
- }
554
- const bucket = bySession.get(line.s) ?? [];
555
- bucket.push({ ...line });
556
- if (identity !== undefined) seenSources.set(identity, bucket.at(-1)!);
557
- bySession.set(line.s, bucket);
558
- }
559
- };
560
- absorb(carried);
561
- absorb(fresh);
562
- return [...bySession.entries()]
563
- .sort(([left], [right]) => left - right)
564
- .flatMap(([, lines]) => lines
565
- .map((line, archiveIndex) => ({ line, archiveIndex }))
566
- .sort((left, right) => {
567
- const leftPosition = left.line.sourcePosition;
568
- const rightPosition = right.line.sourcePosition;
569
- if (leftPosition === undefined && rightPosition === undefined) {
570
- return left.archiveIndex - right.archiveIndex;
571
- }
572
- if (leftPosition === undefined) return 1;
573
- if (rightPosition === undefined) return -1;
574
- return leftPosition - rightPosition || left.archiveIndex - right.archiveIndex;
575
- })
576
- .map(({ line }) => line));
577
- }
578
-
579
196
  /**
580
197
  * Give every logical source row a replay-invariant ordinal. Native ids identify
581
198
  * replayed rows directly; idless rows use their ordinal after the preceding
@@ -657,14 +274,9 @@ function normalizeResolvedRanges(
657
274
  async function projectSessionRanges(input: {
658
275
  readonly s: number;
659
276
  readonly session: TicketProvenanceSession;
660
- readonly seenIds: Set<string>;
661
- readonly coveredSourceIdentities: ReadonlySet<string>;
662
- readonly legacyCoveredSourcePositions: ReadonlySet<number>;
663
277
  readonly home?: string;
664
278
  }): Promise<{
665
- readonly lines: TicketProvenanceLine[];
666
- readonly raw: TicketProvenanceRaw[];
667
- readonly sourcePositionByIdentity: ReadonlyMap<string, number>;
279
+ readonly entries: (TicketProvenanceLine | TicketProvenanceRaw)[];
668
280
  }> {
669
281
  assertDialogueSessionSourcePath(input.session.path, input.home);
670
282
  let sessionLines: LedgerSessionLine[];
@@ -672,9 +284,6 @@ async function projectSessionRanges(input: {
672
284
  sessionLines = await readLedgerSessionJsonlLines(input.session.path);
673
285
  } catch (error) {
674
286
  if (error instanceof TicketProvenanceInputError) throw error;
675
- // Only absence/path-shape misses are model input errors → reask.
676
- // EISDIR = authorized-root path that is a directory, not a session file.
677
- // EIO / EMFILE / EACCES / other runtime faults keep native identity + cause.
678
287
  const code = errnoCode(error);
679
288
  if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
680
289
  const detail = error instanceof Error ? error.message : String(error);
@@ -689,13 +298,7 @@ async function projectSessionRanges(input: {
689
298
  const rows = sessionLines.map((entry) => entry.row);
690
299
  const dialogue = adaptSessionDialogue(rows);
691
300
  const sourceFacts = stableSourceFacts(sessionLines);
692
- const sourcePositionByIdentity = new Map<string, number>();
693
- for (const fact of sourceFacts) {
694
- sourcePositionByIdentity.set(fact.identity, fact.position);
695
- }
696
- const seenIds = input.seenIds;
697
- const lines: TicketProvenanceLine[] = [];
698
- const raw: TicketProvenanceRaw[] = [];
301
+ const entries: (TicketProvenanceLine | TicketProvenanceRaw)[] = [];
699
302
  const ranges = normalizeResolvedRanges(
700
303
  input.session.ranges,
701
304
  sessionLines,
@@ -704,20 +307,13 @@ async function projectSessionRanges(input: {
704
307
  for (const range of ranges) {
705
308
  for (let index = range.fromIndex; index <= range.toIndex; index += 1) {
706
309
  const { position: sourcePosition, identity: sourceIdentity } = sourceFacts[index]!;
707
- if (input.coveredSourceIdentities.has(sourceIdentity) ||
708
- input.legacyCoveredSourcePositions.has(sourcePosition)) continue;
709
310
  const entry = sessionLines[index]!;
710
311
  if (entry.row === undefined) {
711
- raw.push({ raw: entry.raw, s: input.s, sourcePosition, sourceIdentity });
312
+ entries.push({ raw: entry.raw, s: input.s, sourcePosition, sourceIdentity });
712
313
  continue;
713
314
  }
714
315
  for (const event of dialogue[index] ?? []) {
715
- if (event.id !== undefined) {
716
- const identity = dialogueIdentity(input.s, event.id);
717
- if (seenIds.has(identity)) continue;
718
- seenIds.add(identity);
719
- }
720
- lines.push({
316
+ entries.push({
721
317
  speaker: event.speaker,
722
318
  s: input.s,
723
319
  sourcePosition,
@@ -727,15 +323,13 @@ async function projectSessionRanges(input: {
727
323
  }
728
324
  }
729
325
  }
730
- return { lines, raw, sourcePositionByIdentity };
326
+ return { entries };
731
327
  }
732
328
 
733
329
  /**
734
- * Reproject the unique diary from cumulative bounds.
735
- * #918:已投影行即卷宗;本轮只读 prior 未声明的 range(或新 session 区间)。
736
- * 精确重复提交=幂等 no-op,不因历史源不可读而失败;空 sessions 保持 no-op。
737
- * 证不出的 body 原字节经 unprojectedRaw 原样留存。
738
- * Persistence appends one immutable commit through the Sitian appender seam.
330
+ * Append dialogue projection from LLM-submitted bounds into records.jsonl.
331
+ * #1090: 纯追加——不回读已成录历史查重、不折叠、不刷新;同一区间再次提交也留下新的追加记录。
332
+ * 证不出的 body 原字节原样留卷。
739
333
  */
740
334
  export async function reprojectTicketProvenance(input: {
741
335
  readonly ticketNumber: number | null;
@@ -744,102 +338,42 @@ export async function reprojectTicketProvenance(input: {
744
338
  readonly runDirectory?: string;
745
339
  readonly sessions: readonly TicketProvenanceSession[];
746
340
  }): Promise<ReprojectTicketProvenanceResult> {
747
- const prior = await readTicketProvenance(input.ticketNumber, input.cwd, input.home, input.runDirectory);
341
+ const recordInput = ticketProvenanceRecordInput(
342
+ input.ticketNumber,
343
+ input.cwd,
344
+ input.home,
345
+ input.runDirectory,
346
+ );
347
+ const resolved = resolveSitianRecordPath(recordInput);
748
348
  if (input.sessions.length === 0) {
749
- const now = new Date().toISOString();
750
- return {
751
- recordFile: prior.recordFile,
752
- header: prior.header ?? {
753
- repo: resolveBookKeyFromGit(input.cwd),
754
- ticket: input.ticketNumber,
755
- createdAt: now,
756
- updatedAt: now,
757
- sessions: [],
758
- },
759
- lines: prior.lines,
760
- };
761
- }
762
-
763
- const merged = mergeSessionBounds(prior.header?.sessions, input.sessions);
764
- const deltas = undeclaredSessionRanges(prior.header?.sessions, merged.sessions);
765
- if (deltas.length === 0) {
766
- const now = new Date().toISOString();
767
349
  return {
768
- recordFile: prior.recordFile,
769
- header: prior.header ?? {
770
- repo: resolveBookKeyFromGit(input.cwd),
771
- ticket: input.ticketNumber,
772
- createdAt: now,
773
- updatedAt: now,
774
- sessions: merged.sessions,
775
- },
776
- lines: prior.lines,
350
+ recordFile: resolved.recordFile,
351
+ lines: [],
777
352
  };
778
353
  }
779
354
 
780
- const seenIds = new Set(
781
- prior.lines.flatMap((line) =>
782
- line.id === undefined ? [] : [dialogueIdentity(line.s, line.id)]
783
- ),
784
- );
785
- const fresh: TicketProvenanceLine[] = [];
786
- const raw: TicketProvenanceRaw[] = [];
787
- for (const delta of deltas) {
355
+ const entries: (TicketProvenanceLine | TicketProvenanceRaw)[] = [];
356
+ for (let s = 0; s < input.sessions.length; s += 1) {
357
+ const session = input.sessions[s]!;
788
358
  const projected = await projectSessionRanges({
789
- s: delta.s,
790
- session: delta.session,
791
- seenIds,
792
- coveredSourceIdentities: prior.sourceIdentities.get(delta.s) ?? new Set(),
793
- legacyCoveredSourcePositions: prior.legacySourcePositions.get(delta.s) ?? new Set(),
359
+ s,
360
+ session,
794
361
  ...(input.home === undefined ? {} : { home: input.home }),
795
362
  });
796
- fresh.push(...prior.lines.flatMap((line) => {
797
- if (line.s !== delta.s) return [];
798
- const sourceIdentity = line.id === undefined
799
- ? line.sourceIdentity
800
- : `id\u0000${line.id}`;
801
- if (sourceIdentity === undefined) return [];
802
- const sourcePosition = projected.sourcePositionByIdentity.get(sourceIdentity);
803
- return sourcePosition === undefined ? [] : [{ ...line, sourcePosition }];
804
- }));
805
- fresh.push(...projected.lines);
806
- raw.push(...projected.raw);
363
+ entries.push(...projected.entries);
807
364
  }
808
- const lines = mergeFreshIntoCarried(prior.lines, fresh);
809
- const now = new Date().toISOString();
810
- const header: TicketProvenanceHeader = {
811
- repo: prior.header?.repo ?? resolveBookKeyFromGit(input.cwd),
812
- ticket: input.ticketNumber,
813
- createdAt: prior.header?.createdAt ?? now,
814
- updatedAt: now,
815
- sessions: merged.sessions,
816
- };
817
- // One immutable projection commit. Its deterministic identity makes retries of
818
- // the same logical increment converge, while unrelated concurrent increments
819
- // append independently and are folded by readTicketProvenance.
820
- const identityMaterial = JSON.stringify({
821
- ticket: input.ticketNumber,
822
- deltas: deltas.map(({ s, session }) => ({
823
- s,
824
- path: physicalPathIdentity(session.path),
825
- ranges: session.ranges,
826
- })),
827
- lines: [...fresh, ...raw],
828
- });
829
- const identity = `ticket-provenance:${createHash("sha256").update(identityMaterial).digest("hex")}`;
365
+
830
366
  const pointer = appendSitianRecord({
831
- ...ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home, input.runDirectory),
832
- identity,
367
+ ...recordInput,
833
368
  payload: {
834
369
  type: "ticket-provenance-append",
835
- sessions: merged.sessions,
836
- lines: [...fresh, ...raw],
370
+ sessions: input.sessions,
371
+ lines: entries,
837
372
  },
838
373
  });
839
- const folded = await readTicketProvenance(input.ticketNumber, input.cwd, input.home, input.runDirectory);
374
+
840
375
  return {
841
376
  recordFile: pointer.recordFile,
842
- header: folded.header ?? header,
843
- lines: folded.lines,
377
+ lines: entries.filter((entry): entry is TicketProvenanceLine => "speaker" in entry),
844
378
  };
845
379
  }