@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.
@@ -15,6 +15,7 @@ import { resolveLanesHqRoot } from "./hq-root.js";
15
15
  import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
16
16
  import { HISTORY_CAP, HOST_KINDS, LANE_STORY_STATUSES, LANE_SCHEMA_VERSION, QUESTION_SCHEMA_VERSION, SENIOR_KINDS, isHostKind, isLaneProvider, isQueuedReason, isLaneState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseMajorSchema, REQUIRED_LANE_RECORD_FIELDS, LANE_FIELD_WRITERS, LANE_WRITERS, } from "./types.js";
17
17
  import { laneStories } from "./stories.js";
18
+ import { laneStoryLedgerEnabled, syncLaneToProjectStoryLedger, } from "./story-ledger.js";
18
19
  export const LANES_STORE_REL = path.join("workspace", "lanes");
19
20
  export const LANE_RECORDS_DIR = "lanes";
20
21
  export const QUESTION_RECORDS_DIR = "questions";
@@ -319,13 +320,18 @@ function legacyLaneStory(raw) {
319
320
  }
320
321
  function parseLaneStories(raw, filePath) {
321
322
  if (raw.stories === undefined) {
323
+ if (raw.story_id === "" && typeof raw.no_story_reason === "string") {
324
+ raw.stories = [];
325
+ return;
326
+ }
322
327
  // v1 records carried one story in the top-level fields. Keep the old
323
328
  // fields authoritative for the migration and give worker-completed work
324
329
  // the review state until a senior explicitly verifies it.
325
330
  raw.stories = [legacyLaneStory(raw)];
326
331
  return;
327
332
  }
328
- if (!Array.isArray(raw.stories) || raw.stories.length === 0) {
333
+ if (!Array.isArray(raw.stories) ||
334
+ (raw.stories.length === 0 && !(raw.story_id === "" && typeof raw.no_story_reason === "string"))) {
329
335
  throw new LaneSchemaError("stories must be a non-empty array when present", {
330
336
  path: filePath,
331
337
  field: "stories",
@@ -397,7 +403,28 @@ export function parseLaneRecord(raw, filePath) {
397
403
  });
398
404
  }
399
405
  requireString(raw.project_id, "project_id");
400
- requireString(raw.story_id, "story_id");
406
+ if (typeof raw.story_id !== "string" ||
407
+ (raw.story_id.trim() === "" && raw.no_story_reason === undefined)) {
408
+ throw new LaneSchemaError("Expected non-empty string for story_id", {
409
+ path: filePath,
410
+ field: "story_id",
411
+ value: raw.story_id,
412
+ });
413
+ }
414
+ if (raw.no_story_reason !== undefined && (typeof raw.no_story_reason !== "string" || raw.no_story_reason.trim() === "")) {
415
+ throw new LaneSchemaError("no_story_reason must be a non-empty string", { path: filePath, field: "no_story_reason" });
416
+ }
417
+ if (raw.story_id.trim() !== "" && raw.no_story_reason !== undefined) {
418
+ throw new LaneSchemaError("no_story_reason requires an empty story_id", { path: filePath, field: "no_story_reason" });
419
+ }
420
+ if (raw.ledger_sync_error !== undefined && (typeof raw.ledger_sync_error !== "string" || raw.ledger_sync_error.trim() === "")) {
421
+ throw new LaneSchemaError("ledger_sync_error must be a non-empty string", { path: filePath, field: "ledger_sync_error" });
422
+ }
423
+ if (raw.pending_question_ids !== undefined && (!Array.isArray(raw.pending_question_ids) ||
424
+ raw.pending_question_ids.some((id) => typeof id !== "string" || id.trim() === "") ||
425
+ new Set(raw.pending_question_ids).size !== raw.pending_question_ids.length)) {
426
+ throw new LaneSchemaError("pending_question_ids must be an array of unique non-empty ids", { path: filePath, field: "pending_question_ids" });
427
+ }
401
428
  requireTimestamp(raw.created_at, "created_at", filePath);
402
429
  requireTimestamp(raw.updated_at, "updated_at", filePath);
403
430
  if (raw.queued_reason !== undefined && !isQueuedReason(raw.queued_reason)) {
@@ -435,6 +462,15 @@ export function parseLaneRecord(raw, filePath) {
435
462
  throw new LaneSchemaError(`Unknown worker policy delivery mode ${JSON.stringify(raw.worker_policy_delivery)}`, { path: filePath, field: "worker_policy_delivery", value: raw.worker_policy_delivery });
436
463
  }
437
464
  parseLaneStories(raw, filePath);
465
+ if (raw.last_envelope !== undefined) {
466
+ if (!isPlainObject(raw.last_envelope) || typeof raw.last_envelope.ref !== "string" ||
467
+ typeof raw.last_envelope.at !== "string" ||
468
+ !["done", "ask", "blocked"].includes(raw.last_envelope.decision) ||
469
+ (raw.last_envelope.artifacts !== undefined && !Array.isArray(raw.last_envelope.artifacts)) ||
470
+ (raw.last_envelope.blocked_reason !== undefined && typeof raw.last_envelope.blocked_reason !== "string")) {
471
+ throw new LaneSchemaError("last_envelope is malformed", { path: filePath, field: "last_envelope" });
472
+ }
473
+ }
438
474
  parseOptionalCompanyField(raw, filePath);
439
475
  parseOptionalMeshCompanyField(raw, filePath);
440
476
  if (!isPlainObject(raw.senior)) {
@@ -867,13 +903,14 @@ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
867
903
  attribution_history: init.attribution_history ?? [],
868
904
  };
869
905
  parseLaneRecord(record, filePath);
870
- return withRecordLock(filePath, deps, () => {
906
+ const created = withRecordLock(filePath, deps, () => {
871
907
  if (deps.existsSync(filePath)) {
872
908
  throw new LanesError("lane_exists", `Lane ${lane_id} already exists`, { lane_id });
873
909
  }
874
910
  atomicWriteJson(filePath, record, deps);
875
911
  return record;
876
912
  });
913
+ return syncStoryLedgerForLane(hqRoot, created, deps);
877
914
  }
878
915
  /** Hierarchy lock: separate from record writes so callers can CAS-write while holding it. */
879
916
  export function withLaneHireLocks(hqRoot, laneIds, fn, deps = defaultStoreDeps()) {
@@ -899,7 +936,9 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
899
936
  if (!LANE_WRITERS.includes(writer)) {
900
937
  throw new LanesError("lane_writer_unknown", `Unknown lane writer ${String(writer)}`, { writer });
901
938
  }
902
- return casWrite(laneRecordPath(hqRoot, laneId), expectedVersion, parseLaneRecord, mergeLaneRecords, (current) => {
939
+ let before;
940
+ const updated = casWrite(laneRecordPath(hqRoot, laneId), expectedVersion, parseLaneRecord, mergeLaneRecords, (current) => {
941
+ before = current;
903
942
  // `current` is the pre-update snapshot and stays untouched: the updater
904
943
  // receives a deep clone, so mutating its argument in place cannot reach
905
944
  // the record the ownership check compares against.
@@ -916,6 +955,66 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
916
955
  }
917
956
  return updated;
918
957
  }, deps);
958
+ if (laneStoryLedgerEnabled() && before && laneLedgerRelevantChange(before, updated)) {
959
+ return syncStoryLedgerForLane(hqRoot, updated, deps);
960
+ }
961
+ return updated;
962
+ }
963
+ function laneLedgerRelevantChange(before, after) {
964
+ return before.state !== after.state || before.project_id !== after.project_id ||
965
+ before.story_id !== after.story_id || before.no_story_reason !== after.no_story_reason ||
966
+ JSON.stringify(before.pending_question_ids) !== JSON.stringify(after.pending_question_ids) ||
967
+ JSON.stringify(before.stories) !== JSON.stringify(after.stories) ||
968
+ JSON.stringify(before.last_envelope) !== JSON.stringify(after.last_envelope);
969
+ }
970
+ function syncStoryLedgerForLane(hqRoot, lane, deps) {
971
+ if (!laneStoryLedgerEnabled())
972
+ return lane;
973
+ try {
974
+ const questionRefs = [];
975
+ for (const questionId of lane.pending_question_ids ?? []) {
976
+ const question = readQuestion(hqRoot, questionId, deps);
977
+ const sameCompany = question.company === null
978
+ ? lane.company === null
979
+ : lane.company !== null &&
980
+ question.company.uid === lane.company.uid &&
981
+ question.company.slug === lane.company.slug;
982
+ if (question.lane_id !== lane.lane_id || !sameCompany) {
983
+ throw new LanesError("story_ledger_question_mismatch", `Pending question ${questionId} does not belong to lane ${lane.lane_id}`, {
984
+ lane_id: lane.lane_id,
985
+ question_id: questionId,
986
+ question_lane_id: question.lane_id,
987
+ company: question.company?.slug ?? null,
988
+ });
989
+ }
990
+ if (question.status === "pending") {
991
+ questionRefs.push({
992
+ question_id: question.question_id,
993
+ project_id: question.project_id,
994
+ story_id: question.story_id,
995
+ });
996
+ }
997
+ }
998
+ syncLaneToProjectStoryLedger(hqRoot, lane, questionRefs);
999
+ if (lane.ledger_sync_error !== undefined) {
1000
+ retryUpdateLane(hqRoot, lane.lane_id, "story-ledger", (current) => current.ledger_sync_error === undefined
1001
+ ? current
1002
+ : { ...current, ledger_sync_error: undefined }, deps);
1003
+ }
1004
+ return readLane(hqRoot, lane.lane_id, deps);
1005
+ }
1006
+ catch (error) {
1007
+ const message = (error instanceof Error ? error.message : String(error)).slice(0, 1000) || "Story ledger synchronization failed";
1008
+ try {
1009
+ retryUpdateLane(hqRoot, lane.lane_id, "story-ledger", (current) => current.ledger_sync_error === message
1010
+ ? current
1011
+ : { ...current, ledger_sync_error: message }, deps);
1012
+ return readLane(hqRoot, lane.lane_id, deps);
1013
+ }
1014
+ catch (recordError) {
1015
+ throw new LanesError("story_ledger_sync_error_unrecorded", "Story ledger synchronization failed and the failure could not be recorded on lane " + lane.lane_id, { lane_id: lane.lane_id, cause: recordError instanceof Error ? recordError.message : String(recordError) });
1016
+ }
1017
+ }
919
1018
  }
920
1019
  /**
921
1020
  * Replace the primary copy with a terminal snapshot returned by the Outpost
@@ -948,7 +1047,7 @@ export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane,
948
1047
  });
949
1048
  }
950
1049
  const filePath = laneRecordPath(hqRoot, laneId);
951
- return withRecordLock(filePath, deps, () => {
1050
+ const synced = withRecordLock(filePath, deps, () => {
952
1051
  const current = readMergedRecord(filePath, parseLaneRecord, mergeLaneRecords, deps);
953
1052
  if (current === null) {
954
1053
  throw new LaneSchemaError(`Record disappeared under lock: ${filePath}`, {
@@ -982,6 +1081,7 @@ export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane,
982
1081
  atomicWriteJson(filePath, synced, deps);
983
1082
  return synced;
984
1083
  });
1084
+ return syncStoryLedgerForLane(hqRoot, synced, deps);
985
1085
  }
986
1086
  /** Compare-and-set with conflict retry. Callers that race a concurrent writer re-read. */
987
1087
  export function retryUpdateLane(hqRoot, laneId, writer, updater, deps = defaultStoreDeps(), attempts = 5) {
@@ -1056,7 +1156,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
1056
1156
  routing_history: init.routing_history ?? [],
1057
1157
  };
1058
1158
  parseQuestionRecord(record, filePath);
1059
- return withRecordLock(filePath, deps, () => {
1159
+ const created = withRecordLock(filePath, deps, () => {
1060
1160
  if (deps.existsSync(filePath)) {
1061
1161
  throw new LanesError("question_exists", `Question ${question_id} already exists`, { question_id });
1062
1162
  }
@@ -1064,12 +1164,35 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
1064
1164
  syncPendingMarker(hqRoot, record, deps);
1065
1165
  return record;
1066
1166
  });
1167
+ syncLaneQuestionReference(hqRoot, created, deps);
1168
+ return created;
1067
1169
  }
1068
1170
  export function updateQuestion(hqRoot, questionId, expectedVersion, updater, deps = defaultStoreDeps()) {
1069
1171
  const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, updater, deps);
1070
1172
  syncPendingMarker(hqRoot, next, deps);
1173
+ syncLaneQuestionReference(hqRoot, next, deps);
1071
1174
  return next;
1072
1175
  }
1176
+ function syncLaneQuestionReference(hqRoot, question, deps) {
1177
+ if (!laneStoryLedgerEnabled())
1178
+ return;
1179
+ const lane = tryReadLane(hqRoot, question.lane_id, deps);
1180
+ if (!lane || question.company?.slug !== lane.company?.slug || question.company?.uid !== lane.company?.uid)
1181
+ return;
1182
+ retryUpdateLane(hqRoot, lane.lane_id, "story-ledger", (current) => {
1183
+ if (question.company?.slug !== current.company?.slug || question.company?.uid !== current.company?.uid)
1184
+ return current;
1185
+ const ids = new Set(current.pending_question_ids ?? []);
1186
+ const hadQuestion = ids.has(question.question_id);
1187
+ if (question.status === "pending")
1188
+ ids.add(question.question_id);
1189
+ else
1190
+ ids.delete(question.question_id);
1191
+ if (hadQuestion === (question.status === "pending"))
1192
+ return current;
1193
+ return { ...current, pending_question_ids: [...ids].sort() };
1194
+ }, deps);
1195
+ }
1073
1196
  export function appendQuestionRouting(hqRoot, questionId, expectedVersion, entry, deps = defaultStoreDeps()) {
1074
1197
  return updateQuestion(hqRoot, questionId, expectedVersion, (current) => ({
1075
1198
  ...current,
@@ -26,6 +26,9 @@ function legacyStatus(lane) {
26
26
  * lane write, so reading an old record never changes it on disk by itself.
27
27
  */
28
28
  export function laneStories(lane) {
29
+ if (lane.no_story_reason && (!Array.isArray(lane.stories) || lane.stories.length === 0)) {
30
+ return [];
31
+ }
29
32
  if (Array.isArray(lane.stories) && lane.stories.length > 0) {
30
33
  return lane.stories;
31
34
  }
@@ -0,0 +1,78 @@
1
+ import { type LaneRecord, type LaneState } from "./types.js";
2
+ export declare const LANE_STORY_LEDGER_FLAG = "lanes.story-ledger";
3
+ declare const STORY_STATES: readonly ["todo", "in_progress", "blocked", "in_review", "verification", "done", "dropped"];
4
+ export interface ProjectStoryLedger {
5
+ projectId: string;
6
+ companySlug?: string;
7
+ path: string;
8
+ }
9
+ export interface StoryLedgerWriteDeps {
10
+ /** Test seam for an unlocked manager edit immediately before the conflict check. */
11
+ beforeRename?: (filePath: string, attempt: number) => void;
12
+ /** Test seam for a fresh lock replacing the path after a stale lock was inspected. */
13
+ beforeStaleLockRename?: (lockPath: string) => void;
14
+ /** Deterministic clock seam for stale-lock and timeout tests. */
15
+ clock?: {
16
+ now: () => number;
17
+ sleep: (ms: number) => void;
18
+ };
19
+ lockTimeoutMs?: number;
20
+ lockRetryMs?: number;
21
+ }
22
+ export interface StoryLedgerQuestionRef {
23
+ question_id: string;
24
+ project_id: string;
25
+ story_id: string;
26
+ }
27
+ export interface LaneStatusSnapshot {
28
+ schema_version: "hq-lanes-status.v1";
29
+ project_id: string;
30
+ ledger_path: string;
31
+ generated_at: string;
32
+ workers: Array<{
33
+ lane_id: string;
34
+ company: string | null;
35
+ provider: string;
36
+ account: string | null;
37
+ model: string | null;
38
+ effort: string | null;
39
+ stories: string[];
40
+ no_story_reason: string | null;
41
+ state: LaneState;
42
+ queued_reason: string | null;
43
+ }>;
44
+ prs: Array<{
45
+ story_id: string;
46
+ reference: string;
47
+ }>;
48
+ blockers: Array<{
49
+ story_id: string;
50
+ lane_id?: string;
51
+ reason: string;
52
+ }>;
53
+ pending_questions: Array<{
54
+ story_id: string;
55
+ lane_id: string;
56
+ question_ids: string[];
57
+ }>;
58
+ story_counts: {
59
+ total: number;
60
+ by_state: Record<(typeof STORY_STATES)[number], number>;
61
+ };
62
+ }
63
+ export declare function laneStoryLedgerEnabled(): boolean;
64
+ export declare function resolveProjectStoryLedger(hqRoot: string, projectId: string, companySlug?: string): ProjectStoryLedger;
65
+ export declare function validateProjectStories(hqRoot: string, projectId: string, storyIds: readonly string[], companySlug?: string): ProjectStoryLedger;
66
+ export declare function syncLaneToProjectStoryLedger(hqRoot: string, lane: LaneRecord, questionRefs?: readonly StoryLedgerQuestionRef[], deps?: StoryLedgerWriteDeps): void;
67
+ export declare function buildLaneStatusSnapshot(input: {
68
+ hqRoot: string;
69
+ projectId: string;
70
+ companySlug?: string;
71
+ companyUid?: string;
72
+ lanes: readonly LaneRecord[];
73
+ generatedAt: string;
74
+ pendingQuestions?: ReadonlyMap<string, readonly StoryLedgerQuestionRef[]>;
75
+ }): LaneStatusSnapshot;
76
+ export declare function formatLaneStatusHuman(snapshot: LaneStatusSnapshot): string;
77
+ export {};
78
+ //# sourceMappingURL=story-ledger.d.ts.map