@indigoai-us/hq-cli 5.125.0 → 5.127.0

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 (108) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
  3. package/assets/bot-workers/setup/skills/your-business.md +270 -0
  4. package/assets/bot-workers/setup/worker.yaml +189 -58
  5. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  6. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  7. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  8. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  9. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  10. package/dist/command-catalog.generated.d.ts +643 -6
  11. package/dist/command-catalog.generated.js +836 -6
  12. package/dist/command-registration-plan.d.ts +7 -0
  13. package/dist/command-registration-plan.js +11 -1
  14. package/dist/commands/agents.d.ts +12 -0
  15. package/dist/commands/agents.js +46 -2
  16. package/dist/commands/bot.d.ts +10 -0
  17. package/dist/commands/bot.js +6 -0
  18. package/dist/commands/lanes.d.ts +114 -0
  19. package/dist/commands/lanes.js +2969 -0
  20. package/dist/commands/mesh.js +287 -24
  21. package/dist/commands/onboard-create-identity.d.ts +26 -0
  22. package/dist/commands/onboard-create-identity.js +23 -0
  23. package/dist/commands/onboard.js +13 -4
  24. package/dist/lib/bot/progress.d.ts +6 -0
  25. package/dist/lib/bot/progress.js +16 -0
  26. package/dist/lib/bot/runtime/grok.d.ts +30 -7
  27. package/dist/lib/bot/runtime/grok.js +86 -8
  28. package/dist/lib/bot/runtime/index.js +15 -8
  29. package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
  30. package/dist/lib/bot/runtime/messages-stream.js +49 -3
  31. package/dist/lib/lanes/accounts.d.ts +103 -0
  32. package/dist/lib/lanes/accounts.js +318 -0
  33. package/dist/lib/lanes/compaction.d.ts +96 -0
  34. package/dist/lib/lanes/compaction.js +197 -0
  35. package/dist/lib/lanes/config.d.ts +71 -0
  36. package/dist/lib/lanes/config.js +408 -0
  37. package/dist/lib/lanes/delivery.d.ts +151 -0
  38. package/dist/lib/lanes/delivery.js +354 -0
  39. package/dist/lib/lanes/dropbox.d.ts +96 -0
  40. package/dist/lib/lanes/dropbox.js +408 -0
  41. package/dist/lib/lanes/effort.d.ts +50 -0
  42. package/dist/lib/lanes/effort.js +109 -0
  43. package/dist/lib/lanes/errors.d.ts +39 -0
  44. package/dist/lib/lanes/errors.js +72 -0
  45. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  46. package/dist/lib/lanes/hooks-install.js +326 -0
  47. package/dist/lib/lanes/hq-root.d.ts +19 -0
  48. package/dist/lib/lanes/hq-root.js +28 -0
  49. package/dist/lib/lanes/index.d.ts +49 -0
  50. package/dist/lib/lanes/index.js +27 -0
  51. package/dist/lib/lanes/liveness.d.ts +95 -0
  52. package/dist/lib/lanes/liveness.js +332 -0
  53. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  54. package/dist/lib/lanes/machine-identity.js +92 -0
  55. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  56. package/dist/lib/lanes/mesh-events.js +275 -0
  57. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  58. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  59. package/dist/lib/lanes/questions.d.ts +75 -0
  60. package/dist/lib/lanes/questions.js +243 -0
  61. package/dist/lib/lanes/quota.d.ts +95 -0
  62. package/dist/lib/lanes/quota.js +320 -0
  63. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  64. package/dist/lib/lanes/reconcile-install.js +221 -0
  65. package/dist/lib/lanes/reconcile.d.ts +102 -0
  66. package/dist/lib/lanes/reconcile.js +985 -0
  67. package/dist/lib/lanes/registry.d.ts +116 -0
  68. package/dist/lib/lanes/registry.js +521 -0
  69. package/dist/lib/lanes/spawn.d.ts +274 -0
  70. package/dist/lib/lanes/spawn.js +1363 -0
  71. package/dist/lib/lanes/store.d.ts +68 -0
  72. package/dist/lib/lanes/store.js +745 -0
  73. package/dist/lib/lanes/throttle.d.ts +89 -0
  74. package/dist/lib/lanes/throttle.js +297 -0
  75. package/dist/lib/lanes/types.d.ts +535 -0
  76. package/dist/lib/lanes/types.js +350 -0
  77. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  78. package/dist/lib/lanes/watcher-main.js +22 -0
  79. package/dist/lib/lanes/watcher.d.ts +216 -0
  80. package/dist/lib/lanes/watcher.js +859 -0
  81. package/dist/lib/mesh/api.d.ts +71 -7
  82. package/dist/lib/mesh/api.js +167 -28
  83. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  84. package/dist/lib/mesh/live/backfill-held.js +66 -21
  85. package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
  86. package/dist/lib/mesh/live/daemon/config.js +75 -0
  87. package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
  88. package/dist/lib/mesh/live/daemon/index.js +3 -3
  89. package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
  90. package/dist/lib/mesh/live/daemon/install.js +444 -2
  91. package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
  92. package/dist/lib/mesh/live/daemon/paths.js +2 -0
  93. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  94. package/dist/lib/mesh/live/daemon/run.js +111 -54
  95. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  96. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  97. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  98. package/dist/lib/scan-packages/index.d.ts +2 -0
  99. package/dist/lib/scan-packages/index.js +18 -13
  100. package/dist/lib/work-context/config.d.ts +20 -0
  101. package/dist/lib/work-context/config.js +26 -5
  102. package/dist/lib/work-context/reconcile.js +28 -7
  103. package/dist/lib/work-context/state.d.ts +12 -0
  104. package/dist/lib/work-context/state.js +131 -3
  105. package/dist/lib/work-context/windows-acl.d.ts +123 -0
  106. package/dist/lib/work-context/windows-acl.js +425 -0
  107. package/dist/utils/pack-contributions.js +5 -4
  108. package/package.json +3 -1
