@akagilnc/pi-workflow-roles 0.1.3565 → 0.1.3572

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +755 -60
  4. package/dist/diarist-contracts.js +72 -0
  5. package/dist/package-contracts/terminating-tools.js +20 -2
  6. package/dist/packaged-role-registry.js +20 -0
  7. package/dist/public-cli/main.js +533 -634
  8. package/dist/public-cli/registry.js +4 -1
  9. package/extensions/role-runtime.ts +1 -0
  10. package/package.json +1 -1
  11. package/resources/diarist-collect.md +6 -10
  12. package/souls/diarist.md +9 -0
  13. package/src/acp-host/production-host.ts +1 -0
  14. package/src/acp-host/role-envelope.ts +2 -1
  15. package/src/diarist-contracts.ts +88 -0
  16. package/src/diarist-role.ts +60 -0
  17. package/src/diarist.ts +253 -179
  18. package/src/host-contracts.ts +6 -1
  19. package/src/package-contracts/terminating-tools.ts +22 -2
  20. package/src/packaged-role-registry.ts +20 -0
  21. package/src/pi/role-turn-host.ts +8 -0
  22. package/src/public-cli/cli.ts +29 -0
  23. package/src/public-cli/countersign-run.ts +9 -129
  24. package/src/public-cli/diarist-run.ts +312 -0
  25. package/src/public-cli/invocation.ts +23 -2
  26. package/src/public-cli/option-definitions.ts +18 -0
  27. package/src/public-cli/post-admission.ts +2 -2
  28. package/src/public-cli/registry.ts +3 -0
  29. package/src/public-cli/run-lifecycle.ts +31 -3
  30. package/src/public-cli/settlement.ts +50 -2
  31. package/src/public-cli/terminal.ts +2 -1
  32. package/src/role-runtime.ts +117 -6
  33. package/src/ticket-provenance-contracts.ts +1 -0
  34. package/src/ticket-provenance.ts +0 -23
  35. package/src/diarist-llm-collector.ts +0 -324
package/src/diarist.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
- * 起居郎 pipeline step — ADR 0075 / #582.
3
- * Not a public seat: no soul, no locator, no gate attendance.
4
- * Runs as the court-pipeline station before countersign turn.
2
+ * 起居郎 mechanical halves — ADR 0075 `diarist-is-role` / `diarist-collector-is-own-turn`.
3
+ * Semantic collection is the diarist role's own LLM turn; this module keeps the
4
+ * mechanical safeguard band only: source enumeration into a frozen catalog, and
5
+ * the verbatim reverse-verify → idempotent sitian append → watermark commit.
6
+ * No lifecycle here (ADR 0018): the seat prepares, the role envelope commits.
5
7
  */
8
+ import { readFileSync } from "node:fs";
9
+
6
10
  import { extractReferencedAdrPaths } from "./adr-path-refs.ts";
