@akagilnc/pi-workflow-roles 0.1.3706 → 0.1.3712

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,571 +0,0 @@
1
- /**
2
- * 起居郎 mechanical safeguard band — ADR 0075 / #582.
3
- * Deterministic: source enum helpers, candidate anchors, dedup, notification
4
- * filter, and verbatim quote reverse-verify. Never a production relevance gate.
5
- */
6
- import { createHash } from "node:crypto";
7
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
8
- import { basename, join, resolve } from "node:path";
9
- import { homedir } from "node:os";
10
-
11
- import {
12
- pathContainedIn,
13
- physicallyContainedIn,
14
- } from "./activation-ledger-topology.ts";
15
- import type {
16
- TicketProvenanceEntry,
17
- TicketProvenanceSourceKind,
18
- TicketProvenanceSourceRef,
19
- } from "./ticket-provenance-contracts.ts";
20
-
21
- /** One frozen source message/block available for collection. */
22
- export type DiaristSourceBlock = {
23
- readonly sourceKind: TicketProvenanceSourceKind;
24
- readonly sourceRef: TicketProvenanceSourceRef;
25
- readonly transcript: string;
26
- readonly timestamp: string;
27
- /** True user turn (cc); assistant/system/tool are non-user. */
28
- readonly isUserTurn: boolean;
29
- /** Notification / system noise that mechanical filter drops. */
30
- readonly isNotification: boolean;
31
- };
32
-
33
- /** Anchor set used only for reverse-verify notes (never a relevance gate). */
34
- export type DiaristAnchorSet = {
35
- readonly ticketNumber: number;
36
- /** Ticket face 「」 quotes ≥ min length. */
37
- readonly quotes: readonly string[];
38
- };
39
-
40
- export const DEFAULT_QUOTE_MIN = 6;
41
-
42
- /** Typed cause when an enumerated source path cannot be read or interpreted. */
43
- export type DiaristSourceReadReason =
44
- | "readdir-failed"
45
- | "file-unreadable"
46
- | "jsonl-line-unparseable"
47
- | "adr-missing"
48
- | "adr-unreadable"
49
- | "adr-escape";
50
-
51
- export class DiaristSourceReadError extends Error {
52
- readonly code = "diarist-source-read" as const;
53
- readonly reason: DiaristSourceReadReason;
54
- readonly sourcePath: string;
55
- constructor(
56
- reason: DiaristSourceReadReason,
57
- sourcePath: string,
58
- message: string,
59
- options?: ErrorOptions,
60
- ) {
61
- super(message, options);
62
- this.name = "DiaristSourceReadError";
63
- this.reason = reason;
64
- this.sourcePath = sourcePath;
65
- }
66
- }
67
-
68
- /**
69
- * Extract 「」 quotes from ticket face text for mechanical anchors.
70
- * Only quotes meeting min length are kept.
71
- */
72
- export function extractCornerQuotes(
73
- text: string,
74
- minLength: number = DEFAULT_QUOTE_MIN,
75
- ): string[] {
76
- const out: string[] = [];
77
- const re = /「([^」]+)」/g;
78
- let match: RegExpExecArray | null;
79
- while ((match = re.exec(text)) !== null) {
80
- const q = match[1]!.trim();
81
- if (q.length >= minLength) out.push(q);
82
- }
83
- return out;
84
- }
85
-
86
- /** Build the mechanical anchor set from ticket number + face body. */
87
- export function buildDiaristAnchors(input: {
88
- readonly ticketNumber: number;
89
- readonly ticketBody?: string;
90
- readonly extraQuotes?: readonly string[];
91
- }): DiaristAnchorSet {
92
- const fromBody =
93
- input.ticketBody === undefined ? [] : extractCornerQuotes(input.ticketBody);
94
- const quotes = [...fromBody, ...(input.extraQuotes ?? [])];
95
- const seen = new Set<string>();
96
- const uniqueQuotes: string[] = [];
97
- for (const q of quotes) {
98
- if (seen.has(q)) continue;
99
- seen.add(q);
100
- uniqueQuotes.push(q);
101
- }
102
- return {
103
- ticketNumber: input.ticketNumber,
104
- quotes: uniqueQuotes,
105
- };
106
- }
107
-
108
- /** Drop notification/system noise blocks (typed flags only — no prose match). */
109
- export function filterNotifications(
110
- blocks: readonly DiaristSourceBlock[],
111
- ): DiaristSourceBlock[] {
112
- return blocks.filter((b) => !b.isNotification);
113
- }
114
-
115
- function blockKey(block: DiaristSourceBlock): string {
116
- const r = block.sourceRef;
117
- return [
118
- block.sourceKind,
119
- r.sessionFile ?? "",
120
- r.entryId === undefined ? "" : String(r.entryId),
121
- r.path ?? "",
122
- r.url ?? "",
123
- createHash("sha256").update(block.transcript, "utf8").digest("hex").slice(0, 16),
124
- ].join("|");
125
- }
126
-
127
- /** Dedup by source pointer + transcript digest (compression replay ×N). */
128
- export function dedupeSourceBlocks(
129
- blocks: readonly DiaristSourceBlock[],
130
- ): DiaristSourceBlock[] {
131
- const seen = new Set<string>();
132
- const out: DiaristSourceBlock[] = [];
133
- for (const block of blocks) {
134
- const key = blockKey(block);
135
- if (seen.has(key)) continue;
136
- seen.add(key);
137
- out.push(block);
138
- }
139
- return out;
140
- }
141
-
142
- /**
143
- * Verbatim reverse-verify: every quote must appear as a contiguous substring
144
- * of the source text. Spliced multi-span quotes fail (probe: 1/27).
145
- */
146
- export type QuoteVerifyResult =
147
- | { readonly ok: true }
148
- | { readonly ok: false; readonly failedQuotes: readonly string[] };
149
-
150
- export function verifyQuotesVerbatim(
151
- sourceText: string,
152
- quotes: readonly string[],
153
- ): QuoteVerifyResult {
154
- const failed: string[] = [];
155
- for (const q of quotes) {
156
- if (q.length === 0) continue;
157
- if (!sourceText.includes(q)) failed.push(q);
158
- }
159
- if (failed.length === 0) return { ok: true };
160
- return { ok: false, failedQuotes: failed };
161
- }
162
-
163
- /**
164
- * Mechanical safeguard only — never a relevance gate (ADR 0075 / 锚定宪法).
165
- * filter notifications (typed) → dedupe. Semantic selection is LLM-only.
166
- */
167
- export function mechanicalSafeguardPipeline(
168
- blocks: readonly DiaristSourceBlock[],
169
- ): DiaristSourceBlock[] {
170
- return dedupeSourceBlocks(filterNotifications(blocks));
171
- }
172
-
173
- /** Claude Code project-dir encoding: abs path with / → -. */
174
- export function encodeClaudeProjectDir(cwd: string): string {
175
- const abs = cwd.startsWith("/") ? cwd : `/${cwd}`;
176
- return abs.replace(/\//g, "-");
177
- }
178
-
179
- export type ReadCcSessionOptions = {
180
- /** Override Claude projects root (tests). Default ~/.claude/projects. */
181
- readonly projectsRoot?: string;
182
- /** Working directories whose encoded project folders are scanned. */
183
- readonly cwds: readonly string[];
184
- };
185
-
186
- function isRecord(value: unknown): value is Record<string, unknown> {
187
- return typeof value === "object" && value !== null && !Array.isArray(value);
188
- }
189
-
190
- function messageText(message: unknown): string {
191
- if (typeof message === "string") return message;
192
- if (!isRecord(message)) return "";
193
- const content = message.content;
194
- if (typeof content === "string") return content;
195
- if (!Array.isArray(content)) return "";
196
- const parts: string[] = [];
197
- for (const part of content) {
198
- if (typeof part === "string") {
199
- parts.push(part);
200
- continue;
201
- }
202
- if (!isRecord(part)) continue;
203
- if (typeof part.text === "string") parts.push(part.text);
204
- else if (typeof part.content === "string") parts.push(part.content);
205
- }
206
- return parts.join("\n");
207
- }
208
-
209
- function isNotificationRow(type: string, text: string): boolean {
210
- if (type === "queue-operation" || type === "system" || type === "progress") {
211
- return true;
212
- }
213
- if (type === "user") {
214
- // Local command / meta noise common in cc transcripts.
215
- if (text.startsWith("<command-name>") || text.startsWith("<local-command-")) {
216
- return true;
217
- }
218
- if (text.startsWith("<command-message>") || text.startsWith("<command-args>")) {
219
- return true;
220
- }
221
- }
222
- return false;
223
- }
224
-
225
- /**
226
- * Enumerate cc session user/assistant turns and human queued-command attachments under Claude projects roots.
227
- * cc-sessions-first source family (ADR 0075).
228
- * Directory/file/non-empty JSONL line failures throw DiaristSourceReadError
229
- * (失败诚实:不得把未读懂洗成没有来源块).
230
- */
231
- export function readCcSessionBlocks(
232
- options: ReadCcSessionOptions,
233
- ): DiaristSourceBlock[] {
234
- const root = options.projectsRoot ?? join(homedir(), ".claude", "projects");
235
- if (!existsSync(root)) return [];
236
-
237
- const blocks: DiaristSourceBlock[] = [];
238
- const dirs = new Set<string>();
239
- for (const cwd of options.cwds) {
240
- dirs.add(join(root, encodeClaudeProjectDir(cwd)));
241
- }
242
- // Also accept already-encoded folder names passed as cwd (test convenience).
243
- for (const cwd of options.cwds) {
244
- if (cwd.includes("projects")) continue;
245
- const direct = join(root, cwd);
246
- if (existsSync(direct)) dirs.add(direct);
247
- }
248
-
249
- for (const dir of dirs) {
250
- if (!existsSync(dir)) continue;
251
- let dirStat;
252
- try {
253
- dirStat = statSync(dir);
254
- } catch (error) {
255
- throw new DiaristSourceReadError(
256
- "readdir-failed",
257
- dir,
258
- `diarist cc session dir unstatable (${dir})`,
259
- { cause: error },
260
- );
261
- }
262
- if (!dirStat.isDirectory()) continue;
263
-
264
- let files: string[];
265
- try {
266
- files = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
267
- } catch (error) {
268
- throw new DiaristSourceReadError(
269
- "readdir-failed",
270
- dir,
271
- `diarist cc session dir unreadable (${dir})`,
272
- { cause: error },
273
- );
274
- }
275
-
276
- for (const name of files) {
277
- const sessionFile = join(dir, name);
278
- let text: string;
279
- try {
280
- text = readFileSync(sessionFile, "utf8");
281
- } catch (error) {
282
- throw new DiaristSourceReadError(
283
- "file-unreadable",
284
- sessionFile,
285
- `diarist cc session file unreadable (${sessionFile})`,
286
- { cause: error },
287
- );
288
- }
289
- const lines = text.split("\n");
290
- for (let lineNo = 0; lineNo < lines.length; lineNo += 1) {
291
- const line = lines[lineNo]!;
292
- if (!line.trim()) continue;
293
- let parsed: unknown;
294
- try {
295
- parsed = JSON.parse(line);
296
- } catch (error) {
297
- throw new DiaristSourceReadError(
298
- "jsonl-line-unparseable",
299
- sessionFile,
300
- `diarist cc session JSONL line ${lineNo + 1} unparseable (${sessionFile})`,
301
- { cause: error },
302
- );
303
- }
304
- // Unknown row shape (not user/assistant or a human queued-command attachment) is ignored — not a read failure.
305
- if (!isRecord(parsed)) continue;
306
- const type = typeof parsed.type === "string" ? parsed.type : "";
307
- const queuedCommand =
308
- type === "attachment" &&
309
- isRecord(parsed.attachment) &&
310
- parsed.attachment.type === "queued_command" &&
311
- isRecord(parsed.attachment.origin) &&
312
- parsed.attachment.origin.kind === "human" &&
313
- typeof parsed.attachment.prompt === "string"
314
- ? parsed.attachment.prompt
315
- : undefined;
316
- if (type !== "user" && type !== "assistant" && queuedCommand === undefined) {
317
- continue;
318
- }
319
- const transcript = queuedCommand ?? messageText(parsed.message ?? parsed);
320
- if (transcript.trim() === "") continue;
321
- const entryId =
322
- typeof parsed.uuid === "string"
323
- ? parsed.uuid
324
- : typeof parsed.id === "string"
325
- ? parsed.id
326
- : `${basename(sessionFile)}:${lineNo + 1}`;
327
- const timestamp =
328
- typeof parsed.timestamp === "string"
329
- ? parsed.timestamp
330
- : new Date(0).toISOString();
331
- const notification = isNotificationRow(type, transcript);
332
- blocks.push({
333
- sourceKind: "cc-session",
334
- sourceRef: { sessionFile, entryId },
335
- transcript,
336
- timestamp,
337
- isUserTurn: type === "user" || queuedCommand !== undefined,
338
- isNotification: notification,
339
- });
340
- }
341
- }
342
- }
343
- return blocks;
344
- }
345
-
346
- /** One frozen GitHub issue comment for the issue-face candidate stream. */
347
- export type DiaristIssueComment = {
348
- readonly id: number;
349
- readonly body: string;
350
- readonly createdAt: string;
351
- readonly htmlUrl: string;
352
- };
353
-
354
- /**
355
- * Frozen GitHub issue face (body + comments) for diarist source enum.
356
- * Soft-unavailable fetch yields undefined upstream — never invent face from attachments.
357
- */
358
- export type DiaristIssueFace = {
359
- readonly body: string;
360
- readonly bodyUrl: string;
361
- readonly comments: readonly DiaristIssueComment[];
362
- };
363
-
364
- /**
365
- * Issue body + comments + 「」 decree spans as typed source blocks.
366
- * Each block keeps its own sourceRef (url / comment id) — never merge attachments here.
367
- */
368
- export function readIssueFaceBlocks(input: {
369
- readonly face: DiaristIssueFace;
370
- readonly timestamp?: string;
371
- }): DiaristSourceBlock[] {
372
- const fallbackTs = input.timestamp ?? new Date(0).toISOString();
373
- const blocks: DiaristSourceBlock[] = [];
374
- const body = input.face.body;
375
- if (body.trim() !== "") {
376
- blocks.push({
377
- sourceKind: "issue-body-comment",
378
- sourceRef: { url: input.face.bodyUrl, entryId: "body" },
379
- transcript: body,
380
- timestamp: fallbackTs,
381
- isUserTurn: true,
382
- isNotification: false,
383
- });
384
- const quotes = extractCornerQuotes(body);
385
- for (let i = 0; i < quotes.length; i += 1) {
386
- const q = quotes[i]!;
387
- blocks.push({
388
- sourceKind: "ticket-decree-block",
389
- sourceRef: { url: input.face.bodyUrl, entryId: `decree-${i + 1}` },
390
- transcript: q,
391
- timestamp: fallbackTs,
392
- isUserTurn: true,
393
- isNotification: false,
394
- });
395
- }
396
- }
397
- for (const comment of input.face.comments) {
398
- if (comment.body.trim() === "") continue;
399
- blocks.push({
400
- sourceKind: "issue-body-comment",
401
- sourceRef: {
402
- url: comment.htmlUrl,
403
- entryId: comment.id,
404
- },
405
- transcript: comment.body,
406
- timestamp: comment.createdAt || fallbackTs,
407
- isUserTurn: true,
408
- isNotification: false,
409
- });
410
- }
411
- return blocks;
412
- }
413
-
414
- /** Relative root every ADR decision-key read must stay under (ADR 0038). */
415
- export const DIARIST_ADR_ROOT_REL = "docs/adr" as const;
416
-
417
- /**
418
- * Read applicable ADR files as decision-key source blocks.
419
- * Real IO seam owns lexical + physical containment under cwd/docs/adr (ADR 0038).
420
- * Missing / unreadable / escape → typed DiaristSourceReadError (失败诚实).
421
- */
422
- export function readAdrDecisionKeyBlocks(input: {
423
- readonly cwd: string;
424
- readonly adrPaths: readonly string[];
425
- readonly timestamp?: string;
426
- }): DiaristSourceBlock[] {
427
- const timestamp = input.timestamp ?? new Date(0).toISOString();
428
- const blocks: DiaristSourceBlock[] = [];
429
- const cwdAbs = resolve(input.cwd);
430
- const adrRootAbs = resolve(cwdAbs, DIARIST_ADR_ROOT_REL);
431
- let adrRootReal: string;
432
- try {
433
- // Prefer the real ADR root when it exists; otherwise fall back to cwd real
434
- // so physical checks still have a stable anchor before the first ADR file.
435
- adrRootReal = existsSync(adrRootAbs)
436
- ? realpathSync(adrRootAbs)
437
- : resolve(realpathSync(cwdAbs), DIARIST_ADR_ROOT_REL);
438
- } catch (error) {
439
- throw new DiaristSourceReadError(
440
- "adr-unreadable",
441
- cwdAbs,
442
- `diarist ADR root is not resolvable (${adrRootAbs})`,
443
- { cause: error },
444
- );
445
- }
446
- for (const rel of input.adrPaths) {
447
- const segments = rel.split(/[/\\]/);
448
- // Claimed path must sit under docs/adr/ with no traversal segments.
449
- if (
450
- segments[0] !== "docs" ||
451
- segments[1] !== "adr" ||
452
- segments.length < 3 ||
453
- segments.some((part) => part === "" || part === "." || part === "..")
454
- ) {
455
- throw new DiaristSourceReadError(
456
- "adr-escape",
457
- resolve(cwdAbs, rel),
458
- `diarist referenced ADR escapes docs/adr (${rel})`,
459
- );
460
- }
461
- const abs = resolve(cwdAbs, rel);
462
- // Lexical containment under docs/adr before any read (ADR 0038).
463
- if (abs !== adrRootAbs && !pathContainedIn(adrRootAbs, abs)) {
464
- throw new DiaristSourceReadError(
465
- "adr-escape",
466
- abs,
467
- `diarist referenced ADR escapes docs/adr (${rel})`,
468
- );
469
- }
470
- if (!existsSync(abs)) {
471
- throw new DiaristSourceReadError(
472
- "adr-missing",
473
- abs,
474
- `diarist referenced ADR missing (${rel})`,
475
- );
476
- }
477
- let realAbs: string;
478
- try {
479
- realAbs = realpathSync(abs);
480
- } catch (error) {
481
- throw new DiaristSourceReadError(
482
- "adr-unreadable",
483
- abs,
484
- `diarist ADR path is not resolvable (${abs})`,
485
- { cause: error },
486
- );
487
- }
488
- if (realAbs !== adrRootReal && !physicallyContainedIn(adrRootReal, realAbs)) {
489
- throw new DiaristSourceReadError(
490
- "adr-escape",
491
- abs,
492
- `diarist referenced ADR escapes docs/adr physically (${rel})`,
493
- );
494
- }
495
- let text: string;
496
- try {
497
- text = readFileSync(realAbs, "utf8");
498
- } catch (error) {
499
- throw new DiaristSourceReadError(
500
- "adr-unreadable",
501
- realAbs,
502
- `diarist ADR file unreadable (${realAbs})`,
503
- { cause: error },
504
- );
505
- }
506
- if (text.trim() === "") continue;
507
- blocks.push({
508
- sourceKind: "adr-decision-key",
509
- sourceRef: { path: rel },
510
- transcript: text,
511
- timestamp,
512
- isUserTurn: true,
513
- isNotification: false,
514
- });
515
- }
516
- return blocks;
517
- }
518
-
519
- /**
520
- * Build an llm-semantic entry after reverse-verify succeeds.
521
- * `quotes` are the LLM-claimed quotes that must be verbatim in transcript.
522
- * basis.anchors record ticket # + mechanical/claimed quotes for audit only.
523
- * Failure returns failedQuotes only — caller records a single diagnostic (not a
524
- * disguised diary entry / dead basis.method).
525
- */
526
- export function blockToLlmEntry(
527
- block: DiaristSourceBlock,
528
- input: {
529
- readonly anchors: DiaristAnchorSet;
530
- readonly quotes: readonly string[];
531
- readonly note?: string;
532
- },
533
- ):
534
- | { readonly ok: true; readonly entry: TicketProvenanceEntry }
535
- | {
536
- readonly ok: false;
537
- readonly failedQuotes: readonly string[];
538
- readonly cause: string;
539
- } {
540
- const verify = verifyQuotesVerbatim(block.transcript, input.quotes);
541
- if (!verify.ok) {
542
- return {
543
- ok: false,
544
- failedQuotes: verify.failedQuotes,
545
- cause: `verbatim verify failed: ${verify.failedQuotes.join(" | ")}`,
546
- };
547
- }
548
- // Ticket + mechanical face quotes + claimed quotes — reference/反验 only.
549
- const anchorNotes: string[] = [`#${input.anchors.ticketNumber}`];
550
- const seen = new Set<string>(anchorNotes);
551
- for (const q of [...input.anchors.quotes, ...input.quotes]) {
552
- if (seen.has(q)) continue;
553
- seen.add(q);
554
- anchorNotes.push(q);
555
- if (anchorNotes.length >= 12) break;
556
- }
557
- return {
558
- ok: true,
559
- entry: {
560
- basis: {
561
- method: "llm-semantic",
562
- anchors: anchorNotes,
563
- ...(input.note === undefined ? {} : { note: input.note }),
564
- },
565
- sourceKind: block.sourceKind,
566
- sourceRef: block.sourceRef,
567
- transcript: block.transcript,
568
- timestamp: block.timestamp,
569
- },
570
- };
571
- }