@akagilnc/pi-workflow-roles 0.1.4204 → 0.1.4228
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 +1203 -770
- package/dist/atomic-write.js +23 -0
- package/dist/diarist-contracts.js +22 -8
- package/dist/headless-host/production-host.js +1191 -758
- package/dist/ledger-session-read.js +52 -21
- package/dist/migrate-book-topology.js +238 -205
- package/dist/public-cli/case-dossier-delivery.js +0 -1
- package/dist/public-cli/main.js +303 -261
- package/dist/session-dialogue.js +221 -0
- package/dist/sitian-facade.js +1 -0
- package/dist/sitian-volume.js +67 -0
- package/dist/ticket-provenance-contracts.js +122 -39
- package/dist/ticket-provenance.js +289 -204
- package/package.json +1 -1
- package/src/book-topology-migration.ts +0 -7
- package/src/book-topology-record-class-migrators.ts +203 -99
- package/src/diarist-contracts.ts +42 -29
- package/src/diarist-role.ts +77 -31
- package/src/diarist.ts +31 -67
- package/src/ledger-session-read.ts +70 -27
- package/src/package-contracts/reviewer-output.ts +1 -1
- package/src/public-cli/case-dossier-delivery.ts +0 -1
- package/src/reviewer-role.ts +3 -3
- package/src/role-runtime.ts +56 -14
- package/src/session-dialogue.ts +225 -0
- package/src/sitian-appender.ts +1 -1
- package/src/sitian-facade.ts +7 -1
- package/src/sitian-volume.ts +91 -0
- package/src/ticket-provenance-contracts.ts +158 -101
- package/src/ticket-provenance.ts +387 -263
|
@@ -1,246 +1,331 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 起居录 volume helpers — ADR 0075
|
|
3
|
-
*
|
|
2
|
+
* 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901。
|
|
3
|
+
* 一册=一个文件:首行册子头,其后裸对话行。每轮按当前区间重投影是唯一机制;
|
|
4
|
+
* 无 append 水位、无 SitianRecord 外壳。目的地解析与读写经司天台唯一入口
|
|
5
|
+
* (ADR 0065 records-owner / record-entry;ADR 0081 入录经司天台)。
|
|
4
6
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
|
|
9
|
+
import { errnoCode, packageMachineHome, physicallyContainedIn, resolveActivationLedgerHome, } from "./activation-ledger-topology.js";
|
|
10
|
+
import { readLedgerSessionJsonlLines, } from "./ledger-session-read.js";
|
|
9
11
|
import { isSafePositiveTicketNumber } from "./run-ticket-number.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
import { adaptSessionDialogue, nativeEventId } from "./session-dialogue.js";
|
|
13
|
+
import { ensureSitianVolume, readSitianVolumeText, resolveSitianVolume, rewriteSitianVolume, } from "./sitian-facade.js";
|
|
14
|
+
import { TICKET_PROVENANCE_KIND, projectTicketProvenanceHeader, projectTicketProvenanceLine, } from "./ticket-provenance-contracts.js";
|
|
15
|
+
/**
|
|
16
|
+
* Typed input failure for diarist bounds/session path (reask, not infrastructure).
|
|
17
|
+
* Accept hook discriminates with instanceof — never Error.message prefixes.
|
|
18
|
+
*/
|
|
19
|
+
export class TicketProvenanceInputError extends Error {
|
|
20
|
+
code = "ticket-provenance-input";
|
|
21
|
+
constructor(message, options) {
|
|
22
|
+
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
|
23
|
+
this.name = "TicketProvenanceInputError";
|
|
15
24
|
}
|
|
16
|
-
return String(ticketNumber);
|
|
17
25
|
}
|
|
18
26
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
27
|
+
* Host-owned session source roots (ADR 0038 / ADR 0081 cross-host).
|
|
28
|
+
* Narrow directory identities — not whole `.pi` / whole `.ak-roles`.
|
|
29
|
+
*/
|
|
30
|
+
export function dialogueSessionSourceRoots(home) {
|
|
31
|
+
const machineHome = typeof home === "string" && home.trim() !== "" ? home : packageMachineHome();
|
|
32
|
+
return [
|
|
33
|
+
join(machineHome, ".claude", "projects"),
|
|
34
|
+
join(machineHome, ".codex", "sessions"),
|
|
35
|
+
join(machineHome, ".pi", "agent", "sessions"),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* True when path is a sitian role-run session volume:
|
|
40
|
+
* `<ledger>/books/.../session/session.jsonl` (basename + parent only — no body probe).
|
|
21
41
|
*/
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ref.url ?? "",
|
|
29
|
-
].join("\u0001");
|
|
30
|
-
const material = [
|
|
31
|
-
String(input.ticketNumber),
|
|
32
|
-
input.sourceKind,
|
|
33
|
-
refKey,
|
|
34
|
-
input.transcript,
|
|
35
|
-
].join("\u0000");
|
|
36
|
-
return createHash("sha256").update(material, "utf8").digest("hex");
|
|
42
|
+
function isLedgerRoleSessionFile(absolute, home) {
|
|
43
|
+
const machineHome = typeof home === "string" && home.trim() !== "" ? home : packageMachineHome();
|
|
44
|
+
const ledgerHome = resolveActivationLedgerHome(machineHome);
|
|
45
|
+
if (!physicallyContainedIn(ledgerHome, absolute))
|
|
46
|
+
return false;
|
|
47
|
+
return basename(absolute) === "session.jsonl" && basename(dirname(absolute)) === "session";
|
|
37
48
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const sourceRef = typeof record.sourceRef === "object" && record.sourceRef !== null && !Array.isArray(record.sourceRef)
|
|
45
|
-
? record.sourceRef
|
|
46
|
-
: {};
|
|
47
|
-
return ticketProvenanceEntryIdentity({
|
|
48
|
-
ticketNumber,
|
|
49
|
-
sourceKind: record.sourceKind,
|
|
50
|
-
sourceRef,
|
|
51
|
-
transcript: record.transcript,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
49
|
+
/** Real I/O seam gate: only host session stores or sitian role-run session.jsonl. */
|
|
50
|
+
function assertDialogueSessionSourcePath(path, home) {
|
|
51
|
+
const absolute = resolve(path);
|
|
52
|
+
for (const root of dialogueSessionSourceRoots(home)) {
|
|
53
|
+
if (physicallyContainedIn(root, absolute))
|
|
54
|
+
return;
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
if (isLedgerRoleSessionFile(absolute, home))
|
|
57
|
+
return;
|
|
58
|
+
throw new TicketProvenanceInputError(`session unreadable: ${path} (outside authorized source roots)`);
|
|
58
59
|
}
|
|
59
|
-
/**
|
|
60
|
-
export function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
: undefined;
|
|
66
|
-
const sourceRef = record !== undefined && typeof record.sourceRef === "object" && record.sourceRef !== null && !Array.isArray(record.sourceRef)
|
|
67
|
-
? record.sourceRef
|
|
68
|
-
: undefined;
|
|
69
|
-
return sitianReport({
|
|
70
|
-
level: "event",
|
|
71
|
-
kind: TICKET_PROVENANCE_KIND,
|
|
72
|
-
identity,
|
|
73
|
-
subject,
|
|
74
|
-
cwd: input.cwd,
|
|
75
|
-
sessionParent: input.sessionParent,
|
|
76
|
-
...(input.home === undefined ? {} : { home: input.home }),
|
|
77
|
-
host: input.host ?? "diarist",
|
|
78
|
-
source: input.source ?? "diarist",
|
|
79
|
-
payload: input.payload,
|
|
80
|
-
raw: sourceRef !== undefined
|
|
81
|
-
&& typeof sourceRef.sessionFile === "string"
|
|
82
|
-
&& (typeof sourceRef.entryId === "string" || typeof sourceRef.entryId === "number")
|
|
83
|
-
? {
|
|
84
|
-
sessionFile: sourceRef.sessionFile,
|
|
85
|
-
entryId: sourceRef.entryId,
|
|
86
|
-
}
|
|
87
|
-
: undefined,
|
|
88
|
-
});
|
|
60
|
+
/** Subject string for ticket-keyed volumes — history follows the ticket. */
|
|
61
|
+
export function ticketProvenanceSubject(ticketNumber) {
|
|
62
|
+
if (!isSafePositiveTicketNumber(ticketNumber)) {
|
|
63
|
+
throw new Error(`ticket-provenance subject requires a positive ticket number, got ${String(ticketNumber)}`);
|
|
64
|
+
}
|
|
65
|
+
return String(ticketNumber);
|
|
89
66
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
/** Topology input only — no destination parameter (ADR 0065 record-entry). */
|
|
68
|
+
function ticketProvenanceRecordInput(ticketNumber, cwd, home) {
|
|
69
|
+
return {
|
|
93
70
|
level: "event",
|
|
94
71
|
kind: TICKET_PROVENANCE_KIND,
|
|
95
72
|
subject: ticketProvenanceSubject(ticketNumber),
|
|
96
73
|
cwd,
|
|
97
74
|
...(home === undefined ? {} : { home }),
|
|
98
|
-
});
|
|
99
|
-
return {
|
|
100
|
-
recordFile: path.recordFile,
|
|
101
|
-
volumeDir: path.sessionDir,
|
|
102
|
-
humanViewFile: join(path.sessionDir, TICKET_PROVENANCE_HUMAN_VIEW),
|
|
103
75
|
};
|
|
104
76
|
}
|
|
105
|
-
/**
|
|
77
|
+
/** Resolve volume paths for a ticket without writing. */
|
|
78
|
+
export function resolveTicketProvenanceVolume(ticketNumber, cwd, home) {
|
|
79
|
+
return resolveSitianVolume(ticketProvenanceRecordInput(ticketNumber, cwd, home));
|
|
80
|
+
}
|
|
81
|
+
/** Read the unique diary file (empty/absent → no header, no lines). */
|
|
106
82
|
export async function readTicketProvenance(ticketNumber, cwd, home) {
|
|
107
|
-
const { recordFile } =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
let
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
83
|
+
const { recordFile, text } = await readSitianVolumeText(ticketProvenanceRecordInput(ticketNumber, cwd, home));
|
|
84
|
+
if (text === undefined) {
|
|
85
|
+
return { header: undefined, lines: [], recordFile };
|
|
86
|
+
}
|
|
87
|
+
const physical = text.split("\n");
|
|
88
|
+
let header;
|
|
89
|
+
const lines = [];
|
|
90
|
+
let sawFirst = false;
|
|
91
|
+
for (let index = 0; index < physical.length; index += 1) {
|
|
92
|
+
const raw = physical[index];
|
|
93
|
+
if (!raw.trim())
|
|
116
94
|
continue;
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
117
98
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (diagnostic !== undefined) {
|
|
121
|
-
diagnostics.push(diagnostic);
|
|
99
|
+
catch {
|
|
100
|
+
// Stock / damaged rows stay unprojected — no backfill, no guess.
|
|
122
101
|
continue;
|
|
123
102
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
103
|
+
if (!sawFirst) {
|
|
104
|
+
sawFirst = true;
|
|
105
|
+
header = projectTicketProvenanceHeader(parsed);
|
|
106
|
+
// First line that is not a header is treated as a body line (stock shapes).
|
|
107
|
+
if (header !== undefined)
|
|
108
|
+
continue;
|
|
128
109
|
}
|
|
129
|
-
|
|
110
|
+
const line = projectTicketProvenanceLine(parsed);
|
|
111
|
+
if (line !== undefined)
|
|
112
|
+
lines.push(line);
|
|
130
113
|
}
|
|
131
|
-
return {
|
|
114
|
+
return { header, lines, recordFile };
|
|
132
115
|
}
|
|
133
116
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
117
|
+
* Ensure the per-ticket directory + volume file exist (ADR 0075 ticket-provenance-file).
|
|
118
|
+
* Empty file is lawful (bound court with no dialogue yet). Does not forge entries.
|
|
136
119
|
*/
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
const runs = text.match(/`+/g);
|
|
140
|
-
if (runs !== null) {
|
|
141
|
-
for (const run of runs) {
|
|
142
|
-
if (run.length > longest)
|
|
143
|
-
longest = run.length;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return "`".repeat(Math.max(3, longest + 1));
|
|
120
|
+
export function ensureTicketProvenanceVolume(ticketNumber, cwd, home) {
|
|
121
|
+
return ensureSitianVolume(ticketProvenanceRecordInput(ticketNumber, cwd, home));
|
|
147
122
|
}
|
|
148
123
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
124
|
+
* Resolve a bound endpoint against this round's session lines.
|
|
125
|
+
* id → first physical line carrying that native id; line → that 1-based line.
|
|
126
|
+
* Either missing from the volume → undefined (caller reasks).
|
|
151
127
|
*/
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
let index = 0;
|
|
161
|
-
for (const entry of input.entries) {
|
|
162
|
-
index += 1;
|
|
163
|
-
const sourceKind = typeof entry.sourceKind === "string" ? entry.sourceKind : "未投影";
|
|
164
|
-
const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : "";
|
|
165
|
-
lines.push(`## ${index}. ${sourceKind} · ${timestamp}`);
|
|
166
|
-
lines.push("");
|
|
167
|
-
const basis = entry.basis;
|
|
168
|
-
const method = typeof basis === "object" && basis !== null && !Array.isArray(basis) && typeof basis.method === "string"
|
|
169
|
-
? basis.method
|
|
170
|
-
: "未投影";
|
|
171
|
-
lines.push(`- basis.method: \`${method}\``);
|
|
172
|
-
if (typeof basis === "object" &&
|
|
173
|
-
basis !== null &&
|
|
174
|
-
!Array.isArray(basis) &&
|
|
175
|
-
Array.isArray(basis.anchors) &&
|
|
176
|
-
basis.anchors.length > 0) {
|
|
177
|
-
lines.push(`- anchors: ${basis.anchors.map((a) => `\`${String(a)}\``).join(", ")}`);
|
|
128
|
+
function resolveBoundIndex(bound, sessionLines) {
|
|
129
|
+
if (typeof bound.id === "string" && bound.id !== "") {
|
|
130
|
+
for (let index = 0; index < sessionLines.length; index += 1) {
|
|
131
|
+
const row = sessionLines[index].row;
|
|
132
|
+
if (row === undefined)
|
|
133
|
+
continue;
|
|
134
|
+
if (nativeEventId(row) === bound.id)
|
|
135
|
+
return index;
|
|
178
136
|
}
|
|
179
|
-
|
|
180
|
-
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
if (typeof bound.line === "number") {
|
|
140
|
+
for (let index = 0; index < sessionLines.length; index += 1) {
|
|
141
|
+
if (sessionLines[index].line === bound.line)
|
|
142
|
+
return index;
|
|
181
143
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
function amendmentKey(s, line) {
|
|
149
|
+
return `${s}:${line}`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Resolve submitted ranges against the session, then sort by source position and
|
|
153
|
+
* merge overlaps so each physical row is visited once (#901 source order).
|
|
154
|
+
*/
|
|
155
|
+
function normalizeResolvedRanges(ranges, sessionLines, sessionPath) {
|
|
156
|
+
const resolved = [];
|
|
157
|
+
for (const range of ranges) {
|
|
158
|
+
const fromIndex = resolveBoundIndex(range.from, sessionLines);
|
|
159
|
+
const toIndex = resolveBoundIndex(range.to, sessionLines);
|
|
160
|
+
if (fromIndex === undefined || toIndex === undefined) {
|
|
161
|
+
throw new TicketProvenanceInputError(`bound endpoint not found in ${sessionPath} (from=${JSON.stringify(range.from)} to=${JSON.stringify(range.to)})`);
|
|
188
162
|
}
|
|
189
|
-
if (
|
|
190
|
-
|
|
163
|
+
if (fromIndex > toIndex) {
|
|
164
|
+
throw new TicketProvenanceInputError(`bound range inverted in ${sessionPath} (from index ${fromIndex} > to index ${toIndex})`);
|
|
191
165
|
}
|
|
192
|
-
|
|
193
|
-
|
|
166
|
+
resolved.push({ fromIndex, toIndex });
|
|
167
|
+
}
|
|
168
|
+
resolved.sort((left, right) => left.fromIndex - right.fromIndex || left.toIndex - right.toIndex);
|
|
169
|
+
const merged = [];
|
|
170
|
+
for (const range of resolved) {
|
|
171
|
+
const last = merged[merged.length - 1];
|
|
172
|
+
if (last !== undefined && range.fromIndex <= last.toIndex + 1) {
|
|
173
|
+
last.toIndex = Math.max(last.toIndex, range.toIndex);
|
|
174
|
+
continue;
|
|
194
175
|
}
|
|
195
|
-
|
|
196
|
-
|
|
176
|
+
merged.push({ fromIndex: range.fromIndex, toIndex: range.toIndex });
|
|
177
|
+
}
|
|
178
|
+
return merged;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Project one session's ranges into diary lines.
|
|
182
|
+
* Unusable bounds throw with a stable message the accept hook turns into reask.
|
|
183
|
+
*/
|
|
184
|
+
async function projectSessionRanges(input) {
|
|
185
|
+
assertDialogueSessionSourcePath(input.session.path, input.home);
|
|
186
|
+
let sessionLines;
|
|
187
|
+
try {
|
|
188
|
+
sessionLines = await readLedgerSessionJsonlLines(input.session.path);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error instanceof TicketProvenanceInputError)
|
|
192
|
+
throw error;
|
|
193
|
+
// Only absence/path-shape misses are model input errors → reask.
|
|
194
|
+
// EISDIR = authorized-root path that is a directory, not a session file.
|
|
195
|
+
// EIO / EMFILE / EACCES / other runtime faults keep native identity + cause.
|
|
196
|
+
const code = errnoCode(error);
|
|
197
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
|
|
198
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
199
|
+
throw new TicketProvenanceInputError(`session unreadable: ${input.session.path} (${detail})`, { cause: error });
|
|
197
200
|
}
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
const rows = sessionLines.map((entry) => entry.row);
|
|
204
|
+
const dialogue = adaptSessionDialogue(rows);
|
|
205
|
+
const seenIds = input.seenIds;
|
|
206
|
+
const lines = [];
|
|
207
|
+
const unparsable = [];
|
|
208
|
+
const ranges = normalizeResolvedRanges(input.session.ranges, sessionLines, input.session.path);
|
|
209
|
+
for (const range of ranges) {
|
|
210
|
+
for (let index = range.fromIndex; index <= range.toIndex; index += 1) {
|
|
211
|
+
const entry = sessionLines[index];
|
|
212
|
+
if (entry.row === undefined) {
|
|
213
|
+
const key = amendmentKey(input.s, entry.line);
|
|
214
|
+
const amendment = input.amendmentsByKey.get(key);
|
|
215
|
+
if (amendment !== undefined) {
|
|
216
|
+
lines.push({
|
|
217
|
+
speaker: amendment.speaker,
|
|
218
|
+
s: input.s,
|
|
219
|
+
line: entry.line,
|
|
220
|
+
text: amendment.text,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
unparsable.push({ s: input.s, line: entry.line, raw: entry.raw });
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
for (const event of dialogue[index] ?? []) {
|
|
229
|
+
if (event.id !== undefined) {
|
|
230
|
+
if (seenIds.has(event.id))
|
|
231
|
+
continue;
|
|
232
|
+
seenIds.add(event.id);
|
|
233
|
+
}
|
|
234
|
+
lines.push({
|
|
235
|
+
speaker: event.speaker,
|
|
236
|
+
s: input.s,
|
|
237
|
+
line: entry.line,
|
|
238
|
+
...(event.id === undefined ? {} : { id: event.id }),
|
|
239
|
+
text: event.text,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
200
242
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const fence = markdownFenceFor(transcript);
|
|
204
|
-
lines.push(fence);
|
|
205
|
-
lines.push(transcript);
|
|
206
|
-
lines.push(fence);
|
|
207
|
-
lines.push("");
|
|
208
|
-
}
|
|
209
|
-
for (const payload of unprojected) {
|
|
210
|
-
index += 1;
|
|
211
|
-
lines.push(`## ${index}. 未投影`);
|
|
212
|
-
lines.push("");
|
|
213
|
-
const text = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
214
|
-
const fence = markdownFenceFor(text);
|
|
215
|
-
lines.push(fence);
|
|
216
|
-
lines.push(text);
|
|
217
|
-
lines.push(fence);
|
|
218
|
-
lines.push("");
|
|
219
|
-
}
|
|
220
|
-
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
return { lines, unparsable };
|
|
221
245
|
}
|
|
222
246
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* (
|
|
247
|
+
* Reproject the unique diary file from the submitted bounds + optional amendments.
|
|
248
|
+
* Header + locating fields may change; dialogue text is taken from the source
|
|
249
|
+
* (or from a typed amendment). Does not append; the whole file is the projection.
|
|
250
|
+
* Persistence goes through the Sitian volume seam (rewriteSitianVolume).
|
|
226
251
|
*/
|
|
227
|
-
export function
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
252
|
+
export async function reprojectTicketProvenance(input) {
|
|
253
|
+
const recordInput = ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home);
|
|
254
|
+
const prior = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
|
|
255
|
+
const priorRaw = await readSitianVolumeText(recordInput);
|
|
256
|
+
const priorNonEmpty = priorRaw.text !== undefined && priorRaw.text.trim() !== "";
|
|
257
|
+
const amendments = input.amendments ?? [];
|
|
258
|
+
// Empty sessions: pure empty selection preserves non-empty volume. Amendments-only
|
|
259
|
+
// continuation reuses prior header sessions — never wash into accepted no-op.
|
|
260
|
+
let sessions = input.sessions;
|
|
261
|
+
if (sessions.length === 0) {
|
|
262
|
+
if (amendments.length > 0) {
|
|
263
|
+
const priorSessions = prior.header?.sessions;
|
|
264
|
+
if (priorSessions === undefined || priorSessions.length === 0) {
|
|
265
|
+
throw new TicketProvenanceInputError("amendments require sessions bounds (none submitted and no prior header sessions)");
|
|
266
|
+
}
|
|
267
|
+
sessions = priorSessions;
|
|
268
|
+
}
|
|
269
|
+
else if (priorNonEmpty) {
|
|
270
|
+
const now = new Date().toISOString();
|
|
271
|
+
const header = prior.header ??
|
|
272
|
+
{
|
|
273
|
+
repo: resolveBookKeyFromGit(input.cwd),
|
|
274
|
+
ticket: input.ticketNumber,
|
|
275
|
+
createdAt: now,
|
|
276
|
+
updatedAt: now,
|
|
277
|
+
sessions: [],
|
|
278
|
+
};
|
|
279
|
+
return {
|
|
280
|
+
recordFile: prior.recordFile,
|
|
281
|
+
header,
|
|
282
|
+
lines: prior.lines,
|
|
283
|
+
unparsable: [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const amendmentsByKey = new Map();
|
|
288
|
+
for (const amendment of amendments) {
|
|
289
|
+
amendmentsByKey.set(amendmentKey(amendment.s, amendment.line), amendment);
|
|
290
|
+
}
|
|
291
|
+
const lines = [];
|
|
292
|
+
const unparsable = [];
|
|
293
|
+
// First-seen native id across the whole reproject (rewritten session copies).
|
|
294
|
+
const seenIds = new Set();
|
|
295
|
+
for (let s = 0; s < sessions.length; s += 1) {
|
|
296
|
+
const projected = await projectSessionRanges({
|
|
297
|
+
s,
|
|
298
|
+
session: sessions[s],
|
|
299
|
+
amendmentsByKey,
|
|
300
|
+
seenIds,
|
|
301
|
+
...(input.home === undefined ? {} : { home: input.home }),
|
|
302
|
+
});
|
|
303
|
+
lines.push(...projected.lines);
|
|
304
|
+
unparsable.push(...projected.unparsable);
|
|
305
|
+
}
|
|
306
|
+
const now = new Date().toISOString();
|
|
307
|
+
const header = {
|
|
308
|
+
repo: resolveBookKeyFromGit(input.cwd),
|
|
309
|
+
ticket: input.ticketNumber,
|
|
310
|
+
createdAt: prior.header?.createdAt ?? now,
|
|
311
|
+
updatedAt: now,
|
|
312
|
+
sessions,
|
|
313
|
+
};
|
|
314
|
+
// Still-open gaps → reask without publishing a partial/rejected projection.
|
|
315
|
+
if (unparsable.length > 0) {
|
|
316
|
+
return {
|
|
317
|
+
recordFile: prior.recordFile,
|
|
318
|
+
header,
|
|
319
|
+
lines,
|
|
320
|
+
unparsable,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const body = `${[JSON.stringify(header), ...lines.map((line) => JSON.stringify(line))].join("\n")}\n`;
|
|
324
|
+
const volume = await rewriteSitianVolume({ ...recordInput, body });
|
|
325
|
+
return {
|
|
326
|
+
recordFile: volume.recordFile,
|
|
327
|
+
header,
|
|
328
|
+
lines,
|
|
329
|
+
unparsable,
|
|
330
|
+
};
|
|
246
331
|
}
|
package/package.json
CHANGED
|
@@ -44,12 +44,6 @@ export type BookTopologyMigrationContext = {
|
|
|
44
44
|
readonly backupBooksDirectory: string;
|
|
45
45
|
/** Fresh destination at the former books/ path. */
|
|
46
46
|
readonly booksDirectory: string;
|
|
47
|
-
/**
|
|
48
|
-
* Ticket numbers whose records.jsonl grew during record-class placement.
|
|
49
|
-
* Human view is finalized once after all record-class migrators finish —
|
|
50
|
-
* never after ticket-provenance home alone (misplaced lines can still land).
|
|
51
|
-
*/
|
|
52
|
-
readonly touchedTicketsByBook: Map<string, Set<number>>;
|
|
53
47
|
};
|
|
54
48
|
|
|
55
49
|
export type BookTopologyPartitionMigrator = {
|
|
@@ -203,7 +197,6 @@ export async function migrateBookTopology(input: {
|
|
|
203
197
|
const context: BookTopologyMigrationContext = {
|
|
204
198
|
backupBooksDirectory,
|
|
205
199
|
booksDirectory,
|
|
206
|
-
touchedTicketsByBook: new Map(),
|
|
207
200
|
};
|
|
208
201
|
const partitions: MigrationPartitionReport[] = [];
|
|
209
202
|
for (const migrator of input.migrators) {
|