@indigoai-us/hq-cli 5.167.0 → 5.169.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 (37) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/command-catalog.generated.d.ts +88 -1
  3. package/dist/command-catalog.generated.js +112 -1
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +79 -22
  6. package/dist/commands/lanes.js +146 -31
  7. package/dist/commands/mesh.js +13 -19
  8. package/dist/commands/outpost-pruner.d.ts +3 -0
  9. package/dist/commands/outpost-pruner.js +125 -0
  10. package/dist/lib/daemon/catalog.d.ts +11 -1
  11. package/dist/lib/daemon/catalog.js +31 -6
  12. package/dist/lib/daemon/install.d.ts +7 -0
  13. package/dist/lib/daemon/install.js +18 -2
  14. package/dist/lib/daemon/legacy.js +6 -1
  15. package/dist/lib/daemon/machine-type.d.ts +4 -1
  16. package/dist/lib/daemon/machine-type.js +29 -9
  17. package/dist/lib/daemon/migrate.js +2 -2
  18. package/dist/lib/daemon/remote-control.d.ts +32 -9
  19. package/dist/lib/daemon/remote-control.js +55 -11
  20. package/dist/lib/daemon/run.d.ts +6 -0
  21. package/dist/lib/daemon/run.js +43 -2
  22. package/dist/lib/daemon/tools.d.ts +52 -0
  23. package/dist/lib/daemon/tools.js +95 -0
  24. package/dist/lib/daemon/updater.d.ts +23 -15
  25. package/dist/lib/daemon/updater.js +97 -60
  26. package/dist/lib/lanes/mesh-events.d.ts +4 -0
  27. package/dist/lib/lanes/mesh-events.js +72 -4
  28. package/dist/lib/lanes/outpost-pruner-install.d.ts +37 -0
  29. package/dist/lib/lanes/outpost-pruner-install.js +226 -0
  30. package/dist/lib/lanes/outpost-worktree-pruner-flag.d.ts +19 -0
  31. package/dist/lib/lanes/outpost-worktree-pruner-flag.js +76 -0
  32. package/dist/lib/lanes/outpost-worktree-pruner.d.ts +60 -0
  33. package/dist/lib/lanes/outpost-worktree-pruner.js +1135 -0
  34. package/dist/lib/mesh/live/session-events-client.js +3 -2
  35. package/dist/lib/mesh/project-board.d.ts +7 -0
  36. package/dist/lib/mesh/project-board.js +29 -1
  37. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { randomBytes } from "node:crypto";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
+ import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
13
14
  import { ensureCognitoToken } from "../utils/cognito-session.js";
14
15
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
15
16
  import { resolveProcessFlagGate } from "../lib/flag-registry.js";
@@ -1339,13 +1340,23 @@ async function emitLaneTerminalStateSoft(ctx, lane) {
1339
1340
  }
1340
1341
  }
