@akagilnc/pi-workflow-roles 0.1.4204 → 0.1.4228

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.
@@ -2,7 +2,7 @@
2
2
  * #866 (T10): record-class partition migrators (build only; execution is #861).
3
3
  *
4
4
  * Line-closed placement by each row's typed kind (never by source directory alone):
5
- * - ticket-provenance → <ticket>/ (records.jsonl + human view; live topology)
5
+ * - ticket-provenance → <ticket>/records.jsonl (live topology; human view cancelled #900)
6
6
  * - submission-ledger kinds → owning run session/submission-ledger/
7
7
  * - attempt-history → owning run session/attempt-history/
8
8
  * - unknown ownership → unbound/ (never silent discard)
@@ -34,30 +34,13 @@ import {
34
34
  type MigrationItemOutcome,
35
35
  } from "./book-topology-migration.ts";
36
36
  import { S4_SUBMISSION_LEDGER_KINDS } from "./sitian-appender.ts";
37
- import {
38
- projectTicketProvenanceEntry,
39
- TICKET_PROVENANCE_HUMAN_VIEW,
40
- } from "./ticket-provenance-contracts.ts";
41
- import { renderTicketProvenanceMarkdown } from "./ticket-provenance.ts";
37
+ import { projectTicketProvenanceHeader } from "./ticket-provenance-contracts.ts";
42
38
 
43
39
  const TICKET_PROVENANCE = "ticket-provenance";
44
40
  const SUBMISSION_LEDGER = "submission-ledger";
45
41
  const ATTEMPT_HISTORY = "attempt-history";
46
42
  const MISPLACED = "misplaced-record-class";
47
43
 
48
- function noteTouchedTicket(
49
- context: BookTopologyMigrationContext,
50
- bookKey: string,
51
- ticketNumber: number,
52
- ): void {
53
- let set = context.touchedTicketsByBook.get(bookKey);
54
- if (set === undefined) {
55
- set = new Set<number>();
56
- context.touchedTicketsByBook.set(bookKey, set);
57
- }
58
- set.add(ticketNumber);
59
- }
60
-
61
44
  type RecordClass = typeof TICKET_PROVENANCE | typeof SUBMISSION_LEDGER | typeof ATTEMPT_HISTORY;
62
45
 
63
46
  type JsonLine =
@@ -108,9 +91,20 @@ async function ensureDir(path: string): Promise<void> {
108
91
  await mkdir(path, { recursive: true });
109
92
  }
110
93
 
94
+ /** One provisional line written to a ticket dest before a bare volume may supersede it. */
95
+ type DestLinePlacement = {
96
+ readonly raw: string;
97
+ /** Index into the partition outcomes array — flipped to unbound if bare replaces dest. */
98
+ readonly outcomeIndex: number;
99
+ };
100
+
111
101
  /** Per-destination identity cache — one read per file, O(1) subsequent checks. */
112
102
  class RecordWriteCache {
113
103
  private readonly identities = new Map<string, Set<string>>();
104
+ /** Dest paths written as whole #901 bare volumes — legacy must not append under them. */
105
+ private readonly bareVolumeFiles = new Set<string>();
106
+ /** Lines currently on a dest that are still claimed `placed` in outcomes. */
107
+ private readonly destPlacements = new Map<string, DestLinePlacement[]>();
114
108
 
115
109
  private async load(recordFile: string): Promise<Set<string>> {
116
110
  const cached = this.identities.get(recordFile);
@@ -125,19 +119,81 @@ class RecordWriteCache {
125
119
  return set;
126
120
  }
127
121
 
128
- async append(recordFile: string, raw: string): Promise<void> {
122
+ markBareVolume(recordFile: string): void {
123
+ this.bareVolumeFiles.add(recordFile);
124
+ this.identities.set(recordFile, new Set());
125
+ }
126
+
127
+ isBareVolume(recordFile: string): boolean {
128
+ return this.bareVolumeFiles.has(recordFile);
129
+ }
130
+
131
+ /**
132
+ * After a bare whole-file write, register its lines so a later bare for the
133
+ * same ticket can rehome them and flip dispositions (multi-bare honesty).
134
+ */
135
+ setDestPlacements(recordFile: string, placements: readonly DestLinePlacement[]): void {
136
+ this.destPlacements.set(recordFile, [...placements]);
137
+ }
138
+
139
+ /**
140
+ * Install a bare volume as the sole ticket file. Any lines already placed on
141
+ * this dest (legacy appends or a prior bare) are moved to unbound and their
142
+ * outcomes flipped — report stays honest.
143
+ */
144
+ async replaceWholeBare(
145
+ recordFile: string,
146
+ body: string,
147
+ outcomes: MigrationItemOutcome[],
148
+ unboundFileForRaw: (raw: string) => string,
149
+ ): Promise<void> {
150
+ const prior = this.destPlacements.get(recordFile) ?? [];
151
+ for (const placement of prior) {
152
+ await this.append(unboundFileForRaw(placement.raw), placement.raw);
153
+ const previous = outcomes[placement.outcomeIndex];
154
+ if (previous !== undefined) {
155
+ outcomes[placement.outcomeIndex] = {
156
+ disposition: "unbound",
157
+ source: previous.source,
158
+ };
159
+ }
160
+ }
161
+ this.destPlacements.delete(recordFile);
162
+ await ensureDir(dirname(recordFile));
163
+ const text = body.endsWith("\n") ? body : `${body}\n`;
164
+ await writeFile(recordFile, text, "utf8");
165
+ this.markBareVolume(recordFile);
166
+ }
167
+
168
+ async append(
169
+ recordFile: string,
170
+ raw: string,
171
+ options?: { readonly outcomeIndex?: number },
172
+ ): Promise<void> {
129
173
  await ensureDir(dirname(recordFile));
130
174
  const line = raw.endsWith("\n") ? raw : `${raw}\n`;
131
175
  const parsed = parseJsonlLine(raw);
132
176
  if (parsed.ok && typeof parsed.value.identity === "string") {
133
177
  const existing = await this.load(recordFile);
134
- if (existing.has(parsed.value.identity)) return;
135
- existing.add(parsed.value.identity);
136
- } else if (!this.identities.has(recordFile)) {
137
- // Ensure subsequent identity loads see a live map even when first rows lack identity.
138
- this.identities.set(recordFile, await this.load(recordFile));
178
+ if (!existing.has(parsed.value.identity)) {
179
+ existing.add(parsed.value.identity);
180
+ await appendFile(recordFile, line, "utf8");
181
+ }
182
+ // Identity hit skips a second physical copy, but the source row still claims
183
+ // this dest — register outcome below so bare replace flips every claim.
184
+ } else {
185
+ if (!this.identities.has(recordFile)) {
186
+ // Ensure subsequent identity loads see a live map even when first rows lack identity.
187
+ this.identities.set(recordFile, await this.load(recordFile));
188
+ }
189
+ await appendFile(recordFile, line, "utf8");
190
+ }
191
+ // Register outcome claims on dest even when identity skipped a write.
192
+ if (options?.outcomeIndex !== undefined && !this.bareVolumeFiles.has(recordFile)) {
193
+ const list = this.destPlacements.get(recordFile) ?? [];
194
+ list.push({ raw, outcomeIndex: options.outcomeIndex });
195
+ this.destPlacements.set(recordFile, list);
139
196
  }
140
- await appendFile(recordFile, line, "utf8");
141
197
  }
142
198
  }
143
199
 
@@ -342,6 +398,8 @@ async function placeTicketProvenanceLine(
342
398
  source: string,
343
399
  raw: string,
344
400
  value: Record<string, unknown> | undefined,
401
+ /** Outcomes length at call time = index of the outcome the caller is about to push. */
402
+ outcomeIndex: number,
345
403
  ): Promise<MigrationItemOutcome> {
346
404
  if (value === undefined) {
347
405
  await writes.append(unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)), raw);
@@ -353,11 +411,30 @@ async function placeTicketProvenanceLine(
353
411
  return { disposition: "unbound", source };
354
412
  }
355
413
  // Live topology (docs/dossier-topology.md): ticket root holds records.jsonl.
356
- await writes.append(
357
- join(context.booksDirectory, bookKey, String(ticketNumber), "records.jsonl"),
358
- raw,
414
+ const dest = join(
415
+ context.booksDirectory,
416
+ bookKey,
417
+ String(ticketNumber),
418
+ "records.jsonl",
359
419
  );
360
- noteTouchedTicket(context, bookKey, ticketNumber);
420
+ // Never append legacy SitianRecord under a bare #901 volume (header must stay first).
421
+ if (writes.isBareVolume(dest)) {
422
+ await writes.append(
423
+ unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)),
424
+ raw,
425
+ );
426
+ return { disposition: "unbound", source };
427
+ }
428
+ const existing = await readJsonlLines(dest);
429
+ if (tryBareTicketProvenanceVolume(existing) !== undefined) {
430
+ writes.markBareVolume(dest);
431
+ await writes.append(
432
+ unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)),
433
+ raw,
434
+ );
435
+ return { disposition: "unbound", source };
436
+ }
437
+ await writes.append(dest, raw, { outcomeIndex });
361
438
  return { disposition: "placed", source };
