@indigoai-us/hq-cli 5.188.1 → 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.
@@ -7,6 +7,22 @@ function assertState(state, bundle, companyUid, operationId) {
7
7
  state.phase !== "preparing")
8
8
  throw new Error("Promotion preparation returned a different identity or phase");
9
9
  }
10
+ function hasValidEncryptionHeaders(headers) {
11
+ const valuesFor = (name) => Object.entries(headers)
12
+ .filter(([key]) => key.toLowerCase() === name)
13
+ .map(([, value]) => value);
14
+ const encryptionValues = valuesFor("x-amz-server-side-encryption");
15
+ const keyIdValues = valuesFor("x-amz-server-side-encryption-aws-kms-key-id");
16
+ // Multiple differently-cased copies are ambiguous, even when their values
17
+ // happen to match. Accept one exact SSE-KMS pair or no effective SSE values.
18
+ if (encryptionValues.length > 1 || keyIdValues.length > 1)
19
+ return false;
20
+ const encryption = encryptionValues[0] ?? "";
21
+ const keyId = keyIdValues[0] ?? "";
22
+ if (!encryption.trim() && !keyId.trim())
23
+ return true;
24
+ return encryption === "aws:kms" && Boolean(keyId.trim());
25
+ }
10
26
  /** Only the presigned object request receives these bytes. Owner authorization
11
27
  * is handled by BotApi and is never forwarded to S3 or a redirect destination.
12
28
  */