7
11
  import {
8
12
  createGhApiRunner,
@@ -22,13 +26,9 @@ import {
22
26
  type DiaristIssueFace,
23
27
  type DiaristSourceBlock,
24
28
  } from "./diarist-mechanical.ts";
25
- import {
26
- createHermesDiaristCollector,
27
- type DiaristLlmCollectResult,
28
- } from "./diarist-llm-collector.ts";
29
+ import type { DiaristSelection } from "./diarist-contracts.ts";
29
30
  import { parseGitHubOriginRemote } from "./reviewer-pinned-git.ts";
30
31
  import {
31
- appendCollectorFailureDiagnostic,
32
32
  appendIssueSourceFailureDiagnostic,
33
33
  appendQuoteVerifyFailureDiagnostic,
34
34
  appendTicketProvenanceEntry,
@@ -40,8 +40,7 @@ import {
40
40
  ticketProvenanceEntryIdentity,
41
41
  writeTicketProvenanceHumanView,
42
42
  } from "./ticket-provenance.ts";
43
- import type { TicketProvenanceEntry } from "./ticket-provenance-contracts.ts";
44
- import { readSitianRecords, type RecordPointer } from "./sitian-facade.ts";
43
+ import { readSitianRecords } from "./sitian-facade.ts";
45
44
  import { execFileSync } from "node:child_process";
46
45
 
47
46
  export type { DiaristIssueFace } from "./diarist-mechanical.ts";
@@ -72,55 +71,15 @@ export class DiaristIssueSourceError extends Error {
72
71
  }
73
72
 
74
73
  /**
75
- * Issue-face fetch for the diarist station.
76
- * Production never soft-returns undefined (Reviewer Spec soft-fetch is not reused).
77
- * Test injectors may return undefined to simulate unavailability — station converts
78
- * that into a typed DiaristIssueSourceError + durable diagnostic.
74
+ * Issue-face fetch for the diarist seat. Unavailability is a typed
75
+ * DiaristIssueSourceError — there is no soft-undefined face.
79
76
  */
80
77
  export type DiaristIssueFaceFetcher = (input: {
81
78
  readonly owner: string;
82
79
  readonly repo: string;
83
80
  readonly ticketNumber: number;
84
81
  readonly signal?: AbortSignal;
85
- }) => Promise<DiaristIssueFace | undefined>;
86
-
87
- export type DiaristRunInput = {
88
- readonly ticketNumber: number;
89
- readonly cwd: string;
90
- /** Explicit package home (admitted run / tests); never process.env.HOME (#604). */
91
- readonly home?: string;
92
- /**
93
- * Frozen GitHub issue face (body + comments). Production loads via shared gh seam.
94
- * Soft-unavailable → omit (no fake face from attachments).
95
- */
96
- readonly issueFace?: DiaristIssueFace;
97
- /** Extra cwd roots whose cc project folders are scanned. */
98
- readonly sessionCwds?: readonly string[];
99
- readonly signal?: AbortSignal;
100
- /** Package root for hermes collector method material resolution. */
101
- readonly packageRoot?: string;
102
- };
103
-
104
- export type DiaristRunResult = {
105
- readonly ticketNumber: number;
106
- /** Safeguard-cleaned source count before incremental filter. */
107
- readonly candidateCount: number;
108
- /** Blocks not yet on the volume — sole set sent to the collector this court. */
109
- readonly freshCount: number;
110
- readonly appended: number;
111
- readonly rejectedQuotes: number;
112
- readonly pointers: readonly RecordPointer[];
113
- readonly entries: readonly TicketProvenanceEntry[];
114
- readonly humanViewFile: string;
115
- readonly volumeRecordFile: string;
116
- readonly collectorStatus:
117
- | "ok"
118
- | "skipped-no-fresh"
119
- | "failed"
120
- | "empty-selection";
121
- readonly collectorError?: string;
122
- readonly llmRawStdout?: string;
123
- };
82
+ }) => Promise<DiaristIssueFace>;
124
83
 
125
84
  /**
126
85
  * Production issue-face capability over shared gh execution seams.
@@ -216,10 +175,78 @@ export function resolveDiaristGithubOrigin(
216
175
  return parseGitHubOriginRemote(remoteUrl);
217
176
  }
218
177
 
178
+ /**
179
+ * Acquire the issue face for a bound ticket. Failures are typed and durable on
180
+ * the ticket-provenance volume, then propagated — never washed into empty face.
181
+ */
182
+ export async function loadDiaristIssueFace(input: {
183
+ readonly ticketNumber: number;
184
+ readonly projectRoot: string;
185
+ readonly home?: string;
186
+ readonly fetcher?: DiaristIssueFaceFetcher;
187
+ }): Promise<DiaristIssueFace> {
188
+ const persistAndThrow = (error: DiaristIssueSourceError): DiaristIssueSourceError => {
189
+ appendIssueSourceFailureDiagnostic({
190
+ ticketNumber: input.ticketNumber,
191
+ cwd: input.projectRoot,
192
+ ...(input.home === undefined ? {} : { home: input.home }),
193
+ cause: error.message,
194
+ reason: error.reason,
195
+ });
196
+ return error;
197
+ };
198
+
199
+ const origin = resolveDiaristGithubOrigin(input.projectRoot);
200
+ if (origin === undefined) {
201
+ throw persistAndThrow(
202
+ new DiaristIssueSourceError(
203
+ "origin-unresolved",
204
+ `bound ticket #${input.ticketNumber} issue face requires a resolvable github.com origin remote`,
205
+ ),
206
+ );
207
+ }
208
+
209
+ const fetcher = input.fetcher ?? createDiaristIssueFaceFetcher();
210
+ try {
211
+ return await fetcher({
212
+ owner: origin.owner,
213
+ repo: origin.repo,
214
+ ticketNumber: input.ticketNumber,
215
+ });
216
+ } catch (error) {
217
+ throw persistAndThrow(
218
+ error instanceof DiaristIssueSourceError
219
+ ? error
220
+ : new DiaristIssueSourceError(
221
+ "issue-unavailable",
222
+ `issue face fetch failed for ${origin.owner}/${origin.repo}#${input.ticketNumber}`,
223
+ { cause: error },
224
+ ),
225
+ );
226
+ }
227
+ }
228
+
229
+ /** One frozen candidate the diarist turn may select by index. */
230
+ export type DiaristSourceCandidate = DiaristSourceBlock & {
231
+ readonly candidateIndex: number;
232
+ };
233
+
234
+ /**
235
+ * Frozen per-ticket catalog handed to the diarist turn and re-read by the
236
+ * envelope at accept time. Carries its own volume coordinates so neither side
237
+ * re-derives them from ambient state.
238
+ */
239
+ export type DiaristSourceCatalog = {
240
+ readonly ticketNumber: number;
241
+ readonly cwd: string;
242
+ readonly home?: string;
243
+ readonly candidates: readonly DiaristSourceCandidate[];
244
+ };
245
+
219
246
  /**
220
247
  * Identities already processed for this ticket:
221
248
  * - volume record identities (selected / verify-fail residue)
222
- * - offered watermark (blocks shown to collector, selected or not)
249
+ * - offered watermark (blocks shown to the diarist, selected or not)
223
250
  */
