@akagilnc/pi-workflow-roles 0.1.3708 → 0.1.3716

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 (37) hide show
  1. package/README.md +4 -0
  2. package/README.zh-CN.md +4 -0
  3. package/dist/acp-host/production-host.js +262 -518
  4. package/dist/adr-path-refs.js +1 -1
  5. package/dist/diarist-contracts.js +31 -21
  6. package/dist/diarist-role.js +17 -8
  7. package/dist/diarist.js +39 -388
  8. package/dist/package-contracts/terminating-tools.js +1 -1
  9. package/dist/packaged-role-registry.js +4 -8
  10. package/dist/pi/role-turn-host.js +1 -7
  11. package/dist/public-cli/config.js +106 -2
  12. package/dist/public-cli/main.js +263 -1488
  13. package/dist/public-cli/option-definitions.js +3 -0
  14. package/dist/role-runtime.js +60 -88
  15. package/dist/ticket-provenance-contracts.js +0 -5
  16. package/dist/ticket-provenance.js +2 -149
  17. package/package.json +1 -1
  18. package/resources/diarist-collect.md +21 -11
  19. package/src/acp-host/role-envelope.ts +1 -1
  20. package/src/adr-path-refs.ts +1 -1
  21. package/src/diarist-contracts.ts +48 -32
  22. package/src/diarist-role.ts +31 -16
  23. package/src/diarist.ts +54 -635
  24. package/src/host-contracts.ts +1 -5
  25. package/src/package-contracts/terminating-tools.ts +1 -1
  26. package/src/packaged-role-registry.ts +4 -8
  27. package/src/pi/role-turn-host.ts +1 -7
  28. package/src/public-cli/cli.ts +54 -0
  29. package/src/public-cli/config.ts +157 -1
  30. package/src/public-cli/countersign-run.ts +2 -3
  31. package/src/public-cli/diarist-run.ts +15 -117
  32. package/src/public-cli/option-definitions.ts +3 -0
  33. package/src/role-runtime.ts +73 -105
  34. package/src/ticket-provenance-contracts.ts +6 -12
  35. package/src/ticket-provenance.ts +1 -207
  36. package/dist/diarist-mechanical.js +0 -411
  37. package/src/diarist-mechanical.ts +0 -571
package/src/diarist.ts CHANGED
@@ -1,658 +1,84 @@
1
1
  /**
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.
2
+ * 起居郎 volume commit — ADR 0075 `diarist-is-role` / #779.
3
+ * Semantic collection is the diarist role's own LLM turn (finds materials itself).
4
+ * This module only appends submitted whole blocks idempotently and refreshes the
5
+ * human view. No frozen catalog, no quote reverse-verify, no ticket-number
6
+ * re-judgment of LLM output (#779 owner: 机械层凭什么能判断llm的输出).
6
7
  * No lifecycle here (ADR 0018): the seat prepares, the role envelope commits.
7
8
  */
8
- import { readFileSync } from "node:fs";
9
-
10
- import { extractReferencedAdrPaths } from "./adr-path-refs.ts";
11
- import {
12
- createGhApiRunner,
13
- createGhCollectorGitHubTransport,
14
- projectGhIssueBody,
15
- type CollectorGitHubTransport,
16
- type GhApiRunner,
17
- } from "./collector-github.ts";
18
9
  import {
19
- blockToLlmEntry,
20
- buildDiaristAnchors,
21
- mechanicalSafeguardPipeline,
22
- readAdrDecisionKeyBlocks,
23
- readCcSessionBlocks,
24
- readIssueFaceBlocks,
25
- type DiaristAnchorSet,
26
- type DiaristIssueFace,
27
- type DiaristSourceBlock,
28
- } from "./diarist-mechanical.ts";
29
- import type { DiaristSelection } from "./diarist-contracts.ts";
30
- import { parseGitHubOriginRemote } from "./reviewer-pinned-git.ts";
31
- import {
32
- appendIssueSourceFailureDiagnostic,
33
- appendQuoteVerifyFailureDiagnostic,
34
10
  appendTicketProvenanceEntry,
35
11
  ensureTicketProvenanceVolume,
36
- readOfferedIdentities,
37
12
  readTicketProvenance,
38
- recordOfferedIdentities,
39
- resolveTicketProvenanceVolume,
40
- ticketProvenanceEntryIdentity,
41
13
  writeTicketProvenanceHumanView,
42
14
  } from "./ticket-provenance.ts";
