@indigoai-us/hq-cli 5.292.0 → 5.294.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +5 -1
  3. package/dist/command-catalog.generated.d.ts +45 -24
  4. package/dist/command-catalog.generated.js +57 -30
  5. package/dist/commands/core-command-catalog.js +3 -2
  6. package/dist/commands/core-delegate-w1b.d.ts +2 -0
  7. package/dist/commands/core-delegate-w1b.js +28 -2
  8. package/dist/commands/core-delegate.d.ts +3 -1
  9. package/dist/commands/core-delegate.js +35 -5
  10. package/dist/commands/core-handoff-fast.d.ts +18 -0
  11. package/dist/commands/core-handoff-fast.js +103 -0
  12. package/dist/commands/core-handoff-open-steps.d.ts +21 -0
  13. package/dist/commands/core-handoff-open-steps.js +264 -0
  14. package/dist/commands/core-handoff.d.ts +4 -2
  15. package/dist/commands/core-handoff.js +22 -249
  16. package/dist/commands/core-jobs-native.d.ts +2 -0
  17. package/dist/commands/core-jobs-native.js +22 -11
  18. package/dist/commands/core-knowledge-prefs.d.ts +2 -0
  19. package/dist/commands/core-knowledge-prefs.js +13 -1
  20. package/dist/commands/core-ontology-native.d.ts +2 -0
  21. package/dist/commands/core-ontology-native.js +22 -2
  22. package/dist/commands/core-policy.d.ts +2 -0
  23. package/dist/commands/core-policy.js +18 -1
  24. package/dist/commands/core.js +2 -1
  25. package/dist/commands/daemon.d.ts +2 -1
  26. package/dist/commands/daemon.js +22 -7
  27. package/dist/commands/lanes-autopilot.d.ts +11 -0
  28. package/dist/commands/lanes-autopilot.js +336 -0
  29. package/dist/commands/lanes-move.js +54 -1
  30. package/dist/commands/lanes-questions.js +12 -5
  31. package/dist/commands/lanes-shared.d.ts +17 -0
  32. package/dist/commands/lanes-stories.d.ts +14 -0
  33. package/dist/commands/lanes-stories.js +308 -0
  34. package/dist/commands/lanes.d.ts +2 -0
  35. package/dist/commands/lanes.js +158 -314
  36. package/dist/fast-core.js +53 -25
  37. package/dist/fast-scaffold.d.ts +15 -2
  38. package/dist/fast-scaffold.js +167 -7
  39. package/dist/lib/core-utils/sentry-report.js +2 -2
  40. package/dist/lib/core-utils/worktree-resume.d.ts +2 -0
  41. package/dist/lib/core-utils/worktree-resume.js +16 -0
  42. package/dist/lib/daemon/migrate.d.ts +8 -1
  43. package/dist/lib/daemon/migrate.js +19 -7
  44. package/dist/lib/daemon/run.d.ts +11 -0
  45. package/dist/lib/daemon/run.js +17 -1
  46. package/dist/lib/lanes/autopilot-metrics.d.ts +99 -0
  47. package/dist/lib/lanes/autopilot-metrics.js +301 -0
  48. package/dist/lib/lanes/autopilot.d.ts +13 -1
  49. package/dist/lib/lanes/autopilot.js +271 -28
  50. package/dist/lib/lanes/grants.d.ts +1 -1
  51. package/dist/lib/lanes/grants.js +7 -5
  52. package/dist/lib/lanes/index.d.ts +1 -1
  53. package/dist/lib/lanes/keep-alive.d.ts +5 -0
  54. package/dist/lib/lanes/keep-alive.js +9 -4
  55. package/dist/lib/lanes/questions.d.ts +2 -1
  56. package/dist/lib/lanes/questions.js +2 -0
  57. package/dist/lib/lanes/reconcile.js +186 -9
  58. package/dist/lib/lanes/spawn.js +4 -0
  59. package/dist/lib/lanes/store.d.ts +2 -0
  60. package/dist/lib/lanes/store.js +53 -4
  61. package/dist/lib/lanes/types.d.ts +13 -1
  62. package/dist/lib/lanes/types.js +2 -2
  63. package/dist/lib/lanes/watcher.js +3 -1
  64. package/dist/native-fast-route.d.ts +3 -1
  65. package/dist/native-fast-route.js +10 -8
  66. package/dist/utils/contribution-table.d.ts +1 -1
  67. package/dist/utils/fast-stream-errors.d.ts +12 -0
  68. package/dist/utils/fast-stream-errors.js +18 -0
  69. package/dist/utils/scaffold-fast-import-guard.d.ts +2 -2
  70. package/dist/utils/scaffold-fast-import-guard.js +6 -3
  71. package/dist/utils/self-update.d.ts +7 -0
  72. package/dist/utils/self-update.js +5 -1
  73. package/dist/utils/version-gate.d.ts +15 -0
  74. package/dist/utils/version-gate.js +46 -2
  75. package/package.json +1 -1