@@ -0,0 +1,745 @@
1
+ /**
2
+ * File-backed lane / question record store (spec §10, §10.1).
3
+ *
4
+ * Root: workspace/lanes/ under the HQ root. One stable path per record;
5
+ * `status` is the pending/answered discriminator — files are never moved
6
+ * between directories. Every mutation is temp-file-plus-rename and a
7
+ * compare-and-set on `version`. Histories are capped at 50 at the append site.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { generateUlid } from "../mesh/live/ulid.js";
14
+ import { resolveLanesHqRoot } from "./hq-root.js";
15
+ import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
16
+ import { HISTORY_CAP, HOST_KINDS, LANE_SCHEMA_VERSION, QUESTION_SCHEMA_VERSION, SENIOR_KINDS, isHostKind, isLaneProvider, isLaneState, isQuestionStatus, isSeniorKind, parseMajorSchema, REQUIRED_LANE_RECORD_FIELDS, LANE_FIELD_WRITERS, LANE_WRITERS, } from "./types.js";
17
+ export const LANES_STORE_REL = path.join("workspace", "lanes");
18
+ export const LANE_RECORDS_DIR = "lanes";
19
+ export const QUESTION_RECORDS_DIR = "questions";
20
+ export const PENDING_INDEX_DIR = path.join("questions", "index", "pending");
21
+ const LOCK_STALE_MS = 30_000;
22
+ const LOCK_RETRY_MS = 10;
23
+ const LOCK_TIMEOUT_MS = 5_000;
24
+ function defaultStoreDeps() {
25
+ return {
26
+ now: () => new Date(),
27
+ hostname: () => os.hostname(),
28
+ mkdirSync: fs.mkdirSync.bind(fs),
29
+ readFileSync: fs.readFileSync.bind(fs),
30
+ writeFileSync: fs.writeFileSync.bind(fs),
31
+ renameSync: fs.renameSync.bind(fs),
32
+ unlinkSync: fs.unlinkSync.bind(fs),
33
+ existsSync: fs.existsSync.bind(fs),
34
+ readdirSync: fs.readdirSync.bind(fs),
35
+ openSync: fs.openSync.bind(fs),
36
+ closeSync: fs.closeSync.bind(fs),
37
+ fsyncSync: fs.fsyncSync.bind(fs),
38
+ chmodSync: fs.chmodSync.bind(fs),
39
+ };
40
+ }
41
+ export function resolveStoreHqRoot(hqRoot, env = process.env, cwd) {
42
+ return hqRoot ?? resolveLanesHqRoot({ env, cwd });
43
+ }
44
+ export function lanesStoreRoot(hqRoot) {
45
+ return path.join(resolveStoreHqRoot(hqRoot), LANES_STORE_REL);
46
+ }
47
+ export function laneRecordPath(hqRoot, laneId) {
48
+ return path.join(lanesStoreRoot(hqRoot), LANE_RECORDS_DIR, `${laneId}.json`);
49
+ }
50
+ export function questionRecordPath(hqRoot, questionId) {
51
+ return path.join(lanesStoreRoot(hqRoot), QUESTION_RECORDS_DIR, `${questionId}.json`);
52
+ }
53
+ export function pendingMarkerPath(hqRoot, questionId) {
54
+ return path.join(lanesStoreRoot(hqRoot), PENDING_INDEX_DIR, questionId);
55
+ }
56
+ export function sanitizeHostComponent(hostname) {
57
+ const cleaned = hostname
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, "-")
60
+ .replace(/^-+|-+$/g, "");
61
+ return (cleaned || "host").slice(0, 32);
62
+ }
63
+ /** Collision-safe across machines: ULID plus a host component. */
64
+ export function mintLaneId(deps = defaultStoreDeps()) {
65
+ const host = sanitizeHostComponent(deps.hostname());
66
+ return `${generateUlid(deps.now().getTime())}_${host}`;
67
+ }
68
+ export function mintQuestionId(deps = defaultStoreDeps()) {
69
+ const host = sanitizeHostComponent(deps.hostname());
70
+ const extra = randomBytes(2).toString("hex");
71
+ return `q_${generateUlid(deps.now().getTime())}_${host}_${extra}`;
72
+ }
73
+ export function appendCappedHistory(history, entry, cap = HISTORY_CAP) {
74
+ const next = [...history, entry];
75
+ if (next.length <= cap)
76
+ return next;
77
+ return next.slice(next.length - cap);
78
+ }
79
+ function ensureDir(dir, deps) {
80
+ deps.mkdirSync(dir, { recursive: true });
81
+ }
82
+ function fsyncDir(dir, deps) {
83
+ try {
84
+ const fd = deps.openSync(dir, "r");
85
+ try {
86
+ deps.fsyncSync(fd);
87
+ }
88
+ finally {
89
+ deps.closeSync(fd);
90
+ }
91
+ }
92
+ catch (err) {
93
+ const code = err.code;
94
+ if (code === "EISDIR" || code === "EINVAL" || code === "ENOTSUP")
95
+ return;
96
+ // Directory fsync is best-effort on platforms that reject it; log once.
97
+ process.stderr.write(`lanes store: directory fsync failed for ${dir}: ${err instanceof Error ? err.message : String(err)}\n`);
98
+ }
99
+ }
100
+ function atomicWriteJson(target, value, deps) {
101
+ const dir = path.dirname(target);
102
+ ensureDir(dir, deps);
103
+ const tmp = path.join(dir, `.${path.basename(target)}.tmp.${process.pid}.${Date.now()}.${randomBytes(4).toString("hex")}`);
104
+ let fd;
105
+ try {
106
+ fd = deps.openSync(tmp, "wx");
107
+ const buf = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
108
+ fs.writeSync(fd, buf);
109
+ deps.fsyncSync(fd);
110
+ deps.closeSync(fd);
111
+ fd = undefined;
112
+ deps.renameSync(tmp, target);
113
+ fsyncDir(dir, deps);
114
+ try {
115
+ deps.chmodSync(target, 0o600);
116
+ }
117
+ catch (err) {
118
+ process.stderr.write(`lanes store: chmod 0600 failed for ${target}: ${err instanceof Error ? err.message : String(err)}\n`);
119
+ }
120
+ }
121
+ catch (err) {
122
+ if (fd !== undefined) {
123
+ try {
124
+ deps.closeSync(fd);
125
+ }
126
+ catch (closeErr) {
127
+ process.stderr.write(`lanes store: close failed for ${tmp}: ${closeErr instanceof Error ? closeErr.message : String(closeErr)}\n`);
128
+ }
129
+ }
130
+ try {
131
+ deps.unlinkSync(tmp);
132
+ }
133
+ catch (unlinkErr) {
134
+ const code = unlinkErr.code;
135
+ if (code !== "ENOENT") {
136
+ process.stderr.write(`lanes store: unlink tmp failed for ${tmp}: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}\n`);
137
+ }
138
+ }
139
+ throw err;
140
+ }
141
+ }
142
+ function wait(ms) {
143
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
144
+ }
145
+ function lockPathFor(recordPath) {
146
+ return `${recordPath}.lock`;
147
+ }
148
+ function withRecordLock(recordPath, deps, fn) {
149
+ const lockPath = lockPathFor(recordPath);
150
+ ensureDir(path.dirname(recordPath), deps);
151
+ const started = Date.now();
152
+ while (true) {
153
+ let fd;
154
+ try {
155
+ fd = deps.openSync(lockPath, "wx");
156
+ try {
157
+ fs.writeSync(fd, Buffer.from(`${process.pid}\n${Date.now()}\n`, "utf8"));
158
+ }
159
+ catch (err) {
160
+ deps.closeSync(fd);
161
+ fd = undefined;
162
+ throw err;
163
+ }
164
+ try {
165
+ return fn();
166
+ }
167
+ finally {
168
+ try {
169
+ deps.closeSync(fd);
170
+ }
171
+ catch (err) {
172
+ process.stderr.write(`lanes store: lock close failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
173
+ }
174
+ try {
175
+ deps.unlinkSync(lockPath);
176
+ }
177
+ catch (err) {
178
+ const code = err.code;
179
+ if (code !== "ENOENT") {
180
+ process.stderr.write(`lanes store: lock unlink failed for ${lockPath}: ${err instanceof Error ? err.message : String(err)}\n`);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ catch (err) {
186
+ const code = err.code;
187
+ if (code !== "EEXIST")
188
+ throw err;
189
+ if (Date.now() - started > LOCK_TIMEOUT_MS) {
190
+ throw new LanesError("store_lock_timeout", `Timed out waiting for lock ${lockPath}`, { path: lockPath });
191
+ }
192
+ try {
193
+ const raw = deps.readFileSync(lockPath, "utf8");
194
+ const tsLine = raw.split("\n")[1];
195
+ const ts = tsLine ? Number(tsLine) : NaN;
196
+ if (Number.isFinite(ts) && Date.now() - ts > LOCK_STALE_MS) {
197
+ try {
198
+ deps.unlinkSync(lockPath);
199
+ }
200
+ catch (unlinkErr) {
201
+ const unlinkCode = unlinkErr.code;
202
+ if (unlinkCode !== "ENOENT")
203
+ throw unlinkErr;
204
+ }
205
+ continue;
206
+ }
207
+ }
208
+ catch (statErr) {
209
+ const statCode = statErr.code;
210
+ if (statCode !== "ENOENT") {
211
+ process.stderr.write(`lanes store: stale-lock probe failed for ${lockPath}: ${statErr instanceof Error ? statErr.message : String(statErr)}\n`);
212
+ }
213
+ }
214
+ wait(LOCK_RETRY_MS);
215
+ }
216
+ }
217
+ }
218
+ function readJsonFile(filePath, deps) {
219
+ let text;
220
+ try {
221
+ text = deps.readFileSync(filePath, "utf8");
222
+ }
223
+ catch (err) {
224
+ const code = err.code;
225
+ if (code === "ENOENT")
226
+ return null;
227
+ throw err;
228
+ }
229
+ try {
230
+ return JSON.parse(text);
231
+ }
232
+ catch (err) {
233
+ throw new LaneSchemaError(`Invalid JSON in ${filePath}: ${err instanceof Error ? err.message : String(err)}`, { path: filePath });
234
+ }
235
+ }
236
+ function isPlainObject(value) {
237
+ return value !== null && typeof value === "object" && !Array.isArray(value);
238
+ }
239
+ function requireString(value, field) {
240
+ if (typeof value !== "string" || value.length === 0) {
241
+ throw new LaneSchemaError(`Expected non-empty string for ${field}`, {
242
+ field,
243
+ });
244
+ }
245
+ return value;
246
+ }
247
+ function requireInt(value, field) {
248
+ if (typeof value !== "number" || !Number.isInteger(value)) {
249
+ throw new LaneSchemaError(`Expected integer for ${field}`, { field });
250
+ }
251
+ return value;
252
+ }
253
+ /**
254
+ * Company on a lane/question record is either a slug+uid pair or JSON `null`.
255
+ * Absence (omitted key) is normalised to `null`. Empty strings stay a schema
256
+ * error so a no-company lane cannot be confused with a broken company object.
257
+ */
258
+ function parseOptionalCompanyField(raw, filePath) {
259
+ if (raw.company === undefined) {
260
+ raw.company = null;
261
+ return;
262
+ }
263
+ if (raw.company === null)
264
+ return;
265
+ if (!isPlainObject(raw.company)) {
266
+ throw new LaneSchemaError("company must be an object", { path: filePath });
267
+ }
268
+ requireString(raw.company.slug, "company.slug");
269
+ requireString(raw.company.uid, "company.uid");
270
+ }
271
+ export function parseLaneRecord(raw, filePath) {
272
+ if (!isPlainObject(raw)) {
273
+ throw new LaneSchemaError("Lane record must be a JSON object", {
274
+ path: filePath,
275
+ });
276
+ }
277
+ for (const field of REQUIRED_LANE_RECORD_FIELDS) {
278
+ if (!(field in raw)) {
279
+ throw new LaneSchemaError(`Missing required lane record field ${field}`, { path: filePath, field });
280
+ }
281
+ }
282
+ const schema_version = requireString(raw.schema_version, "schema_version");
283
+ parseMajorSchema(schema_version, "hq-lane", 1);
284
+ requireString(raw.lane_id, "lane_id");
285
+ requireInt(raw.version, "version");
286
+ requireInt(raw.depth, "depth");
287
+ requireInt(raw.priority, "priority");
288
+ requireString(raw.project_id, "project_id");
289
+ requireString(raw.story_id, "story_id");
290
+ if (!isLaneState(raw.state)) {
291
+ throw new LaneSchemaError(`Unknown lane state ${JSON.stringify(raw.state)}`, { path: filePath, value: raw.state });
292
+ }
293
+ if (!isLaneProvider(raw.provider)) {
294
+ throw new LaneSchemaError(`Unknown provider ${JSON.stringify(raw.provider)}`, { path: filePath, value: raw.provider });
295
+ }
296
+ parseOptionalCompanyField(raw, filePath);
297
+ if (!isPlainObject(raw.senior)) {
298
+ throw new LaneSchemaError("senior must be an object", { path: filePath });
299
+ }
300
+ if (!isSeniorKind(raw.senior.kind)) {
301
+ throw new LaneSchemaError(`Unknown senior kind ${JSON.stringify(raw.senior.kind)}`, {
302
+ path: filePath,
303
+ field: "senior.kind",
304
+ value: raw.senior.kind,
305
+ accepted: [...SENIOR_KINDS],
306
+ });
307
+ }
308
+ if (raw.senior.kind !== "user") {
309
+ requireString(raw.senior.id, "senior.id");
310
+ }
311
+ if (!isPlainObject(raw.host)) {
312
+ throw new LaneSchemaError("host must be an object", { path: filePath });
313
+ }
314
+ if (!isHostKind(raw.host.kind)) {
315
+ throw new LaneSchemaError(`Unknown host kind ${JSON.stringify(raw.host.kind)}`, {
316
+ path: filePath,
317
+ field: "host.kind",
318
+ value: raw.host.kind,
319
+ accepted: [...HOST_KINDS],
320
+ });
321
+ }
322
+ requireString(raw.host.id, "host.id");
323
+ if (!Array.isArray(raw.senior_history)) {
324
+ throw new LaneSchemaError("senior_history must be an array", {
325
+ path: filePath,
326
+ });
327
+ }
328
+ if (!Array.isArray(raw.attribution_history)) {
329
+ throw new LaneSchemaError("attribution_history must be an array", {
330
+ path: filePath,
331
+ });
332
+ }
333
+ // Spread-preserving: return the original object so unknown keys survive
334
+ // a parse/rewrite cycle (spec §5.1 field-preservation).
335
+ return raw;
336
+ }
337
+ export function parseQuestionRecord(raw, filePath) {
338
+ if (!isPlainObject(raw)) {
339
+ throw new LaneSchemaError("Question record must be a JSON object", {
340
+ path: filePath,
341
+ });
342
+ }
343
+ const schema_version = requireString(raw.schema_version, "schema_version");
344
+ parseMajorSchema(schema_version, "hq-lane-question", 1);
345
+ if (!isQuestionStatus(raw.status)) {
346
+ throw new LaneSchemaError(`Unknown question status ${JSON.stringify(raw.status)}`, { path: filePath, value: raw.status });
347
+ }
348
+ requireString(raw.question_id, "question_id");
349
+ requireString(raw.lane_id, "lane_id");
350
+ requireInt(raw.version, "version");
351
+ requireInt(raw.hop, "hop");
352
+ parseOptionalCompanyField(raw, filePath);
353
+ if (!isPlainObject(raw.body)) {
354
+ throw new LaneSchemaError("body must be an object", { path: filePath });
355
+ }
356
+ if (!Array.isArray(raw.routing_history)) {
357
+ throw new LaneSchemaError("routing_history must be an array", {
358
+ path: filePath,
359
+ });
360
+ }
361
+ // Optional on the record, but merge-on-read spreads it; a non-array from a
362
+ // synced sidecar must fail here, inside the per-sidecar skip, not later.
363
+ if (raw.answer_history !== undefined && !Array.isArray(raw.answer_history)) {
364
+ throw new LaneSchemaError("answer_history must be an array when present", {
365
+ path: filePath,
366
+ });
367
+ }
368
+ return raw;
369
+ }
370
+ /**
371
+ * HQ Sync sibling-twin grammar from `@indigoai-us/hq-cloud` `buildConflictPath`:
372
+ * `<file>.json.conflict-<ISO-with-dashes>-<machine>.json`
373
+ * (legacy twins; current engines park losers under `.hq/conflict-backups/`,
374
+ * but merge-on-read still has to recover the ones already on disk).
375
+ */
376
+ export function isConflictSidecarName(recordBasename, name) {
377
+ if (name === recordBasename || name.startsWith("."))
378
+ return false;
379
+ return name.startsWith(`${recordBasename}.conflict-`);
380
+ }
381
+ function sidecarPathsFor(recordPath, deps) {
382
+ const dir = path.dirname(recordPath);
383
+ const base = path.basename(recordPath);
384
+ if (!deps.existsSync(dir))
385
+ return [];
386
+ const names = deps.readdirSync(dir);
387
+ const sidecars = [];
388
+ for (const name of names) {
389
+ if (typeof name !== "string")
390
+ continue;
391
+ if (!isConflictSidecarName(base, name))
392
+ continue;
393
+ sidecars.push(path.join(dir, name));
394
+ }
395
+ return sidecars;
396
+ }
397
+ function canonicalJson(value) {
398
+ if (value === null || typeof value !== "object")
399
+ return JSON.stringify(value);
400
+ if (Array.isArray(value)) {
401
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
402
+ }
403
+ const obj = value;
404
+ const keys = Object.keys(obj).sort();
405
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(obj[key])}`).join(",")}}`;
406
+ }
407
+ function historyTimestamp(entry) {
408
+ if (!isPlainObject(entry))
409
+ return "";
410
+ if (typeof entry.at === "string")
411
+ return entry.at;
412
+ if (typeof entry.answered_at === "string")
413
+ return entry.answered_at;
414
+ return "";
415
+ }
416
+ /**
417
+ * Attribution identity is the message that caused the re-point (spec §2).
418
+ * Senior and routing entries have no named id; union is by canonical entry.
419
+ */
420
+ function attributionIdentity(entry) {
421
+ if (typeof entry.message_id === "string" && entry.message_id.length > 0) {
422
+ return `message_id:${entry.message_id}`;
423
+ }
424
+ return `entry:${canonicalJson(entry)}`;
425
+ }
426
+ function entryIdentity(entry) {
427
+ return canonicalJson(entry);
428
+ }
429
+ function questionAnswerHistory(record) {
430
+ return [
431
+ ...(record.answer_history ?? []),
432
+ ...(record.answer ? [record.answer] : []),
433
+ ];
434
+ }
435
+ function unionHistory(lists, identity, preferred) {
436
+ const map = new Map();
437
+ for (const entry of preferred) {
438
+ map.set(identity(entry), entry);
439
+ }
440
+ for (const list of lists) {
441
+ for (const entry of list) {
442
+ const id = identity(entry);
443
+ if (!map.has(id))
444
+ map.set(id, entry);
445
+ }
446
+ }
447
+ const merged = [...map.values()].sort((a, b) => historyTimestamp(a).localeCompare(historyTimestamp(b)));
448
+ if (merged.length <= HISTORY_CAP)
449
+ return merged;
450
+ return merged.slice(merged.length - HISTORY_CAP);
451
+ }
452
+ function higherVersionRecord(live, others) {
453
+ let best = live;
454
+ for (const other of others) {
455
+ if (other.version > best.version)
456
+ best = other;
457
+ }
458
+ return best;
459
+ }
460
+ export function mergeLaneRecords(live, sidecars) {
461
+ if (sidecars.length === 0)
462
+ return live;
463
+ const all = [live, ...sidecars];
464
+ const base = higherVersionRecord(live, sidecars);
465
+ return {
466
+ ...base,
467
+ senior_history: unionHistory(all.map((record) => record.senior_history), entryIdentity, base.senior_history),
468
+ attribution_history: unionHistory(all.map((record) => record.attribution_history), attributionIdentity, base.attribution_history),
469
+ };
470
+ }
471
+ export function mergeQuestionRecords(live, sidecars) {
472
+ if (sidecars.length === 0)
473
+ return live;
474
+ const all = [live, ...sidecars];
475
+ const base = higherVersionRecord(live, sidecars);
476
+ return {
477
+ ...base,
478
+ answer_history: unionHistory(all.map(questionAnswerHistory), entryIdentity, questionAnswerHistory(base)),
479
+ routing_history: unionHistory(all.map((record) => record.routing_history), entryIdentity, base.routing_history),
480
+ };
481
+ }
482
+ function loadSidecarRecords(recordPath, parse, deps) {
483
+ const paths = sidecarPathsFor(recordPath, deps);
484
+ const records = [];
485
+ for (const sidecarPath of paths) {
486
+ try {
487
+ const raw = readJsonFile(sidecarPath, deps);
488
+ if (raw === null)
489
+ continue;
490
+ records.push(parse(raw, sidecarPath));
491
+ }
492
+ catch (err) {
493
+ process.stderr.write(`lanes store: skipping unreadable conflict sidecar ${sidecarPath}: ${err instanceof Error ? err.message : String(err)}\n`);
494
+ }
495
+ }
496
+ return records;
497
+ }
498
+ function readMergedRecord(filePath, parse, merge, deps) {
499
+ const raw = readJsonFile(filePath, deps);
500
+ if (raw === null)
501
+ return null;
502
+ const live = parse(raw, filePath);
503
+ const sidecars = loadSidecarRecords(filePath, parse, deps);
504
+ if (sidecars.length === 0)
505
+ return live;
506
+ return merge(live, sidecars);
507
+ }
508
+ export function tryReadLane(hqRoot, laneId, deps = defaultStoreDeps()) {
509
+ const filePath = laneRecordPath(hqRoot, laneId);
510
+ return readMergedRecord(filePath, parseLaneRecord, mergeLaneRecords, deps);
511
+ }
512
+ export function readLane(hqRoot, laneId, deps = defaultStoreDeps()) {
513
+ const record = tryReadLane(hqRoot, laneId, deps);
514
+ if (!record)
515
+ throw new LaneNotFoundError("lane", laneId);
516
+ return record;
517
+ }
518
+ export function tryReadQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
519
+ const filePath = questionRecordPath(hqRoot, questionId);
520
+ return readMergedRecord(filePath, parseQuestionRecord, mergeQuestionRecords, deps);
521
+ }
522
+ export function readQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
523
+ const record = tryReadQuestion(hqRoot, questionId, deps);
524
+ if (!record)
525
+ throw new LaneNotFoundError("question", questionId);
526
+ return record;
527
+ }
528
+ function recordsEquivalentAsideFromClock(current, updated) {
529
+ if (updated === current)
530
+ return true;
531
+ const strip = (record) => {
532
+ const copy = { ...record };
533
+ delete copy.version;
534
+ delete copy.updated_at;
535
+ return copy;
536
+ };
537
+ return JSON.stringify(strip(current)) === JSON.stringify(strip(updated));
538
+ }
539
+ function casWrite(filePath, expectedVersion, parse, merge, updater, deps) {
540
+ return withRecordLock(filePath, deps, () => {
541
+ const current = readMergedRecord(filePath, parse, merge, deps);
542
+ if (current === null) {
543
+ throw new LaneSchemaError(`Record disappeared under lock: ${filePath}`, {
544
+ path: filePath,
545
+ });
546
+ }
547
+ if (current.version !== expectedVersion) {
548
+ throw new LaneVersionConflict({
549
+ path: filePath,
550
+ expectedVersion,
551
+ actualVersion: current.version,
552
+ });
553
+ }
554
+ const updated = updater(current);
555
+ if (recordsEquivalentAsideFromClock(current, updated)) {
556
+ return current;
557
+ }
558
+ const next = {
559
+ ...current,
560
+ ...updated,
561
+ version: current.version + 1,
562
+ updated_at: deps.now().toISOString(),
563
+ };
564
+ atomicWriteJson(filePath, next, deps);
565
+ return next;
566
+ });
567
+ }
568
+ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
569
+ const now = deps.now().toISOString();
570
+ const lane_id = init.lane_id ?? mintLaneId(deps);
571
+ const filePath = laneRecordPath(hqRoot, lane_id);
572
+ const record = {
573
+ ...init,
574
+ schema_version: init.schema_version ?? LANE_SCHEMA_VERSION,
575
+ lane_id,
576
+ version: 1,
577
+ created_at: init.created_at ?? now,
578
+ updated_at: init.updated_at ?? now,
579
+ senior_history: init.senior_history ?? [],
580
+ attribution_history: init.attribution_history ?? [],
581
+ };
582
+ parseLaneRecord(record, filePath);
583
+ return withRecordLock(filePath, deps, () => {
584
+ if (deps.existsSync(filePath)) {
585
+ throw new LanesError("lane_exists", `Lane ${lane_id} already exists`, { lane_id });
586
+ }
587
+ atomicWriteJson(filePath, record, deps);
588
+ return record;
589
+ });
590
+ }
591
+ /** Value equality over JSON-shaped record fields; the clone breaks reference identity. */
592
+ function fieldUnchanged(before, after) {
593
+ if (Object.is(before, after))
594
+ return true;
595
+ if (before === undefined || after === undefined)
596
+ return false;
597
+ return JSON.stringify(before) === JSON.stringify(after);
598
+ }
599
+ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, deps = defaultStoreDeps()) {
600
+ if (!LANE_WRITERS.includes(writer)) {
601
+ throw new LanesError("lane_writer_unknown", `Unknown lane writer ${String(writer)}`, { writer });
602
+ }
603
+ return casWrite(laneRecordPath(hqRoot, laneId), expectedVersion, parseLaneRecord, mergeLaneRecords, (current) => {
604
+ // `current` is the pre-update snapshot and stays untouched: the updater
605
+ // receives a deep clone, so mutating its argument in place cannot reach
606
+ // the record the ownership check compares against.
607
+ const updated = updater(structuredClone(current));
608
+ for (const key of Object.keys(updated)) {
609
+ if (key === "version" || key === "updated_at" || fieldUnchanged(current[key], updated[key]))
610
+ continue;
611
+ const allowedWriters = LANE_FIELD_WRITERS[key];
612
+ // Unknown forward-version fields are preserved, never assigned an
613
+ // ownership rule by a v1 reader.
614
+ if (allowedWriters && !allowedWriters.includes(writer)) {
615
+ throw new LanesError("lane_writer_unauthorized", `Writer ${writer} cannot write lane field ${key}`, { writer, field: key });
616
+ }
617
+ }
618
+ return updated;
619
+ }, deps);
620
+ }
621
+ /** Compare-and-set with conflict retry. Callers that race a concurrent writer re-read. */
622
+ export function retryUpdateLane(hqRoot, laneId, writer, updater, deps = defaultStoreDeps(), attempts = 5) {
623
+ let last;
624
+ for (let i = 0; i < attempts; i += 1) {
625
+ try {
626
+ const current = readLane(hqRoot, laneId, deps);
627
+ return updateLane(hqRoot, laneId, current.version, writer, updater, deps);
628
+ }
629
+ catch (err) {
630
+ last = err;
631
+ if (!isLaneVersionConflict(err) || i === attempts - 1)
632
+ throw err;
633
+ }
634
+ }
635
+ throw last;
636
+ }
637
+ /** Compare-and-set with conflict retry for question records (spec §6.2). */
638
+ export function retryUpdateQuestion(hqRoot, questionId, updater, deps = defaultStoreDeps(), attempts = 5) {
639
+ let last;
640
+ for (let i = 0; i < attempts; i += 1) {
641
+ try {
642
+ const current = readQuestion(hqRoot, questionId, deps);
643
+ return updateQuestion(hqRoot, questionId, current.version, updater, deps);
644
+ }
645
+ catch (err) {
646
+ last = err;
647
+ if (!isLaneVersionConflict(err) || i === attempts - 1)
648
+ throw err;
649
+ }
650
+ }
651
+ throw last;
652
+ }
653
+ export function appendLaneSeniorHistory(hqRoot, laneId, expectedVersion, entry, deps = defaultStoreDeps()) {
654
+ return updateLane(hqRoot, laneId, expectedVersion, "command", (current) => ({
655
+ ...current,
656
+ senior_history: appendCappedHistory(current.senior_history, entry),
657
+ }), deps);
658
+ }
659
+ export function appendLaneAttribution(hqRoot, laneId, expectedVersion, entry, deps = defaultStoreDeps()) {
660
+ return updateLane(hqRoot, laneId, expectedVersion, "command", (current) => ({
661
+ ...current,
662
+ attribution_history: appendCappedHistory(current.attribution_history, entry),
663
+ }), deps);
664
+ }
665
+ function syncPendingMarker(hqRoot, question, deps) {
666
+ const marker = pendingMarkerPath(hqRoot, question.question_id);
667
+ if (question.status === "pending") {
668
+ ensureDir(path.dirname(marker), deps);
669
+ if (!deps.existsSync(marker)) {
670
+ deps.writeFileSync(marker, "", { encoding: "utf8" });
671
+ }
672
+ return;
673
+ }
674
+ try {
675
+ deps.unlinkSync(marker);
676
+ }
677
+ catch (err) {
678
+ const code = err.code;
679
+ if (code !== "ENOENT")
680
+ throw err;
681
+ }
682
+ }
683
+ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
684
+ const question_id = init.question_id ?? mintQuestionId(deps);
685
+ const filePath = questionRecordPath(hqRoot, question_id);
686
+ const record = {
687
+ ...init,
688
+ schema_version: init.schema_version ?? QUESTION_SCHEMA_VERSION,
689
+ question_id,
690
+ version: 1,
691
+ routing_history: init.routing_history ?? [],
692
+ };
693
+ parseQuestionRecord(record, filePath);
694
+ return withRecordLock(filePath, deps, () => {
695
+ if (deps.existsSync(filePath)) {
696
+ throw new LanesError("question_exists", `Question ${question_id} already exists`, { question_id });
697
+ }
698
+ atomicWriteJson(filePath, record, deps);
699
+ syncPendingMarker(hqRoot, record, deps);
700
+ return record;
701
+ });
702
+ }
703
+ export function updateQuestion(hqRoot, questionId, expectedVersion, updater, deps = defaultStoreDeps()) {
704
+ const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, updater, deps);
705
+ syncPendingMarker(hqRoot, next, deps);
706
+ return next;
707
+ }
708
+ export function appendQuestionRouting(hqRoot, questionId, expectedVersion, entry, deps = defaultStoreDeps()) {
709
+ return updateQuestion(hqRoot, questionId, expectedVersion, (current) => ({
710
+ ...current,
711
+ routing_history: appendCappedHistory(current.routing_history, entry),
712
+ }), deps);
713
+ }
714
+ function jsonRecordIds(names) {
715
+ return names
716
+ .filter((name) => typeof name === "string" &&
717
+ name.endsWith(".json") &&
718
+ !name.startsWith(".") &&
719
+ !name.includes(".conflict-"))
720
+ .map((name) => name.slice(0, -".json".length));
721
+ }
722
+ /**
723
+ * Absent directory → empty list. A path that exists but cannot be listed
724
+ * (ENOTDIR, EACCES, …) is unknown, not empty — throw `store_unreadable`.
725
+ */
726
+ function listRecordIds(dir, label, deps) {
727
+ let names;
728
+ try {
729
+ names = deps.readdirSync(dir);
730
+ }
731
+ catch (err) {
732
+ const code = err.code;
733
+ if (code === "ENOENT")
734
+ return [];
735
+ throw new LanesError("store_unreadable", `Failed to read ${label} directory ${dir}: ${err instanceof Error ? err.message : String(err)}`, { path: dir });
736
+ }
737
+ return jsonRecordIds(names);
738
+ }
739
+ export function listLaneIds(hqRoot, deps = defaultStoreDeps()) {
740
+ return listRecordIds(path.join(lanesStoreRoot(hqRoot), LANE_RECORDS_DIR), "lane", deps);
741
+ }
742
+ export function listQuestionIds(hqRoot, deps = defaultStoreDeps()) {
743
+ return listRecordIds(path.join(lanesStoreRoot(hqRoot), QUESTION_RECORDS_DIR), "question", deps);
744
+ }
745
+ //# sourceMappingURL=store.js.map