43
- import { readSitianRecords } from "./sitian-facade.ts";
44
- import { execFileSync } from "node:child_process";
45
-
46
- export type { DiaristIssueFace } from "./diarist-mechanical.ts";
47
-
48
- /** Live existence check for an asserted ticket number. */
49
- export type TicketExistenceChecker = (input: {
50
- readonly owner: string;
51
- readonly repo: string;
52
- readonly ticketNumber: number;
53
- readonly signal?: AbortSignal;
54
- }) => Promise<boolean>;
55
-
56
- export type DiaristTicketVerificationReason =
57
- | "number-not-in-instruction"
58
- | "ticket-missing"
59
- | "origin-unresolved"
60
- | "assertion-uninterpretable";
61
-
62
- /**
63
- * Honest failure of mechanical ticket verification (ADR 0075).
64
- * Must settle as failure — never wash into true-unbound / 无录.
65
- */
66
- export class DiaristTicketVerificationError extends Error {
67
- readonly code = "diarist-ticket-verification" as const;
68
- readonly reason: DiaristTicketVerificationReason;
69
- constructor(
70
- reason: DiaristTicketVerificationReason,
71
- message: string,
72
- options?: ErrorOptions,
73
- ) {
74
- super(message, options);
75
- this.name = "DiaristTicketVerificationError";
76
- this.reason = reason;
77
- }
78
- }
79
-
80
- /**
81
- * Decision-key exact identity: complete decimal number N appears in instruction.
82
- * A longer number's digit substring (e.g. 82 inside 582) is not N.
83
- * Verifies a typed claim — does not harvest candidates from prose.
84
- */
85
- export function instructionContainsTicketNumber(
86
- instruction: string,
87
- ticketNumber: number,
88
- ): boolean {
89
- if (!Number.isSafeInteger(ticketNumber) || ticketNumber < 1) return false;
90
- // Complete decimal token: not preceded or followed by another digit.
91
- // Plain string (not template) so the digit-class backslash survives emit.
92
- return new RegExp("(?<!\\d)" + String(ticketNumber) + "(?!\\d)").test(
93
- instruction,
94
- );
95
- }
96
-
97
- /**
98
- * Production live-ticket check over shared gh issue-body projection.
99
- * Available issue face → exists; unavailable/invalid → missing.
100
- */
101
- export function createGhTicketExistenceChecker(options?: {
102
- readonly runner?: GhApiRunner;
103
- }): TicketExistenceChecker {
104
- const runner = options?.runner ?? createGhApiRunner();
105
- return async (input) => {
106
- const projected = await projectGhIssueBody(runner, {
107
- owner: input.owner,
108
- repo: input.repo,
109
- ticketNumber: input.ticketNumber,
110
- ...(input.signal === undefined ? {} : { signal: input.signal }),
111
- });
112
- return projected.status === "available";
113
- };
114
- }
115
-
116
- /**
117
- * Mechanical verify of an LLM typed ticket assertion (ADR 0075).
118
- * Complete decimal of N must appear in the summons; ticket must exist live.
119
- * Throws DiaristTicketVerificationError — caller must not wash into 无录.
120
- */
121
- export async function verifyAssertedTicketNumber(input: {
122
- readonly ticketNumber: number;
123
- readonly instruction: string;
124
- readonly projectRoot: string;
125
- readonly checkExistence?: TicketExistenceChecker;
126
- readonly signal?: AbortSignal;
127
- }): Promise<void> {
128
- const n = input.ticketNumber;
129
- if (!Number.isSafeInteger(n) || n < 1) {
130
- throw new DiaristTicketVerificationError(
131
- "assertion-uninterpretable",
132
- `diarist ticket assertion must be a safe integer >= 1, got ${String(n)}`,
133
- );
134
- }
135
- if (!instructionContainsTicketNumber(input.instruction, n)) {
136
- throw new DiaristTicketVerificationError(
137
- "number-not-in-instruction",
138
- `diarist ticket assertion #${n} complete decimal number does not appear in accepted instruction`,
139
- );
140
- }
141
- const origin = resolveDiaristGithubOrigin(input.projectRoot);
142
- if (origin === undefined) {
143
- throw new DiaristTicketVerificationError(
144
- "origin-unresolved",
145
- `diarist ticket assertion #${n} requires a resolvable github.com origin remote for live verification`,
146
- );
147
- }
148
- const checkExistence = input.checkExistence ?? createGhTicketExistenceChecker();
149
- let exists: boolean;
150
- try {
151
- exists = await checkExistence({
152
- owner: origin.owner,
153
- repo: origin.repo,
154
- ticketNumber: n,
155
- ...(input.signal === undefined ? {} : { signal: input.signal }),
156
- });
157
- } catch (error) {
158
- throw new DiaristTicketVerificationError(
159
- "ticket-missing",
160
- `diarist ticket assertion #${n} live verification failed`,
161
- { cause: error },
162
- );
163
- }
164
- if (!exists) {
165
- throw new DiaristTicketVerificationError(
166
- "ticket-missing",
167
- `diarist ticket assertion #${n} does not exist as a live issue on ${origin.owner}/${origin.repo}`,
168
- );
169
- }
170
- }
171
-
172
- /** Typed reasons when bound-ticket issue face cannot be acquired honestly. */
173
- export type DiaristIssueSourceReason =
174
- | "origin-unresolved"
175
- | "issue-unavailable"
176
- | "issue-not-json"
177
- | "issue-not-object"
178
- | "issue-is-pull-request"
179
- | "issue-body-invalid"
180
- | "comments-failed";
181
-
182
- /** Bound-ticket issue source failure — not a soft degrade to empty face. */
183
- export class DiaristIssueSourceError extends Error {
184
- readonly code = "diarist-issue-source" as const;
185
- readonly reason: DiaristIssueSourceReason;
186
- constructor(
187
- reason: DiaristIssueSourceReason,
188
- message: string,
189
- options?: ErrorOptions,
190
- ) {
191
- super(message, options);
192
- this.name = "DiaristIssueSourceError";
193
- this.reason = reason;
194
- }
195
- }
196
-
197
- /**
198
- * Issue-face fetch for the diarist seat. Unavailability is a typed
199
- * DiaristIssueSourceError — there is no soft-undefined face.
200
- */
201
- export type DiaristIssueFaceFetcher = (input: {
202
- readonly owner: string;
203
- readonly repo: string;
204
- readonly ticketNumber: number;
205
- readonly signal?: AbortSignal;
206
- }) => Promise<DiaristIssueFace>;
207
-
208
- /**
209
- * Production issue-face capability over shared gh execution seams.
210
- * Body fetch/parse is sole-owned by projectGhIssueBody; this maps hard disposition
211
- * (typed DiaristIssueSourceError) instead of Reviewer Spec soft-undefined.
212
- * Comment list failures after body success keep true cause under comments-failed.
213
- */
214
- export function createDiaristIssueFaceFetcher(options?: {
215
- readonly runner?: GhApiRunner;
216
- readonly transport?: CollectorGitHubTransport;
217
- }): DiaristIssueFaceFetcher {
218
- const runner = options?.runner ?? createGhApiRunner();
219
- const transport = options?.transport ?? createGhCollectorGitHubTransport();
220
- return async (input) => {
221
- const projected = await projectGhIssueBody(runner, input);
222
- let body: string;
223
- if (projected.status === "available") {
224
- body = projected.body;
225
- } else if (projected.status === "unavailable") {
226
- if (projected.reason === "pull-request") {
227
- throw new DiaristIssueSourceError(
228
- "issue-is-pull-request",
229
- `ticket #${input.ticketNumber} resolves to a pull request, not an issue face`,
230
- projected.cause === undefined ? undefined : { cause: projected.cause },
231
- );
232
- }
233
- throw new DiaristIssueSourceError(
234
- "issue-unavailable",
235
- `issue face unavailable for ${input.owner}/${input.repo}#${input.ticketNumber}`,
236
- projected.cause === undefined ? undefined : { cause: projected.cause },
237
- );
238
- } else if (projected.reason === "not-json") {
239
- throw new DiaristIssueSourceError(
240
- "issue-not-json",
241
- `issue face payload is not JSON for ${input.owner}/${input.repo}#${input.ticketNumber}`,
242
- projected.cause === undefined ? undefined : { cause: projected.cause },
243
- );
244
- } else if (projected.reason === "not-object") {
245
- throw new DiaristIssueSourceError(
246
- "issue-not-object",
247
- `issue face payload must be a JSON object for ${input.owner}/${input.repo}#${input.ticketNumber}`,
248
- );
249
- } else {
250
- throw new DiaristIssueSourceError(
251
- "issue-body-invalid",
252
- `issue face body must be string or null for ${input.owner}/${input.repo}#${input.ticketNumber}`,
253
- );
254
- }
255
- let listed;
256
- try {
257
- listed = await transport.listIssueComments({
258
- owner: input.owner,
259
- repo: input.repo,
260
- prNumber: input.ticketNumber,
261
- ...(input.signal === undefined ? {} : { signal: input.signal }),
262
- });
263
- } catch (error) {
264
- throw new DiaristIssueSourceError(
265
- "comments-failed",
266
- `issue comments fetch failed for ${input.owner}/${input.repo}#${input.ticketNumber}`,
267
- { cause: error },
268
- );
269
- }
270
- const bodyUrl = `https://github.com/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
271
- return {
272
- body,
273
- bodyUrl,
274
- comments: listed.items.map((c) => ({
275
- id: c.id,
276
- body: c.body,
277
- createdAt: c.createdAt,
278
- htmlUrl: c.htmlUrl,
279
- })),
280
- };
281
- };
282
- }
283
-
284
- /** github.com owner/repo from project origin remote; undefined when absent/non-github. */
285
- export function resolveDiaristGithubOrigin(
286
- projectRoot: string,
287
- ): { readonly owner: string; readonly repo: string } | undefined {
288
- let remoteUrl: string;
289
- try {
290
- remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
291
- cwd: projectRoot,
292
- encoding: "utf8",
293
- stdio: ["ignore", "pipe", "pipe"],
294
- }).trim();
295
- } catch {
296
- return undefined;
297
- }
298
- if (remoteUrl.length === 0) return undefined;
299
- return parseGitHubOriginRemote(remoteUrl);
300
- }
15
+ import { projectTicketProvenanceEntry } from "./ticket-provenance-contracts.ts";
16
+ import type { DiaristEntrySubmission } from "./diarist-contracts.ts";
301
17
 
302
18
  /**
303
- * Acquire the issue face for a bound ticket. Failures are typed and durable on
304
- * the ticket-provenance volume, then propagated — never washed into empty face.
19
+ * Honest machine facts about what this turn actually committed to the volume.
20
+ * Populated by the envelope accept hook — never from model self-report.
305
21
  */
306
- export async function loadDiaristIssueFace(input: {
307
- readonly ticketNumber: number;
308
- readonly projectRoot: string;
309
- readonly home?: string;
310
- readonly fetcher?: DiaristIssueFaceFetcher;
311
- }): Promise<DiaristIssueFace> {
312
- const persistAndThrow = (error: DiaristIssueSourceError): DiaristIssueSourceError => {
313
- appendIssueSourceFailureDiagnostic({
314
- ticketNumber: input.ticketNumber,
315
- cwd: input.projectRoot,
316
- ...(input.home === undefined ? {} : { home: input.home }),
317
- cause: error.message,
318
- reason: error.reason,
319
- });
320
- return error;
321
- };
322
-
323
- const origin = resolveDiaristGithubOrigin(input.projectRoot);
324
- if (origin === undefined) {
325
- throw persistAndThrow(
326
- new DiaristIssueSourceError(
327
- "origin-unresolved",
328
- `bound ticket #${input.ticketNumber} issue face requires a resolvable github.com origin remote`,
329
- ),
330
- );
331
- }
332
-
333
- const fetcher = input.fetcher ?? createDiaristIssueFaceFetcher();
334
- try {
335
- return await fetcher({
336
- owner: origin.owner,
337
- repo: origin.repo,
338
- ticketNumber: input.ticketNumber,
339
- });
340
- } catch (error) {
341
- throw persistAndThrow(
342
- error instanceof DiaristIssueSourceError
343
- ? error
344
- : new DiaristIssueSourceError(
345
- "issue-unavailable",
346
- `issue face fetch failed for ${origin.owner}/${origin.repo}#${input.ticketNumber}`,
347
- { cause: error },
348
- ),
349
- );
350
- }
351
- }
352
-
353
- /** One frozen candidate the diarist turn may select by index. */
354
- export type DiaristSourceCandidate = DiaristSourceBlock & {
355
- readonly candidateIndex: number;
356
- };
357
-
358
- /**
359
- * Frozen catalog handed to the diarist turn and re-read by the envelope at
360
- * accept time. Carries summons text + run coordinates so accept can verify an
361
- * LLM ticket assertion (ADR 0075) without re-deriving ambient state.
362
- * ticketNumber absent = first summons still open for LLM assertion.
363
- */
364
- export type DiaristSourceCatalog = {
365
- /** Bound before turn when book already records the ticket; else LLM asserts. */
366
- readonly ticketNumber?: number;
367
- /** Caller summons — sole text mechanical verify reads for complete-decimal check. */
368
- readonly instruction: string;
369
- /** Run dossier pages accept binds after a verified first-summons assertion. */
370
- readonly runDirectory: string;
371
- readonly projectRoot: string;
372
- readonly cwd: string;
373
- readonly home?: string;
374
- readonly candidates: readonly DiaristSourceCandidate[];
375
- };
376
-
377
- /**
378
- * Identities already processed for this ticket:
379
- * - volume record identities (selected / verify-fail residue)
380
- * - offered watermark (blocks shown to the diarist, selected or not)
381
- */
382
- async function loadSeenEntryIdentities(
383
- ticketNumber: number,
384
- cwd: string,
385
- home?: string,
386
- ): Promise<ReadonlySet<string>> {
387
- const { recordFile } = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
388
- const { records } = await readSitianRecords(recordFile);
389
- const seen = new Set<string>();
390
- for (const record of records) {
391
- if (typeof record.identity === "string" && record.identity.length > 0) {
392
- seen.add(record.identity);
393
- }
394
- }
395
- for (const identity of readOfferedIdentities(ticketNumber, cwd, home)) {
396
- seen.add(identity);
397
- }
398
- return seen;
399
- }
400
-
401
- function blockEntryIdentity(
402
- ticketNumber: number,
403
- block: DiaristSourceBlock,
404
- ): string {
405
- return ticketProvenanceEntryIdentity({
406
- ticketNumber,
407
- sourceKind: block.sourceKind,
408
- sourceRef: block.sourceRef,
409
- transcript: block.transcript,
410
- });
411
- }
412
-
413
- /**
414
- * Single typed candidate stream: cc sessions + GitHub issue face/comments/decree + referenced ADRs.
415
- * Mechanical layer does not prose-filter for relevance (锚定宪法).
416
- * Attachments are never merged in as fake issue-body-comment.
417
- */
418
- function loadSourceBlocks(input: {
419
- readonly cwd: string;
420
- readonly issueFace?: DiaristIssueFace;
421
- readonly sessionCwds?: readonly string[];
422
- }): DiaristSourceBlock[] {
423
- const cwds = input.sessionCwds ?? [input.cwd];
424
- const blocks: DiaristSourceBlock[] = [...readCcSessionBlocks({ cwds })];
425
- if (input.issueFace !== undefined) {
426
- blocks.push(...readIssueFaceBlocks({ face: input.issueFace }));
427
- const faceText = [
428
- input.issueFace.body,
429
- ...input.issueFace.comments.map((c) => c.body),
430
- ].join("\n");
431
- const adrPaths = extractReferencedAdrPaths(faceText);
432
- if (adrPaths.length > 0) {
433
- blocks.push(
434
- ...readAdrDecisionKeyBlocks({
435
- cwd: input.cwd,
436
- adrPaths,
437
- }),
438
- );
439
- }
440
- }
441
- return blocks;
442
- }
443
-
444
- export type PrepareDiaristSourceCatalogInput = {
445
- /** Bound ticket when known before the turn; omit on first-summons identity round. */
446
- readonly ticketNumber?: number;
447
- readonly instruction: string;
448
- readonly runDirectory: string;
449
- readonly projectRoot: string;
450
- readonly cwd: string;
451
- /** Explicit package home (admitted run / tests); never process.env.HOME (#604). */
452
- readonly home?: string;
453
- /** Frozen GitHub issue face (body + comments) from the shared gh seam. */
454
- readonly issueFace?: DiaristIssueFace;
455
- /** Extra cwd roots whose cc project folders are scanned. */
456
- readonly sessionCwds?: readonly string[];
457
- };
458
-
459
- /**
460
- * Mechanical half A — source enumeration into a frozen catalog.
461
- * When ticketNumber is known: establishes the per-ticket volume + human view
462
- * (ADR 0075 `ticket-provenance-file` 每票一份起居录) and offers only blocks not
463
- * yet on the volume / offered watermark (增量幂等).
464
- * When ticketNumber is absent (first summons): offers session candidates without
465
- * minting a volume — the LLM turn asserts identity; accept verifies and mints.
466
- */
467
- export async function prepareDiaristSourceCatalog(
468
- input: PrepareDiaristSourceCatalogInput,
469
- ): Promise<DiaristSourceCatalog> {
470
- const known = input.ticketNumber;
471
- if (known !== undefined) {
472
- ensureTicketProvenanceVolume(known, input.cwd, input.home);
473
- const volume = await readTicketProvenance(known, input.cwd, input.home);
474
- writeTicketProvenanceHumanView({
475
- ticketNumber: known,
476
- cwd: input.cwd,
477
- ...(input.home === undefined ? {} : { home: input.home }),
478
- entries: volume.entries,
479
- });
480
- }
481
-
482
- const rawBlocks = loadSourceBlocks(input);
483
- // Safeguard only (notify filter + dedupe) — never prose-based exclusion.
484
- const safeguarded = mechanicalSafeguardPipeline(rawBlocks);
485
- const seen =
486
- known === undefined
487
- ? new Set<string>()
488
- : await loadSeenEntryIdentities(known, input.cwd, input.home);
489
- const fresh = safeguarded.filter(
490
- (block) =>
491
- known === undefined || !seen.has(blockEntryIdentity(known, block)),
492
- );
493
-
494
- return {
495
- ...(known === undefined ? {} : { ticketNumber: known }),
496
- instruction: input.instruction,
497
- runDirectory: input.runDirectory,
498
- projectRoot: input.projectRoot,
499
- cwd: input.cwd,
500
- ...(input.home === undefined ? {} : { home: input.home }),
501
- candidates: fresh.map((block, candidateIndex) => ({ ...block, candidateIndex })),
502
- };
503
- }
504
-
505
- export function serializeDiaristSourceCatalog(catalog: DiaristSourceCatalog): string {
506
- return JSON.stringify(catalog);
507
- }
508
-
509
- /** Read a frozen catalog written by the seat. Unreadable/malformed fails loudly. */
510
- export function loadDiaristSourceCatalog(path: string): DiaristSourceCatalog {
511
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
512
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
513
- throw new Error(`diarist source catalog is not an object (${path})`);
514
- }
515
- const record = parsed as Record<string, unknown>;
516
- if (typeof record.cwd !== "string") {
517
- throw new Error(`diarist source catalog is missing cwd (${path})`);
518
- }
519
- if (typeof record.instruction !== "string") {
520
- throw new Error(`diarist source catalog is missing instruction (${path})`);
521
- }
522
- if (typeof record.runDirectory !== "string") {
523
- throw new Error(`diarist source catalog is missing runDirectory (${path})`);
524
- }
525
- if (typeof record.projectRoot !== "string") {
526
- throw new Error(`diarist source catalog is missing projectRoot (${path})`);
527
- }
528
- if (
529
- record.ticketNumber !== undefined &&
530
- (typeof record.ticketNumber !== "number" ||
531
- !Number.isSafeInteger(record.ticketNumber) ||
532
- record.ticketNumber < 1)
533
- ) {
534
- throw new Error(`diarist source catalog ticketNumber is not a safe ticket (${path})`);
535
- }
536
- if (!Array.isArray(record.candidates)) {
537
- throw new Error(`diarist source catalog is missing candidates (${path})`);
538
- }
539
- return parsed as DiaristSourceCatalog;
540
- }
541
-
542
- /**
543
- * 失败真因 — typed causes for submitted rows that never reached the volume.
544
- * Names the cause only; the per-row detail stays on the volume diagnostic.
545
- */
546
- export type DiaristFailureCause =
547
- /** Row pointed at no candidate in this turn's frozen catalog. */
548
- | "unknown-candidate"
549
- /** Row's quotes failed verbatim reverse-verify. */
550
- | "quote-verify-rejected";
551
-
552
- /** Honest machine facts about what this turn actually committed to the volume. */
553
22
  export type DiaristCommitFacts = {
554
23
  readonly ticketNumber: number;
555
- /** Candidates offered to the diarist this turn. */
556
- readonly offered: number;
557
- /** New entries this turn actually put on the volume (entry-count delta). */
24
+ /** New body entries this turn actually put on the volume (entry-count delta). */
558
25
  readonly appended: number;
559
- /** Selections rejected by verbatim reverse-verify. */
560
- readonly rejectedQuotes: number;
561
- /** Offered identities newly written to the watermark this turn. */
562
- readonly watermarked: number;
26
+ /** Submitted rows that could not form a lawful volume entry (shape only). */
27
+ readonly dropped: number;
563
28
  readonly volumeRecordFile: string;
564
29
  readonly humanViewFile: string;
565
30
  /**
566
- * What this turn's collection amounted to. A turn whose every row was
567
- * dropped is `nothing-appended`, never `empty-selection` — the diarist
568
- * selecting nothing and the safeguard band rejecting everything are
569
- * different events. Why rows were dropped is `failureCauses`.
31
+ * What this turn's collection amounted to.
32
+ * `empty-selection` = LLM submitted no rows; `nothing-appended` = every row
33
+ * dropped at the write seam; both differ from a successful append.
570
34
  */
571
- readonly collectorStatus:
572
- | "ok"
573
- | "empty-selection"
574
- | "nothing-appended"
575
- | "skipped-no-fresh";
576
- /**
577
- * 失败真因: empty when every submitted row landed. Populated whenever rows
578
- * were dropped — including a partial turn whose collectorStatus is `ok`.
579
- */
580
- readonly failureCauses: readonly DiaristFailureCause[];
35
+ readonly collectorStatus: "ok" | "empty-selection" | "nothing-appended";
581
36
  };
