@akagilnc/pi-workflow-roles 0.1.3558 → 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 (49) hide show
  1. package/README.md +4 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/description.js +21 -0
  4. package/dist/{grok → acp-host}/mcp-relay.mjs +5 -5
  5. package/dist/{grok → acp-host}/production-host.js +860 -162
  6. package/dist/diarist-contracts.js +72 -0
  7. package/dist/host-descriptions.js +34 -0
  8. package/dist/package-contracts/terminating-tools.js +20 -2
  9. package/dist/packaged-role-registry.js +20 -0
  10. package/dist/public-cli/config.js +23 -10
  11. package/dist/public-cli/main.js +622 -671
  12. package/dist/public-cli/registry.js +4 -1
  13. package/extensions/role-runtime.ts +1 -0
  14. package/package.json +1 -1
  15. package/resources/diarist-collect.md +6 -10
  16. package/scripts/build-package.mjs +8 -8
  17. package/souls/diarist.md +9 -0
  18. package/src/acp-host/description.ts +43 -0
  19. package/src/{grok → acp-host}/mcp-relay.mjs +5 -5
  20. package/src/{grok → acp-host}/production-host.ts +31 -37
  21. package/src/{grok → acp-host}/role-envelope.ts +25 -24
  22. package/src/{grok → acp-host}/role-turn-host.ts +46 -81
  23. package/src/{grok → acp-host}/session-identity.ts +7 -7
  24. package/src/diarist-contracts.ts +88 -0
  25. package/src/diarist-role.ts +60 -0
  26. package/src/diarist.ts +253 -179
  27. package/src/host-contracts.ts +11 -4
  28. package/src/host-descriptions.ts +51 -0
  29. package/src/host-transition-prior-native.ts +12 -24
  30. package/src/package-contracts/terminating-tools.ts +22 -2
  31. package/src/packaged-role-registry.ts +20 -0
  32. package/src/pi/role-turn-host.ts +10 -1
  33. package/src/public-cli/cli.ts +39 -9
  34. package/src/public-cli/config.ts +24 -11
  35. package/src/public-cli/countersign-run.ts +9 -129
  36. package/src/public-cli/diarist-run.ts +312 -0
  37. package/src/public-cli/invocation.ts +23 -2
  38. package/src/public-cli/load-production-acp-host.ts +52 -0
  39. package/src/public-cli/option-definitions.ts +18 -0
  40. package/src/public-cli/post-admission.ts +3 -3
  41. package/src/public-cli/registry.ts +3 -0
  42. package/src/public-cli/run-lifecycle.ts +31 -3
  43. package/src/public-cli/settlement.ts +50 -2
  44. package/src/public-cli/terminal.ts +2 -1
  45. package/src/role-runtime.ts +117 -6
  46. package/src/ticket-provenance-contracts.ts +1 -0
  47. package/src/ticket-provenance.ts +0 -23
  48. package/src/diarist-llm-collector.ts +0 -324
  49. package/src/public-cli/load-production-grok-host.ts +0 -35
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 }
@@ -140,12 +145,14 @@ export type RoleTurnModelConfig = {
140
145
 
141
146
  /**
142
147
  * Typed cross-host resume handoff (#617 DK-4).
143
- * Present only when post-admission projects a real switch between known hosts.
144
- * Cross-host prior volume: previous native record paths for the live host (DK-7).
148
+ * Present only when post-admission projects a real host switch.
149
+ * priorNativeKind names the record family the paths belong to — Pi's own
150
+ * session file, or sitian run records (ADR 0077) — so a consuming adapter
151
+ * reads the handoff without knowing which host wrote it.
145
152
  * Target host reads those files itself; projector never copies bytes.
146
153
  */
147
154
  export type RoleTurnHostTransition = {
148
- readonly previousHost: "pi" | "grok-build";
155
+ readonly priorNativeKind: "pi-native" | "sitian";
149
156
  readonly priorNativePaths: readonly string[];
150
157
  };
151
158
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Packaged host description table (#729 / #731).
3
+ * Key = seat-table `host` value; the row is the generic ACP factory's input.
4
+ * pi is the in-process default, not a row.
5
+ * Unregistered names fail closed (#510); this table does not fallback.
6
+ */
7
+ import type { AcpHostDescription } from "./acp-host/description.ts";
8
+
9
+ /** Grok CLI reads vendor-private compat surfaces unless each is disabled by name. */
10
+ const PRIVATE_COMPAT_ENV = Object.fromEntries(
11
+ ["CLAUDE", "CURSOR", "CODEX"].flatMap((vendor) =>
12
+ ["SKILLS", "RULES", "AGENTS", "MCPS", "HOOKS", "SESSIONS"].map((kind) =>
13
+ [`GROK_${vendor}_${kind}_ENABLED`, "false"] as const)),
14
+ );
15
+
16
+ export const DEFAULT_ROLE_TURN_HOST = "pi" as const;
17
+
18
+ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = Object.freeze({
19
+ /** Operator home `~/.grok`, native session/load resume, `agent [--model X] stdio`. */
20
+ "grok-build": Object.freeze({
21
+ binaryFromHome: Object.freeze([".grok", "bin", "grok"]),
22
+ argv: Object.freeze({
23
+ prefix: Object.freeze(["agent"]),
24
+ suffix: Object.freeze(["stdio"]),
25
+ modelFlag: "--model",
26
+ }),
27
+ boundResume: "session/load",
28
+ sessionBindingFile: "grok-acp-session.json",
29
+ childEnv: Object.freeze({
30
+ ...PRIVATE_COMPAT_ENV,
31
+ GROK_MEMORY: "0",
32
+ GROK_SUBAGENTS: "0",
33
+ }),
34
+ }),
35
+ });
36
+
37
+ export function lookupHostDescription(host: string): AcpHostDescription | undefined {
38
+ return Object.hasOwn(HOST_DESCRIPTIONS, host) ? HOST_DESCRIPTIONS[host] : undefined;
39
+ }
40
+
41
+ export function packagedExternalHostNames(): readonly string[] {
42
+ return Object.keys(HOST_DESCRIPTIONS);
43
+ }
44
+
45
+ /** Legal persistent/invocation host: default pi, or a table key. */
46
+ export function assertRegisteredHostName(host: string): string {
47
+ if (host === DEFAULT_ROLE_TURN_HOST || lookupHostDescription(host) !== undefined) {
48
+ return host;
49
+ }
50
+ throw new Error(`unregistered host: ${host}`);
51
+ }