@akagilnc/pi-workflow-roles 0.1.3708 → 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.
- package/dist/acp-host/production-host.js +151 -514
- package/dist/adr-path-refs.js +1 -1
- package/dist/diarist-contracts.js +31 -21
- package/dist/diarist-role.js +17 -8
- package/dist/diarist.js +39 -388
- package/dist/package-contracts/terminating-tools.js +1 -1
- package/dist/packaged-role-registry.js +4 -8
- package/dist/pi/role-turn-host.js +1 -7
- package/dist/public-cli/main.js +93 -1470
- package/dist/role-runtime.js +60 -88
- package/dist/ticket-provenance-contracts.js +0 -5
- package/dist/ticket-provenance.js +2 -149
- package/package.json +1 -1
- package/resources/diarist-collect.md +21 -11
- package/src/acp-host/role-envelope.ts +1 -1
- package/src/adr-path-refs.ts +1 -1
- package/src/diarist-contracts.ts +48 -32
- package/src/diarist-role.ts +31 -16
- package/src/diarist.ts +54 -635
- package/src/host-contracts.ts +1 -5
- package/src/package-contracts/terminating-tools.ts +1 -1
- package/src/packaged-role-registry.ts +4 -8
- package/src/pi/role-turn-host.ts +1 -7
- package/src/public-cli/countersign-run.ts +2 -3
- package/src/public-cli/diarist-run.ts +15 -117
- package/src/role-runtime.ts +73 -105
- package/src/ticket-provenance-contracts.ts +6 -12
- package/src/ticket-provenance.ts +1 -207
- package/dist/diarist-mechanical.js +0 -411
- package/src/diarist-mechanical.ts +0 -571
package/src/ticket-provenance.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Write/read via sitian facade only; no parallel destination logic.
|
|
4
4
|
*/
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { appendFileSync,
|
|
6
|
+
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
9
|
import {
|
|
@@ -16,8 +16,6 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
TICKET_PROVENANCE_HUMAN_VIEW,
|
|
18
18
|
TICKET_PROVENANCE_KIND,
|
|
19
|
-
TICKET_PROVENANCE_OFFERED_WATERMARK,
|
|
20
|
-
TICKET_PROVENANCE_RECORD_CLASS_DIAGNOSTIC,
|
|
21
19
|
projectTicketProvenanceDiagnostic,
|
|
22
20
|
projectTicketProvenanceEntry,
|
|
23
21
|
type TicketProvenanceDiagnostic,
|
|
@@ -102,7 +100,6 @@ export type TicketProvenanceVolumePath = {
|
|
|
102
100
|
readonly recordFile: string;
|
|
103
101
|
readonly volumeDir: string;
|
|
104
102
|
readonly humanViewFile: string;
|
|
105
|
-
readonly offeredWatermarkFile: string;
|
|
106
103
|
};
|
|
107
104
|
|
|
108
105
|
/** Resolve volume paths for a ticket without writing. */
|
|
@@ -122,115 +119,9 @@ export function resolveTicketProvenanceVolume(
|
|
|
122
119
|
recordFile: path.recordFile,
|
|
123
120
|
volumeDir: path.sessionDir,
|
|
124
121
|
humanViewFile: join(path.sessionDir, TICKET_PROVENANCE_HUMAN_VIEW),
|
|
125
|
-
offeredWatermarkFile: join(path.sessionDir, TICKET_PROVENANCE_OFFERED_WATERMARK),
|
|
126
122
|
};
|
|
127
123
|
}
|
|
128
124
|
|
|
129
|
-
/** Typed cause of an offered-identity watermark read failure. */
|
|
130
|
-
export type TicketProvenanceWatermarkReason =
|
|
131
|
-
| "unreadable"
|
|
132
|
-
| "malformed-json"
|
|
133
|
-
| "bad-shape";
|
|
134
|
-
|
|
135
|
-
/** Honest failure when the offered-identity watermark cannot be read as written. */
|
|
136
|
-
export class TicketProvenanceWatermarkError extends Error {
|
|
137
|
-
readonly code = "ticket-provenance-watermark" as const;
|
|
138
|
-
readonly reason: TicketProvenanceWatermarkReason;
|
|
139
|
-
constructor(
|
|
140
|
-
reason: TicketProvenanceWatermarkReason,
|
|
141
|
-
message: string,
|
|
142
|
-
options?: ErrorOptions,
|
|
143
|
-
) {
|
|
144
|
-
super(message, options);
|
|
145
|
-
this.name = "TicketProvenanceWatermarkError";
|
|
146
|
-
this.reason = reason;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Read identities already offered to the collector (append-only watermark).
|
|
152
|
-
* Absent file → empty set. Any non-empty line that is not a JSON object with a
|
|
153
|
-
* non-empty string `identity` throws TicketProvenanceWatermarkError (失败诚实:
|
|
154
|
-
* never interpret corruption as "unseen" and silently re-offer).
|
|
155
|
-
*/
|
|
156
|
-
export function readOfferedIdentities(
|
|
157
|
-
ticketNumber: number,
|
|
158
|
-
cwd: string,
|
|
159
|
-
home?: string,
|
|
160
|
-
): ReadonlySet<string> {
|
|
161
|
-
const { offeredWatermarkFile } = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
|
|
162
|
-
if (!existsSync(offeredWatermarkFile)) return new Set();
|
|
163
|
-
let text: string;
|
|
164
|
-
try {
|
|
165
|
-
text = readFileSync(offeredWatermarkFile, "utf8");
|
|
166
|
-
} catch (error) {
|
|
167
|
-
throw new TicketProvenanceWatermarkError(
|
|
168
|
-
"unreadable",
|
|
169
|
-
`ticket-provenance offered watermark unreadable (${offeredWatermarkFile})`,
|
|
170
|
-
{ cause: error },
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
const seen = new Set<string>();
|
|
174
|
-
const lines = text.split("\n");
|
|
175
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
176
|
-
const trimmed = lines[index]!.trim();
|
|
177
|
-
if (!trimmed) continue;
|
|
178
|
-
let parsed: unknown;
|
|
179
|
-
try {
|
|
180
|
-
parsed = JSON.parse(trimmed);
|
|
181
|
-
} catch (error) {
|
|
182
|
-
throw new TicketProvenanceWatermarkError(
|
|
183
|
-
"malformed-json",
|
|
184
|
-
`ticket-provenance offered watermark malformed JSON at line ${index + 1} (${offeredWatermarkFile})`,
|
|
185
|
-
{ cause: error },
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
if (
|
|
189
|
-
typeof parsed !== "object" ||
|
|
190
|
-
parsed === null ||
|
|
191
|
-
Array.isArray(parsed) ||
|
|
192
|
-
typeof (parsed as { identity?: unknown }).identity !== "string" ||
|
|
193
|
-
(parsed as { identity: string }).identity.length === 0
|
|
194
|
-
) {
|
|
195
|
-
throw new TicketProvenanceWatermarkError(
|
|
196
|
-
"bad-shape",
|
|
197
|
-
`ticket-provenance offered watermark bad shape at line ${index + 1} (${offeredWatermarkFile})`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
seen.add((parsed as { identity: string }).identity);
|
|
201
|
-
}
|
|
202
|
-
return seen;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Append offered identities after a successful collector pass (selected or not).
|
|
207
|
-
* Idempotent per identity within the file (skip already-present). Creates volume
|
|
208
|
-
* dir only when writing the first watermark row.
|
|
209
|
-
*/
|
|
210
|
-
export function recordOfferedIdentities(input: {
|
|
211
|
-
readonly ticketNumber: number;
|
|
212
|
-
readonly cwd: string;
|
|
213
|
-
readonly home?: string;
|
|
214
|
-
readonly identities: readonly string[];
|
|
215
|
-
}): void {
|
|
216
|
-
if (input.identities.length === 0) return;
|
|
217
|
-
const { volumeDir, offeredWatermarkFile } = resolveTicketProvenanceVolume(
|
|
218
|
-
input.ticketNumber,
|
|
219
|
-
input.cwd,
|
|
220
|
-
input.home,
|
|
221
|
-
);
|
|
222
|
-
const already = new Set(readOfferedIdentities(input.ticketNumber, input.cwd, input.home));
|
|
223
|
-
const rows: string[] = [];
|
|
224
|
-
for (const identity of input.identities) {
|
|
225
|
-
if (identity.length === 0 || already.has(identity)) continue;
|
|
226
|
-
rows.push(`${JSON.stringify({ identity })}\n`);
|
|
227
|
-
already.add(identity);
|
|
228
|
-
}
|
|
229
|
-
if (rows.length === 0) return;
|
|
230
|
-
mkdirSync(volumeDir, { recursive: true });
|
|
231
|
-
appendFileSync(offeredWatermarkFile, rows.join(""), "utf8");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
125
|
export type ReadTicketProvenanceResult = {
|
|
235
126
|
/** Readable diary body entries only. */
|
|
236
127
|
readonly entries: readonly TicketProvenanceEntry[];
|
|
@@ -359,103 +250,6 @@ export function ensureTicketProvenanceVolume(
|
|
|
359
250
|
return volume;
|
|
360
251
|
}
|
|
361
252
|
|
|
362
|
-
/**
|
|
363
|
-
* Append a typed diagnostic onto the ticket-provenance volume (append-only).
|
|
364
|
-
* Same sitian kind + subject partition as diary entries; payload uses recordClass
|
|
365
|
-
* discriminator — never forged as a source entry. No sidecar / parallel ledger.
|
|
366
|
-
*/
|
|
367
|
-
export function appendTicketProvenanceDiagnostic(input: {
|
|
368
|
-
readonly ticketNumber: number;
|
|
369
|
-
readonly cwd: string;
|
|
370
|
-
readonly home?: string;
|
|
371
|
-
readonly diagnostic: TicketProvenanceDiagnostic;
|
|
372
|
-
readonly host?: string;
|
|
373
|
-
readonly source?: string;
|
|
374
|
-
}): RecordPointer {
|
|
375
|
-
const subject = ticketProvenanceSubject(input.ticketNumber);
|
|
376
|
-
// quote-verify: content-stable identity so mid-batch retry does not duplicate.
|
|
377
|
-
// collector/issue-source: recordedAt keeps per-court failure history.
|
|
378
|
-
const identityParts =
|
|
379
|
-
input.diagnostic.diagnosticKind === "quote-verify-failed"
|
|
380
|
-
? [
|
|
381
|
-
subject,
|
|
382
|
-
input.diagnostic.recordClass,
|
|
383
|
-
input.diagnostic.diagnosticKind,
|
|
384
|
-
input.diagnostic.cause,
|
|
385
|
-
input.diagnostic.reason ?? "",
|
|
386
|
-
]
|
|
387
|
-
: [
|
|
388
|
-
subject,
|
|
389
|
-
input.diagnostic.recordClass,
|
|
390
|
-
input.diagnostic.diagnosticKind,
|
|
391
|
-
input.diagnostic.recordedAt,
|
|
392
|
-
input.diagnostic.cause,
|
|
393
|
-
input.diagnostic.reason ?? "",
|
|
394
|
-
];
|
|
395
|
-
const identity = createHash("sha256")
|
|
396
|
-
.update(identityParts.join("\u0000"), "utf8")
|
|
397
|
-
.digest("hex");
|
|
398
|
-
return sitianReport({
|
|
399
|
-
level: "event",
|
|
400
|
-
kind: TICKET_PROVENANCE_KIND,
|
|
401
|
-
identity,
|
|
402
|
-
subject,
|
|
403
|
-
cwd: input.cwd,
|
|
404
|
-
...(input.home === undefined ? {} : { home: input.home }),
|
|
405
|
-
host: input.host ?? "diarist",
|
|
406
|
-
source: input.source ?? "diarist-diagnostic",
|
|
407
|
-
payload: input.diagnostic,
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/** Issue-face source acquisition failure — typed diagnostic on the ticket volume. */
|
|
412
|
-
export function appendIssueSourceFailureDiagnostic(input: {
|
|
413
|
-
readonly ticketNumber: number;
|
|
414
|
-
readonly cwd: string;
|
|
415
|
-
readonly home?: string;
|
|
416
|
-
readonly cause: string;
|
|
417
|
-
readonly reason: string;
|
|
418
|
-
readonly recordedAt?: string;
|
|
419
|
-
}): RecordPointer {
|
|
420
|
-
const recordedAt = input.recordedAt ?? new Date().toISOString();
|
|
421
|
-
return appendTicketProvenanceDiagnostic({
|
|
422
|
-
ticketNumber: input.ticketNumber,
|
|
423
|
-
cwd: input.cwd,
|
|
424
|
-
...(input.home === undefined ? {} : { home: input.home }),
|
|
425
|
-
source: "diarist-issue-source",
|
|
426
|
-
diagnostic: {
|
|
427
|
-
recordClass: TICKET_PROVENANCE_RECORD_CLASS_DIAGNOSTIC,
|
|
428
|
-
diagnosticKind: "issue-source-failed",
|
|
429
|
-
cause: input.cause,
|
|
430
|
-
recordedAt,
|
|
431
|
-
reason: input.reason,
|
|
432
|
-
},
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/** Quote reverse-verify failure — single diagnostic expression (not a diary entry). */
|
|
437
|
-
export function appendQuoteVerifyFailureDiagnostic(input: {
|
|
438
|
-
readonly ticketNumber: number;
|
|
439
|
-
readonly cwd: string;
|
|
440
|
-
readonly home?: string;
|
|
441
|
-
readonly cause: string;
|
|
442
|
-
readonly recordedAt?: string;
|
|
443
|
-
}): RecordPointer {
|
|
444
|
-
const recordedAt = input.recordedAt ?? new Date().toISOString();
|
|
445
|
-
return appendTicketProvenanceDiagnostic({
|
|
446
|
-
ticketNumber: input.ticketNumber,
|
|
447
|
-
cwd: input.cwd,
|
|
448
|
-
...(input.home === undefined ? {} : { home: input.home }),
|
|
449
|
-
source: "diarist-quote-verify",
|
|
450
|
-
diagnostic: {
|
|
451
|
-
recordClass: TICKET_PROVENANCE_RECORD_CLASS_DIAGNOSTIC,
|
|
452
|
-
diagnosticKind: "quote-verify-failed",
|
|
453
|
-
cause: input.cause,
|
|
454
|
-
recordedAt,
|
|
455
|
-
},
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
|
|
459
253
|
/** Write the co-located human view next to the JSONL volume (derived, not dual-source).
|
|
460
254
|
* Ensures volume dir exists so empty courts still get the md face. */
|
|
461
255
|
export function writeTicketProvenanceHumanView(input: {
|
|
@@ -1,411 +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
|
-
import { pathContainedIn, physicallyContainedIn, } from "./activation-ledger-topology.js";
|
|
11
|
-
export const DEFAULT_QUOTE_MIN = 6;
|
|
12
|
-
export class DiaristSourceReadError extends Error {
|
|
13
|
-
code = "diarist-source-read";
|
|
14
|
-
reason;
|
|
15
|
-
sourcePath;
|
|
16
|
-
constructor(reason, sourcePath, message, options) {
|
|
17
|
-
super(message, options);
|
|
18
|
-
this.name = "DiaristSourceReadError";
|
|
19
|
-
this.reason = reason;
|
|
20
|
-
this.sourcePath = sourcePath;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Extract 「」 quotes from ticket face text for mechanical anchors.
|
|
25
|
-
* Only quotes meeting min length are kept.
|
|
26
|
-
*/
|
|
27
|
-
export function extractCornerQuotes(text, minLength = DEFAULT_QUOTE_MIN) {
|
|
28
|
-
const out = [];
|
|
29
|
-
const re = /「([^」]+)」/g;
|
|
30
|
-
let match;
|
|
31
|
-
while ((match = re.exec(text)) !== null) {
|
|
32
|
-
const q = match[1].trim();
|
|
33
|
-
if (q.length >= minLength)
|
|
34
|
-
out.push(q);
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
/** Build the mechanical anchor set from ticket number + face body. */
|
|
39
|
-
export function buildDiaristAnchors(input) {
|
|
40
|
-
const fromBody = input.ticketBody === undefined ? [] : extractCornerQuotes(input.ticketBody);
|
|
41
|
-
const quotes = [...fromBody, ...(input.extraQuotes ?? [])];
|
|
42
|
-
const seen = new Set();
|
|
43
|
-
const uniqueQuotes = [];
|
|
44
|
-
for (const q of quotes) {
|
|
45
|
-
if (seen.has(q))
|
|
46
|
-
continue;
|
|
47
|
-
seen.add(q);
|
|
48
|
-
uniqueQuotes.push(q);
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
ticketNumber: input.ticketNumber,
|
|
52
|
-
quotes: uniqueQuotes,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/** Drop notification/system noise blocks (typed flags only — no prose match). */
|
|
56
|
-
export function filterNotifications(blocks) {
|
|
57
|
-
return blocks.filter((b) => !b.isNotification);
|
|
58
|
-
}
|
|
59
|
-
function blockKey(block) {
|
|
60
|
-
const r = block.sourceRef;
|
|
61
|
-
return [
|
|
62
|
-
block.sourceKind,
|
|
63
|
-
r.sessionFile ?? "",
|
|
64
|
-
r.entryId === undefined ? "" : String(r.entryId),
|
|
65
|
-
r.path ?? "",
|
|
66
|
-
r.url ?? "",
|
|
67
|
-
createHash("sha256").update(block.transcript, "utf8").digest("hex").slice(0, 16),
|
|
68
|
-
].join("|");
|
|
69
|
-
}
|
|
70
|
-
/** Dedup by source pointer + transcript digest (compression replay ×N). */
|
|
71
|
-
export function dedupeSourceBlocks(blocks) {
|
|
72
|
-
const seen = new Set();
|
|
73
|
-
const out = [];
|
|
74
|
-
for (const block of blocks) {
|
|
75
|
-
const key = blockKey(block);
|
|
76
|
-
if (seen.has(key))
|
|
77
|
-
continue;
|
|
78
|
-
seen.add(key);
|
|
79
|
-
out.push(block);
|
|
80
|
-
}
|
|
81
|
-
return out;
|
|
82
|
-
}
|
|
83
|
-
export function verifyQuotesVerbatim(sourceText, quotes) {
|
|
84
|
-
const failed = [];
|
|
85
|
-
for (const q of quotes) {
|
|
86
|
-
if (q.length === 0)
|
|
87
|
-
continue;
|
|
88
|
-
if (!sourceText.includes(q))
|
|
89
|
-
failed.push(q);
|
|
90
|
-
}
|
|
91
|
-
if (failed.length === 0)
|
|
92
|
-
return { ok: true };
|
|
93
|
-
return { ok: false, failedQuotes: failed };
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Mechanical safeguard only — never a relevance gate (ADR 0075 / 锚定宪法).
|
|
97
|
-
* filter notifications (typed) → dedupe. Semantic selection is LLM-only.
|
|
98
|
-
*/
|
|
99
|
-
export function mechanicalSafeguardPipeline(blocks) {
|
|
100
|
-
return dedupeSourceBlocks(filterNotifications(blocks));
|
|
101
|
-
}
|
|
102
|
-
/** Claude Code project-dir encoding: abs path with / → -. */
|
|
103
|
-
export function encodeClaudeProjectDir(cwd) {
|
|
104
|
-
const abs = cwd.startsWith("/") ? cwd : `/${cwd}`;
|
|
105
|
-
return abs.replace(/\//g, "-");
|
|
106
|
-
}
|
|
107
|
-
function isRecord(value) {
|
|
108
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
109
|
-
}
|
|
110
|
-
function messageText(message) {
|
|
111
|
-
if (typeof message === "string")
|
|
112
|
-
return message;
|
|
113
|
-
if (!isRecord(message))
|
|
114
|
-
return "";
|
|
115
|
-
const content = message.content;
|
|
116
|
-
if (typeof content === "string")
|
|
117
|
-
return content;
|
|
118
|
-
if (!Array.isArray(content))
|
|
119
|
-
return "";
|
|
120
|
-
const parts = [];
|
|
121
|
-
for (const part of content) {
|
|
122
|
-
if (typeof part === "string") {
|
|
123
|
-
parts.push(part);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
if (!isRecord(part))
|
|
127
|
-
continue;
|
|
128
|
-
if (typeof part.text === "string")
|
|
129
|
-
parts.push(part.text);
|
|
130
|
-
else if (typeof part.content === "string")
|
|
131
|
-
parts.push(part.content);
|
|
132
|
-
}
|
|
133
|
-
return parts.join("\n");
|
|
134
|
-
}
|
|
135
|
-
function isNotificationRow(type, text) {
|
|
136
|
-
if (type === "queue-operation" || type === "system" || type === "progress") {
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
if (type === "user") {
|
|
140
|
-
// Local command / meta noise common in cc transcripts.
|
|
141
|
-
if (text.startsWith("<command-name>") || text.startsWith("<local-command-")) {
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
if (text.startsWith("<command-message>") || text.startsWith("<command-args>")) {
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return false;
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Enumerate cc session user/assistant turns and human queued-command attachments under Claude projects roots.
|
|
152
|
-
* cc-sessions-first source family (ADR 0075).
|
|
153
|
-
* Directory/file/non-empty JSONL line failures throw DiaristSourceReadError
|
|
154
|
-
* (失败诚实:不得把未读懂洗成没有来源块).
|
|
155
|
-
*/
|
|
156
|
-
export function readCcSessionBlocks(options) {
|
|
157
|
-
const root = options.projectsRoot ?? join(homedir(), ".claude", "projects");
|
|
158
|
-
if (!existsSync(root))
|
|
159
|
-
return [];
|
|
160
|
-
const blocks = [];
|
|
161
|
-
const dirs = new Set();
|
|
162
|
-
for (const cwd of options.cwds) {
|
|
163
|
-
dirs.add(join(root, encodeClaudeProjectDir(cwd)));
|
|
164
|
-
}
|
|
165
|
-
// Also accept already-encoded folder names passed as cwd (test convenience).
|
|
166
|
-
for (const cwd of options.cwds) {
|
|
167
|
-
if (cwd.includes("projects"))
|
|
168
|
-
continue;
|
|
169
|
-
const direct = join(root, cwd);
|
|
170
|
-
if (existsSync(direct))
|
|
171
|
-
dirs.add(direct);
|
|
172
|
-
}
|
|
173
|
-
for (const dir of dirs) {
|
|
174
|
-
if (!existsSync(dir))
|
|
175
|
-
continue;
|
|
176
|
-
let dirStat;
|
|
177
|
-
try {
|
|
178
|
-
dirStat = statSync(dir);
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
throw new DiaristSourceReadError("readdir-failed", dir, `diarist cc session dir unstatable (${dir})`, { cause: error });
|
|
182
|
-
}
|
|
183
|
-
if (!dirStat.isDirectory())
|
|
184
|
-
continue;
|
|
185
|
-
let files;
|
|
186
|
-
try {
|
|
187
|
-
files = readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
|
|
188
|
-
}
|
|
189
|
-
catch (error) {
|
|
190
|
-
throw new DiaristSourceReadError("readdir-failed", dir, `diarist cc session dir unreadable (${dir})`, { cause: error });
|
|
191
|
-
}
|
|
192
|
-
for (const name of files) {
|
|
193
|
-
const sessionFile = join(dir, name);
|
|
194
|
-
let text;
|
|
195
|
-
try {
|
|
196
|
-
text = readFileSync(sessionFile, "utf8");
|
|
197
|
-
}
|
|
198
|
-
catch (error) {
|
|
199
|
-
throw new DiaristSourceReadError("file-unreadable", sessionFile, `diarist cc session file unreadable (${sessionFile})`, { cause: error });
|
|
200
|
-
}
|
|
201
|
-
const lines = text.split("\n");
|
|
202
|
-
for (let lineNo = 0; lineNo < lines.length; lineNo += 1) {
|
|
203
|
-
const line = lines[lineNo];
|
|
204
|
-
if (!line.trim())
|
|
205
|
-
continue;
|
|
206
|
-
let parsed;
|
|
207
|
-
try {
|
|
208
|
-
parsed = JSON.parse(line);
|
|
209
|
-
}
|
|
210
|
-
catch (error) {
|
|
211
|
-
throw new DiaristSourceReadError("jsonl-line-unparseable", sessionFile, `diarist cc session JSONL line ${lineNo + 1} unparseable (${sessionFile})`, { cause: error });
|
|
212
|
-
}
|
|
213
|
-
// Unknown row shape (not user/assistant or a human queued-command attachment) is ignored — not a read failure.
|
|
214
|
-
if (!isRecord(parsed))
|
|
215
|
-
continue;
|
|
216
|
-
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
217
|
-
const queuedCommand = type === "attachment" &&
|
|
218
|
-
isRecord(parsed.attachment) &&
|
|
219
|
-
parsed.attachment.type === "queued_command" &&
|
|
220
|
-
isRecord(parsed.attachment.origin) &&
|
|
221
|
-
parsed.attachment.origin.kind === "human" &&
|
|
222
|
-
typeof parsed.attachment.prompt === "string"
|
|
223
|
-
? parsed.attachment.prompt
|
|
224
|
-
: undefined;
|
|
225
|
-
if (type !== "user" && type !== "assistant" && queuedCommand === undefined) {
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
const transcript = queuedCommand ?? messageText(parsed.message ?? parsed);
|
|
229
|
-
if (transcript.trim() === "")
|
|
230
|
-
continue;
|
|
231
|
-
const entryId = typeof parsed.uuid === "string"
|
|
232
|
-
? parsed.uuid
|
|
233
|
-
: typeof parsed.id === "string"
|
|
234
|
-
? parsed.id
|
|
235
|
-
: `${basename(sessionFile)}:${lineNo + 1}`;
|
|
236
|
-
const timestamp = typeof parsed.timestamp === "string"
|
|
237
|
-
? parsed.timestamp
|
|
238
|
-
: new Date(0).toISOString();
|
|
239
|
-
const notification = isNotificationRow(type, transcript);
|
|
240
|
-
blocks.push({
|
|
241
|
-
sourceKind: "cc-session",
|
|
242
|
-
sourceRef: { sessionFile, entryId },
|
|
243
|
-
transcript,
|
|
244
|
-
timestamp,
|
|
245
|
-
isUserTurn: type === "user" || queuedCommand !== undefined,
|
|
246
|
-
isNotification: notification,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return blocks;
|
|
252
|
-
}
|
|
253
|
-
/**
|
|
254
|
-
* Issue body + comments + 「」 decree spans as typed source blocks.
|
|
255
|
-
* Each block keeps its own sourceRef (url / comment id) — never merge attachments here.
|
|
256
|
-
*/
|
|
257
|
-
export function readIssueFaceBlocks(input) {
|
|
258
|
-
const fallbackTs = input.timestamp ?? new Date(0).toISOString();
|
|
259
|
-
const blocks = [];
|
|
260
|
-
const body = input.face.body;
|
|
261
|
-
if (body.trim() !== "") {
|
|
262
|
-
blocks.push({
|
|
263
|
-
sourceKind: "issue-body-comment",
|
|
264
|
-
sourceRef: { url: input.face.bodyUrl, entryId: "body" },
|
|
265
|
-
transcript: body,
|
|
266
|
-
timestamp: fallbackTs,
|
|
267
|
-
isUserTurn: true,
|
|
268
|
-
isNotification: false,
|
|
269
|
-
});
|
|
270
|
-
const quotes = extractCornerQuotes(body);
|
|
271
|
-
for (let i = 0; i < quotes.length; i += 1) {
|
|
272
|
-
const q = quotes[i];
|
|
273
|
-
blocks.push({
|
|
274
|
-
sourceKind: "ticket-decree-block",
|
|
275
|
-
sourceRef: { url: input.face.bodyUrl, entryId: `decree-${i + 1}` },
|
|
276
|
-
transcript: q,
|
|
277
|
-
timestamp: fallbackTs,
|
|
278
|
-
isUserTurn: true,
|
|
279
|
-
isNotification: false,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
for (const comment of input.face.comments) {
|
|
284
|
-
if (comment.body.trim() === "")
|
|
285
|
-
continue;
|
|
286
|
-
blocks.push({
|
|
287
|
-
sourceKind: "issue-body-comment",
|
|
288
|
-
sourceRef: {
|
|
289
|
-
url: comment.htmlUrl,
|
|
290
|
-
entryId: comment.id,
|
|
291
|
-
},
|
|
292
|
-
transcript: comment.body,
|
|
293
|
-
timestamp: comment.createdAt || fallbackTs,
|
|
294
|
-
isUserTurn: true,
|
|
295
|
-
isNotification: false,
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
return blocks;
|
|
299
|
-
}
|
|
300
|
-
/** Relative root every ADR decision-key read must stay under (ADR 0038). */
|
|
301
|
-
export const DIARIST_ADR_ROOT_REL = "docs/adr";
|
|
302
|
-
/**
|
|
303
|
-
* Read applicable ADR files as decision-key source blocks.
|
|
304
|
-
* Real IO seam owns lexical + physical containment under cwd/docs/adr (ADR 0038).
|
|
305
|
-
* Missing / unreadable / escape → typed DiaristSourceReadError (失败诚实).
|
|
306
|
-
*/
|
|
307
|
-
export function readAdrDecisionKeyBlocks(input) {
|
|
308
|
-
const timestamp = input.timestamp ?? new Date(0).toISOString();
|
|
309
|
-
const blocks = [];
|
|
310
|
-
const cwdAbs = resolve(input.cwd);
|
|
311
|
-
const adrRootAbs = resolve(cwdAbs, DIARIST_ADR_ROOT_REL);
|
|
312
|
-
let adrRootReal;
|
|
313
|
-
try {
|
|
314
|
-
// Prefer the real ADR root when it exists; otherwise fall back to cwd real
|
|
315
|
-
// so physical checks still have a stable anchor before the first ADR file.
|
|
316
|
-
adrRootReal = existsSync(adrRootAbs)
|
|
317
|
-
? realpathSync(adrRootAbs)
|
|
318
|
-
: resolve(realpathSync(cwdAbs), DIARIST_ADR_ROOT_REL);
|
|
319
|
-
}
|
|
320
|
-
catch (error) {
|
|
321
|
-
throw new DiaristSourceReadError("adr-unreadable", cwdAbs, `diarist ADR root is not resolvable (${adrRootAbs})`, { cause: error });
|
|
322
|
-
}
|
|
323
|
-
for (const rel of input.adrPaths) {
|
|
324
|
-
const segments = rel.split(/[/\\]/);
|
|
325
|
-
// Claimed path must sit under docs/adr/ with no traversal segments.
|
|
326
|
-
if (segments[0] !== "docs" ||
|
|
327
|
-
segments[1] !== "adr" ||
|
|
328
|
-
segments.length < 3 ||
|
|
329
|
-
segments.some((part) => part === "" || part === "." || part === "..")) {
|
|
330
|
-
throw new DiaristSourceReadError("adr-escape", resolve(cwdAbs, rel), `diarist referenced ADR escapes docs/adr (${rel})`);
|
|
331
|
-
}
|
|
332
|
-
const abs = resolve(cwdAbs, rel);
|
|
333
|
-
// Lexical containment under docs/adr before any read (ADR 0038).
|
|
334
|
-
if (abs !== adrRootAbs && !pathContainedIn(adrRootAbs, abs)) {
|
|
335
|
-
throw new DiaristSourceReadError("adr-escape", abs, `diarist referenced ADR escapes docs/adr (${rel})`);
|
|
336
|
-
}
|
|
337
|
-
if (!existsSync(abs)) {
|
|
338
|
-
throw new DiaristSourceReadError("adr-missing", abs, `diarist referenced ADR missing (${rel})`);
|
|
339
|
-
}
|
|
340
|
-
let realAbs;
|
|
341
|
-
try {
|
|
342
|
-
realAbs = realpathSync(abs);
|
|
343
|
-
}
|
|
344
|
-
catch (error) {
|
|
345
|
-
throw new DiaristSourceReadError("adr-unreadable", abs, `diarist ADR path is not resolvable (${abs})`, { cause: error });
|
|
346
|
-
}
|
|
347
|
-
if (realAbs !== adrRootReal && !physicallyContainedIn(adrRootReal, realAbs)) {
|
|
348
|
-
throw new DiaristSourceReadError("adr-escape", abs, `diarist referenced ADR escapes docs/adr physically (${rel})`);
|
|
349
|
-
}
|
|
350
|
-
let text;
|
|
351
|
-
try {
|
|
352
|
-
text = readFileSync(realAbs, "utf8");
|
|
353
|
-
}
|
|
354
|
-
catch (error) {
|
|
355
|
-
throw new DiaristSourceReadError("adr-unreadable", realAbs, `diarist ADR file unreadable (${realAbs})`, { cause: error });
|
|
356
|
-
}
|
|
357
|
-
if (text.trim() === "")
|
|
358
|
-
continue;
|
|
359
|
-
blocks.push({
|
|
360
|
-
sourceKind: "adr-decision-key",
|
|
361
|
-
sourceRef: { path: rel },
|
|
362
|
-
transcript: text,
|
|
363
|
-
timestamp,
|
|
364
|
-
isUserTurn: true,
|
|
365
|
-
isNotification: false,
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
return blocks;
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Build an llm-semantic entry after reverse-verify succeeds.
|
|
372
|
-
* `quotes` are the LLM-claimed quotes that must be verbatim in transcript.
|
|
373
|
-
* basis.anchors record ticket # + mechanical/claimed quotes for audit only.
|
|
374
|
-
* Failure returns failedQuotes only — caller records a single diagnostic (not a
|
|
375
|
-
* disguised diary entry / dead basis.method).
|
|
376
|
-
*/
|
|
377
|
-
export function blockToLlmEntry(block, input) {
|
|
378
|
-
const verify = verifyQuotesVerbatim(block.transcript, input.quotes);
|
|
379
|
-
if (!verify.ok) {
|
|
380
|
-
return {
|
|
381
|
-
ok: false,
|
|
382
|
-
failedQuotes: verify.failedQuotes,
|
|
383
|
-
cause: `verbatim verify failed: ${verify.failedQuotes.join(" | ")}`,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
// Ticket + mechanical face quotes + claimed quotes — reference/反验 only.
|
|
387
|
-
const anchorNotes = [`#${input.anchors.ticketNumber}`];
|
|
388
|
-
const seen = new Set(anchorNotes);
|
|
389
|
-
for (const q of [...input.anchors.quotes, ...input.quotes]) {
|
|
390
|
-
if (seen.has(q))
|
|
391
|
-
continue;
|
|
392
|
-
seen.add(q);
|
|
393
|
-
anchorNotes.push(q);
|
|
394
|
-
if (anchorNotes.length >= 12)
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
|
-
return {
|
|
398
|
-
ok: true,
|
|
399
|
-
entry: {
|
|
400
|
-
basis: {
|
|
401
|
-
method: "llm-semantic",
|
|
402
|
-
anchors: anchorNotes,
|
|
403
|
-
...(input.note === undefined ? {} : { note: input.note }),
|
|
404
|
-
},
|
|
405
|
-
sourceKind: block.sourceKind,
|
|
406
|
-
sourceRef: block.sourceRef,
|
|
407
|
-
transcript: block.transcript,
|
|
408
|
-
timestamp: block.timestamp,
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
}
|