582
37
 
583
38
  /**
584
- * Mechanical half B — commit the diarist turn's selections.
585
- * Verbatim reverse-verify → idempotent sitian append → watermark → human view.
586
- * Verify failure records a single typed diagnostic and drops that selection; it
587
- * never bounces the receipt (第 0 条) and never enters the volume as an entry.
39
+ * Append LLM-submitted whole blocks under a bound ticket.
40
+ * Entry identity keeps the volume idempotent across re-summons.
41
+ * Shape projection is write-seam self-check only — not content judgment (#779).
588
42
  */
589
- export async function commitDiaristSelections(input: {
590
- readonly catalog: DiaristSourceCatalog;
591
- readonly selections: readonly DiaristSelection[];
43
+ export async function commitDiaristEntries(input: {
44
+ readonly ticketNumber: number;
45
+ readonly cwd: string;
46
+ readonly home?: string;
47
+ readonly entries: readonly DiaristEntrySubmission[];
592
48
  }): Promise<DiaristCommitFacts> {
593
- const ticketNumber = input.catalog.ticketNumber;
594
- if (ticketNumber === undefined) {
595
- throw new Error(
596
- "commitDiaristSelections requires a bound ticketNumber on the catalog",
597
- );
598
- }
599
- const { cwd } = input.catalog;
600
- const homeOpt = input.catalog.home === undefined ? {} : { home: input.catalog.home };
601
- const volumePaths = ensureTicketProvenanceVolume(ticketNumber, cwd, input.catalog.home);
602
-
603
- const anchors: DiaristAnchorSet = buildDiaristAnchors({ ticketNumber });
604
- // Entry-count baseline: sitian entry identity already makes a repeat of an
605
- // already-recorded block a no-op append, so the honest `appended` is what the
606
- // volume gained — not how many rows survived verify.
607
- const before = await readTicketProvenance(ticketNumber, cwd, input.catalog.home);
608
- let acceptedRows = 0;
609
- let rejectedQuotes = 0;
610
- let unknownCandidate = false;
611
-
612
- for (const selection of input.selections) {
613
- const block = input.catalog.candidates[selection.candidateIndex];
614
- if (block === undefined) {
615
- unknownCandidate = true;
616
- continue;
617
- }
618
- const projected = blockToLlmEntry(block, {
619
- anchors,
620
- quotes: selection.quotes,
621
- ...(selection.note === undefined ? {} : { note: selection.note }),
49
+ const { ticketNumber, cwd } = input;
50
+ const homeOpt = input.home === undefined ? {} : { home: input.home };
51
+ const volumePaths = ensureTicketProvenanceVolume(ticketNumber, cwd, input.home);
52
+
53
+ const before = await readTicketProvenance(ticketNumber, cwd, input.home);
54
+ let dropped = 0;
55
+
56
+ for (const submitted of input.entries) {
57
+ const entry = projectTicketProvenanceEntry({
58
+ basis: {
59
+ method: "llm-semantic",
60
+ anchors: [`#${ticketNumber}`],
61
+ ...(submitted.note === undefined ? {} : { note: submitted.note }),
62
+ },
63
+ sourceKind: submitted.sourceKind,
64
+ sourceRef: submitted.sourceRef,
65
+ transcript: submitted.transcript,
66
+ timestamp: submitted.timestamp,
622
67
  });
623
- if (!projected.ok) {
624
- rejectedQuotes += 1;
625
- // Single diagnostic expression — never a disguised diary entry.
626
- appendQuoteVerifyFailureDiagnostic({
627
- ticketNumber,
628
- cwd,
629
- ...homeOpt,
630
- cause: projected.cause,
631
- });
68
+ if (entry === undefined) {
69
+ dropped += 1;
632
70
  continue;
633
71
  }
634
72
  appendTicketProvenanceEntry({
635
73
  ticketNumber,
636
74
  cwd,
637
75
  ...homeOpt,
638
- entry: projected.entry,
76
+ entry,
639
77
  source: "diarist",
640
78
  });
641
- acceptedRows += 1;
642
79
  }
643
80
 
644
- // Watermark advances only after the durable volume writes above, so a crash
645
- // mid-commit retries the batch on the next summons. Entry identity and
646
- // quote-verify diagnostic identity are stable — retry duplicates neither.
647
- const identities = input.catalog.candidates.map((block) =>
648
- blockEntryIdentity(ticketNumber, block),
649
- );
650
- const alreadyWatermarked = readOfferedIdentities(ticketNumber, cwd, input.catalog.home);
651
- const watermarked = identities.filter((identity) => !alreadyWatermarked.has(identity)).length;
652
- recordOfferedIdentities({ ticketNumber, cwd, ...homeOpt, identities });
653
-
654
- // Refresh the human view from the full volume (includes prior summons).
655
- const volume = await readTicketProvenance(ticketNumber, cwd, input.catalog.home);
81
+ const volume = await readTicketProvenance(ticketNumber, cwd, input.home);
656
82
  const humanViewFile = writeTicketProvenanceHumanView({
657
83
  ticketNumber,
658
84
  cwd,
@@ -660,25 +86,18 @@ export async function commitDiaristSelections(input: {
660
86
  entries: volume.entries,
661
87
  });
662
88
 
89
+ const appended = volume.entries.length - before.entries.length;
663
90
  return {
664
91
  ticketNumber,
665
- offered: input.catalog.candidates.length,
666
- appended: volume.entries.length - before.entries.length,
667
- rejectedQuotes,
668
- watermarked,
92
+ appended,
93
+ dropped,
669
94
  volumeRecordFile: volumePaths.recordFile,
670
95
  humanViewFile,
671
96
  collectorStatus:
672
- input.catalog.candidates.length === 0
673
- ? "skipped-no-fresh"
674
- : acceptedRows > 0
675
- ? "ok"
676
- : input.selections.length === 0
677
- ? "empty-selection"
678
- : "nothing-appended",
679
- failureCauses: [
680
- ...(unknownCandidate ? (["unknown-candidate"] as const) : []),
681
- ...(rejectedQuotes > 0 ? (["quote-verify-rejected"] as const) : []),
682
- ],
97
+ appended > 0
98
+ ? "ok"
99
+ : input.entries.length === 0
100
+ ? "empty-selection"
101
+ : "nothing-appended",
683
102
  };
684
103
  }