@indigoai-us/hq-cli 5.345.55 → 5.345.57

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 (66) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +10 -1
  3. package/assets/scaffold/core/scripts/jobs-validate.sh +2 -1
  4. package/assets/scaffold/core/scripts/session-title.sh +18 -15
  5. package/dist/command-catalog.generated.d.ts +124 -0
  6. package/dist/command-catalog.generated.js +155 -0
  7. package/dist/commands/core-delegate-w1b.js +18 -5
  8. package/dist/commands/core-derive-trigger-facts-command.js +14 -0
  9. package/dist/commands/core-derive-trigger-facts-native.d.ts +1 -0
  10. package/dist/commands/core-derive-trigger-facts-native.js +20 -1
  11. package/dist/commands/doctor.d.ts +1 -0
  12. package/dist/commands/doctor.js +15 -2
  13. package/dist/commands/lanes-checks.d.ts +3 -0
  14. package/dist/commands/lanes-checks.js +34 -0
  15. package/dist/commands/lanes-loop.d.ts +12 -0
  16. package/dist/commands/lanes-loop.js +214 -0
  17. package/dist/commands/lanes-questions.js +37 -11
  18. package/dist/commands/lanes-resume.js +19 -1
  19. package/dist/commands/lanes-session-start.d.ts +2 -7
  20. package/dist/commands/lanes-session-start.js +9 -29
  21. package/dist/commands/lanes-shared.d.ts +14 -2
  22. package/dist/commands/lanes.d.ts +3 -1
  23. package/dist/commands/lanes.js +376 -35
  24. package/dist/commands/members.d.ts +4 -0
  25. package/dist/commands/members.js +23 -2
  26. package/dist/lanes-drain-hook.js +6 -2
  27. package/dist/lib/doctor/checks/sync-health.d.ts +2 -0
  28. package/dist/lib/doctor/checks/sync-health.js +47 -23
  29. package/dist/lib/lanes/delivery.d.ts +3 -0
  30. package/dist/lib/lanes/delivery.js +5 -0
  31. package/dist/lib/lanes/dropbox.d.ts +7 -2
  32. package/dist/lib/lanes/dropbox.js +157 -10
  33. package/dist/lib/lanes/flag-keys.d.ts +12 -0
  34. package/dist/lib/lanes/flag-keys.js +12 -0
  35. package/dist/lib/lanes/hooks-install.d.ts +14 -11
  36. package/dist/lib/lanes/hooks-install.js +70 -50
  37. package/dist/lib/lanes/loop.d.ts +158 -0
  38. package/dist/lib/lanes/loop.js +743 -0
  39. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
  40. package/dist/lib/lanes/outpost-dispatch.js +21 -7
  41. package/dist/lib/lanes/reaper.d.ts +58 -0
  42. package/dist/lib/lanes/reaper.js +403 -0
  43. package/dist/lib/lanes/reconcile.d.ts +19 -0
  44. package/dist/lib/lanes/reconcile.js +173 -6
  45. package/dist/lib/lanes/spawn.d.ts +4 -0
  46. package/dist/lib/lanes/spawn.js +41 -9
  47. package/dist/lib/lanes/status-fields.d.ts +12 -0
  48. package/dist/lib/lanes/status-fields.js +189 -0
  49. package/dist/lib/lanes/store.js +49 -2
  50. package/dist/lib/lanes/types.d.ts +18 -0
  51. package/dist/lib/lanes/types.js +3 -3
  52. package/dist/lib/lanes/watcher.js +3 -0
  53. package/dist/lib/lanes/worker-continuity.d.ts +18 -0
  54. package/dist/lib/lanes/worker-continuity.js +81 -0
  55. package/dist/lib/lanes/worker-process.d.ts +4 -0
  56. package/dist/lib/lanes/worker-process.js +77 -0
  57. package/dist/main.js +9 -3
  58. package/dist/utils/cli-telemetry.d.ts +4 -1
  59. package/dist/utils/cli-telemetry.js +8 -0
  60. package/dist/utils/jq-windows-compat.d.ts +13 -0
  61. package/dist/utils/jq-windows-compat.js +48 -0
  62. package/dist/utils/run-bundled-script.d.ts +3 -0
  63. package/dist/utils/run-bundled-script.js +9 -80
  64. package/dist/utils/windows-bash.d.ts +5 -0
  65. package/dist/utils/windows-bash.js +75 -0
  66. package/package.json +1 -1
@@ -13,6 +13,7 @@ import { LANES_RUNS_REL } from "../lib/lanes/spawn.js";
13
13
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
14
14
  import { checkCompanyPath } from "../lib/lanes/checked-company-path.js";
15
15
  import { findOrphanedBareIdDropboxes } from "../lib/lanes/orphaned-dropbox.js";