362
439
  }
363
440
 
@@ -409,10 +486,11 @@ async function placeRecordClassLine(
409
486
  raw: string,
410
487
  value: Record<string, unknown>,
411
488
  fallbackCategory: RecordClass,
489
+ outcomeIndex: number,
412
490
  ): Promise<MigrationItemOutcome> {
413
491
  const recordClass = recordClassOfKind(value.kind);
414
492
  if (recordClass === TICKET_PROVENANCE) {
415
- return placeTicketProvenanceLine(context, bookKey, writes, source, raw, value);
493
+ return placeTicketProvenanceLine(context, bookKey, writes, source, raw, value, outcomeIndex);
416
494
  }
417
495
  if (recordClass === SUBMISSION_LEDGER || recordClass === ATTEMPT_HISTORY) {
418
496
  return placeRunOwnedLine(context, bookKey, writes, recordClass, source, raw, value);
@@ -426,6 +504,59 @@ async function placeRecordClassLine(
426
504
  return { disposition: "unbound", source };
427
505
  }
428
506
 
507
+ /**
508
+ * #901 bare diary volume: first non-empty line is a header without SitianRecord
509
+ * `kind`. Recognise the whole file as one ticket volume — do not split rows into unbound.
510
+ */
511
+ function tryBareTicketProvenanceVolume(
512
+ lines: readonly string[],
513
+ ): { readonly ticket: number; readonly body: readonly string[] } | undefined {
514
+ const body: string[] = [];
515
+ for (const raw of lines) {
516
+ if (raw.trim() === "") continue;
517
+ body.push(raw);
518
+ }
519
+ if (body.length === 0) return undefined;
520
+ const first = parseJsonlLine(body[0]!);
521
+ if (!first.ok) return undefined;
522
+ // Legacy SitianRecord rows carry a typed kind — leave them to line placement.
523
+ if (recordClassOfKind(first.value.kind) !== undefined) return undefined;
524
+ const header = projectTicketProvenanceHeader(first.value);
525
+ if (header === undefined) return undefined;
526
+ return { ticket: header.ticket, body };
527
+ }
528
+
529
+ async function placeBareTicketProvenanceVolume(
530
+ context: BookTopologyMigrationContext,
531
+ bookKey: string,
532
+ writes: RecordWriteCache,
533
+ filePath: string,
534
+ bare: { readonly ticket: number; readonly body: readonly string[] },
535
+ outcomes: MigrationItemOutcome[],
536
+ ): Promise<void> {
537
+ const dest = join(
538
+ context.booksDirectory,
539
+ bookKey,
540
+ String(bare.ticket),
541
+ "records.jsonl",
542
+ );
543
+ // Whole-file bare install. Prior dest lines (legacy or earlier bare) rehome to
544
+ // unbound with flipped dispositions — header stays first; no silent erase.
545
+ const body = bare.body.map((raw) => (raw.endsWith("\n") ? raw : `${raw}\n`)).join("");
546
+ await writes.replaceWholeBare(dest, body, outcomes, (raw) =>
547
+ unboundCategoryFile(context.booksDirectory, bookKey, TICKET_PROVENANCE, stableKey(raw)),
548
+ );
549
+ const barePlacements: DestLinePlacement[] = [];
550
+ for (let index = 0; index < bare.body.length; index += 1) {
551
+ const source = lineSource(context.backupBooksDirectory, filePath, index);
552
+ const outcomeIndex = outcomes.length;
553
+ outcomes.push({ disposition: "placed", source });
554
+ barePlacements.push({ raw: bare.body[index]!, outcomeIndex });
555
+ }
556
+ // Register so a later bare for the same ticket can supersede honestly.
557
+ writes.setDestPlacements(dest, barePlacements);
558
+ }
559
+
429
560
  async function migrateJsonlFileByKind(
430
561
  context: BookTopologyMigrationContext,
431
562
  bookKey: string,
@@ -435,6 +566,13 @@ async function migrateJsonlFileByKind(
435
566
  outcomes: MigrationItemOutcome[],
436
567
  ): Promise<void> {
437
568
  const lines = await readJsonlLines(filePath);
569
+ if (fallbackCategory === TICKET_PROVENANCE) {
570
+ const bare = tryBareTicketProvenanceVolume(lines);
571
+ if (bare !== undefined) {
572
+ await placeBareTicketProvenanceVolume(context, bookKey, writes, filePath, bare, outcomes);
573
+ return;
574
+ }
575
+ }
438
576
  for (let index = 0; index < lines.length; index += 1) {
439
577
  const raw = lines[index]!;
440
578
  if (raw.trim() === "") continue;
@@ -443,14 +581,33 @@ async function migrateJsonlFileByKind(
443
581
  if (!parsed.ok) {
444
582
  // Malformed: preserve under the home partition's unbound bucket.
445
583
  if (fallbackCategory === TICKET_PROVENANCE) {
446
- outcomes.push(await placeTicketProvenanceLine(context, bookKey, writes, source, raw, undefined));
584
+ outcomes.push(
585
+ await placeTicketProvenanceLine(
586
+ context,
587
+ bookKey,
588
+ writes,
589
+ source,
590
+ raw,
591
+ undefined,
592
+ outcomes.length,
593
+ ),
594
+ );
447
595
  } else {
448
596
  outcomes.push(await placeRunOwnedLine(context, bookKey, writes, fallbackCategory, source, raw, undefined));
449
597
  }
450
598
  continue;
451
599
  }
452
600
  outcomes.push(
453
- await placeRecordClassLine(context, bookKey, writes, source, raw, parsed.value, fallbackCategory),
601
+ await placeRecordClassLine(
602
+ context,
603
+ bookKey,
604
+ writes,
605
+ source,
606
+ raw,
607
+ parsed.value,
608
+ fallbackCategory,
609
+ outcomes.length,
610
+ ),
454
611
  );
455
612
  }
456
613
  }
@@ -482,65 +639,11 @@ async function mergeOfferedIdentities(
482
639
  }
483
640
  }
484
641
 
485
- /**
486
- * Derive the human-read face once from the merged authoritative records.jsonl.
487
- * Never copy any pre-existing md as if it were the complete view.
488
- */
489
- async function writeMergedTicketHumanView(
490
- destDir: string,
491
- ticketNumber: number,
492
- ): Promise<void> {
493
- const lines = await readJsonlLines(join(destDir, "records.jsonl"));
494
- const entries = [];
495
- const unprojected: unknown[] = [];
496
- for (const raw of lines) {
497
- if (raw.trim() === "") continue;
498
- const parsed = parseJsonlLine(raw);
499
- if (!parsed.ok) continue;
500
- if (recordClassOfKind(parsed.value.kind) !== TICKET_PROVENANCE) continue;
501
- const entry = projectTicketProvenanceEntry(parsed.value.payload);
502
- if (entry === undefined) {
503
- if (parsed.value.payload !== undefined) unprojected.push(parsed.value.payload);
504
- continue;
505
- }
506
- entries.push(entry);
507
- }
508
- await ensureDir(destDir);
509
- await writeFile(
510
- join(destDir, TICKET_PROVENANCE_HUMAN_VIEW),
511
- renderTicketProvenanceMarkdown({
512
- ticketNumber,
513
- entries,
514
- ...(unprojected.length > 0 ? { unprojected } : {}),
515
- }),
516
- "utf8",
517
- );
518
- }
519
-
520
- /**
521
- * After all sources for a ticket have contributed records + offered-identities,
522
- * emit the single derived human view from the merged JSONL.
523
- */
524
- async function finalizeTicketCompanions(
525
- context: BookTopologyMigrationContext,
526
- bookKey: string,
527
- ticketNumbers: ReadonlySet<number>,
528
- ): Promise<void> {
529
- for (const ticketNumber of ticketNumbers) {
530
- const destDir = join(context.booksDirectory, bookKey, String(ticketNumber));
531
- try {
532
- await stat(join(destDir, "records.jsonl"));
533
- } catch (error) {
534
- if (isMigrationEnoent(error)) continue;
535
- throw error;
536
- }
537
- await writeMergedTicketHumanView(destDir, ticketNumber);
538
- }
539
- }
540
-
541
642
  /** Collect typed ticket numbers present in a source volume's records.jsonl. */
542
643
  async function ticketNumbersInVolume(volumeDir: string): Promise<number[]> {
543
644
  const lines = await readJsonlLines(join(volumeDir, "records.jsonl"));
645
+ const bare = tryBareTicketProvenanceVolume(lines);
646
+ if (bare !== undefined) return [bare.ticket];
544
647
  const out: number[] = [];
545
648
  const seen = new Set<number>();
546
649
  for (const raw of lines) {
@@ -563,7 +666,6 @@ async function mergeCompanionsFromVolume(
563
666
  writes: RecordWriteCache,
564
667
  ): Promise<void> {
565
668
  for (const ticketNumber of await ticketNumbersInVolume(volumeDir)) {
566
- noteTouchedTicket(context, bookKey, ticketNumber);
567
669
  const destDir = join(context.booksDirectory, bookKey, String(ticketNumber));
568
670
  await ensureDir(destDir);
569
671
  await mergeOfferedIdentities(destDir, volumeDir, writes);
@@ -578,8 +680,6 @@ async function migrateHomePartitionBook(
578
680
  outcomes: MigrationItemOutcome[],
579
681
  ): Promise<void> {
580
682
  const backupBook = join(context.backupBooksDirectory, bookKey);
581
- // Touches accumulate via noteTouchedTicket — human view finalizes once after
582
- // misplaced lines also land (not here).
583
683
  // listVolumeRecordFiles covers partition-root records.jsonl + hashed sub-volumes.
584
684
  const rootFiles = await listVolumeRecordFiles(join(backupBook, category));
585
685
  for (const filePath of rootFiles) {
@@ -609,8 +709,6 @@ async function migrateHomePartitionBook(
609
709
  }
610
710
  await migrateJsonlFileByKind(context, bookKey, writes, recordsFile, TICKET_PROVENANCE, outcomes);
611
711
  await mergeCompanionsFromVolume(context, bookKey, volumeDir, writes);
612
- // Ticket-dir name itself is a typed ticket when the volume lives under it.
613
- noteTouchedTicket(context, bookKey, Number(entry.name));
614
712
  }
615
713
  }
616
714
  }
@@ -685,7 +783,17 @@ async function migrateMisplacedBook(
685
783
 
686
784
  const source = lineSource(context.backupBooksDirectory, filePath, index);
687
785
  if (recordClass === TICKET_PROVENANCE) {
688
- outcomes.push(await placeTicketProvenanceLine(context, bookKey, writes, source, raw, parsed.value));
786
+ outcomes.push(
787
+ await placeTicketProvenanceLine(
788
+ context,
789
+ bookKey,
790
+ writes,
791
+ source,
792
+ raw,
793
+ parsed.value,
794
+ outcomes.length,
795
+ ),
796
+ );
689
797
  } else {
690
798
  outcomes.push(
691
799
  await placeRunOwnedLine(context, bookKey, writes, recordClass, source, raw, parsed.value),
@@ -763,10 +871,6 @@ export const misplacedRecordClassPartitionMigrator: BookTopologyPartitionMigrato
763
871
  async migrate(context) {
764
872
  const outcomes: MigrationItemOutcome[] = [];
765
873
  await forEachBook(context, (bookKey, writes) => migrateMisplacedBook(context, bookKey, writes, outcomes));
766
- // All record-class lines (home + misplaced) have landed — one human-view pass.
767
- for (const [bookKey, tickets] of context.touchedTicketsByBook) {
768
- await finalizeTicketCompanions(context, bookKey, tickets);
769
- }
770
874
  return reconcileMigrationPartition(MISPLACED, "lines", outcomes);
771
875
  },
772
876
  };
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Public 起居郎 (diarist) terminating receipt contracts — ADR 0075(起居郎是 LLM 角色)。
2
+ * Public 起居郎 (diarist) terminating receipt contracts — ADR 0075 `diarist-is-role` / #901.
3
3
  * Lawful explicit releases: completed (入录) | escalate (认不出本庭对象上抛).
4
- * Machine facts about the volume come from the mechanical sitian seam, never
4
+ * Machine facts about the volume come from the mechanical reproject seam, never
5
5
  * from model self-report (锚定宪法); this module owns the receipt shape only.
6
6
  *
7
- * #779: no frozen candidate catalog; LLM finds sources and submits whole blocks.
8
- * Mechanical layer does not judge LLM output (no quote/ticket/relevance verify).
7
+ * #901: LLM submits bounds (+ optional amendments); mechanical layer projects.
9
8
  */
10
9
 
11
- import type {
12
- TicketProvenanceSourceKind,
13
- TicketProvenanceSourceRef,
10
+ import {
11
+ projectTicketProvenanceAmendments,
12
+ projectTicketProvenanceSessions,
13
+ type TicketProvenanceAmendment,
14
+ type TicketProvenanceSession,
14
15
  } from "./ticket-provenance-contracts.ts";
15
16
  import {
16
17
  isSafePositiveTicketNumber,
@@ -20,31 +21,17 @@ import {
20
21
  export const DIARIST_OUTPUT_TOOL_NAME = "ak_diarist_output";
21
22
  export const DIARIST_ACCEPTED_TEXT = "起居郎回执已接受";
22
23
 
23
- /**
24
- * One whole block the diarist chose to enter (ADR 0075 誊录整块对话)。
25
- * LLM supplies the block bytes + source pointer; mechanical layer appends as-is.
26
- */
27
- export type DiaristEntrySubmission = {
28
- readonly sourceKind: TicketProvenanceSourceKind;
29
- readonly sourceRef: TicketProvenanceSourceRef;
30
- /** Whole-block transcript — not a pointer-only stand-in. */
31
- readonly transcript: string;
32
- readonly timestamp: string;
33
- /** Human-facing note (relation to this case). Not a machine gate. */
34
- readonly note?: string;
35
- };
36
-
37
24
  export type DiaristOutput =
38
25
  | {
39
26
  readonly status: "completed";
40
27
  /**
41
- * Typed court-target assertion (ADR 0075 起居郎 LLM 自行认票)。
28
+ * Typed court-target assertion (ADR 0075 `diarist-resolves-ticket-llm-layer`).
42
29
  * Positive integer = 本庭对象=票N; null/absent = true-unbound (真无票→无录).
43
30
  * LLM owns recognition; mechanical layer does not re-judge the number.
44
31
  */
45
32
  readonly ticketNumber?: number | null;
46
33
  /**
47
- * #871 typed co-review set (ADR 0075 合审票集 narrow extension)。
34
+ * #871 typed co-review set (ADR 0075 `diarist-resolves-ticket-llm-layer` narrow extension).
48
35
  * Positive integers = tickets that should each receive a diary this court (includes main).
49
36
  * Field absent → no new set this turn (resume keeps stored run fact; first court defaults to [main]).
50
37
  * Explicit empty array → single-ticket face [main] whole-set replace.
@@ -52,8 +39,17 @@ export type DiaristOutput =
52
39
  * Mechanical layer only type-projects/dedupes and guarantees principal membership when applying a set.
53
40
  */
54
41
  readonly courtTicketNumbers?: readonly number[] | null;
55
- /** Whole blocks to append under the asserted ticket. Empty list is lawful. */
56
- readonly entries?: readonly DiaristEntrySubmission[];
42
+ /**
43
+ * Dialogue bounds: one entry per session volume, each with one or more ranges.
44
+ * Empty / absent = lawful empty selection (no dialogue this turn).
45
+ * Malformed endpoints → accept hook reasks (`reask-not-explode`).
46
+ */
47
+ readonly sessions?: readonly TicketProvenanceSession[];
48
+ /**
49
+ * Optional amendments for unparsable source lines (s + line + speaker + text).
50
+ * Absent = no amendments this turn (not a shape rejection).
51
+ */
52
+ readonly amendments?: readonly TicketProvenanceAmendment[];
57
53
  }
58
54
  | {
59
55
  /** LLM cannot tell which ticket this summons is about — escalate, never wash into 无录. */
@@ -180,8 +176,25 @@ export function validateRecordedDiaristOutput(value: unknown): DiaristOutput {
180
176
  throw new Error("Diarist output has no execution discriminator");
181
177
  }
182
178
 
183
- /** Entries array as the diarist wrote it — no field rewrite (#836 B6.8/B6.9). */
184
- export function projectDiaristEntries(value: unknown): unknown[] {
185
- const rows = (value as { entries?: unknown } | null)?.entries;
186
- return Array.isArray(rows) ? [...rows] : [];
179
+ /**
180
+ * Project sessions from a completed diarist payload.
181
+ * Absent key → empty (lawful). Present but unusable → undefined (caller reasks).
182
+ */
183
+ export function projectDiaristSessions(
184
+ value: unknown,
185
+ ): readonly TicketProvenanceSession[] | undefined {
186
+ const raw = (value as { sessions?: unknown } | null)?.sessions;
187
+ if (raw === undefined) return [];
188
+ return projectTicketProvenanceSessions(raw);
189
+ }
190
+
191
+ /**
192
+ * Project optional amendments. Absent / non-array → empty (not a rejection).
193
+ */
194
+ export function projectDiaristAmendments(
195
+ value: unknown,
196
+ ): readonly TicketProvenanceAmendment[] {
197
+ const raw = (value as { amendments?: unknown } | null)?.amendments;
198
+ if (raw === undefined) return [];
199
+ return projectTicketProvenanceAmendments(raw);
187
200
  }
@@ -16,13 +16,10 @@ export {
16
16
  export type { DiaristOutput };
17
17
  export { validateRecordedDiaristOutput };
18
18
 
19
- // #836 r16 class 1: entries[].sourceKind/sourceRef/transcript/timestamp are
20
- // LLM/human-read content — src/ticket-provenance-contracts.ts:130-140 reads
21
- // sourceKind/transcript, but the lawful path on a miss is to keep the entry as
22
- // unprojected raw payload (src/diarist.ts:52-75 never rejects or drops it), so
23
- // provider `required` would cut off that very branch; sourceRef/timestamp are
24
- // not branched on at all.
25
- /** 起居郎交卷形状;形状指引,非 schema 闸。 */
19
+ /**
20
+ * 起居郎交卷形状;形状指引,非 schema 闸。
21
+ * #901:交边界(sessions)+ 可选坏行补写(amendments);正文由机械投影。
22
+ */
26
23
  export const diaristOutputSchema = withInfrastructureFailureDeclaration(
27
24
  openToolObject(
28
25
  Type.Object({
@@ -46,34 +43,82 @@ export const diaristOutputSchema = withInfrastructureFailureDeclaration(
46
43
  description: "status 为 escalate 时:认不出本庭对象的原因",
47
44
  }),
48
45
  ),
49
- entries: Type.Optional(
46
+ sessions: Type.Optional(
47
+ Type.Array(
48
+ Type.Object(
49
+ {
50
+ path: Type.Optional(
51
+ Type.String({ description: "会话卷绝对或可读路径" }),
52
+ ),
53
+ ranges: Type.Optional(
54
+ Type.Array(
55
+ Type.Object(
56
+ {
57
+ from: Type.Optional(
58
+ Type.Object(
59
+ {
60
+ id: Type.Optional(Type.String()),
61
+ line: Type.Optional(Type.Unknown()),
62
+ },
63
+ {
64
+ additionalProperties: true,
65
+ description: "起点:原生 id 或本轮行号二选一",
66
+ },
67
+ ),
68
+ ),
69
+ to: Type.Optional(
70
+ Type.Object(
71
+ {
72
+ id: Type.Optional(Type.String()),
73
+ line: Type.Optional(Type.Unknown()),
74
+ },
75
+ {
76
+ additionalProperties: true,
77
+ description: "终点:原生 id 或本轮行号二选一",
78
+ },
79
+ ),
80
+ ),
81
+ },
82
+ { additionalProperties: true, description: "一段对话区间" },
83
+ ),
84
+ { description: "本卷本轮各区间;至少一段" },
85
+ ),
86
+ ),
87
+ },
88
+ { additionalProperties: true, description: "一卷会话的边界" },
89
+ ),
90
+ {
91
+ description:
92
+ "本票对话边界;空列表=本轮无对话可划。端点无法指名时走 reask,不中止。",
93
+ },
94
+ ),
95
+ ),
96
+ amendments: Type.Optional(
50
97
  Type.Array(
51
98
  Type.Object(
52
99
  {
53
- sourceKind: Type.Optional(Type.String({
54
- description:
55
- "来源族:cc-session | issue-body-comment | adr-decision-key | ticket-decree-block",
56
- })),
57
- sourceRef: Type.Optional(Type.Object(
58
- {
59
- sessionFile: Type.Optional(Type.String()),
60
- entryId: Type.Optional(Type.Unknown()),
61
- path: Type.Optional(Type.String()),
62
- url: Type.Optional(Type.String()),
63
- },
64
- { additionalProperties: true, description: "不可变源指针" },
65
- )),
66
- transcript: Type.Optional(Type.String({
67
- description: "整块原文(誊录整块,不指针化)",
68
- })),
69
- timestamp: Type.Optional(Type.String({ description: "源时间戳 ISO" })),
70
- note: Type.Optional(
71
- Type.String({ description: "该材料与本案的关系(人读)" }),
100
+ s: Type.Optional(
101
+ Type.Unknown({ description: "卷下标(sessions 位置)" }),
102
+ ),
103
+ line: Type.Optional(
104
+ Type.Unknown({ description: "源卷 1-based 行号" }),
72
105
  ),
106
+ speaker: Type.Optional(
107
+ Type.String({ description: "owner | runner" }),
108
+ ),
109
+ text: Type.Optional(
110
+ Type.String({ description: "补写正文(原话)" }),
111
+ ),
112
+ },
113
+ {
114
+ additionalProperties: true,
115
+ description: "一条坏行补写;缺字段的成员机械跳过",
73
116
  },
74
- { additionalProperties: true, description: "一条入录整块" },
75
117
  ),
76
- { description: "入录整块;空列表合法完局;escalate 时可不交" },
118
+ {
119
+ description:
120
+ "可选;机械解析不了的行交此补写。缺本字段=无补写,不拒收。",
121
+ },
77
122
  ),
78
123
  ),
79
124
  }),
@@ -92,7 +137,8 @@ export type DiaristRuntimeDependencies = {
92
137
  export const DIARIST_TOOL_SPEC = {
93
138
  name: DIARIST_OUTPUT_TOOL_NAME,
94
139
  label: "起居郎输出",
95
- description: "起居郎入录整块与本票身份断言;认不出本庭对象则 escalate。",
96
- promptSnippet: "起居郎入录整块与本票身份",
140
+ description:
141
+ "起居郎交本票对话边界(sessions)与可选坏行补写(amendments);认不出本庭对象则 escalate。",
142
+ promptSnippet: "起居郎交边界与可选补写",
97
143
  parameters: diaristOutputSchema,
98
144
  } as const;