@unbrained/pm-cli 2026.9.2 → 2026.9.3

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.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +17 -0
  3. package/dist/cli/after-command-context-usage.js +16 -3
  4. package/dist/cli-bundle/bundle-manifest.json +154 -154
  5. package/dist/cli-bundle/chunks/{chunk-T6H5BZCP.js → chunk-34HASX3S.js} +2 -2
  6. package/dist/cli-bundle/chunks/chunk-AY324UJ5.js +9 -0
  7. package/dist/cli-bundle/chunks/{chunk-TAG4VC22.js → chunk-DKGP3QLE.js} +27 -27
  8. package/dist/cli-bundle/chunks/chunk-E5RBBCXO.js +230 -0
  9. package/dist/cli-bundle/chunks/{chunk-DTGWEL23.js → chunk-EDPIS7EB.js} +2 -2
  10. package/dist/cli-bundle/chunks/{chunk-X4RCGLTX.js → chunk-IEHHE6GA.js} +5 -5
  11. package/dist/cli-bundle/chunks/{chunk-UDW64J2W.js → chunk-L7B7343B.js} +2 -2
  12. package/dist/cli-bundle/chunks/{chunk-FZ4Z2J4F.js → chunk-LENC656S.js} +41 -41
  13. package/dist/cli-bundle/chunks/{chunk-ZLJIFSOE.js → chunk-LISESA7W.js} +33 -33
  14. package/dist/cli-bundle/chunks/{chunk-3WICXLVT.js → chunk-UPPM2FOZ.js} +2 -2
  15. package/dist/cli-bundle/chunks/{chunk-Y7VOMSZT.js → chunk-Y4PJ6HPK.js} +2 -2
  16. package/dist/cli-bundle/chunks/{register-list-query-XYNX4ASQ.js → register-list-query-W2ZTOMKP.js} +2 -2
  17. package/dist/cli-bundle/chunks/{register-mutation-WFQ7MJTX.js → register-mutation-RVND5NCG.js} +2 -2
  18. package/dist/cli-bundle/chunks/{register-operations-JVTI7JPP.js → register-operations-TTROIVC4.js} +2 -2
  19. package/dist/cli-bundle/chunks/{register-setup-KVNC5KPL.js → register-setup-D3ZFHY3L.js} +2 -2
  20. package/dist/cli-bundle/focused-chunks/chunk-3ZBI35HU.js +176 -0
  21. package/dist/cli-bundle/focused-chunks/{chunk-SVXJYD6G.js → chunk-4HJFY4MB.js} +2 -2
  22. package/dist/cli-bundle/focused-chunks/chunk-4RRHC3S4.js +6 -0
  23. package/dist/cli-bundle/focused-chunks/chunk-4WR46RMG.js +9 -0
  24. package/dist/cli-bundle/focused-chunks/chunk-5RFXUPN5.js +3 -0
  25. package/dist/cli-bundle/focused-chunks/chunk-BWKHEIVF.js +2 -0
  26. package/dist/cli-bundle/focused-chunks/{chunk-4O4CJAR6.js → chunk-D7KXFBXV.js} +2 -2
  27. package/dist/cli-bundle/focused-chunks/{chunk-MKHG3T76.js → chunk-GHCZ3YFR.js} +3 -3
  28. package/dist/cli-bundle/focused-chunks/{chunk-KB5OFSFD.js → chunk-GT7MXDLW.js} +2 -2
  29. package/dist/cli-bundle/focused-chunks/chunk-KBLZRU62.js +2 -0
  30. package/dist/cli-bundle/focused-chunks/chunk-KZ2NB3ET.js +2 -0
  31. package/dist/cli-bundle/focused-chunks/chunk-M5M6SVHS.js +3 -0
  32. package/dist/cli-bundle/focused-chunks/{chunk-BEVHCEWG.js → chunk-OL5EH6I3.js} +2 -2
  33. package/dist/cli-bundle/focused-chunks/{chunk-MEASX544.js → chunk-Q45NHPF2.js} +2 -2
  34. package/dist/cli-bundle/focused-chunks/{chunk-O67VDLOT.js → chunk-QKHCSUGJ.js} +42 -42
  35. package/dist/cli-bundle/focused-chunks/chunk-S27XMBIN.js +8 -0
  36. package/dist/cli-bundle/focused-chunks/chunk-TXE4TSHM.js +12 -0
  37. package/dist/cli-bundle/focused-chunks/chunk-UCLO3XDU.js +2 -0
  38. package/dist/cli-bundle/main.js +15 -15
  39. package/dist/cli-bundle/sdk-authoring.js +1 -1
  40. package/dist/cli-bundle/sdk-contracts.js +1 -1
  41. package/dist/cli-bundle/sdk-core.js +28 -28
  42. package/dist/cli-bundle/sdk-governance.js +1 -1
  43. package/dist/cli-bundle/sdk-graph.js +1 -1
  44. package/dist/cli-bundle/sdk-merge.js +35 -33
  45. package/dist/cli-bundle/sdk-query.js +1 -1
  46. package/dist/cli-bundle/sdk-runtime.js +1 -1
  47. package/dist/cli-bundle/sdk-testing.js +1 -1
  48. package/dist/cli-bundle/sdk.js +9 -9
  49. package/dist/core/history/event-classification.d.ts +21 -0
  50. package/dist/core/history/event-classification.js +109 -0
  51. package/dist/core/history/event-index.d.ts +14 -3
  52. package/dist/core/history/event-index.js +543 -88
  53. package/dist/core/history/history.js +47 -18
  54. package/dist/core/history/workspace-history.js +7 -6
  55. package/dist/core/lock/lock.d.ts +22 -2
  56. package/dist/core/lock/lock.js +121 -19
  57. package/dist/core/output/output.js +31 -7
  58. package/dist/core/shared/time.d.ts +2 -0
  59. package/dist/core/shared/time.js +8 -2
  60. package/dist/core/store/item-metadata-query-index.js +5 -5
  61. package/dist/sdk/context/recency.d.ts +9 -0
  62. package/dist/sdk/context/recency.js +34 -0
  63. package/dist/sdk/context/usage-egress.d.ts +9 -0
  64. package/dist/sdk/context/usage-egress.js +30 -0
  65. package/dist/sdk/context-intent-contracts.js +7 -3
  66. package/dist/sdk/context-relevance.d.ts +23 -0
  67. package/dist/sdk/context-relevance.js +94 -23
  68. package/dist/sdk/context-signal-store.d.ts +7 -3
  69. package/dist/sdk/context-signal-store.js +155 -42
  70. package/dist/sdk/context-usage.d.ts +54 -0
  71. package/dist/sdk/context-usage.js +245 -16
  72. package/dist/sdk/generated/generated-error-code-catalog-part-1.js +14 -14
  73. package/dist/sdk/generated/generated-error-code-catalog-part-2.js +14 -2
  74. package/dist/sdk/index.d.ts +2 -0
  75. package/dist/sdk/index.js +4 -2
  76. package/dist/sdk/init.d.ts +2 -0
  77. package/dist/sdk/init.js +18 -9
  78. package/dist/sdk/query/context.d.ts +3 -1
  79. package/dist/sdk/query/context.js +21 -9
  80. package/dist/sdk/query/next.js +18 -9
  81. package/dist/sdk/runtime-input.d.ts +2 -0
  82. package/dist/sdk/runtime-input.js +25 -2
  83. package/dist/sdk/runtime.d.ts +1 -0
  84. package/dist/sdk/runtime.js +17 -26
  85. package/dist/types.d.ts +2 -0
  86. package/dist/types.js +2 -2
  87. package/docs/CONTEXT_RELEVANCE.md +44 -1
  88. package/docs/PERFORMANCE.md +6 -3
  89. package/docs/READ_OUTPUT_CONTRACTS.md +9 -1
  90. package/docs/SDK.md +11 -8
  91. package/docs/SDK_CONTEXT.md +7 -0
  92. package/docs/generated/REFUSAL_CLOSURE_CENSUS.md +4 -3
  93. package/marketplace.json +2 -2
  94. package/package.json +4 -4
  95. package/packages/pm-beads/package.json +1 -1
  96. package/packages/pm-calendar/package.json +1 -1
  97. package/packages/pm-command-kit/package.json +1 -1
  98. package/packages/pm-digital-twin/package.json +1 -1
  99. package/packages/pm-governance-audit/package.json +1 -1
  100. package/packages/pm-guide-shell/package.json +1 -1
  101. package/packages/pm-kanban/package.json +1 -1
  102. package/packages/pm-lifecycle-hooks/package.json +1 -1
  103. package/packages/pm-linked-test-adapters/package.json +1 -1
  104. package/packages/pm-search-advanced/package.json +1 -1
  105. package/packages/pm-templates/package.json +1 -1
  106. package/packages/pm-todos/package.json +1 -1
  107. package/packages/pm-vcs/package.json +1 -1
  108. package/plugins/pm-claude/.claude-plugin/plugin.json +1 -1
  109. package/plugins/pm-codex/.codex-plugin/plugin.json +1 -1
  110. package/sdk/public-surface.json +241 -24
  111. package/dist/cli-bundle/chunks/chunk-GOACULJW.js +0 -13
  112. package/dist/cli-bundle/chunks/chunk-NOPL37X4.js +0 -205
  113. package/dist/cli-bundle/focused-chunks/chunk-242AXYB2.js +0 -2
  114. package/dist/cli-bundle/focused-chunks/chunk-2FEZ5SKM.js +0 -3
  115. package/dist/cli-bundle/focused-chunks/chunk-6GCRSLPG.js +0 -2
  116. package/dist/cli-bundle/focused-chunks/chunk-7VATS5QY.js +0 -16
  117. package/dist/cli-bundle/focused-chunks/chunk-JEFKLWMM.js +0 -2
  118. package/dist/cli-bundle/focused-chunks/chunk-PF3E6Q36.js +0 -8
  119. package/dist/cli-bundle/focused-chunks/chunk-QDFJIRKN.js +0 -4
  120. package/dist/cli-bundle/focused-chunks/chunk-RMRCQPWU.js +0 -2
  121. package/dist/cli-bundle/focused-chunks/chunk-S4U5CN5Q.js +0 -2
  122. package/dist/cli-bundle/focused-chunks/chunk-T2LXXSOD.js +0 -158
  123. package/dist/cli-bundle/focused-chunks/chunk-Z2USIBR2.js +0 -5