16
+ import { parseReapMinAge, reapLocalLanes } from "../lib/lanes/reaper.js";
16
17
  export function mappingIncludesStory(mapping, storyId) {
17
18
  return (mapping.storyPrefix === undefined || storyId.startsWith(mapping.storyPrefix))
18
19
  && (mapping.excludePrefix === undefined || !storyId.startsWith(mapping.excludePrefix));
@@ -905,6 +906,39 @@ async function orphanReport(root, company, now, minAgeMinutes, userIdleHours, de
905
906
  output("orphans.stop", stopped);
906
907
  }
907
908
  export function registerLaneChecksCommand(parent, deps, wrap) {
909
+ parent.command("reap")
910
+ // CL-5's SessionStart hook launches this alias detached and does not wait for it.
911
+ .alias("_reap")
912
+ .description("Classify local lanes whose session senior has ended; report only by default")
913
+ .option("--min-age <minutes>", "Minimum lane age in minutes (default: 120)")
914
+ .option("--apply-stale-state", "Remove stale worker and watcher pid markers")
915
+ .option("--stop", "Stop candidate process trees when the hq-flags stop key is enabled")
916
+ .option("--reason <text>", "Required audit reason when --stop is set")
917
+ .option("--json", "Print machine-readable JSON (default)")
918
+ .action(wrap(async (options) => {
919
+ const reason = options.reason?.trim();
920
+ if (options.stop && !reason)
921
+ fail("lane_reap_reason_required", "--stop requires a non-empty --reason", {});
922
+ if (!options.stop && options.reason !== undefined)
923
+ fail("lane_reap_reason_requires_stop", "--reason can only be used with --stop", {});
924
+ if (reason)
925
+ assertNoSecretText("stop reason", reason);
926
+ const minAgeMinutes = parseReapMinAge(options.minAge);
927
+ const report = await reapLocalLanes(deps.hqRoot(), {
928
+ minAgeMinutes,
929
+ stop: options.stop === true,
930
+ applyStaleState: options.applyStaleState === true,
931
+ reason: reason ?? "Orphaned lane: owning session is confirmed ended",
932
+ json: true,
933
+ }, {
934
+ ...deps.reaperDeps,
935
+ now: deps.now,
936
+ sessionState: deps.sessionState,
937
+ stopEnabled: deps.stopEnabled,
938
+ stopLane: deps.stopLane,
939
+ });
940
+ console.log(JSON.stringify(report, null, 2));
941
+ }));
908
942
  parent.command("checks")
909
943
  .description("Evaluate tracker and lane health checks")
910
944
  .option("--company <slug>", "Select the company by slug")
@@ -0,0 +1,12 @@
1
+ import type { Ctx, LaneResumeOptions } from "./lanes-shared.js";
2
+ import type { LaneRecord } from "../lib/lanes/types.js";
3
+ export interface LoopControllerDeps {
4
+ resumeLane(laneId: string, options: LaneResumeOptions): Promise<unknown>;
5
+ interruptLane(laneId: string, reason: string, options: {
6
+ preserveLoopQueue: boolean;
7
+ }): Promise<void>;
8
+ emitTerminal(lane: LaneRecord): Promise<void>;
9
+ }
10
+ export declare function startLaneLoopController(ctx: Ctx, laneId: string, initialAdmission?: boolean): number;
11
+ export declare function runLaneLoopController(ctx: Ctx, laneId: string, deps: LoopControllerDeps): Promise<void>;
12
+ //# sourceMappingURL=lanes-loop.d.ts.map
@@ -0,0 +1,214 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { LanesError } from "../lib/lanes/errors.js";
5
+ import { readLane, retryUpdateLane } from "../lib/lanes/store.js";
6
+ import { readLatestEnvelopeRecord } from "../lib/lanes/watcher.js";
7
+ import { spawnLaneWorker } from "../lib/lanes/spawn.js";
8
+ import { raiseLaneQuestion } from "../lib/lanes/questions.js";
9
+ import { isTerminalLaneState } from "../lib/lanes/types.js";
10
+ import { clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
11
+ import { laneStories } from "../lib/lanes/stories.js";
12
+ const LOOP_RUNS_REL = path.join("workspace", "lanes-runs");
13
+ function loopRunDir(hqRoot, laneId) {
14
+ return path.join(hqRoot, LOOP_RUNS_REL, laneId);
15
+ }
16
+ export function startLaneLoopController(ctx, laneId, initialAdmission = false) {
17
+ const entry = process.argv[1];
18
+ if (!entry)
19
+ throw new LanesError("loop_controller_entry_missing", "Cannot resolve the hq CLI entry point for the loop controller", { lane_id: laneId });
20
+ const child = spawn(process.execPath, [entry, "lanes", "_loop-worker", laneId], {
21
+ cwd: ctx.hqRoot(),
22
+ detached: true,
23
+ stdio: "ignore",
24
+ env: { ...ctx.env, HQ_LANE_LOOP_CONTROLLER: "1", ...(initialAdmission ? { HQ_LANE_LOOP_INITIAL: "1" } : {}) },
25
+ });
26
+ child.unref();
27
+ if (child.pid === undefined)
28
+ throw new LanesError("loop_controller_spawn_failed", "Loop controller did not start", { lane_id: laneId });
29
+ setLoopControllerPid(loopRunDir(ctx.hqRoot(), laneId), laneId, child.pid);
30
+ return child.pid;
31
+ }
32
+ export async function runLaneLoopController(ctx, laneId, deps) {
33
+ const hqRoot = ctx.hqRoot();
34
+ const lane = readLane(hqRoot, laneId);
35
+ if (lane.loop_mode !== true)
36
+ throw new LanesError("lane_not_loop_mode", `Lane ${laneId} is not a loop lane`, { lane_id: laneId });
37
+ const runDir = loopRunDir(hqRoot, laneId);
38
+ let initialAdmission = ctx.env.HQ_LANE_LOOP_INITIAL === "1";
39
+ const stop = new AbortController();
40
+ const onSignal = () => stop.abort();
41
+ process.once("SIGTERM", onSignal);
42
+ process.once("SIGINT", onSignal);
43
+ try {
44
+ const result = await runLoopLane({
45
+ lane,
46
+ runDir,
47
+ signal: stop.signal,
48
+ idlePollMs: 250,
49
+ execute: async ({ envelope, signal, fresh, sessionId }) => {
50
+ let current = readLane(hqRoot, laneId);
51
+ const data = envelope;
52
+ const storyId = typeof data.story_id === "string" && data.story_id.trim() ? data.story_id.trim() : current.story_id;
53
+ if (storyId !== current.story_id) {
54
+ current = retryUpdateLane(hqRoot, laneId, "command", (record) => {
55
+ const stories = laneStories(record);
56
+ if (!stories.some((story) => story.project_id === record.project_id && story.story_id === storyId)) {
57
+ stories.push({ project_id: record.project_id, story_id: storyId, status: "in_progress", added_at: ctx.now().toISOString(), updated_at: ctx.now().toISOString() });
58
+ }
59
+ return { ...record, story_id: storyId, stories };
60
+ });
61
+ }
62
+ const worktree = typeof data.worktree === "string" ? path.resolve(data.worktree) : hqRoot;
63
+ if (typeof data.worktree === "string") {
64
+ let isDirectory = false;
65
+ try {
66
+ isDirectory = fs.statSync(worktree).isDirectory();
67
+ }
68
+ catch (error) {
69
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR")
70
+ throw error;
71
+ }
72
+ if (!isDirectory)
73
+ throw new Error(`phase worktree is not an accessible directory: ${worktree}`);
74
+ }
75
+ const phaseContext = data.schema === "hq-phase-envelope/v1"
76
+ ? [
77
+ `Story id: ${storyId}`,
78
+ `Story title: ${String(data.story_title ?? "")}`,
79
+ `Story description: ${String(data.story_description ?? "")}`,
80
+ `Phase: ${String(data.phase)}`,
81
+ `Working directory (worktree): ${worktree}`,
82
+ `Incoming handoff: ${String(data.incoming_handoff ?? "None: this is the first phase")}`,
83
+ "Acceptance criteria:",
84
+ ...(Array.isArray(data.acceptance_criteria) ? data.acceptance_criteria.map((item, index) => `${index}. ${String(item)}`) : []),
85
+ "Constraints:",
86
+ ...(Array.isArray(data.constraints) ? data.constraints.map((item) => `- ${String(item)}`) : []),
87
+ ].join("\n")
88
+ : `Execute the ${String(data.phase ?? "requested")} phase for story ${storyId}.`;
89
+ const prompt = typeof data.prompt === "string" ? data.prompt :
90
+ `${phaseContext}\n\nPhase envelope:\n${JSON.stringify(envelope, null, 2)}`;
91
+ const briefPath = path.join(runDir, "brief.md");
92
+ fs.writeFileSync(briefPath, [
93
+ "Follow the lane worker instructions and complete this single bounded phase.",
94
+ "Return the standard hq-lane-envelope.v1 envelope when the phase is complete. For a section 8 phase envelope, put a complete hq-phase-handoff/v1 object in artifacts; it must identify the requested story_id and phase and include worker_id, status, summary, files_changed, commits, back_pressure (tests/lint/typecheck/build), and context_for_next.",
95
+ "",
96
+ prompt,
97
+ ].join("\n"));
98
+ const admissionPath = loopAdmissionPath(current.state, current.pending_resume !== undefined, initialAdmission);
99
+ if (admissionPath === "initial") {
100
+ initialAdmission = false;
101
+ if (current.state === "running") {
102
+ retryUpdateLane(hqRoot, laneId, "command", (record) => ({
103
+ ...record,
104
+ state: "queued",
105
+ queued_reason: undefined,
106
+ queued_details: undefined,
107
+ admission_reserved_at: undefined,
108
+ }));
109
+ }
110
+ await spawnLaneWorker(hqRoot, current, {
111
+ env: ctx.env,
112
+ modelDeps: { readFileSync: ctx.readFileSync, probe: ctx.probeProvider, env: ctx.env },
113
+ existsSync: ctx.existsSync,
114
+ spawnProcess: ctx.spawnProcess,
115
+ proveDetached: ctx.proveDetached,
116
+ killProcess: ctx.killProcess,
117
+ now: ctx.now,
118
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
119
+ runCompaction: ctx.runCompaction,
120
+ locateTranscript: ctx.locateTranscript,
121
+ workingDirectory: worktree,
122
+ });
123
+ }
124
+ else if (admissionPath === "deferred") {
125
+ retryUpdateLane(hqRoot, laneId, "command", (record) => ({
126
+ ...record,
127
+ pending_resume: record.pending_resume ? {
128
+ ...record.pending_resume,
129
+ ...(fresh ? { session_id: undefined, fresh_thread: true } : sessionId ? { session_id: sessionId, fresh_thread: undefined } : {}),
130
+ working_directory: worktree,
131
+ } : undefined,
132
+ }));
133
+ }
134
+ else {
135
+ await deps.resumeLane(laneId, {
136
+ text: `Read ${briefPath} and follow it as the complete instruction for this phase.`,
137
+ project: current.project_id,
138
+ story: storyId,
139
+ freshThread: fresh,
140
+ ...(sessionId ? { loopSessionId: sessionId } : {}),
141
+ workingDirectory: worktree,
142
+ });
143
+ }
144
+ let interruptPromise;
145
+ const abortPhase = () => {
146
+ interruptPromise = deps.interruptLane(laneId, "loop phase deadline exceeded", { preserveLoopQueue: true }).catch((error) => {
147
+ process.stderr.write(`hq lanes: could not interrupt stalled loop phase ${laneId}: ${error instanceof Error ? error.message : String(error)}\n`);
148
+ });
149
+ };
150
+ signal.addEventListener("abort", abortPhase, { once: true });
151
+ try {
152
+ for (;;) {
153
+ if (signal.aborted) {
154
+ await interruptPromise;
155
+ throw signal.reason ?? new Error("phase deadline exceeded");
156
+ }
157
+ const latest = readLane(hqRoot, laneId);
158
+ if (latest.state === "done" || latest.state === "blocked" || latest.state === "awaiting_input" || isTerminalLaneState(latest.state)) {
159
+ const envelopeRecord = readLatestEnvelopeRecord(hqRoot, laneId);
160
+ if (latest.state !== "done" && latest.state !== "blocked" && latest.state !== "awaiting_input") {
161
+ throw new Error(`lane phase ended in ${latest.state}${latest.terminal_reason ? ` (${latest.terminal_reason})` : ""}`);
162
+ }
163
+ if (!envelopeRecord)
164
+ throw new Error(`lane ${laneId} ended without writing a phase envelope`);
165
+ const handoff = envelopeRecord.envelope.artifacts.find((artifact) => typeof artifact === "object" && artifact !== null &&
166
+ artifact.schema === "hq-phase-handoff/v1");
167
+ if (data.schema === "hq-phase-envelope/v1" && !handoff) {
168
+ throw new Error("section 8 phase requires an hq-phase-handoff/v1 object in the lane envelope artifacts");
169
+ }
170
+ return { output: JSON.stringify(handoff ?? envelopeRecord.envelope), ...(latest.provider_session_id ? { sessionId: latest.provider_session_id } : {}) };
171
+ }
172
+ await new Promise((resolve) => setTimeout(resolve, 250));
173
+ }
174
+ }
175
+ finally {
176
+ signal.removeEventListener("abort", abortPhase);
177
+ }
178
+ },
179
+ raiseQuestion: ({ envelope, envelopeId, attempt, error }) => {
180
+ const current = readLane(hqRoot, laneId);
181
+ const data = envelope;
182
+ raiseLaneQuestion({
183
+ hqRoot,
184
+ lane: current,
185
+ raisedVia: "cli",
186
+ at: ctx.now().toISOString(),
187
+ body: {
188
+ question: `Loop phase ${String(data.phase ?? envelopeId)} stalled twice. How should it proceed?`,
189
+ context: `${error}\nThe phase is parked; later envelopes remain queued. Stall attempt: ${attempt}.`,
190
+ options: ["Retry with a longer deadline", "Reroute to another worker", "Drop this phase and block the story"],
191
+ recommended: "Retry with a longer deadline",
192
+ },
193
+ });
194
+ },
195
+ });
196
+ if (result.state === "stopped") {
197
+ const stopped = retryUpdateLane(hqRoot, laneId, "command", (current) => ({
198
+ ...current,
199
+ state: "done",
200
+ queued_reason: undefined,
201
+ queued_details: undefined,
202
+ admission_reserved_at: undefined,
203
+ terminal_reason: "loop_stopped",
204
+ }));
205
+ await deps.emitTerminal(stopped);
206
+ }
207
+ }
208
+ finally {
209
+ process.removeListener("SIGTERM", onSignal);
210
+ process.removeListener("SIGINT", onSignal);
211
+ clearLoopControllerPid(runDir, laneId, process.pid);
212
+ }
213
+ }
214
+ //# sourceMappingURL=lanes-loop.js.map
@@ -291,7 +291,7 @@ export function createLaneQuestionsHandlers(deps) {
291
291
  const body = parseOutpostQuestionBody(response.body, outpostId, question.question_id);
292
292
  return { question, audience, body };
293
293
  }
294
- async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
294
+ async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter, questionId, visibilityFilter) {
295
295
  const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
296
296
  !laneIsHostedHere(ctx, lane) &&
297
297
  (!companyFilter ||
@@ -356,7 +356,7 @@ export function createLaneQuestionsHandlers(deps) {
356
356
  };
357
357
  }
358
358
  try {
359
- const remoteQuestions = await readOutpostQuestionRecords(ctx, outpostId, group.company);
359
+ const remoteQuestions = await readOutpostQuestionRecords(ctx, outpostId, group.company, questionId, visibilityFilter);
360
360
  const localLanesById = new Map(group.lanes.map((lane) => [lane.lane_id, lane]));
361
361
  const groupQuestions = [];
362
362
  const seenQuestionIds = new Set();
@@ -595,8 +595,32 @@ export function createLaneQuestionsHandlers(deps) {
595
595
  }
596
596
  return true;
597
597
  });
598
+ const deliveredLocalQuestions = new Map();
599
+ const currentSessionId = sessionIdFromEnv(ctx.env);
600
+ if (currentSessionId) {
601
+ const deliveredAt = ctx.now().toISOString();
602
+ for (const question of localQuestions) {
603
+ if (question.status !== "pending")
604
+ continue;
605
+ const audience = resolveAudience(hqRoot, question, ctx);
606
+ if (audience.kind !== "session" || audience.id !== currentSessionId)
607
+ continue;
608
+ const delivered = casUpdateQuestion(hqRoot, question.question_id, (current) => current.status !== "pending" ||
609
+ (current.seen_at && current.seen_session_id === currentSessionId)
610
+ ? current
611
+ : { ...current, seen_at: deliveredAt, seen_session_id: currentSessionId });
612
+ deliveredLocalQuestions.set(question.question_id, delivered);
613
+ }
614
+ }
598
615
  const outpostLoad = opts.allHosts
599
- ? await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company)
616
+ ? await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company, undefined, {
617
+ sessionId: currentSessionId ?? undefined,
618
+ for: opts.for,
619
+ from: opts.from,
620
+ project: opts.project,
621
+ story: opts.story,
622
+ status: opts.status,
623
+ })
600
624
  : { questions: [], errors: [] };
