@indigoai-us/hq-cli 5.189.0 → 5.190.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.
@@ -0,0 +1,762 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { resolveProcessFlagGate } from "../flag-registry.js";
5
+ import { LanesError } from "./errors.js";
6
+ import { isTerminalLaneState } from "./types.js";
7
+ export const LANE_STORY_LEDGER_FLAG = "lanes.story-ledger";
8
+ const LOCK_TIMEOUT_MS = 10_000;
9
+ const LOCK_STALE_MS = 60_000;
10
+ const LOCK_RETRY_MS = 10;
11
+ const STORY_STATES = ["todo", "in_progress", "blocked", "in_review", "verification", "done", "dropped"];
12
+ const MANAGER_OWNED_STATES = new Set(["in_review", "verification", "done", "dropped"]);
13
+ const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed"]);
14
+ export function laneStoryLedgerEnabled() {
15
+ return resolveProcessFlagGate(LANE_STORY_LEDGER_FLAG, { fallback: false }, () => false);
16
+ }
17
+ function isSafePathSegment(value) {
18
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value) &&
19
+ value !== "." &&
20
+ value !== "..";
21
+ }
22
+ export function resolveProjectStoryLedger(hqRoot, projectId, companySlug) {
23
+ const normalizedProject = projectId.trim();
24
+ if (!isSafePathSegment(normalizedProject)) {
25
+ throw new LanesError("story_ledger_project_invalid", "Project id must be a single safe path segment", { project_id: projectId });
26
+ }
27
+ if (companySlug !== undefined && !isSafePathSegment(companySlug)) {
28
+ throw new LanesError("story_ledger_company_invalid", "Company slug must be a single safe path segment", { company: companySlug });
29
+ }
30
+ const projectRoot = companySlug === undefined
31
+ ? path.join(hqRoot, "personal", "projects", normalizedProject)
32
+ : path.join(hqRoot, "companies", companySlug, "projects", normalizedProject);
33
+ return {
34
+ projectId: normalizedProject,
35
+ ...(companySlug === undefined ? {} : { companySlug }),
36
+ path: path.join(projectRoot, "prd.json"),
37
+ };
38
+ }
39
+ function ledgerError(error, message, ledger, details = {}) {
40
+ const fix = error === "story_not_in_ledger"
41
+ ? "Add the story to userStories in " + ledger.path + ", then retry."
42
+ : error === "story_ledger_missing"
43
+ ? "Create " + ledger.path + " with a userStories array containing the story, then retry."
44
+ : error === "story_ledger_unreadable"
45
+ ? "Make " + ledger.path + " readable and add the story to its userStories array, then retry."
46
+ : error === "story_ledger_malformed" || error === "story_ledger_invalid"
47
+ ? "Repair " + ledger.path + " so it contains valid JSON with a userStories array, add the story, then retry."
48
+ : undefined;
49
+ return new LanesError(error, message, {
50
+ project_id: ledger.projectId,
51
+ path: ledger.path,
52
+ ...(fix ? { fix } : {}),
53
+ ...details,
54
+ });
55
+ }
56
+ function readLedger(ledger) {
57
+ let text;
58
+ try {
59
+ text = fs.readFileSync(ledger.path, "utf8");
60
+ }
61
+ catch (err) {
62
+ const code = err.code;
63
+ if (code === "ENOENT") {
64
+ throw ledgerError("story_ledger_missing", "Story ledger is missing at " + ledger.path + ". Add the story to userStories in that file, then retry.", ledger, { filesystem_code: code });
65
+ }
66
+ throw ledgerError("story_ledger_unreadable", "Story ledger could not be read at " + ledger.path + ". Check access to the project file.", ledger, { filesystem_code: code ?? "unknown" });
67
+ }
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(text);
71
+ }
72
+ catch {
73
+ throw ledgerError("story_ledger_malformed", "Story ledger is not valid JSON at " + ledger.path, ledger);
74
+ }
75
+ if (parsed === null ||
76
+ typeof parsed !== "object" ||
77
+ Array.isArray(parsed) ||
78
+ !Array.isArray(parsed.userStories)) {
79
+ throw ledgerError("story_ledger_invalid", "Story ledger must be an object with a userStories array at " + ledger.path, ledger);
80
+ }
81
+ const stories = parsed.userStories;
82
+ const seen = new Set();
83
+ const validated = [];
84
+ for (const [index, value] of stories.entries()) {
85
+ const id = isJsonObject(value) ? value.id : undefined;
86
+ if (!isJsonObject(value) ||
87
+ typeof id !== "string" ||
88
+ id.trim() === "") {
89
+ throw ledgerError("story_ledger_invalid", "Story ledger contains a userStories entry without a non-empty id at " + ledger.path, ledger, { index });
90
+ }
91
+ const normalizedId = id.trim();
92
+ if (seen.has(normalizedId)) {
93
+ throw ledgerError("story_ledger_ambiguous", "Story ledger contains duplicate story id " + JSON.stringify(normalizedId) + " at " + ledger.path, ledger, { story_id: normalizedId });
94
+ }
95
+ seen.add(normalizedId);
96
+ validated.push(value);
97
+ }
98
+ return { ...parsed, userStories: validated };
99
+ }
100
+ export function validateProjectStories(hqRoot, projectId, storyIds, companySlug) {
101
+ const ledger = resolveProjectStoryLedger(hqRoot, projectId, companySlug);
102
+ const parsed = readLedger(ledger);
103
+ const known = new Set(parsed.userStories.map((story) => story.id.trim()));
104
+ for (const rawId of storyIds) {
105
+ const storyId = rawId.trim();
106
+ if (!storyId) {
107
+ throw ledgerError("story_ledger_story_invalid", "Story id must not be empty", ledger);
108
+ }
109
+ if (!known.has(storyId)) {
110
+ throw ledgerError("story_not_in_ledger", "Story " + JSON.stringify(storyId) + " was not found in " + ledger.path +
111
+ ". Add it to userStories in that file, then retry.", ledger, { story_id: storyId, add_to: "userStories" });
112
+ }
113
+ }
114
+ return ledger;
115
+ }
116
+ function sleepSync(ms) {
117
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
118
+ }
119
+ function lockPathFor(filePath) {
120
+ return filePath + ".hq-lanes-lock";
121
+ }
122
+ function lockOwnerPath(lockPath) {
123
+ return path.join(lockPath, "owner.json");
124
+ }
125
+ function readLockOwner(lockPath) {
126
+ const ownerPath = lockOwnerPath(lockPath);
127
+ let contents;
128
+ try {
129
+ contents = fs.readFileSync(ownerPath, "utf8");
130
+ }
131
+ catch (err) {
132
+ if (err.code === "ENOENT")
133
+ return { kind: "missing" };
134
+ throw new LanesError("story_ledger_lock_unavailable", "Could not read story ledger lock owner receipt: " + ownerPath, { path: ownerPath });
135
+ }
136
+ let owner;
137
+ try {
138
+ owner = JSON.parse(contents);
139
+ }
140
+ catch {
141
+ throw new LanesError("story_ledger_lock_invalid", "Story ledger lock owner receipt is not valid JSON: " + ownerPath, { path: ownerPath });
142
+ }
143
+ return { kind: "present", owner };
144
+ }
145
+ function hasLockOwnerToken(ownerRead) {
146
+ return ownerRead.kind === "present" &&
147
+ typeof ownerRead.owner.token === "string" && ownerRead.owner.token.length > 0;
148
+ }
149
+ function lockNow(deps) {
150
+ return deps.clock?.now() ?? Date.now();
151
+ }
152
+ function lockSleep(deps, ms) {
153
+ if (deps.clock)
154
+ deps.clock.sleep(ms);
155
+ else
156
+ sleepSync(ms);
157
+ }
158
+ function restoreMovedLockWithoutReplacement(lockPath, stalePath) {
159
+ const entries = fs.readdirSync(stalePath);
160
+ if (entries.some((entry) => entry !== "owner.json")) {
161
+ throw new Error("Unexpected entry in moved story ledger lock");
162
+ }
163
+ const ownerContents = entries.includes("owner.json")
164
+ ? fs.readFileSync(lockOwnerPath(stalePath), "utf8")
165
+ : undefined;
166
+ fs.mkdirSync(lockPath);
167
+ if (ownerContents !== undefined) {
168
+ fs.writeFileSync(lockOwnerPath(lockPath), ownerContents, {
169
+ encoding: "utf8",
170
+ flag: "wx",
171
+ mode: 0o600,
172
+ });
173
+ }
174
+ fs.rmSync(stalePath, { recursive: true, force: true });
175
+ }
176
+ function clearStaleLock(lockPath, deps) {
177
+ let stat;
178
+ try {
179
+ stat = fs.lstatSync(lockPath);
180
+ }
181
+ catch (err) {
182
+ if (err.code === "ENOENT")
183
+ return true;
184
+ throw err;
185
+ }
186
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
187
+ throw new LanesError("story_ledger_lock_invalid", "Story ledger lock path is not a regular directory: " + lockPath, { path: lockPath });
188
+ }
189
+ const ownerRead = readLockOwner(lockPath);
190
+ const hasOwnerToken = hasLockOwnerToken(ownerRead);
191
+ let lockAt = stat.mtimeMs;
192
+ if (hasOwnerToken && typeof ownerRead.owner.at === "string") {
193
+ const parsedAt = Date.parse(ownerRead.owner.at);
194
+ if (Number.isFinite(parsedAt))
195
+ lockAt = parsedAt;
196
+ }
197
+ if (lockNow(deps) - lockAt < LOCK_STALE_MS)
198
+ return false;
199
+ deps.beforeStaleLockRename?.(lockPath);
200
+ const stalePath = lockPath + ".stale-" + randomBytes(8).toString("hex");
201
+ try {
202
+ fs.renameSync(lockPath, stalePath);
203
+ }
204
+ catch (err) {
205
+ if (err.code === "ENOENT")
206
+ return true;
207
+ throw err;
208
+ }
209
+ const moved = fs.lstatSync(stalePath);
210
+ const movedOwnerRead = readLockOwner(stalePath);
211
+ let movedLockAt = moved.mtimeMs;
212
+ if (hasLockOwnerToken(movedOwnerRead) && typeof movedOwnerRead.owner.at === "string") {
213
+ const parsedAt = Date.parse(movedOwnerRead.owner.at);
214
+ if (Number.isFinite(parsedAt))
215
+ movedLockAt = parsedAt;
216
+ }
217
+ if (moved.dev !== stat.dev || moved.ino !== stat.ino ||
218
+ lockNow(deps) - movedLockAt < LOCK_STALE_MS) {
219
+ try {
220
+ // mkdir is exclusive, unlike rename on POSIX which can replace a fresh
221
+ // empty lock directory that appears between an existence check and rename.
222
+ restoreMovedLockWithoutReplacement(lockPath, stalePath);
223
+ }
224
+ catch {
225
+ throw new LanesError("story_ledger_lock_race", "A replacement story ledger lock was preserved at " + stalePath +
226
+ " because it could not safely be restored to " + lockPath, { path: lockPath, preserved_path: stalePath });
227
+ }
228
+ return false;
229
+ }
230
+ fs.rmSync(stalePath, { recursive: true, force: true });
231
+ return true;
232
+ }
233
+ function acquireLedgerLock(filePath, deps) {
234
+ const lockPath = lockPathFor(filePath);
235
+ const token = randomBytes(12).toString("hex");
236
+ const started = lockNow(deps);
237
+ const timeoutMs = deps.lockTimeoutMs ?? LOCK_TIMEOUT_MS;
238
+ const retryMs = deps.lockRetryMs ?? LOCK_RETRY_MS;
239
+ while (lockNow(deps) - started < timeoutMs) {
240
+ try {
241
+ fs.mkdirSync(lockPath);
242
+ fs.writeFileSync(lockOwnerPath(lockPath), JSON.stringify({ pid: process.pid, token, at: new Date(lockNow(deps)).toISOString() }) + "\n", { mode: 0o600 });
243
+ return { lockPath, token };
244
+ }
245
+ catch (err) {
246
+ if (err.code !== "EEXIST") {
247
+ throw new LanesError("story_ledger_lock_unavailable", "Could not create story ledger lock for " + filePath, { path: filePath });
248
+ }
249
+ if (clearStaleLock(lockPath, deps))
250
+ continue;
251
+ lockSleep(deps, retryMs);
252
+ }
253
+ }
254
+ throw new LanesError("story_ledger_lock_timeout", "Timed out waiting for story ledger lock for " + filePath, { path: filePath });
255
+ }
256
+ function releaseLedgerLock(lock) {
257
+ try {
258
+ const owner = JSON.parse(fs.readFileSync(lockOwnerPath(lock.lockPath), "utf8"));
259
+ if (owner.token !== lock.token)
260
+ return;
261
+ fs.unlinkSync(lockOwnerPath(lock.lockPath));
262
+ fs.rmdirSync(lock.lockPath);
263
+ }
264
+ catch (err) {
265
+ if (err.code !== "ENOENT")
266
+ throw err;
267
+ }
268
+ }
269
+ function atomicWriteLedger(filePath, bytes, deps, attempt) {
270
+ const mode = fs.statSync(filePath).mode & 0o777;
271
+ const temp = filePath + "." + process.pid + "." + randomBytes(6).toString("hex") + ".tmp";
272
+ let fd;
273
+ try {
274
+ fd = fs.openSync(temp, "wx", mode);
275
+ fs.writeFileSync(fd, bytes, "utf8");
276
+ fs.fsyncSync(fd);
277
+ fs.closeSync(fd);
278
+ fd = undefined;
279
+ deps.beforeRename?.(filePath, attempt);
280
+ fs.renameSync(temp, filePath);
281
+ const directoryFd = fs.openSync(path.dirname(filePath), "r");
282
+ try {
283
+ fs.fsyncSync(directoryFd);
284
+ }
285
+ finally {
286
+ fs.closeSync(directoryFd);
287
+ }
288
+ }
289
+ catch (err) {
290
+ if (fd !== undefined) {
291
+ try {
292
+ fs.closeSync(fd);
293
+ }
294
+ catch {
295
+ // Preserve the write error.
296
+ }
297
+ }
298
+ try {
299
+ fs.unlinkSync(temp);
300
+ }
301
+ catch (cleanupErr) {
302
+ if (cleanupErr.code !== "ENOENT")
303
+ throw cleanupErr;
304
+ }
305
+ throw err;
306
+ }
307
+ }
308
+ function updateLedgerAtomically(ledger, update, deps = {}) {
309
+ const lock = acquireLedgerLock(ledger.path, deps);
310
+ try {
311
+ for (let attempt = 0; attempt < 5; attempt += 1) {
312
+ let original;
313
+ try {
314
+ original = fs.readFileSync(ledger.path, "utf8");
315
+ }
316
+ catch (err) {
317
+ const code = err.code;
318
+ if (code === "ENOENT") {
319
+ throw ledgerError("story_ledger_missing", "Story ledger disappeared at " + ledger.path, ledger, { filesystem_code: code });
320
+ }
321
+ throw ledgerError("story_ledger_unreadable", "Story ledger could not be read at " + ledger.path, ledger, { filesystem_code: code ?? "unknown" });
322
+ }
323
+ const current = readLedger(ledger);
324
+ const next = update(current);
325
+ const serialized = JSON.stringify(next, null, 2) + "\n";
326
+ if (serialized === original)
327
+ return;
328
+ try {
329
+ atomicWriteLedger(ledger.path, serialized, {
330
+ beforeRename(filePath, hookAttempt) {
331
+ deps.beforeRename?.(filePath, hookAttempt);
332
+ let latest;
333
+ try {
334
+ latest = fs.readFileSync(filePath, "utf8");
335
+ }
336
+ catch {
337
+ latest = "";
338
+ }
339
+ if (latest !== original) {
340
+ throw new LanesError("story_ledger_write_conflict", "Story ledger changed while it was being updated at " + filePath, { path: filePath });
341
+ }
342
+ },
343
+ }, attempt);
344
+ return;
345
+ }
346
+ catch (err) {
347
+ if (err instanceof LanesError && err.error === "story_ledger_write_conflict")
348
+ continue;
349
+ throw err;
350
+ }
351
+ }
352
+ throw ledgerError("story_ledger_write_conflict", "Story ledger kept changing while it was being updated at " + ledger.path, ledger);
353
+ }
354
+ finally {
355
+ releaseLedgerLock(lock);
356
+ }
357
+ }
358
+ function laneStoriesForLedger(lane) {
359
+ if (lane.no_story_reason && (!lane.stories || lane.stories.length === 0))
360
+ return [];
361
+ if (Array.isArray(lane.stories) && lane.stories.length > 0) {
362
+ return lane.stories.map((story) => ({
363
+ project_id: story.project_id,
364
+ story_id: story.story_id,
365
+ status: story.status,
366
+ blocked_reason: story.blocked_reason,
367
+ }));
368
+ }
369
+ return lane.story_id.trim()
370
+ ? [{ project_id: lane.project_id, story_id: lane.story_id }]
371
+ : [];
372
+ }
373
+ function extractPullRequestLinks(artifacts) {
374
+ const links = new Set();
375
+ const seen = new Set();
376
+ const visit = (value, depth) => {
377
+ if (depth > 5 || value === null || value === undefined)
378
+ return;
379
+ if (typeof value === "string") {
380
+ const match = value.match(/https?:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+(?:\/)?/);
381
+ if (match)
382
+ links.add(match[0].replace(/\/$/, ""));
383
+ return;
384
+ }
385
+ if (typeof value !== "object" || seen.has(value))
386
+ return;
387
+ seen.add(value);
388
+ if (Array.isArray(value)) {
389
+ for (const item of value)
390
+ visit(item, depth + 1);
391
+ return;
392
+ }
393
+ for (const nested of Object.values(value))
394
+ visit(nested, depth + 1);
395
+ };
396
+ for (const artifact of artifacts)
397
+ visit(artifact, 0);
398
+ return [...links].sort();
399
+ }
400
+ function isJsonObject(value) {
401
+ return value !== null && typeof value === "object" && !Array.isArray(value);
402
+ }
403
+ function laneHistoryState(entry) {
404
+ return isJsonObject(entry) ? entry.state : undefined;
405
+ }
406
+ function setLaneEntry(story, lane, laneState, laneStoryStatus, prs, pendingQuestionIds, blockedReason) {
407
+ if (story.lanes === undefined)
408
+ story.lanes = [];
409
+ if (!Array.isArray(story.lanes)) {
410
+ throw new LanesError("story_ledger_invalid", "Story lanes must be an array for story " + String(story.id), { story_id: story.id });
411
+ }
412
+ const lanes = story.lanes;
413
+ const matches = lanes
414
+ .map((value, index) => ({ value, index }))
415
+ .filter(({ value }) => value === lane.lane_id || (isJsonObject(value) && value.lane_id === lane.lane_id));
416
+ if (matches.length > 1) {
417
+ throw new LanesError("story_ledger_ambiguous_lane", "Story ledger contains more than one lane entry for lane " + lane.lane_id, { story_id: story.id, lane_id: lane.lane_id });
418
+ }
419
+ const existing = isJsonObject(matches[0]?.value) ? matches[0]?.value : undefined;
420
+ const oldState = laneHistoryState(existing);
421
+ const at = lane.updated_at || lane.created_at;
422
+ const allPrs = [...new Set([...(Array.isArray(existing?.prs) ? existing.prs.filter((v) => typeof v === "string") : []), ...prs])];
423
+ const priorHistory = existing?.history;
424
+ if (priorHistory !== undefined && !Array.isArray(priorHistory)) {
425
+ throw new LanesError("story_ledger_invalid", "Lane history must be an array for lane " + lane.lane_id, { story_id: story.id, lane_id: lane.lane_id });
426
+ }
427
+ const history = Array.isArray(priorHistory) ? [...priorHistory] : [];
428
+ const terminal = isTerminalLaneState(laneState);
429
+ const alreadyRecorded = history.some((item) => isJsonObject(item) &&
430
+ item.lane_id === lane.lane_id &&
431
+ item.state === laneState &&
432
+ item.terminal === true);
433
+ const stateChanged = oldState !== laneState;
434
+ if (stateChanged || (terminal && !alreadyRecorded)) {
435
+ history.push({
436
+ lane_id: lane.lane_id,
437
+ state: laneState,
438
+ at,
439
+ prs,
440
+ ...(terminal ? { terminal: true } : {}),
441
+ });
442
+ }
443
+ const entry = {
444
+ ...(existing ?? {}),
445
+ lane_id: lane.lane_id,
446
+ state: laneState,
447
+ ...(laneStoryStatus === undefined ? {} : { story_status: laneStoryStatus }),
448
+ at,
449
+ prs: allPrs,
450
+ artifacts: lane.last_envelope?.artifacts ?? [],
451
+ history,
452
+ pending_questions: [...new Set(pendingQuestionIds)],
453
+ ...(laneState === "blocked" && blockedReason ? { blocked_on: blockedReason } : {}),
454
+ };
455
+ if (laneState !== "blocked" || !blockedReason)
456
+ delete entry.blocked_on;
457
+ if (matches.length === 0) {
458
+ lanes.push(entry);
459
+ }
460
+ else {
461
+ lanes[matches[0]?.index] = entry;
462
+ }
463
+ story.lanes = lanes;
464
+ }
465
+ function validPriorQuestions(value, storyId) {
466
+ if (value === undefined)
467
+ return [];
468
+ if (!Array.isArray(value)) {
469
+ throw new LanesError("story_ledger_invalid", "pending_questions must be an array for story " + String(storyId), { story_id: storyId });
470
+ }
471
+ return value;
472
+ }
473
+ function setLaneStatusFields(story, lane, laneStoryStatus) {
474
+ const laneStatus = story.lane_status;
475
+ if (laneStatus !== undefined && !isJsonObject(laneStatus)) {
476
+ throw new LanesError("story_ledger_invalid", "lane_status must be an object for story " + String(story.id), { story_id: story.id });
477
+ }
478
+ const entries = Array.isArray(story.lanes)
479
+ ? story.lanes.filter((entry) => isJsonObject(entry) && typeof entry.lane_id === "string")
480
+ : [];
481
+ const active = entries.filter((entry) => !TERMINAL_STATES.has(String(entry.state)));
482
+ const blockers = active.filter((entry) => (entry.state === "blocked" || (entry.state === "awaiting_input" && Array.isArray(entry.pending_questions) && entry.pending_questions.length > 0)));
483
+ const saved = isJsonObject(laneStatus) && isJsonObject(laneStatus.saved)
484
+ ? laneStatus.saved
485
+ : {
486
+ state: story.state,
487
+ blocked_on: story.blocked_on,
488
+ pending_questions: story.pending_questions,
489
+ };
490
+ const priorBlockedOn = saved?.blocked_on;
491
+ if (priorBlockedOn !== undefined && typeof priorBlockedOn !== "string") {
492
+ throw new LanesError("story_ledger_invalid", "blocked_on must be a string for story " + String(story.id), { story_id: story.id });
493
+ }
494
+ const laneBlockedOn = blockers
495
+ .filter((entry) => entry.state === "blocked")
496
+ .map((entry) => "Lane " + String(entry.lane_id) + ": " +
497
+ (typeof entry.blocked_on === "string" && entry.blocked_on.trim() ? entry.blocked_on : "blocked"));
498
+ if (laneBlockedOn.length > 0) {
499
+ story.blocked_on = [typeof priorBlockedOn === "string" ? priorBlockedOn : "", ...laneBlockedOn]
500
+ .filter((value) => value.trim())
501
+ .join("\n");
502
+ }
503
+ else if (priorBlockedOn === undefined) {
504
+ delete story.blocked_on;
505
+ }
506
+ else {
507
+ story.blocked_on = priorBlockedOn;
508
+ }
509
+ const priorQuestions = validPriorQuestions(saved?.pending_questions, story.id);
510
+ const generatedQuestions = active.flatMap((entry) => isJsonObject(entry) && Array.isArray(entry.pending_questions)
511
+ ? entry.pending_questions.map((questionId) => ({
512
+ lane_id: entry.lane_id,
513
+ question_id: questionId,
514
+ }))
515
+ : []);
516
+ if (priorQuestions.length > 0 || generatedQuestions.length > 0) {
517
+ story.pending_questions = [
518
+ ...priorQuestions,
519
+ ...generatedQuestions.filter((question) => !priorQuestions.some((prior) => isJsonObject(prior) &&
520
+ prior.lane_id === question.lane_id &&
521
+ prior.question_id === question.question_id)),
522
+ ];
523
+ }
524
+ else {
525
+ delete story.pending_questions;
526
+ }
527
+ if (MANAGER_OWNED_STATES.has(String(story.state))) {
528
+ // A manager decision always wins over lane-derived status.
529
+ }
530
+ else if (blockers.length > 0) {
531
+ story.state = "blocked";
532
+ }
533
+ else if (laneStoryStatus === "review") {
534
+ story.state = "in_review";
535
+ }
536
+ else if (laneStoryStatus === "done") {
537
+ story.state = "done";
538
+ }
539
+ else if (laneStoryStatus === "in_progress" || lane.state === "queued" || lane.state === "running" || lane.state === "awaiting_input") {
540
+ story.state = "in_progress";
541
+ }
542
+ else if (saved !== undefined && story.state === "blocked") {
543
+ const previous = saved.state;
544
+ story.state = typeof previous === "string" && STORY_STATES.includes(previous)
545
+ ? previous
546
+ : "in_progress";
547
+ }
548
+ if (blockers.length > 0)
549
+ story.lane_status = { saved };
550
+ else
551
+ delete story.lane_status;
552
+ story.updated_at = lane.updated_at || lane.created_at;
553
+ }
554
+ function updateOneStory(current, ledger, lane, storyId, projectId, questionRefs) {
555
+ const story = current.userStories.find((item) => item.id === storyId);
556
+ if (!story) {
557
+ throw ledgerError("story_not_in_ledger", "Story " + JSON.stringify(storyId) + " was not found in " + ledger.path +
558
+ ". Add it to userStories in that file, then retry.", ledger, { story_id: storyId });
559
+ }
560
+ const prLinks = extractPullRequestLinks(lane.last_envelope?.artifacts ?? []);
561
+ const laneStory = lane.stories?.find((entry) => entry.project_id === projectId && entry.story_id === storyId);
562
+ const pendingQuestionIds = questionRefs
563
+ .filter((question) => question.project_id === projectId && question.story_id === storyId)
564
+ .map((question) => question.question_id);
565
+ const blockedReason = laneStory?.blocked_reason ??
566
+ (laneStoriesForLedger(lane).length <= 1 ? lane.last_envelope?.blocked_reason : undefined);
567
+ const laneState = storyLaneState(lane, laneStory?.status, pendingQuestionIds);
568
+ setLaneEntry(story, lane, laneState, laneStory?.status, prLinks, pendingQuestionIds, blockedReason);
569
+ setLaneStatusFields(story, lane, laneStory?.status);
570
+ if (prLinks.length > 0) {
571
+ if (story.prs === undefined)
572
+ story.prs = [];
573
+ if (!Array.isArray(story.prs)) {
574
+ throw new LanesError("story_ledger_invalid", "Story prs must be an array for story " + storyId, { story_id: storyId });
575
+ }
576
+ const prs = story.prs;
577
+ for (const url of prLinks) {
578
+ if (!prs.includes(url))
579
+ prs.push(url);
580
+ }
581
+ }
582
+ return current;
583
+ }
584
+ export function syncLaneToProjectStoryLedger(hqRoot, lane, questionRefs = [], deps = {}) {
585
+ if (!laneStoryLedgerEnabled())
586
+ return;
587
+ const stories = laneStoriesForLedger(lane);
588
+ if (stories.length === 0)
589
+ return;
590
+ const refs = [...new Map(stories.map((story) => [
591
+ story.project_id + "\u0000" + story.story_id,
592
+ { projectId: story.project_id, storyId: story.story_id, laneStory: story },
593
+ ])).values()].sort((a, b) => a.projectId.localeCompare(b.projectId) || a.storyId.localeCompare(b.storyId));
594
+ for (const ref of refs) {
595
+ const ledger = resolveProjectStoryLedger(hqRoot, ref.projectId, lane.company?.slug);
596
+ updateLedgerAtomically(ledger, (current) => {
597
+ return updateOneStory(current, ledger, lane, ref.storyId, ref.projectId, questionRefs);
598
+ }, deps);
599
+ }
600
+ }
601
+ function storyLaneState(lane, laneStoryStatus, pendingQuestionIds) {
602
+ if (isTerminalLaneState(lane.state))
603
+ return lane.state;
604
+ if (lane.state === "blocked" && laneStoriesForLedger(lane).length <= 1)
605
+ return "blocked";
606
+ if (laneStoryStatus === "blocked")
607
+ return "blocked";
608
+ if (laneStoryStatus === "done")
609
+ return "done";
610
+ if (lane.state === "awaiting_input" && pendingQuestionIds.length > 0)
611
+ return "awaiting_input";
612
+ if (laneStoryStatus === undefined)
613
+ return lane.state;
614
+ if (lane.state === "queued" && laneStoryStatus === "in_progress")
615
+ return "queued";
616
+ return "running";
617
+ }
618
+ function storyState(story) {
619
+ const state = story.state;
620
+ if (typeof state === "string" && STORY_STATES.includes(state)) {
621
+ return state;
622
+ }
623
+ return story.passes === true ? "done" : "todo";
624
+ }
625
+ function laneStoryRefs(lane, projectId) {
626
+ return laneStoriesForLedger(lane)
627
+ .filter((story) => story.project_id === projectId)
628
+ .map((story) => story.story_id);
629
+ }
630
+ export function buildLaneStatusSnapshot(input) {
631
+ const ledger = resolveProjectStoryLedger(input.hqRoot, input.projectId, input.companySlug);
632
+ const parsed = readLedger(ledger);
633
+ const projectLanes = input.lanes
634
+ .filter((lane) => {
635
+ const sameCompany = lane.company?.slug === input.companySlug &&
636
+ (input.companyUid === undefined || lane.company?.uid === input.companyUid);
637
+ return sameCompany && (laneStoriesForLedger(lane).some((story) => story.project_id === input.projectId) ||
638
+ (lane.no_story_reason !== undefined && lane.project_id === input.projectId));
639
+ })
640
+ .sort((a, b) => a.created_at.localeCompare(b.created_at) || a.lane_id.localeCompare(b.lane_id));
641
+ const workers = projectLanes.map((lane) => ({
642
+ lane_id: lane.lane_id,
643
+ company: lane.company?.slug ?? null,
644
+ provider: lane.provider,
645
+ account: lane.account ?? null,
646
+ model: lane.model ?? null,
647
+ effort: lane.effort ?? null,
648
+ stories: laneStoryRefs(lane, input.projectId),
649
+ no_story_reason: lane.no_story_reason ?? null,
650
+ state: lane.state,
651
+ queued_reason: lane.queued_reason ?? null,
652
+ }));
653
+ const projectPrs = [];
654
+ const blockers = [];
655
+ const pendingQuestions = [];
656
+ const byState = Object.fromEntries(STORY_STATES.map((state) => [state, 0]));
657
+ for (const story of parsed.userStories) {
658
+ const storyId = story.id;
659
+ const state = storyState(story);
660
+ byState[state] += 1;
661
+ for (const reference of Array.isArray(story.prs) ? story.prs : []) {
662
+ if (typeof reference === "string" && reference.trim()) {
663
+ projectPrs.push({ story_id: storyId, reference: reference.trim() });
664
+ }
665
+ }
666
+ if (typeof story.blocked_on === "string" && story.blocked_on.trim()) {
667
+ blockers.push({ story_id: storyId, reason: story.blocked_on.trim() });
668
+ }
669
+ if (Array.isArray(story.pending_questions)) {
670
+ for (const item of story.pending_questions) {
671
+ if (isJsonObject(item) && typeof item.lane_id === "string" && typeof item.question_id === "string") {
672
+ pendingQuestions.push({
673
+ story_id: storyId,
674
+ lane_id: item.lane_id,
675
+ question_ids: [item.question_id],
676
+ });
677
+ }
678
+ }
679
+ }
680
+ }
681
+ for (const lane of projectLanes) {
682
+ const carriedStories = laneStoriesForLedger(lane)
683
+ .filter((story) => story.project_id === input.projectId);
684
+ for (const carriedStory of carriedStories) {
685
+ const ids = (input.pendingQuestions?.get(lane.lane_id) ?? [])
686
+ .filter((question) => question.project_id === carriedStory.project_id && question.story_id === carriedStory.story_id)
687
+ .map((question) => question.question_id);
688
+ const state = storyLaneState(lane, carriedStory.status, ids);
689
+ if (state === "blocked") {
690
+ const reason = carriedStory.blocked_reason ??
691
+ (carriedStories.length <= 1 ? lane.last_envelope?.blocked_reason : undefined) ?? "blocked";
692
+ const alreadyListed = blockers.some((blocker) => blocker.story_id === carriedStory.story_id &&
693
+ (blocker.lane_id === lane.lane_id || blocker.reason.startsWith("Lane " + lane.lane_id + ":")));
694
+ if (!alreadyListed)
695
+ blockers.push({ story_id: carriedStory.story_id, lane_id: lane.lane_id, reason });
696
+ }
697
+ if (ids.length > 0) {
698
+ pendingQuestions.push({ story_id: carriedStory.story_id, lane_id: lane.lane_id, question_ids: ids });
699
+ }
700
+ }
701
+ }
702
+ const uniquePrs = [...new Map(projectPrs.map((pr) => [pr.story_id + "\u0000" + pr.reference, pr])).values()];
703
+ const uniqueBlockers = [...new Map(blockers.map((item) => [
704
+ item.story_id + "\u0000" + (item.lane_id ?? "") + "\u0000" + item.reason,
705
+ item,
706
+ ])).values()];
707
+ const uniqueQuestions = [...new Map(pendingQuestions.map((item) => [
708
+ item.story_id + "\u0000" + item.lane_id + "\u0000" + item.question_ids.join(","),
709
+ item,
710
+ ])).values()];
711
+ return {
712
+ schema_version: "hq-lanes-status.v1",
713
+ project_id: input.projectId,
714
+ ledger_path: ledger.path,
715
+ generated_at: input.generatedAt,
716
+ workers,
717
+ prs: uniquePrs,
718
+ blockers: uniqueBlockers,
719
+ pending_questions: uniqueQuestions,
720
+ story_counts: {
721
+ total: parsed.userStories.length,
722
+ by_state: byState,
723
+ },
724
+ };
725
+ }
726
+ export function formatLaneStatusHuman(snapshot) {
727
+ const counts = Object.entries(snapshot.story_counts.by_state)
728
+ .map(([state, count]) => state + "=" + count)
729
+ .join(" ");
730
+ const lines = [
731
+ "Project " + snapshot.project_id + " (" + snapshot.workers.length + " lanes)",
732
+ "Stories: " + counts,
733
+ "Workers:",
734
+ ...(snapshot.workers.length === 0
735
+ ? [" none"]
736
+ : snapshot.workers.map((worker) => {
737
+ const storyLabel = worker.stories.length > 0
738
+ ? worker.stories.join(", ")
739
+ : "no story: " + (worker.no_story_reason ?? "reason not recorded");
740
+ const model = worker.model ? " / " + worker.model : "";
741
+ const effort = worker.effort ? " (" + worker.effort + ")" : "";
742
+ const account = worker.account ? " account " + worker.account : "";
743
+ const queued = worker.queued_reason ? " queued: " + worker.queued_reason : "";
744
+ return " " + worker.lane_id + " " + worker.state + " " + storyLabel +
745
+ " " + worker.provider + model + effort + account + queued;
746
+ })),
747
+ "PRs (may include merged):",
748
+ ...(snapshot.prs.length === 0
749
+ ? [" none"]
750
+ : snapshot.prs.map((pr) => " " + pr.story_id + " " + pr.reference)),
751
+ "Blockers:",
752
+ ...(snapshot.blockers.length === 0
753
+ ? [" none"]
754
+ : snapshot.blockers.map((blocker) => " " + blocker.story_id + (blocker.lane_id ? " / " + blocker.lane_id : "") + " " + blocker.reason)),
755
+ "Pending questions:",
756
+ ...(snapshot.pending_questions.length === 0
757
+ ? [" none"]
758
+ : snapshot.pending_questions.map((pending) => " " + pending.story_id + " / " + pending.lane_id + " " + pending.question_ids.join(", "))),
759
+ ];
760
+ return lines.join("\n");
761
+ }
762
+ //# sourceMappingURL=story-ledger.js.map