@@ -5,20 +5,115 @@
5
5
  * for cursor-resumable cross-item mutation event reads.
6
6
  */
7
7
 
8
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a84b308f-7eef-535c-9531-b33b0775c651")}catch(e){}}();
8
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="72b285a7-970e-5965-bacf-a4e0cfc5f060")}catch(e){}}();
9
9
  import fs from "node:fs/promises";
10
10
  import path from "node:path";
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { createRequire } from "node:module";
13
+ import { isFileMissingError } from "../fs/fs-utils.js";
14
+ import { acquireLock, captureProcessIdentity, isProcessIdentityAlive, } from "../lock/lock.js";
15
+ import { EXIT_CODE } from "../shared/constants.js";
16
+ import { PmCliError } from "../shared/errors.js";
17
+ import { isMillisecondPrecisionRfc3339DateTime } from "../shared/time.js";
13
18
  import { readHistoryEntries } from "./read.js";
19
+ import { classifyHistoryEvent } from "./event-classification.js";
14
20
  const EVENT_INDEX_FILENAME = "history-event-index.sqlite";
15
- const EVENT_INDEX_VERSION = "2";
21
+ const EVENT_INDEX_VERSION = "5";
16
22
  const AUTHORITATIVE_HISTORY_CACHE_LIMIT = 8;