601
625
  for (const owner of outpostLoad.questions) {
602
626
  assertQuestionCompanyAccess(owner.question, owner.lane, company, hqRoot);
@@ -607,12 +631,13 @@ export function createLaneQuestionsHandlers(deps) {
607
631
  ...localQuestions
608
632
  .filter((q) => !remoteQuestionIds.has(q.question_id))
609
633
  .map((q) => {
634
+ const visibleQuestion = deliveredLocalQuestions.get(q.question_id) ?? q;
610
635
  const row = {
611
- ...q,
612
- priority: q.priority ?? null,
613
- priority_display: q.priority ?? "-",
614
- audience: resolveAudience(hqRoot, q, ctx),
615
- ...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
636
+ ...visibleQuestion,
637
+ priority: visibleQuestion.priority ?? null,
638
+ priority_display: visibleQuestion.priority ?? "-",
639
+ audience: resolveAudience(hqRoot, visibleQuestion, ctx),
640
+ ...(visibleQuestion.question_kind === "continue" ? { flag: "continue-loop" } : {}),
616
641
  };
617
642
  if (!opts.allHosts)
618
643
  return row;
@@ -662,7 +687,7 @@ export function createLaneQuestionsHandlers(deps) {
662
687
  catch (err) {
663
688
  if (!isLanesError(err) || err.error !== "question_not_found")
664
689
  throw err;
665
- const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
690
+ const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, undefined, questionId);
666
691
  if (lookup.errors.length > 0) {
667
692
  throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
668
693
  question_id: questionId,
@@ -717,7 +742,7 @@ export function createLaneQuestionsHandlers(deps) {
717
742
  if (!isLanesError(err) || err.error !== "question_not_found")
718
743
  throw err;
719
744
  const company = explicitCompany ?? await resolveActiveQuestionCompany(ctx, "Cannot look up this lane question because the active HQ company could not be resolved; re-run with --company <slug>");
720
- const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company);
745
+ const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company, questionId);
721
746
  const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
722
747
  if (matches.length === 0 && lookup.errors.length > 0) {
723
748
  throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
@@ -726,7 +751,7 @@ export function createLaneQuestionsHandlers(deps) {
726
751
  });
727
752
  }
728
753
  if (matches.length === 0) {
729
- const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
754
+ const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, undefined, questionId);
730
755
  const otherCompanyMatch = allCompanyLookup.questions.find((entry) => entry.question.question_id === questionId);
731
756
  if (otherCompanyMatch) {
732
757
  assertQuestionCompanyAccess(otherCompanyMatch.question, otherCompanyMatch.lane, company, hqRoot);
@@ -818,6 +843,7 @@ export function createLaneQuestionsHandlers(deps) {
818
843
  company: recordedCompany,
819
844
  body: nextBody,
820
845
  status: "answered",
846
+ delivered_at: null,
821
847
  answer: {
822
848
  choice,
823
849
  answer_kind: freeform ? "freeform" : "option",
@@ -91,6 +91,9 @@ export function createLaneResumeHandlers(deps) {
91
91
  const hqRoot = ctx.hqRoot();
92
92
  const resolution = await deps.resolveCommandLane(ctx, laneId);
93
93
  let lane = resolution.lane;
94
+ if (opts.freshThread === true && lane.loop_mode !== true) {
95
+ throw new LanesError("fresh_thread_loop_only", "--fresh-thread is reserved for loop-lane phases", { lane_id: laneId });
96
+ }
94
97
  const failedResumeReason = opts.reason?.trim();
95
98
  if (opts.reason !== undefined && opts.fromFailed !== true) {
96
99
  throw new LanesError("failed_resume_reason_requires_flag", "--reason on hq lanes resume requires --from-failed", { lane_id: laneId });
@@ -153,6 +156,9 @@ export function createLaneResumeHandlers(deps) {
153
156
  throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
154
157
  }
155
158
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
159
+ if (opts.loopSessionId !== undefined && opts.loopSessionId !== lane.provider_session_id) {
160
+ lane = casUpdateLane(hqRoot, laneId, (current) => ({ ...current, provider_session_id: opts.loopSessionId }));
161
+ }
156
162
  if (opts.model !== undefined && opts.account === undefined) {
157
163
  throw new LanesError("model_requires_account", "--model on hq lanes resume is only accepted together with --account", {});
158
164
  }
@@ -233,7 +239,7 @@ export function createLaneResumeHandlers(deps) {
233
239
  }
234
240
  const refusal = queuedProfileRecovery || lane.state === "awaiting_input"
235
241
  ? null
236
- : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true || isFailedQuotaKeepAliveLane(lane) });
242
+ : resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true || isFailedQuotaKeepAliveLane(lane), allowFresh: opts.freshThread === true });
237
243
  if (refusal !== null) {
238
244
  throw new LanesError(refusal.error, refusal.message, {
239
245
  lane_id: laneId,
@@ -435,6 +441,8 @@ export function createLaneResumeHandlers(deps) {
435
441
  ...(deliveryText !== undefined ? { text: deliveryText } : {}),
436
442
  ...(messageId !== undefined ? { message_id: messageId } : {}),
437
443
  compact: opts.compact !== false,
444
+ ...(opts.freshThread === true ? { fresh_thread: true } : {}),
445
+ ...(opts.workingDirectory ? { working_directory: opts.workingDirectory } : {}),
438
446
  },
439
447
  });
440
448
  lane = move.lane;
@@ -469,6 +477,8 @@ export function createLaneResumeHandlers(deps) {
469
477
  ...(deliveryText !== undefined ? { text: deliveryText } : {}),
470
478
  ...(messageId !== undefined ? { message_id: messageId } : {}),
471
479
  compact: opts.compact !== false,
480
+ ...(opts.freshThread === true ? { fresh_thread: true } : {}),
481
+ ...(opts.workingDirectory ? { working_directory: opts.workingDirectory } : {}),
472
482
  },
473
483
  accountSelection: selection,
474
484
  });
@@ -496,6 +506,8 @@ export function createLaneResumeHandlers(deps) {
496
506
  ...(deliveryText !== undefined ? { text: deliveryText } : {}),
497
507
  ...(messageId !== undefined ? { message_id: messageId } : {}),
498
508
  compact: opts.compact !== false,
509
+ ...(opts.freshThread === true ? { fresh_thread: true } : {}),
510
+ ...(opts.workingDirectory ? { working_directory: opts.workingDirectory } : {}),
499
511
  },
500
512
  });
501
513
  lane = move.lane;
@@ -535,6 +547,8 @@ export function createLaneResumeHandlers(deps) {
535
547
  ...(deliveryText !== undefined ? { text: deliveryText } : {}),
536
548
  ...(messageId !== undefined ? { message_id: messageId } : {}),
537
549
  compact: opts.compact !== false,
550
+ ...(opts.freshThread === true ? { fresh_thread: true } : {}),
551
+ ...(opts.workingDirectory ? { working_directory: opts.workingDirectory } : {}),
538
552
  at: quotaHoldAt,
539
553
  };
540
554
  resumed = markSpawnFailure(hqRoot, laneId, "account_quota_exhausted", {
@@ -590,6 +604,8 @@ export function createLaneResumeHandlers(deps) {
590
604
  ...(deliveryText !== undefined ? { text: deliveryText } : {}),
591
605
  ...(messageId !== undefined ? { message_id: messageId } : {}),
592
606
  compact: opts.compact !== false,
607
+ ...(opts.freshThread === true ? { fresh_thread: true } : {}),
608
+ ...(opts.workingDirectory ? { working_directory: opts.workingDirectory } : {}),
593
609
  at,
594
610
  },
595
611
  };
@@ -610,10 +626,12 @@ export function createLaneResumeHandlers(deps) {
610
626
  resolveMachineIdentity: ctx.resolveMachineIdentity,
611
627
  runCompaction: ctx.runCompaction,
612
628
  locateTranscript: ctx.locateTranscript,
629
+ ...(opts.workingDirectory ? { workingDirectory: opts.workingDirectory } : {}),
613
630
  }, {
614
631
  trigger: {
615
632
  kind: "resume",
616
633
  from: resumedFrom,
634
+ ...(opts.freshThread === true ? { fresh: true } : {}),
617
635
  ...(resumedFrom === "awaiting_input" && pendingQuestions.length === 0 ? { allowAwaitingInput: true } : {}),
618
636
  ...(opts.fromFailed === true ? { fromFailed: true } : {}),
619
637
  ...(failedResumeExpectedSenior !== undefined
@@ -7,16 +7,11 @@ export interface SessionStartOptions {
7
7
  stdout?: Pick<NodeJS.WriteStream, "write">;
8
8
  stderr?: Pick<NodeJS.WriteStream, "write">;
9
9
  resolveRoot?: (env: NodeJS.ProcessEnv, cwd?: string) => string;
10
- startSweep?: (root: string, env: NodeJS.ProcessEnv, context: SessionStartContext) => void;
11
- }
12
- interface SessionStartContext {
13
- sessionId?: string;
14
- companySlug?: string;
10
+ startSweep?: (root: string, env: NodeJS.ProcessEnv) => void;
15
11
  }
16
12
  /** Starts the CL-4 read-only orphan report once per host, without holding SessionStart open. */
17
- export declare function startSessionStartOrphanSweep(root: string, env?: NodeJS.ProcessEnv, spawnDetached?: typeof spawn, context?: SessionStartContext): void;
13
+ export declare function startSessionStartOrphanSweep(root: string, env?: NodeJS.ProcessEnv, spawnDetached?: typeof spawn): void;
18
14
  /** SessionStart-compatible, fail-open conduct bootstrap. */
19
15
  export declare function runLaneSessionStart(options?: SessionStartOptions): Promise<void>;
20
16
  export declare function registerLaneSessionStartCommand(lanes: Command): void;
21
- export {};
22
17
  //# sourceMappingURL=lanes-session-start.d.ts.map
@@ -27,22 +27,6 @@ function sessionWasDisabled(root, env, payloadSessionId) {
27
27
  return /^\s*(?:conduct_mode|conduct_enabled|conduct_default):\s*(?:off|false|no|0)\s*(?:#.*)?$/mi.test(text) ||
28
28
  /^\s*conduct_engine:\s*(?:""|''|#.*)?\s*$/mi.test(text);
29
29
  }
30
- function sessionStartContext(root, env, payloadSessionId) {
31
- const sessionId = payloadSessionId || env.HQ_SESSION_ID;
32
- let companySlug = "";
33
- if (sessionId && /^[A-Za-z0-9_-]+$/.test(sessionId)) {
34
- const meta = path.join(root, "workspace", "sessions", sessionId, "meta.yaml");
35
- if (fs.existsSync(meta)) {
36
- const text = fs.readFileSync(meta, "utf8");
37
- companySlug = /^\s*company_slug:\s*["']?([A-Za-z0-9][A-Za-z0-9-]*)["']?\s*(?:#.*)?$/mi.exec(text)?.[1] ?? "";
38
- }
39
- }
40
- if (!companySlug)
41
- companySlug = env.HQ_COMPANY_SLUG?.trim() ?? "";
42
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(companySlug))
43
- companySlug = "";
44
- return { ...(sessionId ? { sessionId } : {}), ...(companySlug ? { companySlug } : {}) };
45
- }
46
30
  function extractConductorBlock(core) {
47
31
  const start = core.indexOf("<!-- inject:start -->");
48
32
  const end = core.indexOf("<!-- inject:end -->");
@@ -52,15 +36,12 @@ function extractConductorBlock(core) {
52
36
  }
53
37
  const ORPHAN_SWEEP_LOCK_TTL_MS = 15 * 60_000;
54
38
  /** Starts the CL-4 read-only orphan report once per host, without holding SessionStart open. */
55
- export function startSessionStartOrphanSweep(root, env = process.env, spawnDetached = spawn, context = {}) {
56
- // The orphan command resolves company membership when --company is absent;
57
- // skip here if this session has no tenant context rather than risk ambiguity.
58
- if (!context.companySlug)
59
- return;
39
+ export function startSessionStartOrphanSweep(root, env = process.env, spawnDetached = spawn) {
60
40
  const stateRoot = env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
61
- const stateDir = path.join(stateRoot, "hq-cli");
62
- const lockPath = path.join(stateDir, "lanes-session-start-orphan-sweep.json");
63
- const reportDir = path.join(stateDir, "lanes-orphan-reports", context.companySlug);
41
+ const host = os.hostname().replace(/[^A-Za-z0-9._-]/g, "_") || "unknown-host";
42
+ const stateDir = path.join(stateRoot, "hq-cli", "lanes-session-start", host);
43
+ const lockPath = path.join(stateDir, "orphan-sweep.lock");
44
+ const reportDir = path.join(stateRoot, "hq-cli", "lanes-orphan-reports", host);
64
45
  const ownerToken = randomUUID();
65
46
  try {
66
47
  fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -108,17 +89,17 @@ export function startSessionStartOrphanSweep(root, env = process.env, spawnDetac
108
89
  }
109
90
  const wrapper = [
110
91
  "const cp=require('node:child_process'),fs=require('node:fs'),path=require('node:path');",
111
- "const [cli,lock,root,token,report,company]=process.argv.slice(1);",
92
+ "const [cli,lock,root,token,report]=process.argv.slice(1);",
112
93
  "const cleanup=()=>{try{const lease=JSON.parse(fs.readFileSync(lock,'utf8'));if(lease.owner_token===token)fs.rmSync(lock,{force:true})}catch{}};",
113
94
  "const start=()=>{let lease;try{lease=JSON.parse(fs.readFileSync(lock,'utf8'))}catch{}if(lease?.owner_token!==token){if(Date.now()<Number(lease?.started_at||0)+1000)return setTimeout(start,10);process.exit(0)}",
114
95
  "try{fs.writeFileSync(lock,JSON.stringify({...lease,pid:process.pid}))}catch{}",
115
96
  "const tmp=report+'.tmp';let output;try{output=fs.createWriteStream(tmp,{flags:'wx',mode:0o600})}catch{cleanup();process.exit(0)}",
116
- "const child=cp.spawn(process.execPath,[cli,'lanes','orphans','--company',company,'--json'],{stdio:['ignore','pipe','ignore'],env:{...process.env,HQ_ROOT:root,HQ_NO_UPDATE_CHECK:'1'}});",
97
+ "const child=cp.spawn(process.execPath,[cli,'lanes','_reap','--json'],{stdio:['ignore','pipe','ignore'],env:{...process.env,HQ_ROOT:root,HQ_NO_UPDATE_CHECK:'1'}});",
117
98
  "child.stdout.pipe(output);child.on('error',()=>{output.destroy();try{fs.rmSync(tmp,{force:true})}catch{}cleanup()});",
118
99
  "child.on('exit',(code)=>output.end(()=>{try{if(code===0)fs.renameSync(tmp,report);else fs.rmSync(tmp,{force:true})}catch{}cleanup()}));};start();",
119
100
  ].join("");
120
101
  const reportPath = path.join(reportDir, `orphans-${startedAt}-${ownerToken}.json`);
121
- const launcher = spawnDetached(process.execPath, ["-e", wrapper, cli, lockPath, root, ownerToken, reportPath, context.companySlug], {
102
+ const launcher = spawnDetached(process.execPath, ["-e", wrapper, cli, lockPath, root, ownerToken, reportPath], {
122
103
  detached: true,
123
104
  stdio: "ignore",
124
105
  env: { ...env, HQ_ROOT: root, HQ_NO_UPDATE_CHECK: "1" },
@@ -185,8 +166,7 @@ export async function runLaneSessionStart(options = {}) {
185
166
  });
186
167
  if (!flagEnabled)
187
168
  return;
188
- const context = sessionStartContext(root, env, payloadSessionId);
189
- (options.startSweep ?? ((r, e, c) => startSessionStartOrphanSweep(r, e, spawn, c)))(root, env, context);
169
+ (options.startSweep ?? ((r, e) => startSessionStartOrphanSweep(r, e, spawn)))(root, env);
190
170
  if (sessionWasDisabled(root, env, payloadSessionId))
191
171
  return;
192
172
  let enabled = enabledFromSettings(root);
@@ -1,4 +1,4 @@
1
- import type { LaneCompanyRef, LanePlacementPolicy, LaneRecord, LanesConfig, QuestionAnswer, QuestionBody, QuestionRecord, SeniorRef, AccountHistoryEntry, LaneAccountSelection, PendingResume, QueuedReason, ResolvedLaneWorker, LivenessDeps, MachineIdentity, MonitorEvidenceReader, ReconcileHost, RunCommand, ThrottleDeps, withLaneHireLocks } from "../lib/lanes/index.js";
1
+ import type { LaneCompanyRef, LanePlacementPolicy, LaneRecord, LanesConfig, QuestionAnswer, QuestionBody, QuestionRecord, SeniorRef, AccountHistoryEntry, LaneAccountSelection, PendingResume, QueuedReason, ResolvedLaneWorker, LivenessDeps, MachineIdentity, MonitorEvidenceReader, ReconcileHost, RunCommand, ThrottleDeps, withLaneAdmissionLock, withLaneHireLocks } from "../lib/lanes/index.js";
2
2
  import type { OutpostIdentity } from "../lib/lanes/admission-host.js";
3
3
  import type { OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
4
4
  import type { CpuBusySource } from "../lib/lanes/throttle.js";
@@ -77,6 +77,10 @@ export interface LaneResumeOptions {
77
77
  effort?: string;
78
78
  worker?: string;
79
79
  keepAlive?: boolean;
80
+ freshThread?: boolean;
81
+ workingDirectory?: string;
82
+ /** Internal loop runner session; selects this story's saved provider session. */
83
+ loopSessionId?: string;
80
84
  meshCompany?: string;
81
85
  profileRepinContext?: string;
82
86
  grantContext?: string;
@@ -313,6 +317,7 @@ export interface Ctx {
313
317
  storyLedgerEnabled: () => boolean;
314
318
  isSsmOwnerDispatch: () => boolean;
315
319
  withGrantHireLocks: typeof withLaneHireLocks;
320
+ withAdmissionLock: typeof withLaneAdmissionLock;
316
321
  mergeHoldEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
317
322
  mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
318
323
  }
@@ -457,7 +462,14 @@ export interface LaneQuestionsHandlerDeps {
457
462
  questionCompanyForLane(question: QuestionRecord, lane: Pick<LaneRecord, "company" | "mesh_company" | "project_id" | "lane_id">, hqRoot?: string): LaneCompanyRef | null;
458
463
  questionCompanyRefMatches(recordCompany: LaneCompanyRef | null | undefined, filter: LaneCompanyRef): boolean;
459
464
  questionListCompanyArg(company: LaneCompanyRef | null): string | undefined;
460
- readOutpostQuestionRecords(ctx: Ctx, outpostId: string, company: LaneCompanyRef | null): Promise<OutpostQuestionRow[]>;
465
+ readOutpostQuestionRecords(ctx: Ctx, outpostId: string, company: LaneCompanyRef | null, questionId?: string, visibilityFilter?: {
466
+ sessionId?: string;
467
+ for?: string;
468
+ from?: string;
469
+ project?: string;
470
+ story?: string;
471
+ status?: string;
472
+ }): Promise<OutpostQuestionRow[]>;
461
473
  readQuestionBody(hqRoot: string, question: QuestionRecord): QuestionBody | null;
462
474
  printJson(value: unknown): void;
463
475
  readSessionMeta(hqRoot: string, sessionId: string, ctx: Ctx): Record<string, unknown> | null;
@@ -17,7 +17,7 @@ export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/
17
17
  export type { OutpostIdentity } from "../lib/lanes/admission-host.js";
18
18
  import { type OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
19
19
  import { emitLaneDecisionEvents } from "../lib/lanes/mesh-events.js";
20
- import { MAX_LANE_DEPTH, withLaneHireLocks, type LaneCompanyRef, type QuestionBody, type LaneRecord, type QuestionRecord, type ResolvedLaneWorker, type SeniorRef, type LivenessDeps, type MachineIdentity, type MonitorEvidenceReader, type RunCommand, type ThrottleDeps } from "../lib/lanes/index.js";
20
+ import { MAX_LANE_DEPTH, withLaneAdmissionLock, withLaneHireLocks, type LaneCompanyRef, type QuestionBody, type LaneRecord, type QuestionRecord, type ResolvedLaneWorker, type SeniorRef, type LivenessDeps, type MachineIdentity, type MonitorEvidenceReader, type RunCommand, type ThrottleDeps } from "../lib/lanes/index.js";
21
21
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
22
22
  import { type RunCompactionFn } from "../lib/lanes/compaction.js";
23
23
  import type { WatcherLoopDeps } from "../lib/lanes/watcher.js";
@@ -106,6 +106,8 @@ export interface LanesCommandDeps {
106
106
  isSsmOwnerDispatch?: () => boolean;
107
107
  /** Test seam for changing a lane between pre-lock and locked grant reads. */
108
108
  withGrantHireLocks?: typeof withLaneHireLocks;
109
+ /** Test seam proving create admission encloses the worker busy check. */
110
+ withAdmissionLock?: typeof withLaneAdmissionLock;
109
111
  /** Test seam for the review-before-merge registry flag. */
110
112
  mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
111
113
  /** Test seam for the repository-merge-hold registry flag. */