@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.
@@ -1,246 +1,331 @@
1
1
  /**
2
- * 起居录 volume helpers — ADR 0075 / #582.
3
- * Write/read via sitian facade only; no parallel destination logic.
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 { createHash } from "node:crypto";
6
- import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { resolveSitianRecordPath, sitianReport, readSitianRecords, } from "./sitian-facade.js";
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 { TICKET_PROVENANCE_HUMAN_VIEW, TICKET_PROVENANCE_KIND, projectTicketProvenanceDiagnostic, projectTicketProvenanceEntry, } from "./ticket-provenance-contracts.js";
11
- /** Subject string for ticket-keyed volumes — history follows the ticket. */
12
- export function ticketProvenanceSubject(ticketNumber) {
13
- if (!isSafePositiveTicketNumber(ticketNumber)) {
14
- throw new Error(`ticket-provenance subject requires a positive ticket number, got ${String(ticketNumber)}`);
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
- * Deterministic entry identity for sitian entry-level idempotency.
20
- * Same ticket + source pointer + transcript → same identity → no re-append.
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
- export function ticketProvenanceEntryIdentity(input) {
23
- const ref = input.sourceRef;
24
- const refKey = [
25
- ref.sessionFile ?? "",
26
- ref.entryId === undefined ? "" : String(ref.entryId),
27
- ref.path ?? "",
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
- function identityFromPayload(ticketNumber, payload) {
39
- if (typeof payload === "object"
40
- && payload !== null
41
- && !Array.isArray(payload)) {
42
- const record = payload;
43
- if (typeof record.sourceKind === "string" && typeof record.transcript === "string") {
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
- return createHash("sha256")
56
- .update(`${String(ticketNumber)}\u0000${JSON.stringify(payload)}`, "utf8")
57
- .digest("hex");
56
+ if (isLedgerRoleSessionFile(absolute, home))
57
+ return;
58
+ throw new TicketProvenanceInputError(`session unreadable: ${path} (outside authorized source roots)`);
58
59
  }
59
- /** Append one original row; returns existing pointer on identity hit. */
60
- export function appendTicketProvenanceEntry(input) {
61
- const subject = ticketProvenanceSubject(input.ticketNumber);
62
- const identity = identityFromPayload(input.ticketNumber, input.payload);
63
- const record = typeof input.payload === "object" && input.payload !== null && !Array.isArray(input.payload)
64
- ? input.payload
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
- /** Resolve volume paths for a ticket without writing. */
91
- export function resolveTicketProvenanceVolume(ticketNumber, cwd, home) {
92
- const path = resolveSitianRecordPath({
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
- /** Read projected diary entries + diagnostics for a ticket (empty when volume absent). */
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 } = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
108
- const { records } = await readSitianRecords(recordFile);
109
- const entries = [];
110
- const unprojected = [];
111
- const diagnostics = [];
112
- let skipped = 0;
113
- for (const record of records) {
114
- if (record.kind !== TICKET_PROVENANCE_KIND) {
115
- skipped += 1;
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
- // Diagnostics first — same partition, separate projection (never body).
119
- const diagnostic = projectTicketProvenanceDiagnostic(record.payload);
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
- const entry = projectTicketProvenanceEntry(record.payload);
125
- if (entry === undefined) {
126
- unprojected.push(record.payload);
127
- continue;
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
- entries.push(entry);
110
+ const line = projectTicketProvenanceLine(parsed);
111
+ if (line !== undefined)
112
+ lines.push(line);
130
113
  }
131
- return { entries, unprojected, diagnostics, records, recordFile, skipped };
114
+ return { header, lines, recordFile };
132
115
  }
133
116
  /**
134
- * Fence long enough that any backtick run inside `text` cannot close the block.
135
- * Presentation helper only — not a machine contract; not exported.
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 markdownFenceFor(text) {
138
- let longest = 0;
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
- * Render a local human-read markdown view from entries.
150
- * Presentation only — machines bite JSONL. No wording lock for consumers.
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
- export function renderTicketProvenanceMarkdown(input) {
153
- const unprojected = input.unprojected ?? [];
154
- const lines = [
155
- `# 起居录 · #${input.ticketNumber}`,
156
- "",
157
- `条目数:${input.entries.length + unprojected.length}`,
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
- if (typeof basis === "object" && basis !== null && !Array.isArray(basis) && basis.note !== undefined) {
180
- lines.push(`- note: ${String(basis.note)}`);
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
- const sourceRef = typeof entry.sourceRef === "object" && entry.sourceRef !== null && !Array.isArray(entry.sourceRef)
183
- ? entry.sourceRef
184
- : {};
185
- const refParts = [];
186
- if (sourceRef.sessionFile !== undefined) {
187
- refParts.push(`sessionFile=${String(sourceRef.sessionFile)}`);
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 (sourceRef.entryId !== undefined) {
190
- refParts.push(`entryId=${String(sourceRef.entryId)}`);
163
+ if (fromIndex > toIndex) {
164
+ throw new TicketProvenanceInputError(`bound range inverted in ${sessionPath} (from index ${fromIndex} > to index ${toIndex})`);
191
165
  }
192
- if (sourceRef.path !== undefined) {
193
- refParts.push(`path=${String(sourceRef.path)}`);
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
- if (sourceRef.url !== undefined) {
196
- refParts.push(`url=${String(sourceRef.url)}`);
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
- if (refParts.length > 0) {
199
- lines.push(`- sourceRef: ${refParts.join(" · ")}`);
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
- lines.push("");
202
- const transcript = typeof entry.transcript === "string" ? entry.transcript : JSON.stringify(entry);
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
- * Ensure the per-ticket JSONL volume partition exists (empty file OK).
224
- * Every bound-ticket court establishes the volume even with zero diary entries
225
- * (ADR 0075: 每票一份起居录送司天台). Does not forge entries.
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 ensureTicketProvenanceVolume(ticketNumber, cwd, home) {
228
- const volume = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
229
- mkdirSync(volume.volumeDir, { recursive: true });
230
- // Append-open creates an absent volume without ever truncating rows committed
231
- // by a concurrent first writer.
232
- appendFileSync(volume.recordFile, "", "utf8");
233
- return volume;
234
- }
235
- /** Write the co-located human view next to the JSONL volume (derived, not dual-source).
236
- * Ensures volume dir exists so empty courts still get the md face. */
237
- export function writeTicketProvenanceHumanView(input) {
238
- const volume = ensureTicketProvenanceVolume(input.ticketNumber, input.cwd, input.home);
239
- const md = renderTicketProvenanceMarkdown({
240
- ticketNumber: input.ticketNumber,
241
- entries: input.entries,
242
- ...(input.unprojected === undefined ? {} : { unprojected: input.unprojected }),
243
- });
244
- writeFileSync(volume.humanViewFile, md, "utf8");
245
- return volume.humanViewFile;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4204",
3
+ "version": "0.1.4228",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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) {