@indigoai-us/hq-cli 5.345.160 → 5.345.162

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 (73) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/assets/scaffold/core/scripts/session-journal.sh +116 -24
  3. package/dist/command-catalog.generated.d.ts +262 -5
  4. package/dist/command-catalog.generated.js +322 -5
  5. package/dist/command-registration-plan.d.ts +6 -0
  6. package/dist/command-registration-plan.js +1 -0
  7. package/dist/commands/agent-inbox.js +6 -2
  8. package/dist/commands/auth.js +6 -5
  9. package/dist/commands/core-session-helper-runtime.d.ts +1 -0
  10. package/dist/commands/core-session-journal-native.d.ts +1 -0
  11. package/dist/commands/core-session-journal-native.js +158 -34
  12. package/dist/commands/feedback.d.ts +12 -0
  13. package/dist/commands/feedback.js +52 -0
  14. package/dist/commands/files-browse.d.ts +8 -0
  15. package/dist/commands/files-browse.js +83 -1
  16. package/dist/commands/install-global.d.ts +2 -0
  17. package/dist/commands/install-global.js +24 -1
  18. package/dist/commands/integrations-connect.js +1 -0
  19. package/dist/commands/integrations-manage.d.ts +2 -0
  20. package/dist/commands/integrations-manage.js +6 -4
  21. package/dist/commands/lanes-link.js +5 -3
  22. package/dist/commands/lanes-loop.js +7 -7
  23. package/dist/commands/lanes-questions.js +54 -4
  24. package/dist/commands/lanes-session-start.d.ts +12 -0
  25. package/dist/commands/lanes-session-start.js +93 -12
  26. package/dist/commands/lanes.js +2 -1
  27. package/dist/commands/meetings.d.ts +11 -0
  28. package/dist/commands/meetings.js +41 -11
  29. package/dist/commands/sync-held.d.ts +87 -0
  30. package/dist/commands/sync-held.js +191 -0
  31. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3830 -2812
  32. package/dist/hq-cloud-sync-runner/package.json +1 -1
  33. package/dist/index.js +21 -0
  34. package/dist/lanes-session-start-argv.d.ts +7 -0
  35. package/dist/lanes-session-start-argv.js +9 -0
  36. package/dist/lib/agent-kit/inbox-state.d.ts +5 -4
  37. package/dist/lib/agent-kit/inbox-state.js +24 -3
  38. package/dist/lib/bot/log.d.ts +3 -2
  39. package/dist/lib/bot/progress.d.ts +23 -1
  40. package/dist/lib/bot/progress.js +43 -5
  41. package/dist/lib/bot/run.js +2 -0
  42. package/dist/lib/bot/runtime/claude.d.ts +12 -0
  43. package/dist/lib/bot/runtime/claude.js +20 -0
  44. package/dist/lib/bot/runtime/index.d.ts +10 -1
  45. package/dist/lib/bot/runtime/index.js +14 -2
  46. package/dist/lib/bot/runtime/types.d.ts +8 -0
  47. package/dist/lib/doctor/checks/ps1-shims.d.ts +21 -0
  48. package/dist/lib/doctor/checks/ps1-shims.js +82 -0
  49. package/dist/lib/doctor/fix/apply.d.ts +13 -1
  50. package/dist/lib/doctor/fix/apply.js +38 -3
  51. package/dist/lib/doctor/fix/remediation.d.ts +9 -1
  52. package/dist/lib/doctor/fix/remediation.js +22 -0
  53. package/dist/lib/doctor/registry.js +2 -0
  54. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  55. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  56. package/dist/lib/lanes/links.d.ts +2 -1
  57. package/dist/lib/lanes/links.js +235 -17
  58. package/dist/lib/lanes/loop.d.ts +17 -0
  59. package/dist/lib/lanes/loop.js +235 -5
  60. package/dist/lib/lanes/questions.d.ts +1 -0
  61. package/dist/lib/lanes/registry-flag.d.ts +24 -0
  62. package/dist/lib/lanes/registry-flag.js +72 -6
  63. package/dist/lib/lanes/store.d.ts +4 -2
  64. package/dist/lib/lanes/store.js +8 -8
  65. package/dist/lib/windows/ps1-shims.d.ts +60 -0
  66. package/dist/lib/windows/ps1-shims.js +152 -0
  67. package/dist/utils/self-update.d.ts +2 -0
  68. package/dist/utils/self-update.js +8 -0
  69. package/dist/utils/staged-npm-install.js +13 -2
  70. package/dist/utils/token-keychain.d.ts +11 -8
  71. package/dist/utils/token-keychain.js +36 -15
  72. package/package.json +8 -8
  73. package/scripts/install-hook-engine.mjs +12 -0
@@ -1,5 +1,5 @@
1
1
  /** Durable envelope queue and runtime primitives for conduct worker lanes. */
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { providerSupportsSessionResume } from "./delivery.js";
@@ -14,6 +14,174 @@ export function loopAdmissionPath(state, hasPendingResume, initialAdmission) {
14
14
  return "deferred";
15
15
  return "resume";
16
16
  }