224
251
  async function loadSeenEntryIdentities(
225
252
  ticketNumber: number,
@@ -257,7 +284,11 @@ function blockEntryIdentity(
257
284
  * Mechanical layer does not prose-filter for relevance (锚定宪法).
258
285
  * Attachments are never merged in as fake issue-body-comment.
259
286
  */
260
- async function loadSourceBlocks(input: DiaristRunInput): Promise<DiaristSourceBlock[]> {
287
+ function loadSourceBlocks(input: {
288
+ readonly cwd: string;
289
+ readonly issueFace?: DiaristIssueFace;
290
+ readonly sessionCwds?: readonly string[];
291
+ }): DiaristSourceBlock[] {
261
292
  const cwds = input.sessionCwds ?? [input.cwd];
262
293
  const blocks: DiaristSourceBlock[] = [...readCcSessionBlocks({ cwds })];
263
294
  if (input.issueFace !== undefined) {
@@ -279,162 +310,205 @@ async function loadSourceBlocks(input: DiaristRunInput): Promise<DiaristSourceBl
279
310
  return blocks;
280
311
  }
281
312
 
282
- function faceTextForAnchors(face: DiaristIssueFace | undefined): string | undefined {
283
- if (face === undefined) return undefined;
284
- const parts = [face.body, ...face.comments.map((c) => c.body)].filter(
285
- (t) => t.trim() !== "",
286
- );
287
- if (parts.length === 0) return undefined;
288
- return parts.join("\n");
289
- }
313
+ export type PrepareDiaristSourceCatalogInput = {
314
+ readonly ticketNumber: number;
315
+ readonly cwd: string;
316
+ /** Explicit package home (admitted run / tests); never process.env.HOME (#604). */
317
+ readonly home?: string;
318
+ /** Frozen GitHub issue face (body + comments) from the shared gh seam. */
319
+ readonly issueFace?: DiaristIssueFace;
320
+ /** Extra cwd roots whose cc project folders are scanned. */
321
+ readonly sessionCwds?: readonly string[];
322
+ };
290
323
 
291
324
  /**
292
- * Run one diarist pass for a ticket: mechanical candidates → LLM collect →
293
- * reverse-verify → idempotent sitian append → human view refresh.
294
- * Always establishes the per-ticket volume + md.
325
+ * Mechanical half A — source enumeration into a frozen catalog.
326
+ * Establishes the per-ticket volume + human view for every bound run (ADR 0075
327
+ * `ticket-provenance-file` 每票一份起居录), then offers only blocks whose entry
328
+ * identity is not already on the volume or the offered watermark (增量幂等).
295
329
  */
296
- export async function runDiarist(input: DiaristRunInput): Promise<DiaristRunResult> {
297
- const homeOpt = input.home === undefined ? {} : { home: input.home };
298
- // Per-ticket volume exists for every bound court, including empty/fail paths.
299
- const volumePaths = ensureTicketProvenanceVolume(input.ticketNumber, input.cwd, input.home);
300
-
301
- const anchorText = faceTextForAnchors(input.issueFace);
302
- const anchors: DiaristAnchorSet = buildDiaristAnchors({
330
+ export async function prepareDiaristSourceCatalog(
331
+ input: PrepareDiaristSourceCatalogInput,
332
+ ): Promise<DiaristSourceCatalog> {
333
+ ensureTicketProvenanceVolume(input.ticketNumber, input.cwd, input.home);
334
+ const volume = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
335
+ writeTicketProvenanceHumanView({
303
336
  ticketNumber: input.ticketNumber,
304
- ...(anchorText === undefined ? {} : { ticketBody: anchorText }),
337
+ cwd: input.cwd,
338
+ ...(input.home === undefined ? {} : { home: input.home }),
339
+ entries: volume.entries,
305
340
  });
306
- const rawBlocks = await loadSourceBlocks(input);
341
+
342
+ const rawBlocks = loadSourceBlocks(input);
307
343
  // Safeguard only (notify filter + dedupe) — never prose-based exclusion.
308
344
  const safeguarded = mechanicalSafeguardPipeline(rawBlocks);
309
- // Incremental: only blocks whose entry identity is not yet on the volume
310
- // are offered to the collector (ADR 0075 refresh-every-court = 增量幂等).
311
345
  const seen = await loadSeenEntryIdentities(input.ticketNumber, input.cwd, input.home);
312
346
  const fresh = safeguarded.filter(
313
347
  (block) => !seen.has(blockEntryIdentity(input.ticketNumber, block)),
314
348
  );
315
349
 
316
- let collectorStatus: DiaristRunResult["collectorStatus"];
317
- let collectorError: string | undefined;
318
- let llmRawStdout: string | undefined;
319
- let collect: DiaristLlmCollectResult | undefined;
320
-
321
- // Production composition always runs the hermes collector (ADR 0075).
322
- // No injectable skip / alternate collector on this seam.
323
- const collector = createHermesDiaristCollector({
350
+ return {
351
+ ticketNumber: input.ticketNumber,
324
352
  cwd: input.cwd,
325
- ...(input.packageRoot === undefined ? {} : { packageRoot: input.packageRoot }),
326
- });
353
+ ...(input.home === undefined ? {} : { home: input.home }),
354
+ candidates: fresh.map((block, candidateIndex) => ({ ...block, candidateIndex })),
355
+ };
356
+ }
327
357
 
328
- if (fresh.length === 0) {
329
- collectorStatus = "skipped-no-fresh";
330
- } else {
331
- try {
332
- collect = await collector({
333
- ticketNumber: input.ticketNumber,
334
- candidates: fresh,
335
- ...(input.signal === undefined ? {} : { signal: input.signal }),
336
- });
337
- llmRawStdout = collect.rawStdout;
338
- collectorStatus =
339
- collect.selections.length === 0 ? "empty-selection" : "ok";
340
- // Watermark advances only after durable volume writes below — never
341
- // before selected entries / quote diagnostics are committed.
342
- } catch (error) {
343
- collectorStatus = "failed";
344
- collectorError =
345
- error instanceof Error ? error.message : String(error);
346
- // Durable true-cause on the ticket volume (append-only history).
347
- appendCollectorFailureDiagnostic({
348
- ticketNumber: input.ticketNumber,
349
- cwd: input.cwd,
350
- ...homeOpt,
351
- collectorError,
352
- });
353
- }
358
+ export function serializeDiaristSourceCatalog(catalog: DiaristSourceCatalog): string {
359
+ return JSON.stringify(catalog);
360
+ }
361
+
362
+ /** Read a frozen catalog written by the seat. Unreadable/malformed fails loudly. */
363
+ export function loadDiaristSourceCatalog(path: string): DiaristSourceCatalog {
364
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
365
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
366
+ throw new Error(`diarist source catalog is not an object (${path})`);
367
+ }
368
+ const record = parsed as Record<string, unknown>;
369
+ if (typeof record.ticketNumber !== "number" || typeof record.cwd !== "string") {
370
+ throw new Error(`diarist source catalog is missing ticket coordinates (${path})`);
354
371
  }
372
+ if (!Array.isArray(record.candidates)) {
373
+ throw new Error(`diarist source catalog is missing candidates (${path})`);
374
+ }
375
+ return parsed as DiaristSourceCatalog;
376
+ }
377
+
378
+ /**
379
+ * 失败真因 — typed causes for submitted rows that never reached the volume.
380
+ * Names the cause only; the per-row detail stays on the volume diagnostic.
381
+ */
382
+ export type DiaristFailureCause =
383
+ /** Row pointed at no candidate in this turn's frozen catalog. */
384
+ | "unknown-candidate"
385
+ /** Row's quotes failed verbatim reverse-verify. */
386
+ | "quote-verify-rejected";
387
+
388
+ /** Honest machine facts about what this turn actually committed to the volume. */
389
+ export type DiaristCommitFacts = {
390
+ readonly ticketNumber: number;
391
+ /** Candidates offered to the diarist this turn. */
392
+ readonly offered: number;
393
+ /** New entries this turn actually put on the volume (entry-count delta). */
394
+ readonly appended: number;
395
+ /** Selections rejected by verbatim reverse-verify. */
396
+ readonly rejectedQuotes: number;
397
+ /** Offered identities newly written to the watermark this turn. */
398
+ readonly watermarked: number;
399
+ readonly volumeRecordFile: string;
400
+ readonly humanViewFile: string;
401
+ /**
402
+ * What this turn's collection amounted to. A turn whose every row was
403
+ * dropped is `nothing-appended`, never `empty-selection` — the diarist
404
+ * selecting nothing and the safeguard band rejecting everything are
405
+ * different events. Why rows were dropped is `failureCauses`.
406
+ */
407
+ readonly collectorStatus:
408
+ | "ok"
409
+ | "empty-selection"
410
+ | "nothing-appended"
411
+ | "skipped-no-fresh";
412
+ /**
413
+ * 失败真因: empty when every submitted row landed. Populated whenever rows
414
+ * were dropped — including a partial turn whose collectorStatus is `ok`.
415
+ */
416
+ readonly failureCauses: readonly DiaristFailureCause[];
417
+ };
418
+
419
+ /**
420
+ * Mechanical half B — commit the diarist turn's selections.
421
+ * Verbatim reverse-verify → idempotent sitian append → watermark → human view.
422
+ * Verify failure records a single typed diagnostic and drops that selection; it
423
+ * never bounces the receipt (第 0 条) and never enters the volume as an entry.
424
+ */
425
+ export async function commitDiaristSelections(input: {
426
+ readonly catalog: DiaristSourceCatalog;
427
+ readonly selections: readonly DiaristSelection[];
428
+ }): Promise<DiaristCommitFacts> {
429
+ const { ticketNumber, cwd } = input.catalog;
430
+ const homeOpt = input.catalog.home === undefined ? {} : { home: input.catalog.home };
431
+ const volumePaths = ensureTicketProvenanceVolume(ticketNumber, cwd, input.catalog.home);
355
432
 
356
- const pointers: RecordPointer[] = [];
357
- const accepted: TicketProvenanceEntry[] = [];
433
+ const anchors: DiaristAnchorSet = buildDiaristAnchors({ ticketNumber });
434
+ // Entry-count baseline: sitian entry identity already makes a repeat of an
435
+ // already-recorded block a no-op append, so the honest `appended` is what the
436
+ // volume gained — not how many rows survived verify.
437
+ const before = await readTicketProvenance(ticketNumber, cwd, input.catalog.home);
438
+ let acceptedRows = 0;
358
439
  let rejectedQuotes = 0;
440
+ let unknownCandidate = false;
359
441
 
360
- // Only a successful collect (ok / empty-selection already branched) with
361
- // selections present can enter the volume. empty-selection has collect with [].
362
- if (collect !== undefined && collectorStatus === "ok") {
363
- for (const selection of collect.selections) {
364
- // triage is human-face only — never a machine gate (collector contract).
365
- // Inclusion is solely: selection present + quote reverse-verify pass.
366
- const block = fresh[selection.candidateIndex];
367
- if (block === undefined) continue;
368
- const projected = blockToLlmEntry(block, {
369
- anchors,
370
- quotes: selection.quotes,
371
- ...(selection.note === undefined ? {} : { note: selection.note }),
372
- });
373
- if (!projected.ok) {
374
- rejectedQuotes += 1;
375
- // Single diagnostic expression — never a disguised diary entry.
376
- const ptr = appendQuoteVerifyFailureDiagnostic({
377
- ticketNumber: input.ticketNumber,
378
- cwd: input.cwd,
379
- ...homeOpt,
380
- cause: projected.cause,
381
- });
382
- pointers.push(ptr);
383
- continue;
384
- }
385
- const ptr = appendTicketProvenanceEntry({
386
- ticketNumber: input.ticketNumber,
387
- cwd: input.cwd,
442
+ for (const selection of input.selections) {
443
+ const block = input.catalog.candidates[selection.candidateIndex];
444
+ if (block === undefined) {
445
+ unknownCandidate = true;
446
+ continue;
447
+ }
448
+ const projected = blockToLlmEntry(block, {
449
+ anchors,
450
+ quotes: selection.quotes,
451
+ ...(selection.note === undefined ? {} : { note: selection.note }),
452
+ });
453
+ if (!projected.ok) {
454
+ rejectedQuotes += 1;
455
+ // Single diagnostic expression — never a disguised diary entry.
456
+ appendQuoteVerifyFailureDiagnostic({
457
+ ticketNumber,
458
+ cwd,
388
459
  ...homeOpt,
389
- entry: projected.entry,
390
- source: "diarist",
460
+ cause: projected.cause,
391
461
  });
392
- pointers.push(ptr);
393
- accepted.push(projected.entry);
462
+ continue;
394
463
  }
395
- }
396
-
397
- // Successful collector pass (incl. empty selection): mark all offered
398
- // identities only after the volume writes above, so a crash mid-commit
399
- // still retries the batch next court. Entry identity and quote-verify
400
- // diagnostic identity are stable — retry does not duplicate either.
401
- // Failure does not advance the watermark (retry honestly).
402
- if (
403
- collect !== undefined &&
404
- (collectorStatus === "ok" || collectorStatus === "empty-selection")
405
- ) {
406
- recordOfferedIdentities({
407
- ticketNumber: input.ticketNumber,
408
- cwd: input.cwd,
464
+ appendTicketProvenanceEntry({
465
+ ticketNumber,
466
+ cwd,
409
467
  ...homeOpt,
410
- identities: fresh.map((block) =>
411
- blockEntryIdentity(input.ticketNumber, block),
412
- ),
468
+ entry: projected.entry,
469
+ source: "diarist",
413
470
  });
471
+ acceptedRows += 1;
414
472
  }
415
473
 
416
- // Refresh human view from the full volume (includes prior court runs).
417
- // Always write — empty courts still get the md face next to the JSONL.
418
- const volume = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
474
+ // Watermark advances only after the durable volume writes above, so a crash
475
+ // mid-commit retries the batch on the next summons. Entry identity and
476
+ // quote-verify diagnostic identity are stable — retry duplicates neither.
477
+ const identities = input.catalog.candidates.map((block) =>
478
+ blockEntryIdentity(ticketNumber, block),
479
+ );
480
+ const alreadyWatermarked = readOfferedIdentities(ticketNumber, cwd, input.catalog.home);
481
+ const watermarked = identities.filter((identity) => !alreadyWatermarked.has(identity)).length;
482
+ recordOfferedIdentities({ ticketNumber, cwd, ...homeOpt, identities });
483
+
484
+ // Refresh the human view from the full volume (includes prior summons).
485
+ const volume = await readTicketProvenance(ticketNumber, cwd, input.catalog.home);
419
486
  const humanViewFile = writeTicketProvenanceHumanView({
420
- ticketNumber: input.ticketNumber,
421
- cwd: input.cwd,
487
+ ticketNumber,
488
+ cwd,
422
489
  ...homeOpt,
423
490
  entries: volume.entries,
424
491
  });
425
492
 
426
493
  return {
427
- ticketNumber: input.ticketNumber,
428
- candidateCount: safeguarded.length,
429
- freshCount: fresh.length,
430
- appended: accepted.length,
494
+ ticketNumber,
495
+ offered: input.catalog.candidates.length,
496
+ appended: volume.entries.length - before.entries.length,
431
497
  rejectedQuotes,
432
- pointers,
433
- entries: volume.entries,
434
- humanViewFile,
498
+ watermarked,
435
499
  volumeRecordFile: volumePaths.recordFile,
436
- collectorStatus,
437
- ...(collectorError === undefined ? {} : { collectorError }),
438
- ...(llmRawStdout === undefined ? {} : { llmRawStdout }),
500
+ humanViewFile,
501
+ collectorStatus:
502
+ input.catalog.candidates.length === 0
503
+ ? "skipped-no-fresh"
504
+ : acceptedRows > 0
505
+ ? "ok"
506
+ : input.selections.length === 0
507
+ ? "empty-selection"
508
+ : "nothing-appended",
509
+ failureCauses: [
510
+ ...(unknownCandidate ? (["unknown-candidate"] as const) : []),
511
+ ...(rejectedQuotes > 0 ? (["quote-verify-rejected"] as const) : []),
512
+ ],
439
513
  };
440
514
  }
@@ -125,7 +125,12 @@ export type RoleTurnActivation =
125
125
  }
126
126
  | { readonly role: "inspector" }
127
127
  | { readonly role: "gatekeeper" }
128
- | { readonly role: "navigator" };
128
+ | { readonly role: "navigator" }
129
+ | {
130
+ readonly role: "diarist";
131
+ /** Frozen source-catalog path; absent for a true-unbound summons (#708). */
132
+ readonly sourcesPath?: string;
133
+ };
129
134
 
130
135
  export type RoleTurnContinuation =
131
136
  | { readonly kind: "initial"; readonly prompt: string }
@@ -34,6 +34,7 @@ import { NOTARY_ACCEPTED_TEXT, NOTARY_OUTPUT_TOOL_NAME, validateRecordedNotaryOu
34
34
  import { COUNTERSIGN_ACCEPTED_TEXT, COUNTERSIGN_OUTPUT_TOOL_NAME, validateRecordedCountersignOutput, type CountersignVerdict } from "../countersign-contracts.ts";
35
35
  import { GLEANER_LEFT_ACCEPTED_TEXT, GLEANER_LEFT_OUTPUT_TOOL_NAME, validateRecordedGleanerLeftOutput, type GleanerLeftOutput } from "../gleaner-left-contracts.ts";
36
36
  import { INSPECTOR_ACCEPTED_TEXT, INSPECTOR_OUTPUT_TOOL_NAME, validateRecordedInspectorOutput, type InspectorOutput } from "../inspector-contracts.ts";
37
+ import { DIARIST_ACCEPTED_TEXT, DIARIST_OUTPUT_TOOL_NAME, validateRecordedDiaristOutput, type DiaristOutput } from "../diarist-contracts.ts";
37
38
  import {
38
39
  CODER_ACCEPTED_TEXT,
39
40
  CODER_OUTPUT_TOOL_NAME,
@@ -71,6 +72,7 @@ export {
71
72
  validateRecordedInspectorOutput,
72
73
  validateRecordedGatekeeperOutput,
73
74
  validateRecordedNavigatorOutput,
75
+ validateRecordedDiaristOutput,
74
76
  };
75
77
  export type {
76
78
  CollectorReceipt,
@@ -87,6 +89,7 @@ export type {
87
89
  InspectorOutput,
88
90
  GatekeeperDirectOutput,
89
91
  NavigatorAdvice,
92
+ DiaristOutput,
90
93
  };
91
94
 
92
95
  export const TERMINATING_TOOL_NAMES = [
@@ -103,6 +106,7 @@ export const TERMINATING_TOOL_NAMES = [
103
106
  INSPECTOR_OUTPUT_TOOL_NAME,
104
107
  GATEKEEPER_OUTPUT_TOOL_NAME,
105
108
  NAVIGATOR_OUTPUT_TOOL_NAME,
109
+ DIARIST_OUTPUT_TOOL_NAME,
106
110
  ] as const;
107
111
 
108
112
  export type TerminatingToolName = (typeof TERMINATING_TOOL_NAMES)[number];
@@ -119,7 +123,8 @@ export type AcceptedDetails =
119
123
  | GleanerLeftOutput
120
124
  | InspectorOutput
121
125
  | GatekeeperDirectOutput
122
- | NavigatorAdvice;
126
+ | NavigatorAdvice
127
+ | DiaristOutput;
123
128
 
124
129
  export function isTerminatingToolName(
125
130
  name: string,
@@ -155,6 +160,8 @@ export function acceptedTextFor(toolName: TerminatingToolName): string {
155
160
  return GATEKEEPER_ACCEPTED_TEXT;
156
161
  case NAVIGATOR_OUTPUT_TOOL_NAME:
157
162
  return NAVIGATOR_ACCEPTED_TEXT;
163
+ case DIARIST_OUTPUT_TOOL_NAME:
164
+ return DIARIST_ACCEPTED_TEXT;
158
165
  }
159
166
  }
160
167
 
@@ -213,6 +220,7 @@ export function validateAcceptedDetails(
213
220
  [INSPECTOR_OUTPUT_TOOL_NAME]: ["pass", "bounce"],
214
221
  [GATEKEEPER_OUTPUT_TOOL_NAME]: ["dispatch", "pass"],
215
222
  [NAVIGATOR_OUTPUT_TOOL_NAME]: ["advice"],
223
+ [DIARIST_OUTPUT_TOOL_NAME]: ["completed"],
216
224
  };
217
225
  const collectorDiscriminator = toolName === COLLECTOR_OUTPUT_TOOL && Array.isArray(candidate?.groups);
218
226
  const baseDiscriminator = discriminator;
@@ -253,6 +261,8 @@ export function validateAcceptedDetails(
253
261
  return validateRecordedGatekeeperOutput(details);
254
262
  case NAVIGATOR_OUTPUT_TOOL_NAME:
255
263
  return validateRecordedNavigatorOutput(details);
264
+ case DIARIST_OUTPUT_TOOL_NAME:
265
+ return validateRecordedDiaristOutput(details);
256
266
  }
257
267
  } catch (error) {
258
268
  if (error instanceof Error && error.constructor === Error) throw new AcceptedDetailsContractError(error.message, { cause: error });
@@ -280,6 +290,15 @@ export function validateAcceptedLifecycle(
280
290
  if (!deepEqual(testimony, projected)) throw new Error("accepted tool lifecycle details mismatch");
281
291
  return details;
282
292
  }
293
+ if (toolName === DIARIST_OUTPUT_TOOL_NAME) {
294
+ // Envelope-owned mechanical sitian facts are runtime-bound on details only
295
+ // (same shape as the Doctor runtime cost); the submitted arguments carry the
296
+ // role's own selections. Machine facts never come from model self-report.
297
+ const { sitian: _mechanical, ...submitted } = details as DiaristOutput & { sitian?: unknown };
298
+ const testimony = validateAcceptedDetails(toolName, argumentsValue);
299
+ if (!deepEqual(testimony, submitted)) throw new Error("accepted tool lifecycle details mismatch");
300
+ return details;
301
+ }
283
302
  const argumentsDetails = validateAcceptedDetails(toolName, argumentsValue);
284
303
  if (!deepEqual(argumentsDetails, details)) throw new Error("accepted tool lifecycle details mismatch");
285
304
  return details;
@@ -301,7 +320,8 @@ export function acceptedFacts(toolName: TerminatingToolName, details: AcceptedDe
301
320
  case GLEANER_LEFT_OUTPUT_TOOL_NAME:
302
321
  case INSPECTOR_OUTPUT_TOOL_NAME:
303
322
  case GATEKEEPER_OUTPUT_TOOL_NAME:
304
- case NAVIGATOR_OUTPUT_TOOL_NAME: return { status: (details as { status: string }).status };
323
+ case NAVIGATOR_OUTPUT_TOOL_NAME:
324
+ case DIARIST_OUTPUT_TOOL_NAME: return { status: (details as { status: string }).status };
305
325
  case JUDGE_OUTPUT_TOOL_NAME: return { status: (details as { judgeStatus: string }).judgeStatus };
306
326
  case COUNTERSIGN_OUTPUT_TOOL_NAME: return { status: (details as { countersignStatus: string }).countersignStatus };
307
327
  case MERGER_OUTPUT_TOOL_NAME: {
@@ -11,6 +11,10 @@ import { NOTARY_OUTPUT_TOOL_NAME } from "./notary-contracts.ts";
11
11
  import { COUNTERSIGN_OUTPUT_TOOL_NAME } from "./countersign-contracts.ts";
12
12
  import { GLEANER_LEFT_OUTPUT_TOOL_NAME } from "./gleaner-left-contracts.ts";
13
13
  import { INSPECTOR_OUTPUT_TOOL_NAME } from "./inspector-contracts.ts";
14
+ import {
15
+ DIARIST_OUTPUT_TOOL_NAME,
16
+ DIARIST_SOURCES_FLAG,
17
+ } from "./diarist-contracts.ts";
14
18
 
15
19
  /** Shared by public notary and gatekeeper-province notary. */
16
20
  export const NOTARY_SESSION_MATERIALS = [
@@ -185,6 +189,22 @@ export const PUBLIC_ROLE_RECORDS = [
185
189
  activationStage: "load-and-install",
186
190
  sessionMaterials: ["CLAUDE.md", "souls/navigator.md"],
187
191
  },
192
+ // #708 / ADR 0075 `diarist-is-role`: 起居郎 is a seat like any other. The
193
+ // frozen source catalog rides the shared input-flag seam; it is absent for a
194
+ // true-unbound summons (no ticket → no diary).
195
+ {
196
+ role: "diarist",
197
+ phases: [null],
198
+ outputTool: DIARIST_OUTPUT_TOOL_NAME,
199
+ inputFlag: DIARIST_SOURCES_FLAG.name,
200
+ phaseFlag: undefined,
201
+ activationStage: "load-and-install",
202
+ sessionMaterials: [
203
+ "CLAUDE.md",
204
+ "souls/diarist.md",
205
+ "resources/diarist-collect.md",
206
+ ],
207
+ },
188
208
  ] as const;
189
209
 
190
210
  export type PublicRoleRecord = (typeof PUBLIC_ROLE_RECORDS)[number];