1341
1342
  async function emitLaneStartedForStories(ctx, company, lane, threadId) {
1342
- const ready = ctx.mesh.ensureLaneThread
1343
- ? await ensureLaneThreadRecord(ctx, lane, lane.project_id, lane.story_id, "running")
1344
- : lane;
1343
+ let ready = lane;
1344
+ if (ctx.mesh.ensureLaneThread) {
1345
+ try {
1346
+ ready = await ensureLaneThreadRecord(ctx, lane, lane.project_id, lane.story_id, "running");
1347
+ }
1348
+ catch (err) {
1349
+ const warning = storyNotOnBoardWarning(err);
1350
+ if (warning !== undefined)
1351
+ return [warning];
1352
+ throw err;
1353
+ }
1354
+ }
1345
1355
  const stableThreadId = ready.thread_id ?? threadId;
1346
1356
  if (!stableThreadId)
1347
- return;
1357
+ return [];
1348
1358
  const failures = [];
1359
+ const warnings = [];
1349
1360
  for (const story of laneStories(ready)) {
1350
1361
  try {
1351
1362
  await emitLaneMeshEvent(ctx, company, stableThreadId, "lane_started", {
@@ -1354,14 +1365,59 @@ async function emitLaneStartedForStories(ctx, company, lane, threadId) {
1354
1365
  project_id: story.project_id,
1355
1366
  story_id: story.story_id,
1356
1367
  });
1357
- await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
1358
1368
  }
1359
1369
  catch (err) {
1370
+ const warning = storyNotOnBoardWarning(err);
1371
+ if (warning !== undefined) {
1372
+ warnings.push(warning);
1373
+ continue;
1374
+ }
1360
1375
  failures.push(err);
1376
+ continue;
1377
+ }
1378
+ try {
1379
+ await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
1380
+ }
1381
+ catch (err) {
1382
+ const warning = storyNotOnBoardWarning(err);
1383
+ if (warning !== undefined)
1384
+ warnings.push(warning);
1385
+ else
1386
+ failures.push(err);
1361
1387
  }
1362
1388
  }
1363
1389
  if (failures.length > 0)
1364
1390
  throw failures[0];
1391
+ return warnings;
1392
+ }
1393
+ async function assertStoriesOnBoard(ctx, company, projectId, storyIds) {
1394
+ const check = ctx.mesh.assertProjectStoriesOnBoard;
1395
+ if (!check) {
1396
+ throw new LanesError("story_board_check_unavailable", "Cannot confirm stories are on the Work Mesh Board; refusing to write lane state", { project_id: projectId, story_id: storyIds[0] ?? null });
1397
+ }
1398
+ await check.call(ctx.mesh, company, projectId, storyIds);
1399
+ }
1400
+ function storyNotOnBoardWarning(err) {
1401
+ return isLanesError(err) && err.error === "story_not_on_board"
1402
+ ? err.message
1403
+ : undefined;
1404
+ }
1405
+ function combinedStoryWarning(lane, additional = []) {
1406
+ const warnings = [storyWarning(lane), ...additional]
1407
+ .filter((warning) => warning !== undefined && warning.trim().length > 0);
1408
+ return [...new Set(warnings)].join(" ") || null;
1409
+ }
1410
+ async function storyBoardWarning(ctx, company, projectId, storyIds) {
1411
+ try {
1412
+ await assertStoriesOnBoard(ctx, company, projectId, storyIds);
1413
+ return undefined;
1414
+ }
1415
+ catch (err) {
1416
+ const warning = storyNotOnBoardWarning(err);
1417
+ if (warning !== undefined)
1418
+ return warning;
1419
+ throw err;
1420
+ }
1365
1421
  }
1366
1422
  export const PICKUP_TERMINAL_REASONS = [
1367
1423
  "account_not_logged_in",
@@ -1630,6 +1686,12 @@ async function runCreate(ctx, opts) {
1630
1686
  }
1631
1687
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
1632
1688
  assertSameTenant(company, senior, seniorCompany);
1689
+ const createStoryWarnings = [];
1690
+ if (meshCompany) {
1691
+ const warning = await storyBoardWarning(ctx, meshCompany, project, storyIds);
1692
+ if (warning !== undefined)
1693
+ createStoryWarnings.push(warning);
1694
+ }
1633
1695
  let resolvedWorker;
1634
1696
  if (opts.worker) {
1635
1697
  resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company?.slug);
@@ -2078,19 +2140,25 @@ async function runCreate(ctx, opts) {
2078
2140
  try {
2079
2141
  lane = await ensureLaneThreadRecord(ctx, lane);
2080
2142
  }
2081
- catch {
2082
- try {
2083
- lane = casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
2084
- ...current,
2085
- state: "failed",
2086
- queued_reason: undefined,
2087
- terminal_reason: "mesh_thread_registration_failed",
2088
- }));
2143
+ catch (err) {
2144
+ const warning = storyNotOnBoardWarning(err);
2145
+ if (warning !== undefined) {
2146
+ createStoryWarnings.push(warning);
2089
2147
  }
2090
- catch {
2091
- throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed for ${lane.lane_id}, and its local record could not be marked terminal.`, { lane_id: lane.lane_id });
2148
+ else {
2149
+ try {
2150
+ lane = casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
2151
+ ...current,
2152
+ state: "failed",
2153
+ queued_reason: undefined,
2154
+ terminal_reason: "mesh_thread_registration_failed",
2155
+ }));
2156
+ }
2157
+ catch {
2158
+ throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed for ${lane.lane_id}, and its local record could not be marked terminal.`, { lane_id: lane.lane_id });
2159
+ }
2160
+ throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed; lane ${lane.lane_id} was marked failed.`, { lane_id: lane.lane_id, state: lane.state });
2092
2161
  }
2093
- throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed; lane ${lane.lane_id} was marked failed.`, { lane_id: lane.lane_id, state: lane.state });
2094
2162
  }
2095
2163
  }
2096
2164
  // Thread registration runs after persistence; anchor the reservation after
@@ -2136,8 +2204,9 @@ async function runCreate(ctx, opts) {
2136
2204
  runCompaction: ctx.runCompaction,
2137
2205
  locateTranscript: ctx.locateTranscript,
2138
2206
  });