17
+ export const HANDOFF_HOLD_CHOICES = [
18
+ "Use the repaired handoff without running the phase again",
19
+ "Retry the phase; I understand external effects may repeat",
20
+ "Keep this phase held",
21
+ ];
22
+ function handoffHoldPath(runDir) { return path.join(runDir, "loop", "handoff-hold.json"); }
23
+ function readHandoffHold(runDir) {
24
+ const file = handoffHoldPath(runDir);
25
+ if (!fs.existsSync(file))
26
+ return undefined;
27
+ const hold = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readHandoffBytes(file, LOOP_ENVELOPE_LIMIT * 8)));
28
+ if (!isRecord(hold) || hold.schema !== "hq-loop-handoff-hold.v1" || !nonEmpty(hold.envelope_id) || !nonEmpty(hold.question_id)) {
29
+ throw new Error("Invalid durable handoff hold; operator investigation required");
30
+ }
31
+ return hold;
32
+ }
33
+ async function notifyHandoffHold(options, hold, envelope) {
34
+ if (hold.notified || hold.resolution || !options.raiseQuestion)
35
+ return;
36
+ const record = envelope ? undefined : ["stalled", "active"].flatMap((dir) => envelopeFiles(path.join(options.runDir, "loop", dir)))
37
+ .map(fileEnvelope).find((entry) => entry.envelope_id === hold.envelope_id);
38
+ const heldEnvelope = envelope ?? record?.envelope;
39
+ if (!heldEnvelope)
40
+ throw new Error("Held handoff envelope is missing; cannot deliver its repair question");
41
+ await options.raiseQuestion({ envelope: heldEnvelope, envelopeId: hold.envelope_id, attempt: 1, error: hold.error, questionId: hold.question_id, reason: "handoff_needs_repair" });
42
+ const current = readHandoffHold(options.runDir);
43
+ if (current?.question_id === hold.question_id)
44
+ atomicJson(handoffHoldPath(options.runDir), { ...current, notified: true });
45
+ }
46
+ export function loopHandoffHoldIdentity(runDir) {
47
+ const hold = readHandoffHold(runDir);
48
+ return hold ? { envelope_id: hold.envelope_id, question_id: hold.question_id, unresolved: !hold.resolution } : undefined;
49
+ }
50
+ function readHandoffBytes(file, limit = LOOP_ENVELOPE_LIMIT) {
51
+ const before = fs.lstatSync(file);
52
+ if (!before.isFile())
53
+ throw new Error("Repaired handoff must be a bounded regular file");
54
+ if (!Number.isSafeInteger(before.ino) || before.ino <= 0 || !Number.isSafeInteger(before.dev))
55
+ throw new Error("Cannot establish handoff file identity on this filesystem");
56
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
57
+ try {
58
+ const stat = fs.fstatSync(fd);
59
+ const current = fs.lstatSync(file);
60
+ if (!current.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || current.dev !== stat.dev || current.ino !== stat.ino)
61
+ throw new Error("Handoff file identity changed while opening it");
62
+ if (!stat.isFile() || stat.size > limit)
63
+ throw new Error("Repaired handoff must be a bounded regular file");
64
+ const bytes = Buffer.alloc(limit + 1);
65
+ let count = 0;
66
+ while (count < bytes.length) {
67
+ const read = fs.readSync(fd, bytes, count, bytes.length - count, null);
68
+ if (read === 0)
69
+ break;
70
+ count += read;
71
+ }
72
+ if (count > limit)
73
+ throw new Error("Repaired handoff exceeds the 1 MiB limit");
74
+ return bytes.subarray(0, count);
75
+ }
76
+ finally {
77
+ fs.closeSync(fd);
78
+ }
79
+ }
80
+ function validateAcceptedHandoff(output, envelope) {
81
+ if (Buffer.byteLength(output, "utf8") > LOOP_ENVELOPE_LIMIT)
82
+ throw new Error("Repaired handoff exceeds the 1 MiB limit");
83
+ if (envelope.schema !== PHASE_ENVELOPE_SCHEMA)
84
+ throw new Error("Repair hold requires a section 8 phase");
85
+ const phase = envelope;
86
+ const handoff = validateSection8Handoff(JSON.parse(output), phase);
87
+ if (handoff.worker_id !== phase.worker_id || handoff.status !== "passed") {
88
+ throw new Error("Repaired handoff must match the worker and have passed status");
89
+ }
90
+ const criteria = phase.acceptance_criteria;
91
+ if (!Array.isArray(criteria) || !criteria.every((item) => typeof item === "string"))
92
+ throw new Error("Held phase requires string acceptance criteria");
93
+ if (!Array.isArray(handoff.ac_evidence))
94
+ throw new Error("Repaired handoff requires acceptance-criterion evidence");
95
+ for (let index = 0; index < criteria.length; index += 1) {
96
+ const evidence = handoff.ac_evidence.filter((item) => isRecord(item) && item.index === index);
97
+ if (evidence.length !== 1)
98
+ throw new Error(`Repaired handoff requires exactly one evidence entry for criterion ${index}`);
99
+ const item = evidence[0];
100
+ if (item.criterion !== criteria[index] || item.met !== true || !nonEmpty(item.evidence)) {
101
+ throw new Error(`Repaired handoff must copy criterion ${index} verbatim with met=true and string evidence`);
102
+ }
103
+ }
104
+ for (const item of handoff.ac_evidence) {
105
+ if (!isRecord(item) || !Number.isInteger(item.index) || Number(item.index) < 0 || Number(item.index) >= criteria.length) {
106
+ throw new Error("Repaired handoff contains an unknown criterion index");
107
+ }
108
+ }
109
+ return handoff;
110
+ }
111
+ function applyHandoffHoldAnswer(input) {
112
+ const release = acquireEnqueueLock(input.runDir);
113
+ try {
114
+ const hold = readHandoffHold(input.runDir);
115
+ if (!hold || hold.question_id !== input.questionId)
116
+ throw new Error("Handoff hold identity changed");
117
+ if (!HANDOFF_HOLD_CHOICES.includes(input.choice))
118
+ throw new Error("Choose an explicit handoff repair disposition");
119
+ if (loopStatus(input.runDir, path.basename(input.runDir)).state === "stopped")
120
+ throw new Error("An intentionally stopped loop cannot resume from a handoff answer");
121
+ if (fs.existsSync(path.join(input.runDir, "loop", "runner.lock")))
122
+ throw new Error("Handoff controller has not released its lease yet; retry the same answer after it exits");
123
+ const resultPath = path.join(resultsDir(input.runDir), resultName(hold.envelope_id));
124
+ const result = fs.existsSync(resultPath) ? readJson(resultPath) : undefined;
125
+ if (input.choice === HANDOFF_HOLD_CHOICES[2]) {
126
+ if (hold.resolution)
127
+ throw new Error("A handoff disposition is already being committed");
128
+ if (isRecord(result))
129
+ atomicJson(resultPath, { ...result, decision: { question_id: input.questionId, choice: input.choice, at: input.at, ...(input.notes ? { notes: input.notes } : {}) } });
130
+ return { lane_id: path.basename(input.runDir), envelope_id: hold.envelope_id, action: "parked" };
131
+ }
132
+ if (hold.resolution && hold.resolution.choice !== input.choice)
133
+ throw new Error("A different handoff disposition is already recorded");
134
+ if (!hold.resolution) {
135
+ if (!isRecord(result) || result.hold_reason !== "handoff_needs_repair" || result.question_id !== input.questionId || result.envelope_id !== hold.envelope_id)
136
+ throw new Error("Handoff result identity changed");
137
+ const held = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => fileEnvelope(file).envelope_id === hold.envelope_id);
138
+ if (!held)
139
+ throw new Error("Held handoff envelope is missing");
140
+ const record = fileEnvelope(held);
141
+ if (input.choice === HANDOFF_HOLD_CHOICES[0]) {
142
+ const externalPath = record.envelope.result_path;
143
+ if (typeof externalPath !== "string" || !externalPath.trim())
144
+ throw new Error("Held phase has no handoff path");
145
+ const output = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readHandoffBytes(externalPath));
146
+ validateAcceptedHandoff(output, record.envelope);
147
+ delete record.handoff_retry;
148
+ record.accepted_handoff = { output, sha256: createHash("sha256").update(output).digest("hex") };
149
+ }
150
+ else {
151
+ delete record.accepted_handoff;
152
+ record.handoff_retry = true;
153
+ }
154
+ record.stall_attempts = 0;
155
+ hold.resolution = { choice: input.choice, at: input.at, ...(input.notes ? { notes: input.notes } : {}), pending_name: `0000000000000-answer-${path.basename(held)}`, record };
156
+ // Durable authorization precedes every queue/result move. The hold gates
157
+ // raw controllers until all those moves have completed, including retries.
158
+ atomicJson(handoffHoldPath(input.runDir), hold);
159
+ }
160
+ const resolution = hold.resolution;
161
+ const pending = path.join(pendingDir(input.runDir), resolution.pending_name);
162
+ if (path.basename(resolution.pending_name) !== resolution.pending_name || resolution.record.envelope_id !== hold.envelope_id)
163
+ throw new Error("Invalid handoff disposition record");
164
+ const held = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => fileEnvelope(file).envelope_id === hold.envelope_id);
165
+ if (held) {
166
+ atomicJson(held, resolution.record);
167
+ fs.mkdirSync(pendingDir(input.runDir), { recursive: true });
168
+ fs.renameSync(held, pending);
169
+ }
170
+ else if (!fs.existsSync(pending) || JSON.stringify(fileEnvelope(pending)) !== JSON.stringify(resolution.record)) {
171
+ throw new Error("Disposition envelope changed before it could be resumed");
172
+ }
173
+ if (fs.existsSync(resultPath)) {
174
+ atomicJson(resultPath, { ...readJson(resultPath), decision: { question_id: input.questionId, choice: resolution.choice, at: resolution.at, ...(resolution.notes ? { notes: resolution.notes } : {}) } });
175
+ fs.renameSync(resultPath, asidePath(resultPath, "stalled"));
176
+ }
177
+ persistStatus(input.runDir, path.basename(input.runDir), { state: "waiting" });
178
+ fs.unlinkSync(handoffHoldPath(input.runDir));
179
+ return { lane_id: path.basename(input.runDir), envelope_id: hold.envelope_id, action: "requeued" };
180
+ }
181
+ finally {
182
+ release();
183
+ }
184
+ }
17
185
  function asidePath(file, marker) {
18
186
  const parsed = path.parse(file);
19
187
  let suffix = 1;
@@ -520,6 +688,11 @@ export function findLoopStallResult(runDir, questionId) {
520
688
  return resultPath ? readJson(resultPath) : undefined;
521
689
  }
522
690
  export function applyLoopStallAnswer(input) {
691
+ const hold = readHandoffHold(input.runDir);
692
+ if (hold?.question_id === input.questionId)
693
+ return applyHandoffHoldAnswer(input);
694
+ if (hold)
695
+ throw new Error("A different handoff hold must be resolved first");
523
696
  const result = findLoopStallResult(input.runDir, input.questionId);
524
697
  if (!result) {
525
698
  for (const name of stalledResultArchives(input.runDir)) {
@@ -586,12 +759,14 @@ export function loopStatus(runDir, laneId) {
586
759
  const stallCounts = isRecord(loop.stall_counts)
587
760
  ? Object.fromEntries(Object.entries(loop.stall_counts).filter((entry) => typeof entry[1] === "number"))
588
761
  : {};
762
+ const hold = readHandoffHold(runDir);
589
763
  const processState = loop.state === "running" || loop.state === "parked" || loop.state === "stopped" || loop.state === "failed" ? loop.state : "waiting";
590
764
  return {
591
- mode: "loop", lane_id: laneId, state: processState, queue_depth: queueDepth,
765
+ mode: "loop", lane_id: laneId, state: hold && processState !== "stopped" ? "parked" : processState, queue_depth: queueDepth,
592
766
  active_envelope_id: active?.envelope_id ?? null,
593
767
  active_phase: active ? phaseName(active.envelope, active.envelope_id) : null,
594
768
  stall_counts: stallCounts,
769
+ ...(hold ? { handoff_hold: { envelope_id: hold.envelope_id, question_id: hold.question_id } } : {}),
595
770
  ...(typeof loop.pid === "number" ? { pid: loop.pid } : {}),
596
771
  updated_at: typeof loop.updated_at === "string" ? loop.updated_at : new Date(0).toISOString(),
597
772
  };
@@ -746,6 +921,13 @@ function abortAfter(ms, parent) {
746
921
  /** Run queued phases serially. Sessions are isolated by story and provider. */
747
922
  export async function runLoopLane(options) {
748
923
  const now = options.now ?? (() => new Date());
924
+ const held = readHandoffHold(options.runDir);
925
+ if (held) {
926
+ if (loopStatus(options.runDir, options.lane.lane_id).state === "stopped")
927
+ return { state: "stopped", completed: 0 };
928
+ await notifyHandoffHold(options, held);
929
+ return { state: "parked", completed: 0 };
930
+ }
749
931
  const defaultDeadlineSeconds = options.defaultDeadlineSeconds ?? 3600;
750
932
  const sessions = new Map();
751
933
  try {
@@ -766,6 +948,10 @@ export async function runLoopLane(options) {
766
948
  let completed = 0;
767
949
  fs.mkdirSync(options.runDir, { recursive: true });
768
950
  let releaseRunner = acquireRunnerLease(options.runDir);
951
+ if (readHandoffHold(options.runDir)) {
952
+ releaseRunner();
953
+ return { state: loopStatus(options.runDir, options.lane.lane_id).state === "stopped" ? "stopped" : "parked", completed: 0 };
954
+ }
769
955
  persistStatus(options.runDir, options.lane.lane_id, { pid: process.pid });
770
956
  try {
771
957
  const previous = readJson(loopPath(options.runDir));
@@ -821,7 +1007,7 @@ export async function runLoopLane(options) {
821
1007
  const provider = envelopeProvider(envelope, options.lane.provider);
822
1008
  const story = envelopeStory(envelope);
823
1009
  const sessionKey = `${story}\0${provider}`;
824
- const fresh = envelope.fresh === true || envelope.fresh_call === true;
1010
+ const fresh = record.handoff_retry === true || envelope.fresh === true || envelope.fresh_call === true;
825
1011
  const previousSession = sessions.get(sessionKey);
826
1012
  const startedAt = now().toISOString();
827
1013
  const startedAtMs = Date.parse(startedAt);
@@ -835,14 +1021,29 @@ export async function runLoopLane(options) {
835
1021
  atomicJson(file, record);
836
1022
  persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id) });
837
1023
  const timer = abortAfter(budget * 1000, options.signal);
1024
+ let malformedHandoff;
838
1025
  try {
839
1026
  let result;
840
1027
  let fallback = false;
841
1028
  try {
842
1029
  const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
843
- result = await options.execute({ lane: options.lane, envelope, envelopeId: record.envelope_id, signal: timer.signal, fresh: fresh || previousSession === undefined || !resumable, ...(fresh || previousSession === undefined || !resumable ? {} : { sessionId: previousSession }) });
1030
+ if (record.accepted_handoff) {
1031
+ malformedHandoff = "Accepted handoff changed before continuation";
1032
+ const externalPath = envelope.result_path;
1033
+ if (typeof externalPath !== "string" || createHash("sha256").update(readHandoffBytes(externalPath)).digest("hex") !== record.accepted_handoff.sha256)
1034
+ throw new Error(malformedHandoff);
1035
+ if (createHash("sha256").update(record.accepted_handoff.output).digest("hex") !== record.accepted_handoff.sha256)
1036
+ throw new Error("Accepted handoff hash changed");
1037
+ validateAcceptedHandoff(record.accepted_handoff.output, envelope);
1038
+ result = { output: record.accepted_handoff.output };
1039
+ malformedHandoff = undefined;
1040
+ }
1041
+ else
1042
+ result = await options.execute({ lane: options.lane, envelope, envelopeId: record.envelope_id, signal: timer.signal, fresh: fresh || previousSession === undefined || !resumable, ...(fresh || previousSession === undefined || !resumable ? {} : { sessionId: previousSession }) });
844
1043
  }
845
1044
  catch (error) {
1045
+ if (record.accepted_handoff)
1046
+ throw error;
846
1047
  const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
847
1048
  if (!fresh && previousSession && resumable && !timer.signal.aborted) {
848
1049
  fallback = true;
@@ -875,6 +1076,7 @@ export async function runLoopLane(options) {
875
1076
  }
876
1077
  }
877
1078
  catch (validationError) {
1079
+ malformedHandoff = validationError instanceof Error ? validationError.message : String(validationError);
878
1080
  if (options.repairInvalidHandoff !== false && !timer.signal.aborted) {
879
1081
  const repairError = validationError instanceof Error ? validationError.message : String(validationError);
880
1082
  const repaired = await options.execute({
@@ -899,6 +1101,7 @@ export async function runLoopLane(options) {
899
1101
  if (repairedValidated.worker_id !== envelope.worker_id) {
900
1102
  throw new Error(`handoff worker_id must match the queued phase envelope: ${repairError}`);
901
1103
  }
1104
+ malformedHandoff = undefined;
902
1105
  result = repaired;
903
1106
  success = {
904
1107
  ...success,
@@ -932,6 +1135,33 @@ export async function runLoopLane(options) {
932
1135
  persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
933
1136
  return { state: "aborted", completed };
934
1137
  }
1138
+ if (malformedHandoff !== undefined) {
1139
+ const message = error instanceof Error ? error.message : String(error);
1140
+ const questionId = randomUUID();
1141
+ const hold = { schema: "hq-loop-handoff-hold.v1", envelope_id: record.envelope_id, question_id: questionId, error: `${malformedHandoff}; repair failed: ${message}` };
1142
+ try {
1143
+ atomicJson(handoffHoldPath(options.runDir), hold);
1144
+ const externalPath = envelope.result_path;
1145
+ if (typeof externalPath === "string" && fs.existsSync(externalPath)) {
1146
+ const stat = fs.lstatSync(externalPath);
1147
+ if (stat.isFile() && stat.size <= LOOP_ENVELOPE_LIMIT) {
1148
+ const backup = path.join(options.runDir, "loop", "handoff-original", `${resultName(record.envelope_id)}.${questionId}`);
1149
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
1150
+ fs.writeFileSync(backup, readHandoffBytes(externalPath), { flag: "wx", mode: 0o600 });
1151
+ hold.original_copy = backup;
1152
+ atomicJson(handoffHoldPath(options.runDir), hold);
1153
+ }
1154
+ }
1155
+ writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", hold_reason: "handoff_needs_repair", question_id: questionId, started_at: startedAt, finished_at: now().toISOString(), error: hold.error }, options.repairInvalidHandoff !== false);
1156
+ persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
1157
+ }
1158
+ finally {
1159
+ releaseRunner();
1160
+ clearLoopControllerPid(options.runDir, options.lane.lane_id, process.pid);
1161
+ }
1162
+ await notifyHandoffHold(options, hold, envelope);
1163
+ return { state: "parked", completed };
1164
+ }
935
1165
  if (!timer.signal.aborted) {
936
1166
  const failure = error instanceof Error ? error.message : String(error);
937
1167
  writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure }, options.repairInvalidHandoff !== false);
@@ -1035,7 +1265,7 @@ function writeResultAt(runDir, activeFile, record, result, includeHandoffValidat
1035
1265
  schema: PHASE_HANDOFF_SCHEMA,
1036
1266
  story_id: String(envelope.story_id), phase: String(envelope.phase),
1037
1267
  worker_id: String(envelope.worker_id), status: stalled ? "blocked" : "failed",
1038
- summary: stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
1268
+ summary: result.hold_reason === "handoff_needs_repair" ? `Handoff needs repair: ${result.error ?? "invalid handoff"}` : stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
1039
1269
  files_changed: [], commits: [],
1040
1270
  back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
1041
1271
  context_for_next: [result.error ?? result.status, ...(result.question_id ? [`Decision question: ${result.question_id}`] : [])].join("\n"),
@@ -9,6 +9,7 @@ export interface QuestionBody {
9
9
  lane_id: string;
10
10
  envelope_id: string;
11
11
  attempt: number;
12
+ reason?: "handoff_needs_repair";
12
13
  };
13
14
  }
14
15
  export declare function assertQuestionBodySecretSafe(body: QuestionBody): void;
@@ -1,5 +1,6 @@
1
1
  import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
2
2
  import type { LaneCompanyRef } from "./types.js";
3
+ import { type PersistedPersonFlagSnapshot } from "../flag-snapshot-file.js";
3
4
  export declare const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
4
5
  export declare const LANES_FLAG_READ_TIMEOUT_MS = 5000;
5
6
  export interface LanesFlagClient {
@@ -64,5 +65,28 @@ export declare function readLanesRegistryFlagFailOpen(flagKey: string, options?:
64
65
  */
65
66
  export declare function readLanesRegistryFlagFailOff(flagKey: string, options?: ReadLanesFlagOptions, reader?: typeof readLanesRegistryFlag): Promise<boolean>;
66
67
  export declare function _resetThrownFlagReadWarningsForTests(): void;
68
+ /** Offline value for a company-less lanes flag, with how long ago the registry confirmed it. */
69
+ export interface OfflineLanesFlagRead {
70
+ value: boolean;
71
+ ageMs: number;
72
+ source: "local-override" | "lanes-cache" | "person-snapshot";
73
+ }
74
+ export interface ReadLanesFlagOfflineOptions {
75
+ defaultValue?: boolean;
76
+ env?: NodeJS.ProcessEnv;
77
+ /** Last-known-good cache directory; null disables it. Defaults to the HQ state dir. */
78
+ cacheDir?: string | null;
79
+ now?: number;
80
+ /** Person-scope flag snapshot reader. Defaults to the snapshot under the env's cache home. */
81
+ readPersonSnapshot?: () => PersistedPersonFlagSnapshot | null;
82
+ }
83
+ /**
84
+ * Read a company-less lanes flag without any network request. Order: local
85
+ * override, then the lanes last-known-good cache, then the person flag
86
+ * snapshot other hq commands keep (default registry endpoint only). Returns
87
+ * null when no value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS; the
88
+ * caller decides the fallback and whether to refresh in the background.
89
+ */
90
+ export declare function readLanesRegistryFlagOffline(flagKey: string, options?: ReadLanesFlagOfflineOptions): OfflineLanesFlagRead | null;
67
91
  export declare function _resetLanesRegistryFlagCacheForTests(): void;
68
92
  //# sourceMappingURL=registry-flag.d.ts.map
@@ -1,8 +1,8 @@
1
1
  import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens, } from "../../utils/cognito-session.js";
5
4
  import { FLAG_REGISTRY_DEFAULT_ENDPOINT, resolveFlagRegistryEndpoint, } from "../flag-registry-endpoint.js";
5
+ import { readPersonFlagSnapshotFile } from "../flag-snapshot-file.js";
6
6
  export const LANES_FLAG_API_DEFAULT_ENDPOINT = FLAG_REGISTRY_DEFAULT_ENDPOINT;
7
7
  export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
8
8
  export function readLanesFlagLocalOverride(flagKey, env = process.env) {
@@ -55,7 +55,7 @@ function readFlagCacheFile(dir) {
55
55
  return {};
56
56
  }
57
57
  }
58
- function readCachedFlagValue(dir, key, now = Date.now()) {
58
+ function readCachedFlagEntry(dir, key, now = Date.now()) {
59
59
  const entry = readFlagCacheFile(dir)[key];
60
60
  if (!entry || typeof entry.value !== "boolean" || typeof entry.resolvedAt !== "string")
61
61
  return null;
@@ -66,9 +66,15 @@ function readCachedFlagValue(dir, key, now = Date.now()) {
66
66
  // A future timestamp (clock skew or a hand-edited file) is not trusted either.
67
67
  if (age < 0 || age > LANES_FLAG_CACHE_MAX_AGE_MS)
68
68
  return null;
69
- return entry.value;
69
+ return { value: entry.value, missing: entry.missing === true, ageMs: age };
70
70
  }
71
- function writeCachedFlagValue(dir, key, value) {
71
+ function readCachedFlagValue(dir, key, defaultValue) {
72
+ const entry = readCachedFlagEntry(dir, key);
73
+ if (!entry)
74
+ return null;
75
+ return entry.missing ? defaultValue : entry.value;
76
+ }
77
+ function writeCachedFlagValue(dir, key, value, missing = false) {
72
78
  let tmp;
73
79
  try {
74
80
  const entries = readFlagCacheFile(dir);
@@ -76,7 +82,7 @@ function writeCachedFlagValue(dir, key, value) {
76
82
  // measures time since the registry last confirmed it. Delete first so the
77
83
  // key moves to the end and eviction drops the least recently confirmed.
78
84
  delete entries[key];
79
- entries[key] = { value, resolvedAt: new Date().toISOString() };
85
+ entries[key] = { value, resolvedAt: new Date().toISOString(), ...(missing ? { missing: true } : {}) };
80
86
  const keys = Object.keys(entries);
81
87
  for (const stale of keys.slice(0, Math.max(0, keys.length - FLAG_CACHE_MAX_ENTRIES)))
82
88
  delete entries[stale];
@@ -237,7 +243,13 @@ function classifyAttempt(attempt) {
237
243
  return "transient";
238
244
  return "permanent";
239
245
  }
246
+ // Loaded on first token use: the module costs ~350 ms to import, and offline
247
+ // readers such as the SessionStart hook never need a token.
248
+ function loadCognitoSession() {
249
+ return import("../../utils/cognito-session.js");
250
+ }
240
251
  async function defaultRefreshToken() {
252
+ const { ensureCognitoIdToken, loadCachedTokens, refreshActivePersonTokens, wantsMachineCognitoTokens } = await loadCognitoSession();
241
253
  if (wantsMachineCognitoTokens()) {
242
254
  return ensureCognitoIdToken({ interactive: false });
243
255
  }
@@ -250,6 +262,7 @@ async function defaultRefreshToken() {
250
262
  function defaultAcquireToken() {
251
263
  return async () => {
252
264
  try {
265
+ const { ensureCognitoIdToken } = await loadCognitoSession();
253
266
  return await ensureCognitoIdToken({ interactive: false });
254
267
  }
255
268
  catch (error) {
@@ -388,7 +401,7 @@ async function readRegistryValue(flagKey, endpoint, key, options) {
388
401
  // 4xx, and a missing token mean the registry (or the session) refused the
389
402
  // read, so it fails closed to the caller's default instead.
390
403
  const transient = classifyAttempt(attempt) === "transient";
391
- const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key) : null;
404
+ const cachedValue = transient && cacheDir ? readCachedFlagValue(cacheDir, key, options.defaultValue ?? false) : null;
392
405
  if (cachedValue !== null) {
393
406
  return {
394
407
  result: { state: "resolved", value: cachedValue },
@@ -397,8 +410,61 @@ async function readRegistryValue(flagKey, endpoint, key, options) {
397
410
  }
398
411
  return { result: { state: "unknown" }, failure: { ...attempt.failure, diagnostics } };
399
412
  }
413
+ // A clean read that fell back means the registry has no row for this flag.
414
+ // Remember that, so offline readers can apply their default without a request.
415
+ if (attempt.resolution?.source === "fallback" && cacheDir)
416
+ writeCachedFlagValue(cacheDir, key, attempt.resolution.value, true);
400
417
  return { result: { state: "resolved", value: attempt.resolution?.value ?? false } };
401
418
  }
419
+ function defaultPersonSnapshotReader(env, now) {
420
+ return () => {
421
+ // Use only a cache home the env names, so a hermetic env never reads the real home.
422
+ const home = env.HOME?.trim() || env.USERPROFILE?.trim();
423
+ const cacheHome = env.XDG_CACHE_HOME?.trim() || (home ? path.join(home, ".cache") : "");
424
+ if (!cacheHome)
425
+ return null;
426
+ return readPersonFlagSnapshotFile({ env: { ...env, XDG_CACHE_HOME: cacheHome }, now, maxAgeMs: LANES_FLAG_CACHE_MAX_AGE_MS });
427
+ };
428
+ }
429
+ /**
430
+ * Read a company-less lanes flag without any network request. Order: local
431
+ * override, then the lanes last-known-good cache, then the person flag
432
+ * snapshot other hq commands keep (default registry endpoint only). Returns
433
+ * null when no value was confirmed within LANES_FLAG_CACHE_MAX_AGE_MS; the
434
+ * caller decides the fallback and whether to refresh in the background.
435
+ */
436
+ export function readLanesRegistryFlagOffline(flagKey, options = {}) {
437
+ const env = options.env ?? process.env;
438
+ const defaultValue = options.defaultValue ?? false;
439
+ const now = options.now ?? Date.now();
440
+ const localOverride = readLanesFlagLocalOverride(flagKey, env);
441
+ if (localOverride !== null)
442
+ return { value: localOverride, ageMs: 0, source: "local-override" };
443
+ const endpoint = resolveFlagRegistryEndpoint(undefined, env);
444
+ const cacheDir = flagCacheDir({ env, ...(options.cacheDir !== undefined ? { cacheDir: options.cacheDir } : {}) });
445
+ const cached = cacheDir ? readCachedFlagEntry(cacheDir, cacheKey(flagKey, null, endpoint), now) : null;
446
+ let snapshot = null;
447
+ if (endpoint === FLAG_REGISTRY_DEFAULT_ENDPOINT) {
448
+ try {
449
+ snapshot = (options.readPersonSnapshot ?? defaultPersonSnapshotReader(env, now))();
450
+ }
451
+ catch {
452
+ snapshot = null;
453
+ }
454
+ }
455
+ const snapshotAge = snapshot ? now - Date.parse(snapshot.fetched_at) : Number.NaN;
456
+ const snapshotUsable = snapshot !== null && Number.isFinite(snapshotAge) && snapshotAge >= 0 &&
457
+ snapshotAge <= LANES_FLAG_CACHE_MAX_AGE_MS;
458
+ // Prefer whichever source the registry confirmed most recently.
459
+ if (cached && (!snapshotUsable || cached.ageMs <= snapshotAge)) {
460
+ return { value: cached.missing ? defaultValue : cached.value, ageMs: cached.ageMs, source: "lanes-cache" };
461
+ }
462
+ if (snapshotUsable) {
463
+ const value = snapshot.flags[flagKey];
464
+ return { value: typeof value === "boolean" ? value : defaultValue, ageMs: snapshotAge, source: "person-snapshot" };
465
+ }
466
+ return null;
467
+ }
402
468
  function reportRegistryFailure(flagKey, key, outcome, options, failOpen) {
403
469
  if (!outcome.failure)
404
470
  return;
@@ -40,9 +40,11 @@ export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> &
40
40
  export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
41
41
  export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
42
42
  /** Test seam: run withRecordLock with overridden deps and a shorter timeout. */
43
- export declare function withRecordLockForTests<T>(recordPath: string, overrides: Partial<StoreDeps>, fn: () => T, timeoutMs?: number): T;
43
+ export declare function withRecordLockForTests<T>(recordPath: string, overrides: Partial<StoreDeps>, fn: () => T, timeoutMs?: number, reclaimByAge?: boolean): T;
44
44
  /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
45
- export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T): T;
45
+ export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T, options?: {
46
+ reclaimByAge?: boolean;
47
+ }): T;
46
48
  export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
47
49
  export declare function parseQuestionRecord(raw: unknown, filePath?: string): QuestionRecord;
48
50
  /**
@@ -183,7 +183,7 @@ function sameFile(a, b) {
183
183
  * (another process reclaimed it and took a fresh lock in between), the fresh
184
184
  * lock is linked back into place and nothing is reclaimed.
185
185
  */
186
- function recordLockIsStale(lockPath, deps) {
186
+ function recordLockIsStale(lockPath, deps, reclaimByAge = true) {
187
187
  const statSync = deps.statSync ?? ((p) => fs.statSync(p));
188
188
  const linkSync = deps.linkSync ?? fs.linkSync.bind(fs);
189
189
  const before = statSync(lockPath);
@@ -199,7 +199,7 @@ function recordLockIsStale(lockPath, deps) {
199
199
  const mtimeAge = now - before.mtimeMs;
200
200
  const age = Number.isFinite(ts) ? Math.max(now - ts, mtimeAge) : mtimeAge;
201
201
  const stale = (pidKnown && sameHost && pid !== process.pid && !lockOwnerAlive(pid)) ||
202
- age > lockStaleMs();
202
+ (reclaimByAge && age > lockStaleMs());
203
203
  if (!stale)
204
204
  return false;
205
205
  const parked = `${lockPath}.stale.${process.pid}.${randomBytes(6).toString("hex")}`;
@@ -243,7 +243,7 @@ function recordLockIsStale(lockPath, deps) {
243
243
  removeParked();
244
244
  return reclaimed;
245
245
  }
246
- function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
246
+ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS, reclaimByAge = true) {
247
247
  const lockPath = lockPathFor(recordPath);
248
248
  const statSync = deps.statSync ?? ((p) => fs.statSync(p));
249
249
  const fstatSync = deps.fstatSync ?? ((fd) => fs.fstatSync(fd));
@@ -295,7 +295,7 @@ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
295
295
  throw new LanesError("store_lock_timeout", `Timed out waiting for lock ${lockPath}`, { path: lockPath });
296
296
  }
297
297
  try {
298
- if (recordLockIsStale(lockPath, deps))
298
+ if (recordLockIsStale(lockPath, deps, reclaimByAge))
299
299
  continue;
300
300
  }
301
301
  catch (statErr) {
@@ -309,12 +309,12 @@ function withRecordLock(recordPath, deps, fn, timeoutMs = LOCK_TIMEOUT_MS) {
309
309
  }
310
310
  }
311
311
  /** Test seam: run withRecordLock with overridden deps and a shorter timeout. */
312
- export function withRecordLockForTests(recordPath, overrides, fn, timeoutMs) {
313
- return withRecordLock(recordPath, { ...defaultStoreDeps(), ...overrides }, fn, timeoutMs);
312
+ export function withRecordLockForTests(recordPath, overrides, fn, timeoutMs, reclaimByAge = true) {
313
+ return withRecordLock(recordPath, { ...defaultStoreDeps(), ...overrides }, fn, timeoutMs, reclaimByAge);
314
314
  }
315
315
  /** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
316
- export function withLaneRecordFileLock(recordPath, fn) {
317
- return withRecordLock(recordPath, defaultStoreDeps(), fn);
316
+ export function withLaneRecordFileLock(recordPath, fn, options = {}) {
317
+ return withRecordLock(recordPath, defaultStoreDeps(), fn, LOCK_TIMEOUT_MS, options.reclaimByAge ?? true);
318
318
  }
319
319
  function readJsonFile(filePath, deps) {
320
320
  let text;
@@ -0,0 +1,60 @@
1
+ /** hq-cli's own bins and the package file each npm launcher runs. */
2
+ export declare const HQ_CLI_BINS: Readonly<Record<string, string>>;
3
+ /**
4
+ * True when `content` is npm's (cmd-shim) PowerShell launcher for the hq-cli
5
+ * bin `bin`. The launcher resolves its own folder, runs node on
6
+ * `$basedir/node_modules/@indigoai-us/hq-cli/<entry>` and exits with node's
7
+ * code. Either slash direction is accepted.
8
+ */
9
+ export declare function isNpmGeneratedHqCliPs1Shim(content: string, bin: string): boolean;
10
+ export interface Ps1ShimFs {
11
+ readFile: (file: string) => string;
12
+ remove: (file: string) => void;
13
+ }
14
+ /** The npm-generated hq-cli `.ps1` launchers present in `binDir`. */
15
+ export declare function findHqCliPs1Shims(binDir: string, io?: Ps1ShimFs): string[];
16
+ export interface RemovePs1ShimsResult {
17
+ /** Launchers removed by this call. */
18
+ removed: string[];
19
+ /** `.ps1` files with an hq-cli bin name that are not npm's launcher. */
20
+ kept: string[];
21
+ /** Launchers that could not be removed, with the reason. */
22
+ failed: Array<{
23
+ file: string;
24
+ reason: string;
25
+ }>;
26
+ }
27
+ /**
28
+ * Remove the npm-generated `.ps1` launchers for hq-cli's bins from `binDir`
29
+ * (the npm global prefix on Windows). Safe to run again: a missing file is
30
+ * not an error. Never touches another package's `.ps1` or a hand-written one.
31
+ */
32
+ export declare function removeHqCliPs1Shims(binDir: string, io?: Ps1ShimFs): RemovePs1ShimsResult;
33
+ /**
34
+ * True when the effective execution policy blocks npm's unsigned launcher.
35
+ * `Undefined` as the effective policy means no scope set one, which Windows
36
+ * client editions treat as `Restricted`.
37
+ */
38
+ export declare function policyBlocksUnsignedScripts(policy: string): boolean;
39
+ export type ExecutionPolicyRun = () => {
40
+ status: number | null;
41
+ stdout: string;
42
+ };
43
+ /**
44
+ * Read the current user's effective PowerShell execution policy. Read-only:
45
+ * it runs `Get-ExecutionPolicy` and never sets anything. Null when
46
+ * PowerShell could not be run or printed nothing usable.
47
+ */
48
+ export declare function readExecutionPolicy(run?: ExecutionPolicyRun): string | null;
49
+ /**
50
+ * Postinstall entry: after a global npm install on Windows, remove hq-cli's
51
+ * `.ps1` launchers from the prefix holding `packageDir`. Acts only when the
52
+ * package sits at `<prefix>/node_modules/@indigoai-us/hq-cli`, npm's Windows
53
+ * global layout. Null when it did nothing. npm 11.16+ may skip lifecycle
54
+ * scripts, so `hq doctor` reports any launcher left behind.
55
+ */
56
+ export declare function removePs1ShimsAfterNpmInstall(packageDir: string, opts: {
57
+ platform: NodeJS.Platform;
58
+ global: boolean;
59
+ }, io?: Ps1ShimFs): RemovePs1ShimsResult | null;
60
+ //# sourceMappingURL=ps1-shims.d.ts.map