@@ -15,8 +31,7 @@ async function uploadObject(upload, bytes, fetchImpl) {
15
31
  if (url.protocol !== "https:" || url.username || url.password || url.port ||
16
32
  !/^[a-z0-9.-]+\.s3(?:[.-][a-z0-9-]+)?\.amazonaws\.com$/.test(url.hostname) ||
17
33
  upload.method !== "PUT" || upload.bytes !== bytes.length || upload.sha256 !== sha(bytes) ||
18
- upload.headers["x-amz-server-side-encryption"] !== "aws:kms" ||
19
- !upload.headers["x-amz-server-side-encryption-aws-kms-key-id"] ||
34
+ !hasValidEncryptionHeaders(upload.headers) ||
20
35
  upload.headers["if-none-match"] !== "*" ||
21
36
  upload.headers["x-amz-checksum-sha256"] !== Buffer.from(upload.sha256, "hex").toString("base64") ||
22
37
  Object.keys(upload.headers).some(key => /^(authorization|cookie)$/i.test(key))) {
@@ -16,6 +16,8 @@ export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFT
16
16
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
17
17
  export { resolveLanesHqRoot } from "./hq-root.js";
18
18
  export type { LanesHqRootOpts } from "./hq-root.js";
19
+ export { LANE_STORY_LEDGER_FLAG, buildLaneStatusSnapshot, formatLaneStatusHuman, laneStoryLedgerEnabled, resolveProjectStoryLedger, syncLaneToProjectStoryLedger, validateProjectStories, } from "./story-ledger.js";
20
+ export type { LaneStatusSnapshot, ProjectStoryLedger, StoryLedgerWriteDeps } from "./story-ledger.js";
19
21
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
20
22
  export type { OutpostDispatchResult, OutpostPickupPayload, } from "./outpost-dispatch.js";
21
23
  export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
@@ -10,6 +10,7 @@ export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HIS
10
10
  export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
11
11
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
12
12
  export { resolveLanesHqRoot } from "./hq-root.js";
13
+ export { LANE_STORY_LEDGER_FLAG, buildLaneStatusSnapshot, formatLaneStatusHuman, laneStoryLedgerEnabled, resolveProjectStoryLedger, syncLaneToProjectStoryLedger, validateProjectStories, } from "./story-ledger.js";
13
14
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
14
15
  export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
15
16
  export { DEFAULT_LANE_WATCH_INTERVAL_MS, MAX_LANE_WATCH_INTERVAL_MS, MAX_WATCHED_LANES, MAX_WATCH_OUTPUT_LINES, runLaneWatch, } from "./watch.js";
@@ -35,6 +35,8 @@ export interface OutpostLaneCommand {
35
35
  story?: string;
36
36
  account?: string;
37
37
  model?: string;
38
+ effort?: string;
39
+ worker?: string;
38
40
  hire?: string;
39
41
  grantContext?: string;
40
42
  compact?: boolean;
@@ -81,6 +83,8 @@ export declare function buildOutpostBalanceMoveCommand(input: {
81
83
  export declare function buildOutpostIdentityBackfillCommand(expectedId: string): string;
82
84
  /** Build the same `hq outposts exec` remote command used by lane pickup. */
83
85
  export declare function buildOutpostLaneCommand(input: OutpostLaneCommand): string;
86
+ /** True when a remote CLI exited after rejecting the forwarded effort flag. */
87
+ export declare function outpostRejectedEffortOption(result: Pick<OutpostDispatchResult, "exitCode" | "stdout" | "stderr">): boolean;
84
88
  export declare function stageOutpostPayload(port: Pick<OutpostExecPort, "stageExecInput">, outpostId: string, bytes: Uint8Array, put?: typeof fetch): Promise<{
85
89
  getUrl: string;
86
90
  }>;
@@ -104,6 +104,8 @@ export function buildOutpostLaneCommand(input) {
104
104
  ["--story", input.story],
105
105
  ["--account", input.account],
106
106
  ["--model", input.model],
107
+ ["--effort", input.effort],
108
+ ["--worker", input.worker],
107
109
  ["--hire", input.hire],
108
110
  ["--grant-context", input.grantContext],
109
111
  ]) {
@@ -114,6 +116,12 @@ export function buildOutpostLaneCommand(input) {
114
116
  args.push("--no-compact");
115
117
  return withRemoteHqDir(joinCommandParts(args));
116
118
  }
119
+ /** True when a remote CLI exited after rejecting the forwarded effort flag. */
120
+ export function outpostRejectedEffortOption(result) {
121
+ if (result.exitCode === 0)
122
+ return false;
123
+ return /(?:unknown|unrecognized) option[^\n]{0,120}--effort/i.test(`${result.stderr}\n${result.stdout}`);
124
+ }
117
125
  export async function stageOutpostPayload(port, outpostId, bytes, put = fetch) {
118
126
  const staged = await port.stageExecInput(outpostId);
119
127
  const upload = await put(staged.putUrl, {
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { PROVIDER_HOME_ENV } from "./accounts.js";
11
11
  import { type RunCompactionFn } from "./compaction.js";
12
+ import { type ResolvedEffort } from "./effort.js";
12
13
  import { type KnownModelsDeps } from "./models.js";
13
14
  import { type LaunchTrigger } from "./delivery.js";
14
15
  import { type MachineIdentity } from "./machine-identity.js";
@@ -283,6 +284,8 @@ export interface SpawnLaunchOptions {
283
284
  * (`hq lanes message --no-compact`). Default true.
284
285
  */
285
286
  compact?: boolean;
287
+ /** Effort resolved by an explicit resume/message request, or pinned from its record. */
288
+ effort?: ResolvedEffort;
286
289
  }
287
290
  /**
288
291
  * Bind the account, spawn the worker and its watcher outside the caller's
@@ -15,7 +15,7 @@ import { PROVIDER_HOME_ENV, accountStatus, isLoggedOut, resolveAccount, selectBa
15
15
  import { COMPACTION_PROMPT, compactionAfterUsage, compactionDue, compactLaneSession, } from "./compaction.js";
16
16
  import { installDrainHook } from "./hooks-install.js";
17
17
  import { loadLanesConfig, providerAutoCompactTokens, providerCompactAfterMs, } from "./config.js";
18
- import { appendEffortFlag, resolveLaneEffort } from "./effort.js";
18
+ import { appendEffortFlag, resolveLaneEffort, } from "./effort.js";
19
19
  import { defaultProbe, validateLaneModel, validateProfileLaneModel, } from "./models.js";
20
20
  import { claimableStatesFor, planLaneLaunch, providerSupportsSessionResume, } from "./delivery.js";
21
21
  import { AccountResolutionError, LanesError } from "./errors.js";
@@ -1719,7 +1719,13 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1719
1719
  accountModel: accountCfg?.model,
1720
1720
  providerModel: config.providers[lane.provider].model,
1721
1721
  });
1722
- const effort = resolveLaneEffort({
1722
+ const pendingResumeEffort = pendingResume !== undefined
1723
+ ? {
1724
+ ...(existing.effort !== undefined ? { effort: existing.effort } : {}),
1725
+ source: existing.effort_source ?? "none",
1726
+ }
1727
+ : undefined;
1728
+ const effort = launch.effort ?? pendingResumeEffort ?? resolveLaneEffort({
1723
1729
  cliEffort: existing.effort_source === "cli" ? existing.effort : undefined,
1724
1730
  profileEffort,
1725
1731
  accountEffort: accountCfg?.effort,
@@ -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