@@ -0,0 +1,336 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { laneRunsHere } from "../lib/lanes/reconcile.js";
4
+ import { listLaneReviews } from "../lib/lanes/reviews.js";
5
+ import { readAllLanes } from "../lib/lanes/store.js";
6
+ import { LANES_RUNS_REL } from "../lib/lanes/spawn.js";
7
+ import { normalizeIsoTimestamp } from "../lib/lanes/timestamp.js";
8
+ import { buildAutopilotMetricsReport, } from "../lib/lanes/autopilot-metrics.js";
9
+ import { LanesError } from "../lib/lanes/errors.js";
10
+ const OPERATOR_NUDGE_DEFECTS_REL = path.join("workspace", "lane-briefs", "operator-nudge-defects.jsonl");
11
+ const JUNIORS_REL = path.join("workspace", "lane-briefs", "juniors.json");
12
+ const DROPBOX_NAME = "dropbox";
13
+ const RESUME_SUFFIX = ".resume.jsonl";
14
+ function fail(error, message, details = {}) {
15
+ throw new LanesError(error, message, details);
16
+ }
17
+ function isObject(value) {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+ function safeReadText(root, file, missingOk = false) {
21
+ let stat;
22
+ try {
23
+ stat = fs.lstatSync(file);
24
+ }
25
+ catch (err) {
26
+ if (missingOk && err.code === "ENOENT")
27
+ return null;
28
+ return fail("autopilot_metrics_input_unreadable", `Could not inspect report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
29
+ }
30
+ if (!stat.isFile() || stat.isSymbolicLink()) {
31
+ return fail("autopilot_metrics_input_invalid", "Report input must be a regular file", { path: file });
32
+ }
33
+ let realRoot;
34
+ let realFile;
35
+ try {
36
+ realRoot = fs.realpathSync(root);
37
+ realFile = fs.realpathSync(file);
38
+ }
39
+ catch (err) {
40
+ return fail("autopilot_metrics_input_unreadable", `Could not resolve report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
41
+ }
42
+ if (!realFile.startsWith(`${realRoot}${path.sep}`)) {
43
+ return fail("autopilot_metrics_input_invalid", "Report input must stay under the HQ root", { path: file });
44
+ }
45
+ try {
46
+ return fs.readFileSync(file, "utf8");
47
+ }
48
+ catch (err) {
49
+ return fail("autopilot_metrics_input_unreadable", `Could not read report input: ${err instanceof Error ? err.message : String(err)}`, { path: file });
50
+ }
51
+ }
52
+ function parseJsonObject(root, file) {
53
+ const raw = safeReadText(root, file);
54
+ try {
55
+ const parsed = JSON.parse(raw ?? "");
56
+ if (isObject(parsed))
57
+ return parsed;
58
+ }
59
+ catch (err) {
60
+ return fail("autopilot_metrics_input_invalid", `Report input is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, { path: file });
61
+ }
62
+ return fail("autopilot_metrics_input_invalid", "Report input must be a JSON object", { path: file });
63
+ }
64
+ function isCurrentHostLabel(label, host) {
65
+ return label === "local" ? host.outpostId === null : label === host.outpostId || label === host.hostname;
66
+ }
67
+ function loadJuniors(root, host) {
68
+ const file = path.join(root, JUNIORS_REL);
69
+ const parsed = parseJsonObject(root, file);
70
+ if (!Array.isArray(parsed.juniors))
71
+ return fail("autopilot_metrics_input_invalid", "Team registry must contain a juniors array", { path: file });
72
+ const seen = new Set();
73
+ return parsed.juniors.flatMap((value, index) => {
74
+ if (!isObject(value) || typeof value.host !== "string" || !value.host.trim()) {
75
+ return fail("autopilot_metrics_input_invalid", "Team registry row must identify its host", { path: file, index });
76
+ }
77
+ if (!isCurrentHostLabel(value.host, host))
78
+ return [];
79
+ if (["team", "lane", "status", "prd"].some((field) => typeof value[field] !== "string" || !value[field].trim())) {
80
+ return fail("autopilot_metrics_input_invalid", "Selected team registry row must contain team, lane, status, and prd strings", { path: file, index });
81
+ }
82
+ const entry = value;
83
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(entry.team) || entry.lane.includes("/") || entry.lane.includes("\\")) {
84
+ return fail("autopilot_metrics_input_invalid", "Team registry row contains an unsafe team or lane id", { path: file, index });
85
+ }
86
+ if (seen.has(entry.lane))
87
+ return fail("autopilot_metrics_mapping_ambiguous", "A lead lane appears more than once in the selected host registry", { path: file, lane_id: entry.lane });
88
+ seen.add(entry.lane);
89
+ return [entry];
90
+ });
91
+ }
92
+ function projectPath(root, raw, registryPath) {
93
+ if (path.isAbsolute(raw))
94
+ return fail("autopilot_metrics_mapping_invalid", "Project path must be relative to the HQ root", { path: registryPath, project_path: raw });
95
+ const absolute = path.resolve(root, raw);
96
+ if (!absolute.startsWith(`${path.resolve(root)}${path.sep}`)) {
97
+ return fail("autopilot_metrics_mapping_invalid", "Project path must stay under the HQ root", { path: registryPath, project_path: raw });
98
+ }
99
+ return absolute;
100
+ }
101
+ function loadProject(root, projectId, rawPrd) {
102
+ const registryPath = path.join(root, JUNIORS_REL);
103
+ const file = projectPath(root, rawPrd, registryPath);
104
+ const parsed = parseJsonObject(root, file);
105
+ const storiesValue = parsed.userStories === undefined ? parsed.stories : parsed.userStories;
106
+ if (!Array.isArray(storiesValue))
107
+ return fail("autopilot_metrics_input_invalid", "Project must contain a userStories or stories array", { path: file });
108
+ const stories = storiesValue.map((value, index) => {
109
+ if (!isObject(value) || typeof value.id !== "string" || !value.id.trim()) {
110
+ return fail("autopilot_metrics_input_invalid", "Project story must have a non-empty id", { path: file, index });
111
+ }
112
+ return value;
113
+ });
114
+ return { project_id: projectId, stories };
115
+ }
116
+ function parseIso(value, field, file) {
117
+ const normalized = normalizeIsoTimestamp(value);
118
+ if (normalized !== null)
119
+ return normalized;
120
+ if (typeof value === "string") {
121
+ const minutePrecision = /^(\d{4}-\d\d-\d\dT\d\d:\d\d)(Z|[+-]\d\d:\d\d)$/.exec(value);
122
+ if (minutePrecision) {
123
+ const withSeconds = normalizeIsoTimestamp(`${minutePrecision[1]}:00${minutePrecision[2]}`);
124
+ if (withSeconds !== null)
125
+ return withSeconds;
126
+ }
127
+ }
128
+ return fail("autopilot_metrics_input_invalid", `${field} must be an ISO timestamp`, { path: file, field });
129
+ }
130
+ function readJsonl(root, file, missingOk = false) {
131
+ const text = safeReadText(root, file, missingOk);
132
+ if (text === null)
133
+ return [];
134
+ const records = [];
135
+ for (const [index, line] of text.split("\n").entries()) {
136
+ if (!line.trim())
137
+ continue;
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(line);
141
+ }
142
+ catch (err) {
143
+ return fail("autopilot_metrics_input_invalid", `Report JSONL contains malformed JSON: ${err instanceof Error ? err.message : String(err)}`, { path: file, line: index + 1 });
144
+ }
145
+ if (!isObject(parsed))
146
+ return fail("autopilot_metrics_input_invalid", "Report JSONL rows must be objects", { path: file, line: index + 1 });
147
+ records.push(parsed);
148
+ }
149
+ return records;
150
+ }
151
+ function listRegularFiles(root, directory, output, depth = 0) {
152
+ let names;
153
+ try {
154
+ const stat = fs.lstatSync(directory);
155
+ if (stat.isSymbolicLink() || !stat.isDirectory())
156
+ return fail("autopilot_metrics_input_invalid", "Report path must be a regular directory", { path: directory });
157
+ const realRoot = fs.realpathSync(root);
158
+ const realDirectory = fs.realpathSync(directory);
159
+ if (!realDirectory.startsWith(`${realRoot}${path.sep}`))
160
+ return fail("autopilot_metrics_input_invalid", "Report directory must stay under the HQ root", { path: directory });
161
+ names = fs.readdirSync(directory);
162
+ }
163
+ catch (err) {
164
+ if (err.code === "ENOENT")
165
+ return;
166
+ if (err instanceof LanesError)
167
+ throw err;
168
+ return fail("autopilot_metrics_input_unreadable", `Could not list report directory: ${err instanceof Error ? err.message : String(err)}`, { path: directory });
169
+ }
170
+ for (const name of names) {
171
+ if (!name || name.startsWith("."))
172
+ continue;
173
+ const candidate = path.join(directory, name);
174
+ let stat;
175
+ try {
176
+ stat = fs.lstatSync(candidate);
177
+ }
178
+ catch (err) {
179
+ return fail("autopilot_metrics_input_unreadable", `Could not inspect report file: ${err instanceof Error ? err.message : String(err)}`, { path: candidate });
180
+ }
181
+ if (stat.isSymbolicLink())
182
+ return fail("autopilot_metrics_input_invalid", "Report directory may not contain symbolic links", { path: candidate });
183
+ if (stat.isFile() && name.endsWith(".jsonl"))
184
+ output.push(candidate);
185
+ else if (stat.isDirectory() && depth < 2)
186
+ listRegularFiles(root, candidate, output, depth + 1);
187
+ }
188
+ }
189
+ function readLeadMessages(root, laneId) {
190
+ const dropbox = path.join(root, LANES_RUNS_REL, laneId, DROPBOX_NAME);
191
+ const allFiles = [];
192
+ for (const name of ["draining", "inflight", "delivered", "undeliverable"])
193
+ listRegularFiles(root, path.join(dropbox, name), allFiles);
194
+ const inbox = path.join(dropbox, "inbox.jsonl");
195
+ try {
196
+ const stat = fs.lstatSync(inbox);
197
+ if (stat.isSymbolicLink() || !stat.isFile())
198
+ return fail("autopilot_metrics_input_invalid", "Lead inbox must be a regular file", { path: inbox });
199
+ allFiles.push(inbox);
200
+ }
201
+ catch (err) {
202
+ if (err.code !== "ENOENT")
203
+ throw err;
204
+ }
205
+ const seen = new Set();
206
+ const nudges = [];
207
+ const resumes = [];
208
+ for (const file of new Set(allFiles)) {
209
+ const resumeFile = file.endsWith(RESUME_SUFFIX);
210
+ for (const row of readJsonl(root, file)) {
211
+ // Undated records cannot be assigned to a report day, regardless of their file.
212
+ if (typeof row.at !== "string")
213
+ continue;
214
+ const at = parseIso(row.at, "at", file);
215
+ const text = typeof row.text === "string" ? row.text : "";
216
+ const messageId = typeof row.message_id === "string" ? row.message_id : `${at}\u0000${text}`;
217
+ if (!seen.has(messageId)) {
218
+ seen.add(messageId);
219
+ if (resumeFile || row.kind !== "monitor_event")
220
+ nudges.push({ team: "", at });
221
+ }
222
+ if (resumeFile)
223
+ resumes.push({ lane_id: laneId, at });
224
+ }
225
+ }
226
+ return { nudges, resumes };
227
+ }
228
+ function loadNudgeDefects(root, selectedTeams) {
229
+ const file = path.join(root, OPERATOR_NUDGE_DEFECTS_REL);
230
+ return readJsonl(root, file, true).flatMap((row, index) => {
231
+ if (typeof row.team === "string" && !selectedTeams.has(row.team))
232
+ return [];
233
+ const defect = typeof row.defect === "string" && row.defect.trim() ? row.defect : row.why;
234
+ if (typeof row.team !== "string" || !row.team.trim() || typeof defect !== "string" || !defect.trim()) {
235
+ return fail("autopilot_metrics_input_invalid", "Nudge-defect row must contain at, team, and defect (or legacy why)", { path: file, line: index + 1 });
236
+ }
237
+ return [{ at: parseIso(row.at, "at", file), team: row.team, defect }];
238
+ });
239
+ }
240
+ function loadReviews(root) {
241
+ const listing = listLaneReviews(root);
242
+ if (listing.skipped > 0)
243
+ return fail("autopilot_metrics_review_record_invalid", "One or more lane review records could not be read", { skipped: listing.skipped });
244
+ return listing.reviews.flatMap((review) => review.history.map((entry) => ({
245
+ repo: entry.repo,
246
+ pr: entry.pr,
247
+ recorded_at: entry.recorded_at,
248
+ worker_lane: entry.worker_lane,
249
+ })));
250
+ }
251
+ function selectLeads(root, host) {
252
+ const juniors = loadJuniors(root, host);
253
+ const lanes = readAllLanes(root);
254
+ const leads = [];
255
+ const projects = [];
256
+ const laneIds = new Set();
257
+ const projectIds = new Set();
258
+ for (const entry of juniors) {
259
+ const matchingLanes = lanes.filter((candidate) => candidate.lane_id === entry.lane || candidate.lane_id.startsWith(`${entry.lane}_`));
260
+ const lane = matchingLanes.find((candidate) => laneRunsHere(candidate, host));
261
+ if (!lane) {
262
+ return fail("autopilot_metrics_lead_missing", "Registered lead lane could not be read on this host", { team: entry.team, lane_id: entry.lane });
263
+ }
264
+ if (laneIds.has(lane.lane_id))
265
+ return fail("autopilot_metrics_mapping_ambiguous", "A lead lane appears more than once in the host report", { lane_id: lane.lane_id });
266
+ const projectKey = JSON.stringify([lane.project_id, projectPath(root, entry.prd, path.join(root, JUNIORS_REL))]);
267
+ const lead = { team: entry.team, lane_id: lane.lane_id, project_id: projectKey };
268
+ leads.push(lead);
269
+ laneIds.add(lane.lane_id);
270
+ if (!projectIds.has(projectKey)) {
271
+ projects.push(loadProject(root, projectKey, entry.prd));
272
+ projectIds.add(projectKey);
273
+ }
274
+ }
275
+ return { leads, projects, laneIds };
276
+ }
277
+ function windowFromOptions(now, options) {
278
+ const until = normalizeIsoTimestamp(now.toISOString());
279
+ if (until === null)
280
+ return fail("autopilot_metrics_window_invalid", "Current time is invalid");
281
+ if (options.since !== undefined && options.days !== undefined)
282
+ return fail("autopilot_metrics_window_invalid", "Use either --since or --days, not both");
283
+ if (options.since !== undefined) {
284
+ const since = normalizeIsoTimestamp(options.since);
285
+ if (since === null || Date.parse(since) >= Date.parse(until))
286
+ return fail("autopilot_metrics_window_invalid", "--since must be an ISO timestamp before now", { since: options.since });
287
+ return { since, until };
288
+ }
289
+ const days = options.days === undefined ? 3 : Number(options.days);
290
+ if (!Number.isSafeInteger(days) || days < 1 || days > 366)
291
+ return fail("autopilot_metrics_window_invalid", "--days must be an integer from 1 to 366", { days: options.days });
292
+ return { since: new Date(Date.parse(until) - days * 24 * 60 * 60 * 1000).toISOString(), until };
293
+ }
294
+ function printJson(value) {
295
+ console.log(JSON.stringify(value));
296
+ }
297
+ export function registerLaneAutopilotReportCommand(parent, deps, wrap) {
298
+ const autopilot = parent.command("autopilot").description("Measure automated lead-lane follow-through");
299
+ autopilot.command("report")
300
+ .description("Report operator nudges, review latency, and idle slots with ready work")
301
+ .option("--since <iso>", "Include activity since this ISO timestamp")
302
+ .option("--days <n>", "Include the most recent number of days")
303
+ .option("--json", "Print JSON")
304
+ .action(wrap(async (options) => {
305
+ const root = deps.hqRoot();
306
+ const window = windowFromOptions(deps.now(), options);
307
+ const host = deps.currentHost();
308
+ const selected = selectLeads(root, host);
309
+ const lanes = readAllLanes(root);
310
+ const nudges = [];
311
+ const resumes = [];
312
+ for (const lead of selected.leads) {
313
+ const events = readLeadMessages(root, lead.lane_id);
314
+ nudges.push(...events.nudges.map((event) => ({ ...event, team: lead.team })));
315
+ resumes.push(...events.resumes);
316
+ const record = lanes.find((lane) => lane.lane_id === lead.lane_id);
317
+ const lastResume = record?.last_resume?.at;
318
+ if (lastResume && !resumes.some((resume) => resume.lane_id === lead.lane_id && resume.at === lastResume))
319
+ resumes.push({ lane_id: lead.lane_id, at: lastResume });
320
+ }
321
+ const reviewListing = loadReviews(root);
322
+ const defects = loadNudgeDefects(root, new Set(selected.leads.map((lead) => lead.team)));
323
+ const result = buildAutopilotMetricsReport({
324
+ ...window,
325
+ leads: selected.leads,
326
+ lanes: lanes,
327
+ projects: selected.projects,
328
+ reviews: reviewListing,
329
+ nudges,
330
+ nudgeDefects: defects,
331
+ seniorResumes: resumes,
332
+ });
333
+ printJson(result);
334
+ }));
335
+ }
336
+ //# sourceMappingURL=lanes-autopilot.js.map
@@ -19,6 +19,38 @@ export function createLaneMoveHandlers(deps) {
19
19
  lane.last_host_report_at === undefined &&
20
20
  lane.pending_resume === undefined;
21
21
  }
22
+ function sameHost(a, b) {
23
+ return a.kind === b.kind && a.id === b.id;
24
+ }
25
+ function reportAlreadyOnTarget(lane) {
26
+ printJson({
27
+ ok: true,
28
+ action: "move",
29
+ lane_id: lane.lane_id,
30
+ state: lane.state,
31
+ host: lane.host,
32
+ account: lane.account ?? null,
33
+ moved: false,
34
+ reason: "already_on_target",
35
+ lane,
36
+ });
37
+ }
38
+ async function reportAlreadyOnTargetIfCurrent(hqRoot, laneId, provider, target, callerHost, runtimeHost, ctx) {
39
+ const current = await withLaneAdmissionLock(hqRoot, provider, async () => {
40
+ const lane = readLane(hqRoot, laneId);
41
+ if (!laneCanMoveBeforeStart(lane)) {
42
+ throw new LanesError(lane.state === "queued" ? "lane_move_started" : "lane_not_queued", `Lane ${laneId} cannot move after it has started; current state is ${lane.state}`, { lane_id: laneId, state: lane.state });
43
+ }
44
+ return sameHost(lane.host, target) ||
45
+ (laneRunsHere(lane, runtimeHost) && sameHost(callerHost, target))
46
+ ? lane
47
+ : undefined;
48
+ }, ctx.sleep);
49
+ if (current === undefined)
50
+ return false;
51
+ reportAlreadyOnTarget(current);
52
+ return true;
53
+ }
22
54
  function readLaneTimeout(hqRoot, laneId) {
23
55
  const raw = readJsonFile(timeoutPath(hqRoot, laneId));
24
56
  if (raw === null)
@@ -51,6 +83,22 @@ export function createLaneMoveHandlers(deps) {
51
83
  throw new LanesError(source.state === "queued" ? "lane_move_started" : "lane_not_queued", `Lane ${laneId} cannot move after it has started; current state is ${source.state}`, { lane_id: laneId, state: source.state });
52
84
  }
53
85
  const sourceHost = source.host;
86
+ const runtimeHost = reconcileHostFromCtx(ctx);
87
+ const runningHere = laneRunsHere(source, runtimeHost);
88
+ const callerHost = runtimeHost.outpostId
89
+ ? { kind: "outpost", id: runtimeHost.outpostId }
90
+ : { kind: "local", id: runtimeHost.hostname };
91
+ const explicitTarget = policy.kind === "local"
92
+ ? callerHost
93
+ : policy.kind === "outpost"
94
+ ? { kind: "outpost", id: policy.id }
95
+ : undefined;
96
+ if (explicitTarget !== undefined &&
97
+ (sameHost(sourceHost, explicitTarget) ||
98
+ (runningHere && sameHost(callerHost, explicitTarget)))) {
99
+ if (await reportAlreadyOnTargetIfCurrent(hqRoot, laneId, source.provider, explicitTarget, callerHost, runtimeHost, ctx))
100
+ return;
101
+ }
54
102
  const config = loadLanesConfig(hqRoot);
55
103
  const capacity = await loadHostCapacitySnapshots(ctx, hqRoot, config);
56
104
  let targetSnapshots = capacity.snapshots.filter((snapshot) => snapshot.host.kind !== sourceHost.kind || snapshot.host.id !== sourceHost.id);
@@ -69,8 +117,13 @@ export function createLaneMoveHandlers(deps) {
69
117
  ...(source.account !== undefined ? { account: source.account } : {}),
70
118
  ...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
71
119
  });
120
+ if (sameHost(sourceHost, choice.host) ||
121
+ (runningHere && sameHost(callerHost, choice.host))) {
122
+ if (await reportAlreadyOnTargetIfCurrent(hqRoot, laneId, source.provider, choice.host, callerHost, runtimeHost, ctx))
123
+ return;
124
+ }
72
125
  let moved;
73
- if (laneRunsHere(source, reconcileHostFromCtx(ctx))) {
126
+ if (runningHere) {
74
127
  moved = await commitLaneMove(ctx, laneId, choice.host, choice.account, placementEnabled);
75
128
  }
76
129
  else if (source.host.kind === "outpost") {
@@ -20,7 +20,7 @@ export function resolveAnswerChoice(choice, options, freeform) {
20
20
  throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
21
21
  }
22
22
  export function createLaneQuestionsHandlers(deps) {
23
- const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
23
+ const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
24
24
  function requireQuestionBody(hqRoot, question) {
25
25
  const body = readQuestionBody(hqRoot, question);
26
26
  if (!body) {
@@ -73,10 +73,11 @@ export function createLaneQuestionsHandlers(deps) {
73
73
  return;
74
74
  throw new LanesError("question_not_pending", `Question ${current.question_id} is ${current.status}`, { question_id: current.question_id, status: current.status });
75
75
  }
76
- async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
76
+ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options, lookupErrors = []) {
77
77
  if (lane.host.kind !== "outpost") {
78
78
  throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to answer its question`, { lane_id: lane.lane_id, question_id: questionId });
79
79
  }
80
+ assertQuestionAnswerAuthorized(ctx, lane);
80
81
  const outpostId = lane.host.id;
81
82
  let dispatched;
82
83
  try {
@@ -224,6 +225,7 @@ export function createLaneQuestionsHandlers(deps) {
224
225
  outpost_id: outpostId,
225
226
  outpost_command_id: dispatched.commandId,
226
227
  outpost_transport: "outposts_exec",
228
+ ...(lookupErrors.length > 0 ? { lookup_errors: lookupErrors } : {}),
227
229
  });
228
230
  }
229
231
  async function readOutpostQuestionDetail(ctx, owner) {
@@ -280,6 +282,7 @@ export function createLaneQuestionsHandlers(deps) {
280
282
  }
281
283
  async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
282
284
  const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
285
+ !laneIsHostedHere(ctx, lane) &&
283
286
  (!companyFilter ||
284
287
  effectiveLaneQuestionCompany(lane, hqRoot) === null ||
285
288
  questionCompanyRefMatches(effectiveLaneQuestionCompany(lane, hqRoot), companyFilter)));
@@ -475,6 +478,8 @@ export function createLaneQuestionsHandlers(deps) {
475
478
  .map((q) => {
476
479
  const row = {
477
480
  ...q,
481
+ priority: q.priority ?? null,
482
+ priority_display: q.priority ?? "-",
478
483
  audience: resolveAudience(hqRoot, q, ctx),
479
484
  ...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
480
485
  };
@@ -503,6 +508,8 @@ export function createLaneQuestionsHandlers(deps) {
503
508
  })
504
509
  .map(({ question: q, host, audience }) => ({
505
510
  ...q,
511
+ priority: q.priority ?? null,
512
+ priority_display: q.priority ?? "-",
506
513
  host,
507
514
  audience,
508
515
  ...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
@@ -580,13 +587,13 @@ export function createLaneQuestionsHandlers(deps) {
580
587
  throw err;
581
588
  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>");
582
589
  const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company);
583
- if (lookup.errors.length > 0) {
590
+ const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
591
+ if (matches.length === 0 && lookup.errors.length > 0) {
584
592
  throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
585
593
  question_id: questionId,
586
594
  hosts: lookup.errors.map(({ host, error }) => ({ host, error })),
587
595
  });
588
596
  }
589
- const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
590
597
  if (matches.length === 0) {
591
598
  const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
592
599
  const otherCompanyMatch = allCompanyLookup.questions.find((entry) => entry.question.question_id === questionId);
@@ -616,7 +623,7 @@ export function createLaneQuestionsHandlers(deps) {
616
623
  await dispatchOutpostQuestionAnswer(ctx, owner.lane, questionId, {
617
624
  ...opts,
618
625
  company: questionListCompanyArg(ownerCompany),
619
- });
626
+ }, lookup.errors);
620
627
  return;
621
628
  }
622
629
  await yieldEventLoop();
@@ -376,6 +376,7 @@ export interface LaneQuestionsHandlerDeps {
376
376
  MAX_PLACEMENT_OUTPOSTS: number;
377
377
  OUTPOST_QUESTION_LOOKUP_CONCURRENCY: number;
378
378
  assertLaneRunsHereForOperatorMutation(ctx: Ctx, lane: LaneRecord, action: string): void;
379
+ assertQuestionAnswerAuthorized(ctx: Ctx, lane: LaneRecord): void;
379
380
  assertQuestionCompanyMatchesLane(question: QuestionRecord, lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, code: string, hqRoot?: string): void;
380
381
  casUpdateQuestion(hqRoot: string, questionId: string, updater: (current: QuestionRecord) => QuestionRecord): QuestionRecord;
381
382
  effectiveLaneQuestionCompany(lane: Pick<LaneRecord, "company" | "mesh_company" | "project_id" | "lane_id">, hqRoot?: string): LaneCompanyRef | null;
@@ -407,4 +408,20 @@ export interface LaneQuestionsHandlerDeps {
407
408
  seniorOfSession(hqRoot: string, sessionId: string, ctx: Ctx): SeniorRef;
408
409
  writeAnswerDeliveryFailed(hqRoot: string, laneId: string, questionId: string, err: unknown, at: string): void;
409
410
  }
411
+ export interface LaneStoryHandlerDeps {
412
+ assertLaneRunsHereForOperatorMutation(ctx: Ctx, lane: LaneRecord, action: string): void;
413
+ casUpdateLane(hqRoot: string, laneId: string, updater: (current: LaneRecord) => LaneRecord): LaneRecord;
414
+ combinedStoryWarning(lane: LaneRecord, additional?: readonly string[]): string | null;
415
+ emitLaneMeshEvent(ctx: Ctx, company: LaneCompanyRef | null | undefined, threadId: string | undefined, event: LaneMeshEvent, payload: Record<string, unknown>): Promise<void>;
416
+ ensureLaneThreadRecord(ctx: Ctx, lane: LaneRecord, projectId?: string, storyId?: string, laneStatus?: string): Promise<LaneRecord>;
417
+ printJson(value: unknown): void;
418
+ readLane(hqRoot: string, laneId: string): LaneRecord;
419
+ storyBoardCheck(ctx: Ctx, company: LaneCompanyRef, projectId: string, storyIds: readonly string[]): Promise<{
420
+ warning?: string;
421
+ absentStoryIds: ReadonlySet<string>;
422
+ }>;
423
+ storyNotOnBoardWarning(err: unknown): string | undefined;
424
+ updateLane(hqRoot: string, laneId: string, expectedVersion: number, source: "command", updater: (current: LaneRecord) => LaneRecord): LaneRecord;
425
+ validateProjectStories(hqRoot: string, projectId: string, storyIds: string[], companySlug?: string): void;
426
+ }
410
427
  //# sourceMappingURL=lanes-shared.d.ts.map
@@ -0,0 +1,14 @@
1
+ import type { Ctx, LaneStoryHandlerDeps } from "./lanes-shared.js";
2
+ export interface StoryCommandOptions {
3
+ project?: string;
4
+ reason?: string;
5
+ verified?: boolean;
6
+ }
7
+ export declare function createLaneStoryHandlers(deps: LaneStoryHandlerDeps): {
8
+ runStoryList: (ctx: Ctx, laneId: string) => Promise<void>;
9
+ runStoryAdd: (ctx: Ctx, laneId: string, storyId: string, opts: StoryCommandOptions) => Promise<void>;
10
+ runStoryStart: (ctx: Ctx, laneId: string, storyId: string, opts: StoryCommandOptions) => Promise<void>;
11
+ runStoryBlock: (ctx: Ctx, laneId: string, storyId: string, opts: StoryCommandOptions) => Promise<void>;
12
+ runStoryDone: (ctx: Ctx, laneId: string, storyId: string, opts: StoryCommandOptions) => Promise<void>;
13
+ };
14
+ //# sourceMappingURL=lanes-stories.d.ts.map