2139
- if (meshCompany)
2140
- await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id);
2207
+ if (meshCompany) {
2208
+ createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id));
2209
+ }
2141
2210
  }
2142
2211
  else if (outpostId) {
2143
2212
  started = await dispatchCreatedOutpostLane(ctx, {
@@ -2173,7 +2242,7 @@ async function runCreate(ctx, opts) {
2173
2242
  project_id: started.project_id,
2174
2243
  story_id: started.story_id,
2175
2244
  stories: laneStories(started),
2176
- story_warning: storyWarning(started) ?? null,
2245
+ story_warning: combinedStoryWarning(started, createStoryWarnings),
2177
2246
  thread_id: started.thread_id ?? null,
2178
2247
  senior: started.senior,
2179
2248
  depth: started.depth,
@@ -4137,6 +4206,17 @@ async function ensureStoryThread(ctx, lane, projectId, storyId = lane.story_id)
4137
4206
  return lane;
4138
4207
  return ensureLaneThreadRecord(ctx, lane, projectId, storyId);
4139
4208
  }
4209
+ async function ensureStoryThreadForTransition(ctx, lane, projectId, storyId) {
4210
+ try {
4211
+ return { lane: await ensureStoryThread(ctx, lane, projectId, storyId) };
4212
+ }
4213
+ catch (err) {
4214
+ const warning = storyNotOnBoardWarning(err);
4215
+ if (warning === undefined)
4216
+ throw err;
4217
+ return { lane, warning };
4218
+ }
4219
+ }
4140
4220
  async function ensureLaneThreadRecord(ctx, lane, projectId = lane.project_id, storyId = lane.story_id, laneStatus = lane.state) {
4141
4221
  const company = lane.mesh_company ?? lane.company;
4142
4222
  if (!company)
@@ -4184,8 +4264,12 @@ function sameCommittedStory(current, expected, projectId, storyId) {
4184
4264
  async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}) {
4185
4265
  try {
4186
4266
  await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload);
4267
+ return undefined;
4187
4268
  }
4188
4269
  catch (err) {
4270
+ const warning = storyNotOnBoardWarning(err);
4271
+ if (warning !== undefined)
4272
+ return warning;
4189
4273
  try {
4190
4274
  const current = readLane(ctx.hqRoot(), next.lane_id);
4191
4275
  if (!sameCommittedStory(current, next, projectId, storyId)) {
@@ -4230,14 +4314,26 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
4230
4314
  const normalizedStoryId = storyId.trim();
4231
4315
  if (!normalizedStoryId)
4232
4316
  throw new LanesError("missing_story", "<story-id> is required", {});
4317
+ const company = lane.mesh_company ?? lane.company;
4318
+ const warnings = [];
4319
+ if (company) {
4320
+ const warning = await storyBoardWarning(ctx, company, projectId, [normalizedStoryId]);
4321
+ if (warning !== undefined)
4322
+ warnings.push(warning);
4323
+ }
4233
4324
  const at = ctx.now().toISOString();
4234
- lane = await ensureStoryThread(ctx, lane, projectId);
4325
+ const thread = await ensureStoryThreadForTransition(ctx, lane, projectId, normalizedStoryId);
4326
+ lane = thread.lane;
4327
+ if (thread.warning !== undefined)
4328
+ warnings.push(thread.warning);
4235
4329
  const before = lane;
4236
4330
  const next = casUpdateLane(hqRoot, laneId, (current) => {
4237
4331
  assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
4238
4332
  return addCarriedStory(current, projectId, normalizedStoryId, at);
4239
4333
  });
4240
- await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
4334
+ const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
4335
+ if (transitionWarning !== undefined)
4336
+ warnings.push(transitionWarning);
4241
4337
  const finalLane = readLane(hqRoot, laneId);
4242
4338
  printJson({
4243
4339
  ok: true,
@@ -4246,7 +4342,7 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
4246
4342
  project_id: projectId,
4247
4343
  story_id: normalizedStoryId,
4248
4344
  status: "in_progress",
4249
- story_warning: storyWarning(finalLane) ?? null,
4345
+ story_warning: combinedStoryWarning(finalLane, warnings),
4250
4346
  lane: finalLane,
4251
4347
  });
4252
4348
  }
@@ -4263,15 +4359,21 @@ async function runStoryStart(ctx, laneId, storyId, opts) {
4263
4359
  });
4264
4360
  }
4265
4361
  const at = ctx.now().toISOString();
4266
- lane = await ensureStoryThread(ctx, lane, target.projectId);
4362
+ const warnings = [];
4363
+ const thread = await ensureStoryThreadForTransition(ctx, lane, target.projectId, target.storyId);
4364
+ lane = thread.lane;
4365
+ if (thread.warning !== undefined)
4366
+ warnings.push(thread.warning);
4267
4367
  const before = lane;
4268
4368
  const next = casUpdateLane(hqRoot, laneId, (current) => {
4269
4369
  assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
4270
4370
  return updateCarriedStory(current, target.projectId, target.storyId, "in_progress", at);
4271
4371
  });
4272
- await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_started", "in_progress");
4372
+ const transitionWarning = await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_started", "in_progress");
4373
+ if (transitionWarning !== undefined)
4374
+ warnings.push(transitionWarning);
4273
4375
  const finalLane = readLane(hqRoot, laneId);
4274
- printJson({ ok: true, action: "story.start", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "in_progress", story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
4376
+ printJson({ ok: true, action: "story.start", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "in_progress", story_warning: combinedStoryWarning(finalLane, warnings), lane: finalLane });
4275
4377
  }
4276
4378
  async function runStoryBlock(ctx, laneId, storyId, opts) {
4277
4379
  const hqRoot = ctx.hqRoot();
@@ -4289,15 +4391,21 @@ async function runStoryBlock(ctx, laneId, storyId, opts) {
4289
4391
  if (!reason)
4290
4392
  throw new LanesError("missing_block_reason", "--reason is required", {});
4291
4393
  const at = ctx.now().toISOString();
4292
- lane = await ensureStoryThread(ctx, lane, target.projectId);
4394
+ const warnings = [];
4395
+ const thread = await ensureStoryThreadForTransition(ctx, lane, target.projectId, target.storyId);
4396
+ lane = thread.lane;
4397
+ if (thread.warning !== undefined)
4398
+ warnings.push(thread.warning);
4293
4399
  const before = lane;
4294
4400
  const next = casUpdateLane(hqRoot, laneId, (current) => {
4295
4401
  assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
4296
4402
  return updateCarriedStory(current, target.projectId, target.storyId, "blocked", at, { blocked_reason: reason });
4297
4403
  });
4298
- await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_blocked", "blocked", { blocked_reason: reason });
4404
+ const transitionWarning = await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_blocked", "blocked", { blocked_reason: reason });
4405
+ if (transitionWarning !== undefined)
4406
+ warnings.push(transitionWarning);
4299
4407
  const finalLane = readLane(hqRoot, laneId);
4300
- printJson({ ok: true, action: "story.block", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "blocked", blocked_reason: reason, story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
4408
+ printJson({ ok: true, action: "story.block", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "blocked", blocked_reason: reason, story_warning: combinedStoryWarning(finalLane, warnings), lane: finalLane });
4301
4409
  }
4302
4410
  async function runStoryDone(ctx, laneId, storyId, opts) {
4303
4411
  const hqRoot = ctx.hqRoot();
@@ -4322,7 +4430,11 @@ async function runStoryDone(ctx, laneId, storyId, opts) {
4322
4430
  });
4323
4431
  }
4324
4432
  const at = ctx.now().toISOString();
4325
- lane = await ensureStoryThread(ctx, lane, target.projectId);
4433
+ const warnings = [];
4434
+ const thread = await ensureStoryThreadForTransition(ctx, lane, target.projectId, target.storyId);
4435
+ lane = thread.lane;
4436
+ if (thread.warning !== undefined)
4437
+ warnings.push(thread.warning);
4326
4438
  const before = lane;
4327
4439
  const next = casUpdateLane(hqRoot, laneId, (current) => {
4328
4440
  assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
@@ -4331,9 +4443,11 @@ async function runStoryDone(ctx, laneId, storyId, opts) {
4331
4443
  verified_by: current.senior,
4332
4444
  });
4333
4445
  });
4334
- await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_done", "done", { summary: "Senior verification complete" });
4446
+ const transitionWarning = await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_done", "done", { summary: "Senior verification complete" });
4447
+ if (transitionWarning !== undefined)
4448
+ warnings.push(transitionWarning);
4335
4449
  const finalLane = readLane(hqRoot, laneId);
4336
- printJson({ ok: true, action: "story.done", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "done", verified: true, story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
4450
+ printJson({ ok: true, action: "story.done", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "done", verified: true, story_warning: combinedStoryWarning(finalLane, warnings), lane: finalLane });
4337
4451
  }
4338
4452
  /**
4339
4453
  * Registration state of the drain hook in the lane's account home, read the
@@ -5385,6 +5499,7 @@ export function registerLanesCommand(parent, deps = {}) {
5385
5499
  const lanes = parent
5386
5500
  .command("lanes", { hidden: true })
5387
5501
  .description("Create, supervise, and steer worker lanes; routed decision queue");
5502
+ registerOutpostPrunerCommand(lanes);
5388
5503
  lanes
5389
5504
  .command("create")
5390
5505
  .description("Register a lane, run admission, and return immediately")
@@ -14,7 +14,7 @@ import chalk from "chalk";
14
14
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
15
15
  import * as readline from "node:readline/promises";
16
16
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
17
- import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard } from "../lib/mesh/project-board.js";
17
+ import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, formatStoryStatusChanges, mergeProjectStories, } from "../lib/mesh/project-board.js";
18
18
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
19
19
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
20
20
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
@@ -307,23 +307,6 @@ function httpStatus(err) {
307
307
  }
308
308
  return undefined;
309
309
  }
310
- function mergeProjectStories(current, planned) {
311
- const currentById = new Map(current.map((story) => [story.id, story]));
312
- const plannedIds = new Set(planned.map((story) => story.id));
313
- const merged = planned.map((story) => {
314
- const live = currentById.get(story.id);
315
- if (!live)
316
- return story;
317
- const status = live.status || story.status;
318
- return {
319
- ...story,
320
- ...(status ? { status } : {}),
321
- passes: status === "done" ? true : typeof live.passes === "boolean" ? live.passes : story.passes,
322
- ...(live.assigneeUid ? { assigneeUid: live.assigneeUid } : {}),
323
- };
324
- });
325
- return [...merged, ...current.filter((story) => !plannedIds.has(story.id))];
326
- }
327
310
  function mergeProjectRepos(current, planned) {
328
311
  const plannedPaths = new Set(planned.map((repo) => repo.path));
329
312
  return [...planned, ...current.filter((repo) => !plannedPaths.has(repo.path))];
@@ -386,6 +369,10 @@ async function runProjectSet(projectId, opts) {
386
369
  registration = ensured.registration;
387
370
  }
388
371
  else if (opts.name !== undefined || opts.description !== undefined || source !== undefined || explicitRepos !== undefined) {
372
+ const stories = source
373
+ ? mergeProjectStories(current.stories, source.stories, { resetStatus: Boolean(opts.resetStatus) })
374
+ : current.stories;
375
+ const statusChangeLine = source ? formatStoryStatusChanges(current.stories, stories) : undefined;
389
376
  try {
390
377
  view = await putProjectView(token, company.companyUid, projectId, {
391
378
  name: opts.name?.trim() || source?.name || current.name,
@@ -394,7 +381,7 @@ async function runProjectSet(projectId, opts) {
394
381
  : source?.description !== undefined
395
382
  ? { description: source.description }
396
383
  : { description: current.description }),
397
- stories: source?.stories ?? current.stories,
384
+ stories,
398
385
  repos: explicitRepos ?? source?.repos ?? current.repos,
399
386
  ...(current.version !== undefined ? { expectedVersion: current.version } : {}),
400
387
  });
@@ -409,6 +396,12 @@ async function runProjectSet(projectId, opts) {
409
396
  }
410
397
  throw err;
411
398
  }
399
+ if (statusChangeLine) {
400
+ if (opts.json)
401
+ console.error(statusChangeLine);
402
+ else
403
+ console.log(statusChangeLine);
404
+ }
412
405
  }
413
406
  else {
414
407
  view = current;
@@ -1816,6 +1809,7 @@ export function registerMeshCommand(program) {
1816
1809
  .option("--repo <path[@branch]>", "Repository binding (repeatable)", collectRepeatable)
1817
1810
  .option("--channel <channelId>", "Bind the project to an existing company or project channel")
1818
1811
  .option("--create", "Create the Board view when the GET is a 404")
1812
+ .option("--reset-status", "Overwrite live story status from the PRD (prints a one-line summary of changes)")
1819
1813
  .option("--json", "Print machine-readable JSON")
1820
1814
  .action((projectId, opts) => wrap(() => runProjectSet(projectId, opts))());
1821
1815
  project
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ export declare function registerOutpostPrunerCommand(lanes: Command): void;
3
+ //# sourceMappingURL=outpost-pruner.d.ts.map
@@ -0,0 +1,125 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { outpostWorktreePrunerEnabled, OUTPOST_WORKTREE_PRUNER_FLAG } from "../lib/lanes/outpost-worktree-pruner-flag.js";
5
+ import { OUTPOST_WORKTREE_PRUNER_DEFAULT_MIN_AGE_DAYS, runOutpostWorktreePruner, } from "../lib/lanes/outpost-worktree-pruner.js";
6
+ import { defaultOutpostPrunerSystemdDir, installOutpostPrunerTimers, uninstallOutpostPrunerTimers, } from "../lib/lanes/outpost-pruner-install.js";
7
+ import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
8
+ function errorType(error) {
9
+ return error instanceof Error ? error.name : "UnknownError";
10
+ }
11
+ function printJson(value) {
12
+ process.stdout.write(`${JSON.stringify(value)}\n`);
13
+ }
14
+ function printErrorJson(event, error, fields = {}) {
15
+ printJson({ event, ...fields, error: errorType(error) });
16
+ }
17
+ function runSystemctl(binary, args) {
18
+ const result = spawnSync(binary, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
19
+ return {
20
+ status: result.status ?? 1,
21
+ stdout: result.stdout ?? "",
22
+ stderr: result.stderr ?? "",
23
+ };
24
+ }
25
+ function systemdRoot(option) {
26
+ return path.resolve(option ?? defaultOutpostPrunerSystemdDir(os.homedir()));
27
+ }
28
+ function skippedResult(trigger, minAgeDays, reason) {
29
+ return {
30
+ event: "outpost.worktree_pruner.run",
31
+ trigger,
32
+ dry_run: false,
33
+ feature_enabled: false,
34
+ skipped: reason,
35
+ flag: OUTPOST_WORKTREE_PRUNER_FLAG,
36
+ min_age_days: minAgeDays,
37
+ candidates: [],
38
+ removed: [],
39
+ kept: [],
40
+ bytes_freed: 0,
41
+ bytes_freed_basis: "statfs_available_byte_delta",
42
+ package_store: { attempted: false, reason: "not_run" },
43
+ };
44
+ }
45
+ export function registerOutpostPrunerCommand(lanes) {
46
+ const pruner = lanes
47
+ .command("outpost-pruner")
48
+ .description("Inspect, install, and run safe outpost worktree pruning");
49
+ pruner
50
+ .command("run")
51
+ .description("Run the guarded outpost worktree and user-cache cleanup")
52
+ .option("--trigger <kind>", "pressure | daily | manual", "manual")
53
+ .option("--min-age-days <days>", "Minimum age before cleanup (1–365 days)", (value) => Number(value), OUTPOST_WORKTREE_PRUNER_DEFAULT_MIN_AGE_DAYS)
54
+ .option("--dry-run", "List eligible worktrees without deleting files")
55
+ .action(async (options) => {
56
+ const trigger = options.trigger ?? "manual";
57
+ const minAgeDays = options.minAgeDays ?? OUTPOST_WORKTREE_PRUNER_DEFAULT_MIN_AGE_DAYS;
58
+ try {
59
+ if (!["pressure", "daily", "manual"].includes(trigger)) {
60
+ throw new TypeError("invalid trigger");
61
+ }
62
+ const featureEnabled = options.dryRun ? false : await outpostWorktreePrunerEnabled();
63
+ if (!options.dryRun && !featureEnabled) {
64
+ printJson(skippedResult(trigger, minAgeDays, "feature_flag_off"));
65
+ return;
66
+ }
67
+ const result = runOutpostWorktreePruner({
68
+ hqRoot: resolveLanesHqRoot(),
69
+ trigger,
70
+ dryRun: Boolean(options.dryRun),
71
+ featureEnabled,
72
+ minAgeDays,
73
+ });
74
+ printJson({ ...result, flag: OUTPOST_WORKTREE_PRUNER_FLAG });
75
+ }
76
+ catch (error) {
77
+ printErrorJson("outpost.worktree_pruner.run", error, {
78
+ trigger,
79
+ dry_run: Boolean(options.dryRun),
80
+ });
81
+ process.exitCode = 1;
82
+ }
83
+ });
84
+ pruner
85
+ .command("install")
86
+ .description("Write the pressure and daily systemd user timers")
87
+ .option("--systemd-root <path>", "Systemd user unit directory")
88
+ .option("--enable", "Enable both timers after writing the units")
89
+ .action((options) => {
90
+ try {
91
+ const result = installOutpostPrunerTimers({
92
+ systemdRoot: systemdRoot(options.systemdRoot),
93
+ hqRoot: resolveLanesHqRoot(),
94
+ home: os.homedir(),
95
+ enable: Boolean(options.enable),
96
+ runCommand: options.enable ? runSystemctl : undefined,
97
+ });
98
+ printJson(result);
99
+ }
100
+ catch (error) {
101
+ printErrorJson("outpost.worktree_pruner.install", error);
102
+ process.exitCode = 1;
103
+ }
104
+ });
105
+ pruner
106
+ .command("uninstall")
107
+ .description("Disable and remove the pressure and daily systemd user timers")
108
+ .option("--systemd-root <path>", "Systemd user unit directory")
109
+ .option("--disable", "Disable both timers before removing their units")
110
+ .action((options) => {
111
+ try {
112
+ const result = uninstallOutpostPrunerTimers({
113
+ systemdRoot: systemdRoot(options.systemdRoot),
114
+ disable: Boolean(options.disable),
115
+ runCommand: options.disable ? runSystemctl : undefined,
116
+ });
117
+ printJson(result);
118
+ }
119
+ catch (error) {
120
+ printErrorJson("outpost.worktree_pruner.uninstall", error);
121
+ process.exitCode = 1;
122
+ }
123
+ });
124
+ }
125
+ //# sourceMappingURL=outpost-pruner.js.map
@@ -10,7 +10,7 @@
10
10
  * them instead of silently dropping them.
11
11
  */
12
12
  import type { MachineType } from "./machine-type.js";
13
- export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
13
+ export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
14
14
  export type ServiceName = (typeof SERVICE_NAMES)[number];
15
15
  export declare function isServiceName(value: unknown): value is ServiceName;
16
16
  export declare const SERVICE_DESCRIPTIONS: Record<ServiceName, string>;
@@ -79,7 +79,17 @@ export interface PlanContext {
79
79
  tmuxPath: string | null;
80
80
  /** Short host name, used to name this machine's remote-control session. */
81
81
  hostName: string;
82
+ /** The session name the box's own runner used (`--name` in its script), kept when the daemon takes over. */
83
+ remoteControlName: string | null;
84
+ /** Absolute path of the login user's codex binary, or null. */
85
+ codexPath: string | null;
86
+ /** The box's Codex remote-control runner script, when it has one. */
87
+ codexRunnerScript: string | null;
82
88
  }
89
+ /** Written by outpost provisioning; the daemon runs it rather than re-implementing Codex login and relay setup. */
90
+ export declare const CODEX_RUNNER_SCRIPT = "/usr/local/bin/outpost-codex-runner.sh";
91
+ /** The outpost's Claude runner script, read for the session name it uses. */
92
+ export declare const CLAUDE_RUNNER_SCRIPT = "/usr/local/bin/outpost-runner.sh";
83
93
  /** Turn enabled services into concrete units for this host. */
84
94
  export declare function planUnits(services: readonly ServiceName[], ctx: PlanContext): UnitPlan[];
85
95
  //# sourceMappingURL=catalog.d.ts.map
@@ -16,6 +16,7 @@ export const SERVICE_NAMES = [
16
16
  "inbox",
17
17
  "bots",
18
18
  "remote-control",
19
+ "codex-remote-control",
19
20
  "session-host",
20
21
  "lanes-reconcile",
21
22
  "search-index",
@@ -33,16 +34,17 @@ export const SERVICE_DESCRIPTIONS = {
33
34
  inbox: "picks up the agent's incoming messages and tasks",
34
35
  bots: "runs local bots",
35
36
  "remote-control": "keeps Claude remote control running",
37
+ "codex-remote-control": "keeps Codex remote control running",
36
38
  "session-host": "lets the desktop app open this machine's Claude sessions",
37
39
  "lanes-reconcile": "cleans up stalled background lanes",
38
40
  "search-index": "keeps local search up to date",
39
41
  "jobs-reconcile": "keeps scheduled jobs matched to the cloud",
40
- updater: "keeps the hq CLI and Codex up to date",
42
+ updater: "keeps the hq CLI, Claude, Codex and Grok up to date",
41
43
  housekeeping: "rotates daemon logs",
42
44
  };
43
45
  /** The services each machine type runs without being asked. */
44
46
  export const DEFAULT_SERVICES = {
45
- desktop: ["sync", "mesh", "heartbeat", "bots", "lanes-reconcile", "search-index", "housekeeping"],
47
+ desktop: ["sync", "mesh", "heartbeat", "bots", "lanes-reconcile", "search-index", "updater", "housekeeping"],
46
48
  "self-hosted": [
47
49
  "sync",
48
50
  "mesh",
@@ -59,6 +61,7 @@ export const DEFAULT_SERVICES = {
59
61
  "mesh",
60
62
  "heartbeat",
61
63
  "remote-control",
64
+ "codex-remote-control",
62
65
  "session-host",
63
66
  "lanes-reconcile",
64
67
  "search-index",
@@ -93,6 +96,10 @@ function firstBlock(checks) {
93
96
  const hit = checks.find(([blocked]) => blocked);
94
97
  return hit ? { state: "waiting", reason: hit[1] } : undefined;
95
98
  }
99
+ /** Written by outpost provisioning; the daemon runs it rather than re-implementing Codex login and relay setup. */
100
+ export const CODEX_RUNNER_SCRIPT = "/usr/local/bin/outpost-codex-runner.sh";
101
+ /** The outpost's Claude runner script, read for the session name it uses. */
102
+ export const CLAUDE_RUNNER_SCRIPT = "/usr/local/bin/outpost-runner.sh";
96
103
  const NO_HQ_ROOT = "no HQ folder configured (run `hq daemon install --hq-root <path>`)";
97
104
  const NOT_SIGNED_IN = "not signed in (run `hq login`)";
98
105
  const KIT_NOT_SET_UP = "the agent kit is not set up on this machine (run `hq agent kit install`)";
@@ -205,9 +212,6 @@ function planService(service, ctx) {
205
212
  intervalSec: 6 * 3600,
206
213
  initialDelaySec: 900,
207
214
  command: { type: "hq", args: ["daemon", "service", "self-update"] },
208
- blocked: firstBlock([
209
- [!ctx.cliSelfUpdatable, "this hq CLI is not a global install, so it is updated with the project or app that owns it"],
210
- ]),
211
215
  }),
212
216
  ];
213
217
  case "jobs-reconcile": {
@@ -232,12 +236,33 @@ function planService(service, ctx) {
232
236
  }),
233
237
  ];
234
238
  }
239
+ case "codex-remote-control": {
240
+ if (ctx.platform === "win32")
241
+ return [unavailable(service, "not supported on Windows")];
242
+ const script = ctx.codexRunnerScript;
243
+ return [
244
+ base(service, {
245
+ command: {
246
+ type: "hq",
247
+ args: ["daemon", "service", "codex-remote-control", "--script", script ?? "", "--codex", ctx.codexPath ?? ""],
248
+ },
249
+ env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
250
+ blocked: firstBlock([
251
+ [!script, `this machine has no Codex remote-control runner (${CODEX_RUNNER_SCRIPT})`],
252
+ [!ctx.codexPath, "the codex CLI is not installed"],
253
+ [!ctx.tmuxPath, "tmux is not installed (Codex remote control runs inside tmux)"],
254
+ [!ctx.hqRoot, NO_HQ_ROOT],
255
+ ]),
256
+ }),
257
+ ];
258
+ }
235
259
  case "remote-control": {
236
260
  if (ctx.platform === "win32")
237
261
  return [unavailable(service, "not supported on Windows yet")];
238
262
  // Outposts keep the runner's session name and permission mode.
239
263
  const outpost = ctx.machineType === "outpost";
240
- const args = ["daemon", "service", "remote-control", "--claude", ctx.claudePath ?? "", "--name", outpost ? "outpost-1" : ctx.hostName];
264
+ const name = ctx.remoteControlName ?? (outpost ? "outpost-1" : ctx.hostName);
265
+ const args = ["daemon", "service", "remote-control", "--claude", ctx.claudePath ?? "", "--name", name];
241
266
  if (outpost)
242
267
  args.push("--permission-mode", "bypassPermissions");
243
268
  return [
@@ -64,6 +64,13 @@ export interface DaemonUnitResult {
64
64
  export interface InstallDeps {
65
65
  run: RunCommand;
66
66
  }
67
+ export declare const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
68
+ /**
69
+ * Why the daemon must not run here as root, or null when it may. Agent kits
70
+ * are the one exception: they run in containers where root is often the only
71
+ * account.
72
+ */
73
+ export declare function daemonRootRefusal(isRoot: boolean, machineType: MachineType): string | null;
67
74
  /** Boxes run the daemon as a system unit; everything else per user. */
68
75
  export declare function installScopeFor(machineType: MachineType): InstallScope;
69
76
  export declare function planDaemonUnit(host: DaemonHost, options: InstallOptions): DaemonUnitPlan;