@akagilnc/pi-workflow-roles 0.1.4291 → 0.1.4321

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.
@@ -104,8 +104,8 @@ function isCodexOwnerResponseItemUser(message) {
104
104
  }
105
105
  /**
106
106
  * 入队事件适配:人类这次输入经队列的来源。
107
- * 配对的出队事件不另取,也不做正文比对去重——被吸收的插话正是靠入队事件入录
108
- * (它没有独立的消息记录,#901 用户故事 11)。
107
+ * 被吸收的插话靠入队事件入录(无独立消息记录,#901 用户故事 11)。
108
+ * 机器入队由整卷适配层排除(#918);本函数不读正文、不自判机器。
109
109
  */
110
110
  function fromQueueEvent(row) {
111
111
  if (row.type !== "queue-operation" || row.operation !== "enqueue")
@@ -116,6 +116,51 @@ function fromQueueEvent(row) {
116
116
  const id = nativeEventId(row);
117
117
  return [{ speaker: "owner", text: content, ...(id === undefined ? {} : { id }) }];
118
118
  }
119
+ /**
120
+ * 票面 #918 准许的唯一固定起始形状:`<task-notification`。
121
+ * 其它标签不在御裁射程内——不得扩表,不得伪造 decision key / 「陛下拍定」。
122
+ * 开标签后可直接 `>`(无属性)、EOF 或空白再接属性;其它字符不成立。
123
+ *
124
+ * 机器 enqueue 的完整判据=结构化来源(origin.kind)/本固定形状/真实因果与位置;
125
+ * 用可变载荷正文(全等或子串)做 identity join 仍为锚定宪法所禁。
126
+ */
127
+ const TASK_NOTIFICATION_OPEN_TAG = "<task-notification";
128
+ function hasFixedOpenTagPrefix(content, tag) {
129
+ if (!content.startsWith(tag))
130
+ return false;
131
+ const next = content.charAt(tag.length);
132
+ return (next === "" ||
133
+ next === ">" ||
134
+ next === " " ||
135
+ next === "\t" ||
136
+ next === "\n" ||
137
+ next === "\r");
138
+ }
139
+ /** enqueue.content 是否以票面准许的 `<task-notification` 固定开标签起头。 */
140
+ function isFixedNonOwnerEnqueueShape(content) {
141
+ return hasFixedOpenTagPrefix(content, TASK_NOTIFICATION_OPEN_TAG);
142
+ }
143
+ /**
144
+ * 物化 user 行上的结构化来源 kind(CC top-level `origin.kind`)。不读正文。
145
+ * 活体取值含 human / task-notification / peer;缺字段=旧形无 provenance。
146
+ */
147
+ function materializationOriginKind(row) {
148
+ if (!isRecord(row.origin))
149
+ return undefined;
150
+ const kind = row.origin.kind;
151
+ return typeof kind === "string" && kind !== "" ? kind : undefined;
152
+ }
153
+ /**
154
+ * origin.kind 取值是否为非陛下来源(#918 第二类)。
155
+ * - 缺 origin 或 kind=human → 真人路(经队列的真人输入必须保留)
156
+ * - task-notification / peer / 其他具名非 human → 机器或跨会话投递,不得署 owner
157
+ * 判别落在 kind 取值上,不落在字段是否存在。
158
+ */
159
+ function isNonOwnerOriginKind(kind) {
160
+ if (kind === undefined)
161
+ return false;
162
+ return kind !== "human";
163
+ }
119
164
  /**
120
165
  * 消息事件适配:助手回话,以及未经入队事件记录的人类输入。
121
166
  * 思考块、工具调用块、工具结果块按块排除;同消息的说话人 text 保留
@@ -146,76 +191,29 @@ function fromMessageEvent(row) {
146
191
  const id = nativeEventId(row);
147
192
  return [{ speaker, text, ...(id === undefined ? {} : { id }) }];
148
193
  }
149
- /** 该行是否为可产出 owner 对话正文的消息(工具结果块不算;旁路 text 算)。 */
150
- function isOwnerDialogueMessage(row) {
151
- // CC/Pi queue pairing only — Codex owner path is separate.
152
- if (row.type === "response_item" || row.type === "event_msg")
153
- return false;
154
- return fromMessageEvent(row).some((event) => event.speaker === "owner");
155
- }
156
- /** 该行是否为 runner 回话事件(用于解除未物化的 dequeue 配对)。 */
157
- function isRunnerMessage(row) {
158
- const message = messageBody(row);
159
- return message !== undefined && message.role === "assistant";
160
- }
161
- /**
162
- * 逐次来源配对:仅当对应 enqueue 已由 fromQueueEvent 实际形成 retained owner
163
- * 对话时,才把随后的物化 user 当副本跳过。FIFO 对齐 enqueue→dequeue/remove。
164
- * 无正文 enqueue、旧宿主或其他不可投影形状:dequeue 不产生 skip,后续真人
165
- * user 原样保留。未物化的 retained dequeue(被吸收插话)遇 runner 解除。
166
- * 不用「卷内曾出现 enqueue」整卷布尔,也不按正文过滤。
167
- */
168
- function ownerMessagesMaterializingQueue(rows) {
169
- const skip = new Set();
170
- /** 各 enqueue 是否已留下 owner 对话,按入队顺序等 dequeue/remove 消费。 */
171
- const retainedByEnqueue = [];
172
- let pendingSkips = 0;
173
- for (let index = 0; index < rows.length; index += 1) {
174
- const row = rows[index];
175
- if (row === undefined)
176
- continue;
177
- if (row.type === "queue-operation") {
178
- if (row.operation === "enqueue") {
179
- retainedByEnqueue.push(fromQueueEvent(row).length > 0);
180
- continue;
181
- }
182
- if (row.operation === "dequeue" || row.operation === "remove") {
183
- const retained = retainedByEnqueue.shift();
184
- // 只在 enqueue 真留下对话且本事件是 dequeue 物化时才跳过后续 user。
185
- // remove 只消费队列槽,不制造 skip(无物化消息)。
186
- if (row.operation === "dequeue" && retained === true) {
187
- pendingSkips += 1;
188
- }
189
- continue;
190
- }
191
- }
192
- if (isRunnerMessage(row)) {
193
- pendingSkips = 0;
194
- continue;
195
- }
196
- if (pendingSkips > 0 && isOwnerDialogueMessage(row)) {
197
- skip.add(index);
198
- pendingSkips -= 1;
199
- }
200
- }
201
- return skip;
202
- }
203
194
  /**
204
195
  * 整卷适配:返回与入参行等长的对话事实数组(该行不是对话则为空数组)。
205
- * 需要整卷视野:enqueue/dequeue 配对跨行。
196
+ *
197
+ * 物化 user 只消费自身的 typed `origin.kind`:human 保留,具名非 human 排除。
198
+ * enqueue 没有与物化 user 的 typed 关联,故不得以位置把某个 user 来源反扣给它;
199
+ * 仅票面准许的 `<task-notification` 固定形状可直接排除,其余维持既有投影。
206
200
  */
207
201
  export function adaptSessionDialogue(rows) {
208
- const skipOwner = ownerMessagesMaterializingQueue(rows);
209
- return rows.map((row, index) => {
202
+ return rows.map((row) => {
210
203
  if (row === undefined)
211
204
  return [];
205
+ const originKind = materializationOriginKind(row);
206
+ if (isNonOwnerOriginKind(originKind))
207
+ return [];
208
+ if (row.type === "queue-operation" &&
209
+ row.operation === "enqueue" &&
210
+ typeof row.content === "string" &&
211
+ isFixedNonOwnerEnqueueShape(row.content)) {
212
+ return [];
213
+ }
212
214
  const queued = fromQueueEvent(row);
213
215
  if (queued.length > 0)
214
216
  return queued;
215
- // Codex event_msg.user_message 无 content_item_kinds 等 provenance,旧 exec
216
- // 卷中与 worker entrypoint 注入同形——无法证明为 owner 则不取(不猜、不咬正文)。
217
- if (skipOwner.has(index))
218
- return [];
219
217
  return fromMessageEvent(row);
220
218
  });
221
219
  }
@@ -2,7 +2,6 @@ import { appendSitianRecord } from "./sitian-appender.js";
2
2
  export * from "./sitian-contracts.js";
3
3
  export * from "./sitian-appender.js";
4
4
  export * from "./sitian-reader.js";
5
- export * from "./sitian-volume.js";
6
5
  function sitianReport(input) {
7
6
  return appendSitianRecord(input);
8
7
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * 起居录(ticket-provenance)typed 形状 —— ADR 0075「2026-09-14 修订」/ #901。
2
+ * 起居录(ticket-provenance)typed 形状 —— ADR 0075「2026-09-14 修订」/ #901 / #918。
3
3
  *
4
- * 一册=一个文件:第一行册子头,其后每行一条对话。
5
- * 每轮按册子头当前各区间**重投影**这份唯一文件:册子头与各条定位可更新,
6
- * 正文原样不改(`single-volume` / `one-volume-per-issue`)。
4
+ * 一册=一个追加式 records.jsonl;逻辑册子头与对话由读取时折叠不可变投影提交得到。
5
+ * 既有「首行册子头+裸对话行」snapshot 向后可读,后续提交不回写旧行。
6
+ * sessions 为 prior ∪ 各轮累计并集(遗漏不删除),已结转正文不因历史源重读而改。
7
+ * 交卷 `sessions` 输入语义仍是「本轮对话边界」;累计并集是机械合并,不是角色交什么。
7
8
  */
8
9
  /** Sitian kind for per-ticket court diary volumes. */
9
10
  export const TICKET_PROVENANCE_KIND = "ticket-provenance";
@@ -1,17 +1,20 @@
1
1
  /**
2
- * 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901。
3
- * 一册=一个文件:首行册子头,其后裸对话行。每轮按当前区间重投影是唯一机制;
4
- * 无 append 水位、无 SitianRecord 外壳。目的地解析与读写经司天台唯一入口
5
- * (ADR 0065 records-owner / record-entry;ADR 0081 入录经司天台)。
2
+ * 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901 / #918。
3
+ * 一册=一个追加式 records.jsonl。每轮新投影经司天台 appender 追加为不可变提交;
4
+ * 读取时折叠全部提交得到累计 sessions 与对话视图。既有首行册子头+裸对话行的
5
+ * snapshot 继续可读,但后续不为升级回写旧卷。
6
6
  */
7
+ import { createHash } from "node:crypto";
8
+ import { appendFileSync } from "node:fs";
9
+ import { readFile } from "node:fs/promises";
7
10
  import { basename, dirname, join, resolve } from "node:path";
8
11
  import { resolveBookKeyFromGit } from "./activation-ledger-git.js";
9
- import { errnoCode, packageMachineHome, physicallyContainedIn, resolveActivationLedgerHome, } from "./activation-ledger-topology.js";
12
+ import { ensureRealDirectoryTree, errnoCode, packageMachineHome, physicalPathIdentity, physicallyContainedIn, resolveActivationLedgerHome, } from "./activation-ledger-topology.js";
10
13
  import { readLedgerSessionJsonlLines, } from "./ledger-session-read.js";
11
14
  import { isSafePositiveTicketNumber } from "./run-ticket-number.js";
12
15
  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";
16
+ import { appendSitianRecord, resolveSitianRecordPath, } from "./sitian-facade.js";
17
+ import { TICKET_PROVENANCE_KIND, projectTicketProvenanceHeader, projectTicketProvenanceLine, projectTicketProvenanceSessions, } from "./ticket-provenance-contracts.js";
15
18
  /**
16
19
  * Typed input failure for diarist bounds/session path (reask, not infrastructure).
17
20
  * Accept hook discriminates with instanceof — never Error.message prefixes.
@@ -28,7 +31,9 @@ export class TicketProvenanceInputError extends Error {
28
31
  * Narrow directory identities — not whole `.pi` / whole `.ak-roles`.
29
32
  */
30
33
  export function dialogueSessionSourceRoots(home) {
31
- const machineHome = typeof home === "string" && home.trim() !== "" ? home : packageMachineHome();
34
+ const machineHome = typeof home === "string" && home.trim() !== ""
35
+ ? home
36
+ : packageMachineHome();
32
37
  return [
33
38
  join(machineHome, ".claude", "projects"),
34
39
  join(machineHome, ".codex", "sessions"),
@@ -40,11 +45,14 @@ export function dialogueSessionSourceRoots(home) {
40
45
  * `<ledger>/books/.../session/session.jsonl` (basename + parent only — no body probe).
41
46
  */
42
47
  function isLedgerRoleSessionFile(absolute, home) {
43
- const machineHome = typeof home === "string" && home.trim() !== "" ? home : packageMachineHome();
48
+ const machineHome = typeof home === "string" && home.trim() !== ""
49
+ ? home
50
+ : packageMachineHome();
44
51
  const ledgerHome = resolveActivationLedgerHome(machineHome);
45
52
  if (!physicallyContainedIn(ledgerHome, absolute))
46
53
  return false;
47
- return basename(absolute) === "session.jsonl" && basename(dirname(absolute)) === "session";
54
+ return (basename(absolute) === "session.jsonl" &&
55
+ basename(dirname(absolute)) === "session");
48
56
  }
49
57
  /** Real I/O seam gate: only host session stores or sitian role-run session.jsonl. */
50
58
  function assertDialogueSessionSourcePath(path, home) {
@@ -76,17 +84,51 @@ function ticketProvenanceRecordInput(ticketNumber, cwd, home) {
76
84
  }
77
85
  /** Resolve volume paths for a ticket without writing. */
78
86
  export function resolveTicketProvenanceVolume(ticketNumber, cwd, home) {
79
- return resolveSitianVolume(ticketProvenanceRecordInput(ticketNumber, cwd, home));
87
+ const path = resolveSitianRecordPath(ticketProvenanceRecordInput(ticketNumber, cwd, home));
88
+ return { recordFile: path.recordFile, volumeDir: path.sessionDir };
89
+ }
90
+ function projectTicketProvenanceCommit(value) {
91
+ if (typeof value !== "object" || value === null || Array.isArray(value))
92
+ return undefined;
93
+ const record = value;
94
+ if (record.kind !== TICKET_PROVENANCE_KIND || typeof record.timestamp !== "string") {
95
+ return undefined;
96
+ }
97
+ const payload = record.payload;
98
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
99
+ return undefined;
100
+ const body = payload;
101
+ if (body.type !== "ticket-provenance-append")
102
+ return undefined;
103
+ const sessions = projectTicketProvenanceSessions(body.sessions);
104
+ if (sessions === undefined || !Array.isArray(body.lines))
105
+ return undefined;
106
+ const lines = [];
107
+ for (const raw of body.lines) {
108
+ const line = projectTicketProvenanceLine(raw);
109
+ if (line === undefined)
110
+ return undefined;
111
+ lines.push(line);
112
+ }
113
+ return { timestamp: record.timestamp, sessions, lines };
80
114
  }
81
115
  /** Read the unique diary file (empty/absent → no header, no lines). */
82
116
  export async function readTicketProvenance(ticketNumber, cwd, home) {
83
- const { recordFile, text } = await readSitianVolumeText(ticketProvenanceRecordInput(ticketNumber, cwd, home));
84
- if (text === undefined) {
85
- return { header: undefined, lines: [], recordFile };
117
+ const { recordFile } = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
118
+ let text;
119
+ try {
120
+ text = await readFile(recordFile, "utf8");
121
+ }
122
+ catch (error) {
123
+ if (error.code === "ENOENT") {
124
+ return { header: undefined, lines: [], unprojectedRaw: [], recordFile };
125
+ }
126
+ throw error;
86
127
  }
87
128
  const physical = text.split("\n");
88
129
  let header;
89
130
  const lines = [];
131
+ const unprojectedRaw = [];
90
132
  let sawFirst = false;
91
133
  for (let index = 0; index < physical.length; index += 1) {
92
134
  const raw = physical[index];
@@ -97,28 +139,59 @@ export async function readTicketProvenance(ticketNumber, cwd, home) {
97
139
  parsed = JSON.parse(raw);
98
140
  }
99
141
  catch {
100
- // Stock / damaged rows stay unprojected — no backfill, no guess.
142
+ // Stock / damaged rows stay unprojected — keep raw bytes on rewrite.
143
+ unprojectedRaw.push(raw);
101
144
  continue;
102
145
  }
103
146
  if (!sawFirst) {
104
147
  sawFirst = true;
105
148
  header = projectTicketProvenanceHeader(parsed);
106
- // First line that is not a header is treated as a body line (stock shapes).
149
+ // Legacy snapshot: first line header followed by bare dialogue rows.
107
150
  if (header !== undefined)
108
151
  continue;
109
152
  }
110
153
  const line = projectTicketProvenanceLine(parsed);
111
- if (line !== undefined)
154
+ if (line !== undefined) {
112
155
  lines.push(line);
156
+ continue;
157
+ }
158
+ const commit = projectTicketProvenanceCommit(parsed);
159
+ if (commit !== undefined) {
160
+ const merged = mergeSessionBounds(header?.sessions, commit.sessions);
161
+ const remapped = commit.lines.map((entry) => ({
162
+ ...entry,
163
+ s: merged.incomingIndexes[entry.s] ?? entry.s,
164
+ }));
165
+ const now = commit.timestamp;
166
+ header = {
167
+ repo: header?.repo ?? resolveBookKeyFromGit(cwd),
168
+ ticket: ticketNumber,
169
+ createdAt: header?.createdAt ?? now,
170
+ updatedAt: now,
171
+ sessions: merged.sessions,
172
+ };
173
+ const carried = lines.map((entry) => ({
174
+ ...entry,
175
+ s: merged.priorIndexes[entry.s] ?? entry.s,
176
+ }));
177
+ lines.splice(0, lines.length, ...mergeFreshIntoCarried(carried, remapped));
178
+ continue;
179
+ }
180
+ // Unknown stock / damaged rows remain readable and are never upgraded in place.
181
+ unprojectedRaw.push(raw);
113
182
  }
114
- return { header, lines, recordFile };
183
+ return { header, lines, unprojectedRaw, recordFile };
115
184
  }
116
185
  /**
117
186
  * Ensure the per-ticket directory + volume file exist (ADR 0075 ticket-provenance-file).
118
187
  * Empty file is lawful (bound court with no dialogue yet). Does not forge entries.
119
188
  */
120
189
  export function ensureTicketProvenanceVolume(ticketNumber, cwd, home) {
121
- return ensureSitianVolume(ticketProvenanceRecordInput(ticketNumber, cwd, home));
190
+ const input = ticketProvenanceRecordInput(ticketNumber, cwd, home);
191
+ const path = resolveSitianRecordPath(input);
192
+ ensureRealDirectoryTree(path.ledgerHome, path.sessionDir);
193
+ appendFileSync(path.recordFile, "", "utf8");
194
+ return { recordFile: path.recordFile, volumeDir: path.sessionDir };
122
195
  }
123
196
  /**
124
197
  * Resolve a bound endpoint against this round's session lines.
@@ -148,6 +221,127 @@ function resolveBoundIndex(bound, sessionLines) {
148
221
  function amendmentKey(s, line) {
149
222
  return `${s}:${line}`;
150
223
  }
224
+ /** Exact range identity for cumulative header dedupe (idempotent resubmit). */
225
+ function rangeDeclarationKey(range) {
226
+ return JSON.stringify({
227
+ from: range.from,
228
+ to: range.to,
229
+ });
230
+ }
231
+ /**
232
+ * #918 甲案:prior ranges ∪ 本轮 ranges。按 path 合并;先保 prior 序,再追加新 path。
233
+ * path identity 与 I/O 接缝同用 `physicalPathIdentity`(symlink-stable),故
234
+ * `p` / `p/./` / 经 symlink 祖先的别名合为一条;保留首见 path 字面与累计 ranges。
235
+ * 即使 prior 缺失 / sessions=[],incoming 自身也必须按 identity 归并——首轮同一
236
+ * 物理卷不得铸出多个 s(#918 C5)。遗漏不代表删除。
237
+ */
238
+ function mergeSessionBounds(prior, incoming) {
239
+ const merged = [];
240
+ const indexByIdentity = new Map();
241
+ const absorb = (sessions) => {
242
+ const indexes = [];
243
+ for (const session of sessions) {
244
+ const identity = physicalPathIdentity(session.path);
245
+ let targetIndex = indexByIdentity.get(identity);
246
+ if (targetIndex === undefined) {
247
+ targetIndex = merged.length;
248
+ indexByIdentity.set(identity, targetIndex);
249
+ merged.push({ path: session.path, ranges: [] });
250
+ }
251
+ indexes.push(targetIndex);
252
+ const target = merged[targetIndex];
253
+ const seen = new Set(target.ranges.map(rangeDeclarationKey));
254
+ for (const range of session.ranges) {
255
+ const key = rangeDeclarationKey(range);
256
+ if (seen.has(key))
257
+ continue;
258
+ seen.add(key);
259
+ target.ranges.push({ from: { ...range.from }, to: { ...range.to } });
260
+ }
261
+ }
262
+ return indexes;
263
+ };
264
+ const priorIndexes = absorb(prior ?? []);
265
+ const incomingIndexes = absorb(incoming);
266
+ return { sessions: merged, priorIndexes, incomingIndexes };
267
+ }
268
+ /**
269
+ * #918 第一节:已投影行即卷宗。只对 prior 未声明的 range(按 path identity + 精确
270
+ * from/to)读源;已声明的按 s,line 结转,不重读历史源。
271
+ */
272
+ function undeclaredSessionRanges(prior, merged) {
273
+ const priorKeys = new Map();
274
+ for (const session of prior ?? []) {
275
+ const identity = physicalPathIdentity(session.path);
276
+ let keys = priorKeys.get(identity);
277
+ if (keys === undefined) {
278
+ keys = new Set();
279
+ priorKeys.set(identity, keys);
280
+ }
281
+ for (const range of session.ranges)
282
+ keys.add(rangeDeclarationKey(range));
283
+ }
284
+ const out = [];
285
+ for (let s = 0; s < merged.length; s += 1) {
286
+ const session = merged[s];
287
+ const declared = priorKeys.get(physicalPathIdentity(session.path)) ?? new Set();
288
+ const fresh = session.ranges.filter((range) => !declared.has(rangeDeclarationKey(range)));
289
+ if (fresh.length === 0)
290
+ continue;
291
+ out.push({
292
+ s,
293
+ session: {
294
+ path: session.path,
295
+ ranges: fresh.map((range) => ({
296
+ from: { ...range.from },
297
+ to: { ...range.to },
298
+ })),
299
+ },
300
+ });
301
+ }
302
+ return out;
303
+ }
304
+ /** Sort archive rows by session index then source line (stable within equal keys). */
305
+ function compareLinePosition(left, right) {
306
+ if (left.s !== right.s)
307
+ return left.s - right.s;
308
+ const leftLine = left.line ?? Number.MAX_SAFE_INTEGER;
309
+ const rightLine = right.line ?? Number.MAX_SAFE_INTEGER;
310
+ return leftLine - rightLine;
311
+ }
312
+ /**
313
+ * Merge newly projected rows into the carried archive.
314
+ * 既有卷宗按 (s,line) 优先;fresh 不得复制无 id 行(#918 C2/G1)。
315
+ * id 去重仍保留(跨 path 同 id 首见胜)。
316
+ */
317
+ function mergeFreshIntoCarried(carried, fresh) {
318
+ const seenIds = new Set();
319
+ const seenPositions = new Set();
320
+ const out = [];
321
+ for (const line of carried) {
322
+ if (line.id !== undefined)
323
+ seenIds.add(line.id);
324
+ if (line.line !== undefined)
325
+ seenPositions.add(amendmentKey(line.s, line.line));
326
+ out.push(line);
327
+ }
328
+ for (const line of fresh) {
329
+ if (line.id !== undefined) {
330
+ if (seenIds.has(line.id))
331
+ continue;
332
+ seenIds.add(line.id);
333
+ }
334
+ if (line.line !== undefined) {
335
+ const position = amendmentKey(line.s, line.line);
336
+ if (seenPositions.has(position))
337
+ continue;
338
+ seenPositions.add(position);
339
+ }
340
+ out.push(line);
341
+ }
342
+ out.sort(compareLinePosition);
343
+ return out;
344
+ }
151
345
  /**
152
346
  * Resolve submitted ranges against the session, then sort by source position and
153
347
  * merge overlaps so each physical row is visited once (#901 source order).
@@ -244,88 +438,110 @@ async function projectSessionRanges(input) {
244
438
  return { lines, unparsable };
245
439
  }
246
440
  /**
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).
441
+ * Reproject the unique diary from cumulative bounds + optional amendments.
442
+ * #918:已投影行即卷宗——按 s,line 结转;本轮只读 prior 未声明的 range(或新
443
+ * session 区间)。精确重复提交=幂等 no-op,不因历史源不可读而失败。
444
+ * amendments 只在本轮读取的新范围确有对应坏行时生效;空 sessions 保持 no-op。
445
+ * 证不出的 body 原字节经 unprojectedRaw 原样留存。
446
+ * Persistence appends one immutable commit through the Sitian appender seam.
251
447
  */
252
448
  export async function reprojectTicketProvenance(input) {
253
- const recordInput = ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home);
254
449
  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
- }
450
+ if (input.sessions.length === 0) {
451
+ const now = new Date().toISOString();
452
+ return {
453
+ recordFile: prior.recordFile,
454
+ header: prior.header ?? {
455
+ repo: resolveBookKeyFromGit(input.cwd),
456
+ ticket: input.ticketNumber,
457
+ createdAt: now,
458
+ updatedAt: now,
459
+ sessions: [],
460
+ },
461
+ lines: prior.lines,
462
+ unparsable: [],
463
+ };
464
+ }
465
+ const merged = mergeSessionBounds(prior.header?.sessions, input.sessions);
466
+ const deltas = undeclaredSessionRanges(prior.header?.sessions, merged.sessions);
467
+ if (deltas.length === 0) {
468
+ const now = new Date().toISOString();
469
+ return {
470
+ recordFile: prior.recordFile,
471
+ header: prior.header ?? {
472
+ repo: resolveBookKeyFromGit(input.cwd),
473
+ ticket: input.ticketNumber,
474
+ createdAt: now,
475
+ updatedAt: now,
476
+ sessions: merged.sessions,
477
+ },
478
+ lines: prior.lines,
479
+ unparsable: [],
480
+ };
286
481
  }
287
482
  const amendmentsByKey = new Map();
288
- for (const amendment of amendments) {
289
- amendmentsByKey.set(amendmentKey(amendment.s, amendment.line), amendment);
483
+ for (const amendment of input.amendments ?? []) {
484
+ const cumulativeIndex = merged.incomingIndexes[amendment.s];
485
+ if (cumulativeIndex === undefined)
486
+ continue;
487
+ amendmentsByKey.set(amendmentKey(cumulativeIndex, amendment.line), {
488
+ ...amendment,
489
+ s: cumulativeIndex,
490
+ });
290
491
  }
291
- const lines = [];
492
+ const seenIds = new Set(prior.lines.flatMap((line) => line.id === undefined ? [] : [line.id]));
493
+ const fresh = [];
292
494
  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) {
495
+ for (const delta of deltas) {
296
496
  const projected = await projectSessionRanges({
297
- s,
298
- session: sessions[s],
497
+ s: delta.s,
498
+ session: delta.session,
299
499
  amendmentsByKey,
300
500
  seenIds,
301
501
  ...(input.home === undefined ? {} : { home: input.home }),
302
502
  });
303
- lines.push(...projected.lines);
503
+ fresh.push(...projected.lines);
304
504
  unparsable.push(...projected.unparsable);
305
505
  }
506
+ const lines = mergeFreshIntoCarried(prior.lines, fresh);
306
507
  const now = new Date().toISOString();
307
508
  const header = {
308
- repo: resolveBookKeyFromGit(input.cwd),
509
+ repo: prior.header?.repo ?? resolveBookKeyFromGit(input.cwd),
309
510
  ticket: input.ticketNumber,
310
511
  createdAt: prior.header?.createdAt ?? now,
311
512
  updatedAt: now,
312
- sessions,
513
+ sessions: merged.sessions,
313
514
  };
314
- // Still-open gaps → reask without publishing a partial/rejected projection.
315
515
  if (unparsable.length > 0) {
316
- return {
317
- recordFile: prior.recordFile,
318
- header,
319
- lines,
320
- unparsable,
321
- };
516
+ return { recordFile: prior.recordFile, header, lines, unparsable };
322
517
  }
323
- const body = `${[JSON.stringify(header), ...lines.map((line) => JSON.stringify(line))].join("\n")}\n`;
324
- const volume = await rewriteSitianVolume({ ...recordInput, body });
518
+ // One immutable projection commit. Its deterministic identity makes retries of
519
+ // the same logical increment converge, while unrelated concurrent increments
520
+ // append independently and are folded by readTicketProvenance.
521
+ const identityMaterial = JSON.stringify({
522
+ ticket: input.ticketNumber,
523
+ deltas: deltas.map(({ s, session }) => ({
524
+ s,
525
+ path: physicalPathIdentity(session.path),
526
+ ranges: session.ranges,
527
+ })),
528
+ lines: fresh,
529
+ });
530
+ const identity = `ticket-provenance:${createHash("sha256").update(identityMaterial).digest("hex")}`;
531
+ const pointer = appendSitianRecord({
532
+ ...ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home),
533
+ identity,
534
+ payload: {
535
+ type: "ticket-provenance-append",
536
+ sessions: merged.sessions,
537
+ lines: fresh,
538
+ },
539
+ });
540
+ const folded = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
325
541
  return {
326
- recordFile: volume.recordFile,
327
- header,
328
- lines,
329
- unparsable,
542
+ recordFile: pointer.recordFile,
543
+ header: folded.header ?? header,
544
+ lines: folded.lines,
545
+ unparsable: [],
330
546
  };
331
547
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4291",
3
+ "version": "0.1.4321",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",