23
+ const HISTORY_EVENT_INDEX_LOCK_ID = "history-event-index";
24
+ const HISTORY_EVENT_INDEX_LOCK_TTL_SECONDS = 300;
25
+ const HISTORY_EVENT_INDEX_LOCK_WAIT_MS = 30_000;
26
+ const HISTORY_EVENT_INDEX_INVALIDATION_LOCK_ID = "history-event-index-invalidation";
27
+ const HISTORY_EVENT_INDEX_INVALIDATION_DIRECTORY = "history-event-index-invalidations";
28
+ const HISTORY_EVENT_INDEX_INVALIDATION_MAX_UNVERIFIED_AGE_MS = HISTORY_EVENT_INDEX_LOCK_TTL_SECONDS * 2 * 1_000;
29
+ const HISTORY_INDEX_LOCK_CONTENDED = Symbol("history-index-lock-contended");
17
30
  const authoritativeHistoryCaches = new Map();
18
31
  function eventIndexPath(pmRoot) {
19
32
  return path.join(pmRoot, "runtime", EVENT_INDEX_FILENAME);
20
33
  }
21
- async function readAuthoritativeHistoryEvents(pmRoot) {
34
+ function historyEventIndexInvalidationDirectory(pmRoot) {
35
+ return path.join(pmRoot, "runtime", HISTORY_EVENT_INDEX_INVALIDATION_DIRECTORY);
36
+ }
37
+ async function listHistoryEventIndexInvalidations(pmRoot) {
38
+ const entries = await fs
39
+ .readdir(historyEventIndexInvalidationDirectory(pmRoot))
40
+ .catch((error) => {
41
+ if (isFileMissingError(error))
42
+ return [];
43
+ throw error;
44
+ });
45
+ return {
46
+ pending: entries.filter((name) => name.endsWith(".pending")).sort(),
47
+ committed: entries.filter((name) => name.endsWith(".committed")).sort(),
48
+ };
49
+ }
50
+ /** Remove only pending markers proven abandoned by the caller's invalidation-lock ownership. */
51
+ async function recoverAbandonedHistoryEventIndexInvalidations(pmRoot) {
52
+ const invalidations = await listHistoryEventIndexInvalidations(pmRoot);
53
+ const pending = (await Promise.all(invalidations.pending.map(async (name) => {
54
+ const owner = /^(\d+)-(unknown|\d+)-(\d+)-.+\.pending$/u.exec(name);
55
+ if (owner !== null) {
56
+ const processStartIdentity = owner[2];
57
+ if (await isProcessIdentityAlive({
58
+ pid: Number(owner[1]),
59
+ ...(processStartIdentity === "unknown"
60
+ ? {}
61
+ : { process_start_identity: processStartIdentity }),
62
+ }, Math.max(0, Date.now() - Number(owner[3])), HISTORY_EVENT_INDEX_INVALIDATION_MAX_UNVERIFIED_AGE_MS)) {
63
+ return name;
64
+ }
65
+ }
66
+ await fs.rm(path.join(historyEventIndexInvalidationDirectory(pmRoot), name), {
67
+ force: true,
68
+ });
69
+ return null;
70
+ }))).filter((name) => name !== null);
71
+ return { pending, committed: invalidations.committed };
72
+ }
73
+ async function withHistoryEventIndexInvalidationLock(pmRoot, operation) {
74
+ const release = await acquireLock(pmRoot, HISTORY_EVENT_INDEX_INVALIDATION_LOCK_ID, HISTORY_EVENT_INDEX_LOCK_TTL_SECONDS, `history-event-index-invalidation:${process.pid}`, false, false, HISTORY_EVENT_INDEX_LOCK_WAIT_MS);
75
+ try {
76
+ return await operation();
77
+ }
78
+ finally {
79
+ await release();
80
+ }
81
+ }
82
+ async function beginHistoryEventIndexInvalidation(pmRoot) {
83
+ const directory = historyEventIndexInvalidationDirectory(pmRoot);
84
+ await fs.mkdir(directory, { recursive: true });
85
+ const token = randomUUID();
86
+ const processIdentity = await captureProcessIdentity();
87
+ const pendingPath = path.join(directory, `${processIdentity.pid}-${processIdentity.process_start_identity ?? "unknown"}-${Date.now()}-${token}.pending`);
88
+ await fs.writeFile(pendingPath, "pending\n", { flag: "wx" });
89
+ return {
90
+ pendingPath,
91
+ committedPath: path.join(directory, `${token}.committed`),
92
+ };
93
+ }
94
+ function sameInvalidationNames(left, right) {
95
+ return (left.length === right.length &&
96
+ left.every((name, index) => name === right[index]));
97
+ }
98
+ async function invalidateHistoryEventIndex(pmRoot) {
99
+ try {
100
+ await fs.rm(eventIndexPath(pmRoot), { force: true });
101
+ }
102
+ catch {
103
+ // The authoritative append already committed. A retained projection remains
104
+ // unusable because its recorded stream byte size no longer matches authority.
105
+ }
106
+ }
107
+ async function withHistoryEventIndexLock(pmRoot, operation) {
108
+ const release = await acquireLock(pmRoot, HISTORY_EVENT_INDEX_LOCK_ID, HISTORY_EVENT_INDEX_LOCK_TTL_SECONDS, `history-event-index:${process.pid}`, false, false, HISTORY_EVENT_INDEX_LOCK_WAIT_MS);
109
+ try {
110
+ return await operation();
111
+ }
112
+ finally {
113
+ await release();
114
+ }
115
+ }
116
+ async function readAuthoritativeHistorySnapshot(pmRoot) {
22
117
  const historyRoot = path.join(pmRoot, "history");
23
118
  const entries = await fs
24
119
  .readdir(historyRoot, { withFileTypes: true })
@@ -53,9 +148,10 @@ async function readAuthoritativeHistoryEvents(pmRoot) {
53
148
  ].join(":");
54
149
  if (cached.streams.get(streamId)?.signature === signature)
55
150
  continue;
56
- const history = await readHistoryEntries(historyPath, streamId);
151
+ const history = await readIndexableHistoryEntries(historyPath, streamId);
57
152
  cached.streams.set(streamId, {
58
153
  signature,
154
+ byte_size: Number(stats.size),
59
155
  events: history.map((entry, streamOffset) => ({
60
156
  stream_id: streamId,
61
157
  stream_offset: streamOffset,
@@ -81,7 +177,29 @@ async function readAuthoritativeHistoryEvents(pmRoot) {
81
177
  const oldestRoot = authoritativeHistoryCaches.keys().next().value;
82
178
  authoritativeHistoryCaches.delete(oldestRoot);
83
179
  }
84
- return cached.ordered_events;
180
+ return {
181
+ events: cached.ordered_events,
182
+ stream_byte_sizes: new Map([...cached.streams].map(([streamId, stream]) => [
183
+ streamId,
184
+ stream.byte_size,
185
+ ])),
186
+ };
187
+ }
188
+ async function readIndexableHistoryEntries(historyPath, streamId) {
189
+ const entries = await readHistoryEntries(historyPath, streamId);
190
+ for (const [index, entry] of entries.entries()) {
191
+ if (typeof entry.ts !== "string" ||
192
+ !isMillisecondPrecisionRfc3339DateTime(entry.ts.trim())) {
193
+ throw new PmCliError(`History for ${streamId} contains an invalid or sub-millisecond timestamp at line ${index + 1}. Repair or restore the history stream and retry.`, EXIT_CODE.GENERIC_FAILURE, { code: "history_timestamp_invalid" });
194
+ }
195
+ }
196
+ return entries.map((entry) => ({
197
+ ...entry,
198
+ ts: new Date(entry.ts.trim()).toISOString(),
199
+ }));
200
+ }
201
+ async function readAuthoritativeHistoryEvents(pmRoot) {
202
+ return (await readAuthoritativeHistorySnapshot(pmRoot)).events;
85
203
  }
86
204
  function loadDatabaseSync(loadModule) {
87
205
  try {
@@ -121,13 +239,20 @@ function createSchema(database) {
121
239
  agent_harness TEXT,
122
240
  agent_instance TEXT,
123
241
  op TEXT NOT NULL,
242
+ event_class TEXT NOT NULL CHECK(event_class IN ('substantive', 'maintenance')),
124
243
  entry_json TEXT NOT NULL,
125
244
  PRIMARY KEY(stream_id, stream_offset)
126
245
  ) STRICT;
246
+ CREATE TABLE streams (
247
+ stream_id TEXT PRIMARY KEY,
248
+ byte_size INTEGER NOT NULL CHECK(byte_size >= 0)
249
+ ) STRICT;
127
250
  CREATE INDEX events_order
128
251
  ON events(ts, stream_id, stream_offset);
129
252
  CREATE INDEX events_op_order
130
253
  ON events(op, ts, stream_id, stream_offset);
254
+ CREATE INDEX events_class_stream_order
255
+ ON events(event_class, stream_id, ts, stream_offset);
131
256
  CREATE INDEX events_author_order
132
257
  ON events(author, ts, stream_id, stream_offset);
133
258
  CREATE INDEX events_harness_order
@@ -140,41 +265,92 @@ function createSchema(database) {
140
265
  .run(EVENT_INDEX_VERSION);
141
266
  }
142
267
  function insertEvent(database, event) {
268
+ const entry = {
269
+ ...event.entry,
270
+ ts: new Date(event.entry.ts.trim()).toISOString(),
271
+ };
143
272
  database
144
273
  .prepare(`INSERT INTO events(
145
274
  stream_id, stream_offset, ts, author, agent_harness,
146
- agent_instance, op, entry_json
147
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
148
- .run(event.stream_id, event.stream_offset, event.entry.ts, event.entry.author, event.entry.agent_harness ?? null, event.entry.agent_instance ?? null, event.entry.op, JSON.stringify(event.entry));
275
+ agent_instance, op, event_class, entry_json
276
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
277
+ .run(event.stream_id, event.stream_offset, entry.ts, entry.author, entry.agent_harness ?? null, entry.agent_instance ?? null, entry.op, classifyHistoryEvent(entry), JSON.stringify(entry));
278
+ }
279
+ function parseIndexedHistoryEvent(row) {
280
+ const entry = JSON.parse(String(row.entry_json));
281
+ if (typeof entry.ts !== "string" ||
282
+ !isMillisecondPrecisionRfc3339DateTime(entry.ts.trim())) {
283
+ return null;
284
+ }
285
+ return {
286
+ stream_id: String(row.stream_id),
287
+ stream_offset: Number(row.stream_offset),
288
+ entry: { ...entry, ts: new Date(entry.ts.trim()).toISOString() },
289
+ };
290
+ }
291
+ function upsertStreamByteSize(database, streamId, byteSize) {
292
+ database
293
+ .prepare(`INSERT INTO streams(stream_id, byte_size) VALUES (?, ?)
294
+ ON CONFLICT(stream_id) DO UPDATE SET byte_size = excluded.byte_size`)
295
+ .run(streamId, byteSize);
149
296
  }
150
297
  /** Rebuild the complete optional event projection from authoritative streams. */
151
298
  export async function rebuildHistoryEventIndex(pmRoot) {
152
299
  const Database = resolveDatabaseSync();
153
300
  if (!Database)
154
301
  return false;
155
- const targetPath = eventIndexPath(pmRoot);
156
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
157
- const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
158
- let database;
159
- try {
160
- database = new Database(temporaryPath);
161
- createSchema(database);
162
- database.exec("BEGIN IMMEDIATE");
163
- for (const event of await readAuthoritativeHistoryEvents(pmRoot)) {
164
- insertEvent(database, event);
302
+ return withHistoryEventIndexLock(pmRoot, async () => {
303
+ let invalidations;
304
+ try {
305
+ invalidations = await withHistoryEventIndexInvalidationLock(pmRoot, () => recoverAbandonedHistoryEventIndexInvalidations(pmRoot));
165
306
  }
166
- database.exec("COMMIT");
167
- database.close();
168
- database = undefined;
169
- await fs.rm(targetPath, { force: true });
170
- await fs.rename(temporaryPath, targetPath);
171
- return true;
172
- }
173
- catch (error) {
174
- database?.close();
175
- await fs.rm(temporaryPath, { force: true });
176
- throw error;
177
- }
307
+ catch (error) {
308
+ if (error instanceof PmCliError && error.code === "lock_conflict") {
309
+ return false;
310
+ }
311
+ throw error;
312
+ }
313
+ if (invalidations.pending.length > 0)
314
+ return false;
315
+ const targetPath = eventIndexPath(pmRoot);
316
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
317
+ const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
318
+ let database;
319
+ try {
320
+ database = new Database(temporaryPath);
321
+ createSchema(database);
322
+ database.exec("BEGIN IMMEDIATE");
323
+ const authoritative = await readAuthoritativeHistorySnapshot(pmRoot);
324
+ for (const event of authoritative.events) {
325
+ insertEvent(database, event);
326
+ }
327
+ for (const [streamId, byteSize] of authoritative.stream_byte_sizes) {
328
+ upsertStreamByteSize(database, streamId, byteSize);
329
+ }
330
+ database.exec("COMMIT");
331
+ database.close();
332
+ database = undefined;
333
+ const published = await withHistoryEventIndexInvalidationLock(pmRoot, async () => {
334
+ const current = await recoverAbandonedHistoryEventIndexInvalidations(pmRoot);
335
+ if (current.pending.length > 0 ||
336
+ !sameInvalidationNames(invalidations.committed, current.committed)) {
337
+ return false;
338
+ }
339
+ await fs.rm(targetPath, { force: true });
340
+ await fs.rename(temporaryPath, targetPath);
341
+ await Promise.all(invalidations.committed.map((name) => fs.rm(path.join(historyEventIndexInvalidationDirectory(pmRoot), name), { force: true })));
342
+ return true;
343
+ });
344
+ if (!published)
345
+ await fs.rm(temporaryPath, { force: true });
346
+ return published;
347
+ }
348
+ catch (error) {
349
+ database?.close();
350
+ await fs.rm(temporaryPath, { force: true });
351
+ throw error;
352
+ }
353
+ });
178
354
  }
179
355
  function matchesSet(value, accepted) {
180
356
  return !accepted || accepted.length === 0 || accepted.includes(value);
@@ -184,6 +360,12 @@ function compareHistoryEventPosition(left, right) {
184
360
  left.stream_id.localeCompare(right.stream_id) ||
185
361
  left.stream_offset - right.stream_offset);
186
362
  }
363
+ function canonicalHistoryQueryTimestamp(value) {
364
+ const milliseconds = Date.parse(value);
365
+ return Number.isFinite(milliseconds)
366
+ ? new Date(milliseconds).toISOString()
367
+ : value;
368
+ }
187
369
  function isInsideHistoryEventWindow(event, query) {
188
370
  if (query.after_ts !== undefined &&
189
371
  query.after_stream_id !== undefined &&
@@ -191,13 +373,16 @@ function isInsideHistoryEventWindow(event, query) {
191
373
  if (compareHistoryEventPosition(event, {
192
374
  stream_id: query.after_stream_id,
193
375
  stream_offset: query.after_stream_offset,
194
- entry: { ...event.entry, ts: query.after_ts },
376
+ entry: {
377
+ ...event.entry,
378
+ ts: canonicalHistoryQueryTimestamp(query.after_ts),
379
+ },
195
380
  }) <= 0) {
196
381
  return false;
197
382
  }
198
383
  }
199
384
  else if (query.since_ts !== undefined &&
200
- event.entry.ts < query.since_ts) {
385
+ event.entry.ts < canonicalHistoryQueryTimestamp(query.since_ts)) {
201
386
  return false;
202
387
  }
203
388
  return true;
@@ -239,44 +424,116 @@ export async function queryHistoryEventStreams(pmRoot, query) {
239
424
  };
240
425
  }
241
426
  /**
242
- * Update an already-built event projection after one authoritative append.
243
- * Absence is a cheap no-op; corrupt projections are removed for lazy rebuild.
427
+ * Serialize an authoritative append with its optional SQLite projection.
428
+ * The database write lock spans the file append and size capture, so compliant
429
+ * cross-process writers cannot certify a newer stream size with older rows.
244
430
  */
245
- export async function updateHistoryEventIndexAfterAppend(historyPath, entry) {
431
+ export async function appendHistoryEntryWithEventIndex(historyPath, entry, append) {
246
432
  const pmRoot = path.dirname(path.dirname(historyPath));
247
- const targetPath = eventIndexPath(pmRoot);
248
- try {
249
- await fs.access(targetPath);
250
- }
251
- catch {
252
- return;
253
- }
254
433
  const Database = resolveDatabaseSync();
255
- if (!Database)
434
+ if (!Database) {
435
+ await append();
256
436
  return;
257
- let database;
437
+ }
438
+ let authoritativeAppendCommitted = false;
258
439
  try {
259
- database = new Database(targetPath);
260
- const version = database
261
- .prepare("SELECT value FROM metadata WHERE key = 'version'")
262
- .get();
263
- if (version?.value !== EVENT_INDEX_VERSION) {
264
- throw new TypeError("Unsupported history event index version");
265
- }
266
- const streamId = path.basename(historyPath, ".jsonl");
267
- const offset = database
268
- .prepare("SELECT COALESCE(MAX(stream_offset), -1) + 1 AS value FROM events WHERE stream_id = ?")
269
- .get(streamId);
270
- insertEvent(database, {
271
- stream_id: streamId,
272
- stream_offset: Number(offset.value),
273
- entry,
440
+ await withHistoryEventIndexLock(pmRoot, async () => {
441
+ const targetPath = eventIndexPath(pmRoot);
442
+ try {
443
+ await fs.access(targetPath);
444
+ }
445
+ catch {
446
+ await append();
447
+ return;
448
+ }
449
+ let database;
450
+ try {
451
+ database = new Database(targetPath);
452
+ const version = database
453
+ .prepare("SELECT value FROM metadata WHERE key = 'version'")
454
+ .get();
455
+ if (version?.value !== EVENT_INDEX_VERSION) {
456
+ throw new TypeError("Unsupported history event index version");
457
+ }
458
+ }
459
+ catch {
460
+ database?.close();
461
+ await fs.rm(targetPath, { force: true });
462
+ await append();
463
+ return;
464
+ }
465
+ try {
466
+ database.exec("PRAGMA busy_timeout = 5000");
467
+ database.exec("BEGIN IMMEDIATE");
468
+ }
469
+ catch (error) {
470
+ database.close();
471
+ throw error;
472
+ }
473
+ let appended = false;
474
+ try {
475
+ await append();
476
+ appended = true;
477
+ authoritativeAppendCommitted = true;
478
+ const streamId = path.basename(historyPath, ".jsonl");
479
+ const offset = database
480
+ .prepare("SELECT COALESCE(MAX(stream_offset), -1) + 1 AS value FROM events WHERE stream_id = ?")
481
+ .get(streamId);
482
+ insertEvent(database, {
483
+ stream_id: streamId,
484
+ stream_offset: Number(offset.value),
485
+ entry,
486
+ });
487
+ upsertStreamByteSize(database, streamId, (await fs.stat(historyPath)).size);
488
+ database.exec("COMMIT");
489
+ database.close();
490
+ }
491
+ catch (error) {
492
+ database?.close();
493
+ if (!appended)
494
+ throw error;
495
+ await invalidateHistoryEventIndex(pmRoot);
496
+ }
274
497
  });
275
- database.close();
276
498
  }
277
- catch {
278
- database?.close();
279
- await fs.rm(targetPath, { force: true });
499
+ catch (error) {
500
+ if (authoritativeAppendCommitted) {
501
+ await invalidateHistoryEventIndex(pmRoot);
502
+ return;
503
+ }
504
+ if (!(error instanceof PmCliError) || error.code !== "lock_conflict") {
505
+ throw error;
506
+ }
507
+ let pendingMarkerPath;
508
+ try {
509
+ await withHistoryEventIndexInvalidationLock(pmRoot, async () => {
510
+ const marker = await beginHistoryEventIndexInvalidation(pmRoot);
511
+ pendingMarkerPath = marker.pendingPath;
512
+ try {
513
+ await append();
514
+ authoritativeAppendCommitted = true;
515
+ }
516
+ catch (appendError) {
517
+ await fs.rm(marker.pendingPath, { force: true });
518
+ throw appendError;
519
+ }
520
+ await fs.rename(marker.pendingPath, marker.committedPath);
521
+ await invalidateHistoryEventIndex(pmRoot);
522
+ });
523
+ }
524
+ catch (fallbackError) {
525
+ if (authoritativeAppendCommitted) {
526
+ await invalidateHistoryEventIndex(pmRoot);
527
+ try {
528
+ await fs.rm(pendingMarkerPath, { force: true });
529
+ }
530
+ catch {
531
+ // The authoritative append is durable; marker cleanup is best effort.
532
+ }
533
+ return;
534
+ }
535
+ throw fallbackError;
536
+ }
280
537
  }
281
538
  }
282
539
  /** Invalidate the optional event projection after a non-append rewrite. */
@@ -315,11 +572,12 @@ export async function queryHistoryEventIndex(pmRoot, query) {
315
572
  query.after_stream_id !== undefined &&
316
573
  query.after_stream_offset !== undefined) {
317
574
  clauses.push("(ts > ? OR (ts = ? AND stream_id > ?) OR (ts = ? AND stream_id = ? AND stream_offset > ?))");
318
- parameters.push(query.after_ts, query.after_ts, query.after_stream_id, query.after_ts, query.after_stream_id, query.after_stream_offset);
575
+ const afterTimestamp = canonicalHistoryQueryTimestamp(query.after_ts);
576
+ parameters.push(afterTimestamp, afterTimestamp, query.after_stream_id, afterTimestamp, query.after_stream_id, query.after_stream_offset);
319
577
  }
320
578
  else if (query.since_ts !== undefined) {
321
579
  clauses.push("ts >= ?");
322
- parameters.push(query.since_ts);
580
+ parameters.push(canonicalHistoryQueryTimestamp(query.since_ts));
323
581
  }
324
582
  appendSetPredicate(clauses, parameters, "op", query.ops);
325
583
  appendSetPredicate(clauses, parameters, "author", query.authors);
@@ -333,36 +591,233 @@ export async function queryHistoryEventIndex(pmRoot, query) {
333
591
  const where = clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`;
334
592
  let database;
335
593
  try {
336
- database = new Database(eventIndexPath(pmRoot), { readOnly: true });
337
- const version = database
338
- .prepare("SELECT value FROM metadata WHERE key = 'version'")
339
- .get();
340
- if (version?.value !== EVENT_INDEX_VERSION) {
341
- database.close();
342
- return null;
343
- }
344
- const rows = database
345
- .prepare(`SELECT stream_id, stream_offset, entry_json
346
- FROM events${where}
347
- ORDER BY ts, stream_id, stream_offset
348
- LIMIT ?`)
349
- .all(...parameters, Math.max(0, Math.floor(query.limit)) + 1);
350
- const hasMore = rows.length > query.limit;
351
- const events = rows.slice(0, query.limit).map((row) => ({
352
- stream_id: String(row.stream_id),
353
- stream_offset: Number(row.stream_offset),
354
- entry: JSON.parse(String(row.entry_json)),
355
- }));
356
- database.close();
357
- return { events, has_more: hasMore };
594
+ return await withHistoryEventIndexLock(pmRoot, async () => {
595
+ return withHistoryEventIndexInvalidationLock(pmRoot, async () => {
596
+ const invalidations = await recoverAbandonedHistoryEventIndexInvalidations(pmRoot);
597
+ if (invalidations.pending.length > 0 ||
598
+ invalidations.committed.length > 0) {
599
+ return null;
600
+ }
601
+ database = new Database(eventIndexPath(pmRoot), { readOnly: true });
602
+ const version = database
603
+ .prepare("SELECT value FROM metadata WHERE key = 'version'")
604
+ .get();
605
+ const validationStreamIds = await historyIndexValidationStreamIds(database, pmRoot, query.stream_ids);
606
+ if (version?.value !== EVENT_INDEX_VERSION ||
607
+ !(await historyIndexMatchesStreamSizes(database, pmRoot, validationStreamIds))) {
608
+ database.close();
609
+ database = undefined;
610
+ return null;
611
+ }
612
+ const rows = database
613
+ .prepare(`SELECT stream_id, stream_offset, entry_json
614
+ FROM events${where}
615
+ ORDER BY ts, stream_id, stream_offset
616
+ LIMIT ?`)
617
+ .all(...parameters, Math.max(0, Math.floor(query.limit)) + 1);
618
+ const events = rows.slice(0, query.limit).map(parseIndexedHistoryEvent);
619
+ database.close();
620
+ database = undefined;
621
+ return events.includes(null)
622
+ ? null
623
+ : {
624
+ events: events,
625
+ has_more: rows.length > query.limit,
626
+ };
627
+ });
628
+ });
358
629
  }
359
630
  catch {
360
631
  database?.close();
361
632
  return null;
362
633
  }
363
634
  }
635
+ function collectLatestSubstantiveEvents(events, requested) {
636
+ const latest = Object.create(null);
637
+ for (const event of events) {
638
+ if (!requested.has(event.stream_id) ||
639
+ classifyHistoryEvent(event.entry) !== "substantive") {
640
+ continue;
641
+ }
642
+ const current = latest[event.stream_id];
643
+ if (current === undefined ||
644
+ compareHistoryEventPosition(current, event) < 0) {
645
+ latest[event.stream_id] = event;
646
+ }
647
+ }
648
+ return latest;
649
+ }
650
+ async function readHistoryStreamByteSize(pmRoot, streamId) {
651
+ if (path.basename(streamId) !== streamId)
652
+ return null;
653
+ try {
654
+ return (await fs.stat(path.join(pmRoot, "history", `${streamId}.jsonl`)))
655
+ .size;
656
+ }
657
+ catch (error) {
658
+ if (isFileMissingError(error))
659
+ return 0;
660
+ throw error;
661
+ }
662
+ }
663
+ async function historyIndexMatchesStreamSizes(database, pmRoot, streamIds) {
664
+ for (let start = 0; start < streamIds.length; start += 500) {
665
+ const chunk = streamIds.slice(start, start + 500);
666
+ const storedSizes = new Map(database
667
+ .prepare(`SELECT stream_id, byte_size FROM streams
668
+ WHERE stream_id IN (${chunk.map(() => "?").join(", ")})`)
669
+ .all(...chunk)
670
+ .map((row) => [String(row.stream_id), Number(row.byte_size)]));
671
+ const currentSizes = await Promise.all(chunk.map((streamId) => readHistoryStreamByteSize(pmRoot, streamId)));
672
+ for (const [index, currentSize] of currentSizes.entries()) {
673
+ if (currentSize === null)
674
+ return false;
675
+ const streamId = chunk[index];
676
+ const storedSize = storedSizes.get(streamId);
677
+ if (currentSize === 0 && storedSize === undefined)
678
+ continue;
679
+ if (currentSize !== storedSize)
680
+ return false;
681
+ }
682
+ }
683
+ return true;
684
+ }
685
+ async function historyIndexValidationStreamIds(database, pmRoot, requestedStreamIds) {
686
+ if (requestedStreamIds && requestedStreamIds.length > 0) {
687
+ return [
688
+ ...new Set(requestedStreamIds.filter((streamId) => streamId.trim().length > 0)),
689
+ ];
690
+ }
691
+ const indexed = database
692
+ .prepare("SELECT stream_id FROM streams")
693
+ .all()
694
+ .map((row) => String(row.stream_id));
695
+ const authoritative = await fs
696
+ .readdir(path.join(pmRoot, "history"), { withFileTypes: true })
697
+ .then((entries) => entries
698
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
699
+ .map((entry) => entry.name.slice(0, -".jsonl".length)))
700
+ .catch((error) => {
701
+ if (isFileMissingError(error))
702
+ return [];
703
+ throw error;
704
+ });
705
+ return [...new Set([...indexed, ...authoritative])];
706
+ }
707
+ async function readIndexedLatestSubstantiveEvents(Database, pmRoot, streamIds, requested) {
708
+ if (!Database)
709
+ return null;
710
+ let database;
711
+ try {
712
+ return await withHistoryEventIndexLock(pmRoot, async () => {
713
+ return withHistoryEventIndexInvalidationLock(pmRoot, async () => {
714
+ const invalidations = await recoverAbandonedHistoryEventIndexInvalidations(pmRoot);
715
+ if (invalidations.pending.length > 0 ||
716
+ invalidations.committed.length > 0) {
717
+ return null;
718
+ }
719
+ database = new Database(eventIndexPath(pmRoot), { readOnly: true });
720
+ const version = database
721
+ .prepare("SELECT value FROM metadata WHERE key = 'version'")
722
+ .get();
723
+ if (version?.value !== EVENT_INDEX_VERSION ||
724
+ !(await historyIndexMatchesStreamSizes(database, pmRoot, streamIds))) {
725
+ database.close();
726
+ database = undefined;
727
+ return null;
728
+ }
729
+ const indexed = [];
730
+ for (let start = 0; start < streamIds.length; start += 500) {
731
+ const chunk = streamIds.slice(start, start + 500);
732
+ const rows = database
733
+ .prepare(`SELECT stream_id, stream_offset, entry_json
734
+ FROM (
735
+ SELECT stream_id, stream_offset, entry_json,
736
+ ROW_NUMBER() OVER (
737
+ PARTITION BY stream_id
738
+ ORDER BY ts DESC, stream_offset DESC
739
+ ) AS recency_rank
740
+ FROM events
741
+ WHERE event_class = 'substantive'
742
+ AND stream_id IN (${chunk.map(() => "?").join(", ")})
743
+ )
744
+ WHERE recency_rank = 1`)
745
+ .all(...chunk);
746
+ const parsed = rows.map(parseIndexedHistoryEvent);
747
+ if (parsed.includes(null)) {
748
+ database.close();
749
+ database = undefined;
750
+ return null;
751
+ }
752
+ indexed.push(...parsed);
753
+ }
754
+ database.close();
755
+ database = undefined;
756
+ return collectLatestSubstantiveEvents(indexed, requested);
757
+ });
758
+ });
759
+ }
760
+ catch (error) {
761
+ database?.close();
762
+ if (error instanceof PmCliError && error.code === "lock_conflict") {
763
+ return HISTORY_INDEX_LOCK_CONTENDED;
764
+ }
765
+ return null;
766
+ }
767
+ }
768
+ /**
769
+ * Read one latest substantive coordinate per requested stream. The optional
770
+ * SQLite projection is preferred; individual authoritative streams are the
771
+ * fail-closed fallback and remain the source of truth.
772
+ */
773
+ export async function readLatestSubstantiveHistoryEvents(pmRoot, streamIds) {
774
+ const uniqueIds = [
775
+ ...new Set(streamIds.filter((id) => id.trim().length > 0 && path.basename(id) === id)),
776
+ ];
777
+ if (uniqueIds.length === 0)
778
+ return {};
779
+ const requested = new Set(uniqueIds);
780
+ const Database = resolveDatabaseSync();
781
+ const indexed = await readIndexedLatestSubstantiveEvents(Database, pmRoot, uniqueIds, requested);
782
+ if (indexed !== null && indexed !== HISTORY_INDEX_LOCK_CONTENDED) {
783
+ return indexed;
784
+ }
785
+ if (Database && indexed !== HISTORY_INDEX_LOCK_CONTENDED) {
786
+ try {
787
+ if (await rebuildHistoryEventIndex(pmRoot)) {
788
+ const rebuilt = await readIndexedLatestSubstantiveEvents(Database, pmRoot, uniqueIds, requested);
789
+ if (rebuilt !== null && rebuilt !== HISTORY_INDEX_LOCK_CONTENDED) {
790
+ return rebuilt;
791
+ }
792
+ }
793
+ }
794
+ catch {
795
+ // A corrupt unrelated stream must not prevent requested authoritative reads.
796
+ }
797
+ }
798
+ const authoritative = [];
799
+ await Promise.all(uniqueIds.map(async (streamId) => {
800
+ const historyPath = path.join(pmRoot, "history", `${streamId}.jsonl`);
801
+ const entries = await readIndexableHistoryEntries(historyPath, streamId).catch((error) => {
802
+ if (error instanceof PmCliError &&
803
+ error.code === "history_timestamp_invalid") {
804
+ return [];
805
+ }
806
+ throw error;
807
+ });
808
+ entries.forEach((entry, streamOffset) => {
809
+ authoritative.push({
810
+ stream_id: streamId,
811
+ stream_offset: streamOffset,
812
+ entry,
813
+ });
814
+ });
815
+ }));
816
+ return collectLatestSubstantiveEvents(authoritative, requested);
817
+ }
364
818
  /** Test-only dependency seam for runtimes without `node:sqlite`. */
365
819
  export const _testOnly = {
820
+ collectLatestSubstantiveEvents,
366
821
  loadDatabaseSync,
367
822
  loadStableDatabaseSync,
368
823
  setDatabaseSync(databaseSync) {
@@ -374,4 +829,4 @@ export const _testOnly = {
374
829
  },
375
830
  };
376
831
  //# sourceMappingURL=event-index.js.map
377
- //# debugId=a84b308f-7eef-535c-9531-b33b0775c651
832
+ //# debugId=72b285a7-970e-5965-bacf-a4e0cfc5f060