@indigoai-us/hq-cli 5.345.32 → 5.345.34

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 (57) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/command-catalog.generated.d.ts +92 -3
  3. package/dist/command-catalog.generated.js +111 -3
  4. package/dist/commands/cloud-provision.d.ts +2 -0
  5. package/dist/commands/db-dump.d.ts +8 -0
  6. package/dist/commands/db-dump.js +50 -0
  7. package/dist/commands/db-provision.d.ts +3 -10
  8. package/dist/commands/db-provision.js +26 -47
  9. package/dist/commands/db-remote-deps.d.ts +18 -0
  10. package/dist/commands/db-remote-deps.js +59 -0
  11. package/dist/commands/db-sql.d.ts +2 -1
  12. package/dist/commands/db-sql.js +30 -5
  13. package/dist/commands/db-usage.d.ts +7 -0
  14. package/dist/commands/db-usage.js +28 -0
  15. package/dist/commands/db.js +4 -0
  16. package/dist/commands/files.js +33 -5
  17. package/dist/commands/lanes-message.js +40 -8
  18. package/dist/commands/lanes-questions.d.ts +6 -1
  19. package/dist/commands/lanes-questions.js +19 -7
  20. package/dist/commands/lanes-resume.js +17 -6
  21. package/dist/commands/lanes-shared.d.ts +6 -1
  22. package/dist/commands/lanes.js +92 -15
  23. package/dist/commands/mesh.js +150 -26
  24. package/dist/commands/reindex.js +11 -8
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +68 -604
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/lib/daemon/remote-control.js +62 -3
  28. package/dist/lib/db/control-plane.d.ts +32 -0
  29. package/dist/lib/db/control-plane.js +50 -0
  30. package/dist/lib/db/redact.d.ts +4 -0
  31. package/dist/lib/db/redact.js +17 -0
  32. package/dist/lib/db/remote-engine.d.ts +67 -0
  33. package/dist/lib/db/remote-engine.js +165 -0
  34. package/dist/lib/db/usage-format.d.ts +4 -0
  35. package/dist/lib/db/usage-format.js +41 -0
  36. package/dist/lib/index-render/shared.d.ts +3 -1
  37. package/dist/lib/index-render/shared.js +5 -2
  38. package/dist/lib/lanes/depth-cap.d.ts +7 -0
  39. package/dist/lib/lanes/depth-cap.js +41 -0
  40. package/dist/lib/lanes/keep-alive.d.ts +12 -0
  41. package/dist/lib/lanes/keep-alive.js +25 -6
  42. package/dist/lib/lanes/reconcile.d.ts +1 -1
  43. package/dist/lib/lanes/reconcile.js +16 -5
  44. package/dist/lib/mesh/api.d.ts +2 -0
  45. package/dist/lib/mesh/live/daemon/install.d.ts +7 -4
  46. package/dist/lib/mesh/live/daemon/install.js +144 -47
  47. package/dist/lib/mesh/project-board.d.ts +17 -0
  48. package/dist/lib/mesh/project-board.js +28 -0
  49. package/dist/lib/mesh/story-status.d.ts +16 -0
  50. package/dist/lib/mesh/story-status.js +32 -7
  51. package/dist/utils/self-update.d.ts +2 -0
  52. package/dist/utils/self-update.js +61 -23
  53. package/dist/utils/version-check.d.ts +1 -1
  54. package/dist/utils/version-check.js +2 -2
  55. package/dist/utils/version-gate.d.ts +21 -14
  56. package/dist/utils/version-gate.js +119 -28
  57. package/package.json +4 -1
@@ -33,6 +33,7 @@ import { registerLaneReviewCommand } from "./lanes-review.js";
33
33
  import { ensureCognitoToken } from "../utils/cognito-session.js";
34
34
  import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
35
35
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
36
+ import { laneDepthCap } from "../lib/lanes/depth-cap.js";
36
37
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
37
38
  import { runResidentCommandWithPackageLease } from "../utils/resident-package-preload.js";
38
39
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff } from "../lib/lanes/registry-flag.js";
@@ -66,7 +67,7 @@ import { AccountResolutionError, compareLaneQueueOrder, LANE_PROVIDERS, LANE_STA
66
67
  import { laneStories, storyWarning, } from "../lib/lanes/stories.js";
67
68
  import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
68
69
  import { laneCompanyUidSource, resolveLaneCompany } from "../lib/lanes/lane-company.js";
69
- import { doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveNextCheckAtMs, keepAliveExclusion, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
70
+ import { doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveNextCheckAtMs, keepAliveExclusion, keepAliveSupersededBy, loadRetiredLaneEntries, matchesRetiredLaneEntry, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
70
71
  import { questionBodyAbsPath, resolveQuestionCompanyWithFallback, resolveProjectQuestionCompany, } from "../lib/lanes/questions.js";
71
72
  import { assertQuestionBodySecretSafe } from "../lib/lanes/questions.js";
72
73
  import { writeQuestionBody } from "../lib/lanes/questions.js";
@@ -2070,12 +2071,12 @@ function seniorLaneChain(hqRoot, ref, ctx, visited = new Set(), override) {
2070
2071
  const depth = isOverridden ? override.depth : derivedLaneDepth(hqRoot, lane.senior, ctx, override);
2071
2072
  return [...prefix, { lane_id: lane.lane_id, depth }];
2072
2073
  }
2073
- function maxLaneDepthError(depth, chain, description, extraDetails = {}) {
2074
- const parentLane = [...chain].reverse().find((entry) => entry.depth < MAX_LANE_DEPTH);
2074
+ function maxLaneDepthError(depth, chain, description, cap, extraDetails = {}) {
2075
+ const parentLane = [...chain].reverse().find((entry) => entry.depth < cap.max);
2075
2076
  const nextStep = parentLane
2076
2077
  ? `Ask your senior lane ${parentLane.lane_id} to create this worker instead, or re-parent this lane under a shallower senior.`
2077
2078
  : "Ask a shallower senior to create this lane, or re-parent it to a shallower senior.";
2078
- return new LanesError("max_lane_depth", `${description}: senior chain length ${depth} exceeds cap ${MAX_LANE_DEPTH}`, { ...extraDetails, depth, max: MAX_LANE_DEPTH, senior_chain: chain, next_step: nextStep });
2079
+ return new LanesError("max_lane_depth", `${description}: senior chain length ${depth} exceeds cap ${cap.max} (${cap.source === "company" ? "company config" : "default config"})`, { ...extraDetails, depth, max: cap.max, source: cap.source, senior_chain: chain, next_step: nextStep });
2079
2080
  }
2080
2081
  function callerDepth(hqRoot, ctx) {
2081
2082
  const laneId = ctx.env.HQ_LANE_ID?.trim();
@@ -3068,21 +3069,22 @@ async function runCreate(ctx, opts) {
3068
3069
  }
3069
3070
  assertKnownOutpost(requested, known);
3070
3071
  }
3072
+ let company = companyFlag
3073
+ ? await ctx.resolveCompany(companyFlag)
3074
+ : null;
3075
+ const depthCap = laneDepthCap(hqRoot, company?.slug);
3071
3076
  const depth = callerDepth(hqRoot, ctx);
3072
- if (depth >= MAX_LANE_DEPTH) {
3077
+ if (depth >= depthCap.max) {
3073
3078
  const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
3074
3079
  const chain = callerLaneId
3075
3080
  ? seniorLaneChain(hqRoot, { kind: "lane", id: callerLaneId }, ctx)
3076
3081
  : seniorLaneChain(hqRoot, sessionIdFromEnv(ctx.env)
3077
3082
  ? { kind: "session", id: sessionIdFromEnv(ctx.env) }
3078
3083
  : { kind: "user" }, ctx);
3079
- throw maxLaneDepthError(depth + 1, chain, `A depth-${depth} caller may not spawn a depth-${depth + 1} lane`);
3084
+ throw maxLaneDepthError(depth + 1, chain, `A depth-${depth} caller may not spawn a depth-${depth + 1} lane`, depthCap);
3080
3085
  }
3081
3086
  const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
3082
3087
  assertSeniorExists(hqRoot, senior, ctx);
3083
- let company = companyFlag
3084
- ? await ctx.resolveCompany(companyFlag)
3085
- : null;
3086
3088
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
3087
3089
  if (company === null && seniorCompany.binding === "company") {
3088
3090
  const seniorCompanyIdentifier = seniorCompany.slug ?? (seniorCompany.uid
@@ -3104,8 +3106,8 @@ async function runCreate(ctx, opts) {
3104
3106
  }
3105
3107
  const hireGrant = requestedHireGrant ?? 0;
3106
3108
  const laneDepth = derivedLaneDepth(hqRoot, senior, ctx);
3107
- if (laneDepth > MAX_LANE_DEPTH) {
3108
- throw maxLaneDepthError(laneDepth, seniorLaneChain(hqRoot, senior, ctx), `Creating lane at depth ${laneDepth} is refused`);
3109
+ if (laneDepth > depthCap.max) {
3110
+ throw maxLaneDepthError(laneDepth, seniorLaneChain(hqRoot, senior, ctx), `Creating lane at depth ${laneDepth} is refused`, depthCap);
3109
3111
  }
3110
3112
  // A no-company lane must not call company resolution (openMeshTransport).
3111
3113
  if (storyIds.length > 0 && ctx.storyLedgerEnabled()) {
@@ -6007,6 +6009,71 @@ async function runKeepAlive(ctx, laneId, opts) {
6007
6009
  lane: updated,
6008
6010
  });
6009
6011
  }
6012
+ async function runLaneDoctor(ctx, pendingQuestionsForLane) {
6013
+ const hqRoot = ctx.hqRoot();
6014
+ const lanes = loadAllLanes(hqRoot);
6015
+ const retiredEntries = loadRetiredLaneEntries(hqRoot);
6016
+ const findings = [];
6017
+ for (const lane of lanes) {
6018
+ if (lane.keep_alive !== true || isTerminalLaneState(lane.state))
6019
+ continue;
6020
+ const retiredEntry = retiredEntries.find((entry) => matchesRetiredLaneEntry(entry, lane.lane_id));
6021
+ if (retiredEntry !== undefined) {
6022
+ findings.push({
6023
+ kind: "retired_keep_alive_conflict",
6024
+ lane_id: lane.lane_id,
6025
+ state: lane.state,
6026
+ registry_entry: {
6027
+ path: path.join(hqRoot, "workspace", "lane-briefs", "juniors.json"),
6028
+ index: retiredEntry.index,
6029
+ lane: retiredEntry.lane,
6030
+ retired_at: retiredEntry.retired_at ?? null,
6031
+ },
6032
+ });
6033
+ }
6034
+ const companyForLane = (candidate) => resolveLaneCompany(hqRoot, candidate).company;
6035
+ const company = companyForLane(lane);
6036
+ const lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
6037
+ company: company ?? undefined,
6038
+ env: ctx.env,
6039
+ });
6040
+ const successor = keepAliveSupersededBy(lane, lanes, lineageAware, companyForLane);
6041
+ if (successor !== undefined) {
6042
+ findings.push({
6043
+ kind: "superseded_keep_alive_conflict",
6044
+ lane_id: lane.lane_id,
6045
+ state: lane.state,
6046
+ superseded_by: successor.lane_id,
6047
+ suggested_action: `Review successor ${successor.lane_id} and correct stale supersession metadata before retrying. Message delivery still requires an authorized caller and the running-superseded delivery flag.`,
6048
+ });
6049
+ }
6050
+ if (lane.state === "awaiting_input") {
6051
+ const staleCutoff = Date.parse(ctx.now().toISOString()) - 24 * 60 * 60 * 1000;
6052
+ const pendingQuestions = await pendingQuestionsForLane(ctx, lane);
6053
+ for (const question of pendingQuestions) {
6054
+ const askedAt = Date.parse(question.asked_at);
6055
+ if (!Number.isFinite(askedAt) || askedAt > staleCutoff)
6056
+ continue;
6057
+ const companySlug = lane.company?.slug ?? lane.mesh_company ?? "<company-slug>";
6058
+ findings.push({
6059
+ kind: "awaiting_input_keep_alive",
6060
+ lane_id: lane.lane_id,
6061
+ state: lane.state,
6062
+ question_id: question.question_id,
6063
+ asked_at: question.asked_at,
6064
+ audience: question.audience,
6065
+ suggested_command: `hq lanes questions answer ${question.question_id} --company ${companySlug} --freeform <answer>`,
6066
+ });
6067
+ }
6068
+ }
6069
+ }
6070
+ printJson({
6071
+ ok: findings.length === 0,
6072
+ action: "doctor",
6073
+ checked_keep_alive_lanes: lanes.filter((lane) => lane.keep_alive === true && !isTerminalLaneState(lane.state)).length,
6074
+ findings,
6075
+ });
6076
+ }
6010
6077
  function assertKeepAliveChangeAllowed(ctx, lane, laneId) {
6011
6078
  const callerAuthorization = callerSeniorAuthorization(ctx, lane.senior);
6012
6079
  if (!callerAuthorization.authorized) {
@@ -7184,10 +7251,11 @@ function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false
7184
7251
  throw new LanesError("senior_cycle", `Re-parent would create a cycle through ${laneId}`, { lane_id: laneId, senior: nextSenior });
7185
7252
  }
7186
7253
  const newDepth = derivedLaneDepth(hqRoot, nextSenior, ctx);
7187
- if (newDepth > MAX_LANE_DEPTH) {
7254
+ const depthCap = laneDepthCap(hqRoot, lane.company?.slug);
7255
+ if (newDepth > depthCap.max) {
7188
7256
  const chain = seniorLaneChain(hqRoot, nextSenior, ctx);
7189
7257
  chain.push({ lane_id: lane.lane_id, depth: newDepth });
7190
- throw maxLaneDepthError(newDepth, chain, `Re-parent would put lane at depth ${newDepth}`);
7258
+ throw maxLaneDepthError(newDepth, chain, `Re-parent would put lane at depth ${newDepth}`, depthCap);
7191
7259
  }
7192
7260
  const pending = pendingQuestionsForLane(hqRoot, laneId);
7193
7261
  const oldDepth = derivedLaneDepth(hqRoot, lane.senior, ctx);
@@ -7204,10 +7272,11 @@ function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false
7204
7272
  assertLaneRunsHereForOperatorMutation(ctx, other, "reparent descendant of");
7205
7273
  const oldDescendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx);
7206
7274
  const descendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx, override);
7207
- if (descendantDepth > MAX_LANE_DEPTH) {
7275
+ const descendantCap = laneDepthCap(hqRoot, other.company?.slug);
7276
+ if (descendantDepth > descendantCap.max) {
7208
7277
  const chain = seniorLaneChain(hqRoot, other.senior, ctx, new Set(), override);
7209
7278
  chain.push({ lane_id: other.lane_id, depth: descendantDepth });
7210
- throw maxLaneDepthError(descendantDepth, chain, `Re-parent would put descendant ${other.lane_id} at depth ${descendantDepth}`, { lane_id: other.lane_id });
7279
+ throw maxLaneDepthError(descendantDepth, chain, `Re-parent would put descendant ${other.lane_id} at depth ${descendantDepth}`, descendantCap, { lane_id: other.lane_id });
7211
7280
  }
7212
7281
  if (descendantDepth > oldDescendantDepth &&
7213
7282
  pendingQuestionsForLane(hqRoot, other.lane_id).length > 0) {
@@ -8646,6 +8715,7 @@ export function registerLanesCommand(parent, deps = {}) {
8646
8715
  resolveCommandLane: resolveMessageLaneTarget,
8647
8716
  resolveActiveQuestionCompany,
8648
8717
  hasPendingQuestionForLane: questionHandlers.hasPendingQuestionForLane,
8718
+ pendingQuestionsForLane: questionHandlers.pendingQuestionsForLane,
8649
8719
  cancelFallbackQuestionsForLane: questionHandlers.cancelFallbackQuestionsForLane,
8650
8720
  resolveResumeProfile,
8651
8721
  reuseProfileRefreshEnabled,
@@ -8677,6 +8747,7 @@ export function registerLanesCommand(parent, deps = {}) {
8677
8747
  recordResumeSetup,
8678
8748
  resolveCommandLane: (commandCtx, laneId) => resolveCommandLaneTarget(commandCtx, laneId),
8679
8749
  hasPendingQuestionForLane: questionHandlers.hasPendingQuestionForLane,
8750
+ pendingQuestionsForLane: questionHandlers.pendingQuestionsForLane,
8680
8751
  cancelFallbackQuestionsForLane: questionHandlers.cancelFallbackQuestionsForLane,
8681
8752
  resolveResumeProfile,
8682
8753
  reuseProfileRefreshEnabled,
@@ -8862,6 +8933,12 @@ export function registerLanesCommand(parent, deps = {}) {
8862
8933
  .action(wrap(async (opts) => {
8863
8934
  await runBackfillGrants(ctx, opts);
8864
8935
  }));
8936
+ lanes
8937
+ .command("doctor")
8938
+ .description("Report active keep-alive lanes blocked by retired, superseded, or pending-question state")
8939
+ .action(wrap(async () => {
8940
+ await runLaneDoctor(ctx, questionHandlers.pendingQuestionsForLane);
8941
+ }));
8865
8942
  lanes
8866
8943
  .command("list")
8867
8944
  .description("List lanes")
@@ -14,8 +14,8 @@ import chalk from "chalk";
14
14
  import { ensureCognitoToken, isExpiring, isMachineIdentity, loadCachedTokens, } from "../utils/cognito-session.js";
15
15
  import * as readline from "node:readline/promises";
16
16
  import { STORY_STATUSES, appendThreadEvent, addProjectMember, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
17
- import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, boardStoryStatus, formatStoryStatusChanges, mergeProjectStories, prdSyncEventsThatChangeBoard, } from "../lib/mesh/project-board.js";
18
- import { mapSourceStoryStatus } from "../lib/mesh/story-status.js";
17
+ import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, boardStoryStatus, formatStoryStatusChanges, mergeProjectStories, prdSyncEventsThatChangeBoard, reconcileProjectStories, } from "../lib/mesh/project-board.js";
18
+ import { ACCEPTED_LEDGER_STATES, mapSourceStoryStatus, UnknownProjectStoryStateError } from "../lib/mesh/story-status.js";
19
19
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
20
20
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
21
21
  import { clearDefaultCompany, effectiveUsageAttribution, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, setUsageAttribution, } from "../lib/work-context/config.js";
@@ -257,32 +257,43 @@ function projectStoriesFromJson(value, source) {
257
257
  if (!rawStories) {
258
258
  fail(`${source} must be a JSON story array or an object with userStories.`);
259
259
  }
260
- const stories = rawStories.map((raw, index) => {
260
+ const rejectedStories = [];
261
+ const stories = rawStories.flatMap((raw, index) => {
261
262
  const story = record(raw);
262
263
  const id = typeof story?.id === "string" ? story.id.trim() : "";
263
264
  const title = typeof story?.title === "string" ? story.title.trim() : "";
264
265
  if (!story || !id || !title) {
265
266
  fail(`${source} userStories[${index}] requires string id and title.`);
266
267
  }
267
- const status = mapSourceStoryStatus(story, `${source} userStories[${index}]`);
268
+ let status;
269
+ try {
270
+ status = mapSourceStoryStatus({ ...story, id }, `${source} userStories[${index}]`);
271
+ }
272
+ catch (err) {
273
+ if (!(err instanceof UnknownProjectStoryStateError))
274
+ throw err;
275
+ rejectedStories.push(err);
276
+ return [];
277
+ }
268
278
  const resolvedStatus = status || (story.passes === true ? "done" : undefined);
269
- return {
270
- id,
271
- title,
272
- ...(typeof story.description === "string" ? { description: story.description } : {}),
273
- ...(Array.isArray(story.acceptanceCriteria)
274
- ? {
275
- acceptanceCriteria: story.acceptanceCriteria.filter((item) => typeof item === "string"),
276
- }
277
- : {}),
278
- ...(resolvedStatus ? { status: resolvedStatus } : {}),
279
- // hq-pro makes passes mirror the Board status. A completed PRD story
280
- // without an explicit status must therefore become done.
281
- passes: resolvedStatus === "done",
282
- ...(typeof story.priority === "number" && Number.isFinite(story.priority)
283
- ? { priority: story.priority }
284
- : {}),
285
- };
279
+ return [{
280
+ id,
281
+ title,
282
+ ...(typeof story.description === "string" ? { description: story.description } : {}),
283
+ ...(Array.isArray(story.acceptanceCriteria)
284
+ ? {
285
+ acceptanceCriteria: story.acceptanceCriteria.filter((item) => typeof item === "string"),
286
+ }
287
+ : {}),
288
+ ...(resolvedStatus ? { status: resolvedStatus } : {}),
289
+ ...(typeof story.state === "string" ? { ledgerState: story.state.trim() } : {}),
290
+ // hq-pro makes passes mirror the Board status. A completed PRD story
291
+ // without an explicit status must therefore become done.
292
+ passes: resolvedStatus === "done",
293
+ ...(typeof story.priority === "number" && Number.isFinite(story.priority)
294
+ ? { priority: story.priority }
295
+ : {}),
296
+ }];
286
297
  });
287
298
  const metadata = record(plan?.metadata);
288
299
  const repos = metadata && Object.hasOwn(metadata, "repos")
@@ -292,12 +303,44 @@ function projectStoriesFromJson(value, source) {
292
303
  ...(typeof plan?.name === "string" ? { name: plan.name } : {}),
293
304
  ...(typeof plan?.description === "string" ? { description: plan.description } : {}),
294
305
  stories,
306
+ rejectedStories,
295
307
  ...(repos ? { repos } : {}),
296
308
  };
297
309
  }
298
- function parseProjectPlan(raw, source) {
310
+ function projectStateErrors(rejected) {
311
+ return rejected.map((error) => ({
312
+ code: error.code,
313
+ file: error.source.split(" userStories[")[0],
314
+ storyId: error.storyId,
315
+ field: "state",
316
+ value: error.value,
317
+ accepted: ACCEPTED_LEDGER_STATES,
318
+ }));
319
+ }
320
+ class RejectedProjectStoryStatesError extends Error {
321
+ rejected;
322
+ code = "PROJECT_STORY_STATE_INVALID";
323
+ constructor(rejected) {
324
+ super(rejected.map((error) => `${error.code}: ${error.message}`).join("\n"));
325
+ this.rejected = rejected;
326
+ this.name = "RejectedProjectStoryStatesError";
327
+ }
328
+ }
329
+ function failProjectStoryStates(rejected, json) {
330
+ if (json)
331
+ console.log(JSON.stringify({ ok: false, errors: projectStateErrors(rejected) }));
332
+ else
333
+ for (const error of rejected)
334
+ console.error(`${error.code}: ${error.message}`);
335
+ process.exitCode = 1;
336
+ }
337
+ function parseProjectPlan(raw, source, options = {}) {
299
338
  try {
300
- return projectStoriesFromJson(JSON.parse(raw), source);
339
+ const plan = projectStoriesFromJson(JSON.parse(raw), source);
340
+ if (plan.rejectedStories.length && !options.allowRejectedStories) {
341
+ throw new RejectedProjectStoryStatesError(plan.rejectedStories);
342
+ }
343
+ return plan;
301
344
  }
302
345
  catch (err) {
303
346
  if (err instanceof SyntaxError)
@@ -330,6 +373,10 @@ async function runProjectSet(projectId, opts) {
330
373
  : opts.storiesJson
331
374
  ? parseProjectPlan(opts.storiesJson, "--stories-json")
332
375
  : undefined;
376
+ if (source?.rejectedStories.length) {
377
+ failProjectStoryStates(source.rejectedStories, Boolean(opts.json));
378
+ return;
379
+ }
333
380
  const explicitRepos = opts.reposJson
334
381
  ? parseProjectReposJson(opts.reposJson, "--repos-json")
335
382
  : opts.repo?.length
@@ -479,8 +526,12 @@ async function runProjectMemberAdd(projectId, personUid, opts) {
479
526
  async function runProjectEnsure(projectId, opts) {
480
527
  if (!opts.storiesFile?.trim())
481
528
  fail("`--stories-file <prd.json>` is required.");
482
- const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile);
529
+ const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { allowRejectedStories: true });
483
530
  const { token, company } = await withCompany(opts);
531
+ if (source.rejectedStories.length && source.stories.length === 0) {
532
+ failProjectStoryStates(source.rejectedStories, Boolean(opts.json));
533
+ return;
534
+ }
484
535
  let current;
485
536
  try {
486
537
  current = await getProjectView(token, company.companyUid, projectId);
@@ -506,12 +557,13 @@ async function runProjectEnsure(projectId, opts) {
506
557
  }));
507
558
  if (opts.json) {
508
559
  console.log(JSON.stringify({
509
- ok: true,
560
+ ok: source.rejectedStories.length === 0,
510
561
  action: "project.ensure.dry-run",
511
562
  projectId,
512
563
  companyUid: company.companyUid,
513
564
  sourceStatuses,
514
565
  stories: boardStatuses,
566
+ ...(source.rejectedStories.length ? { errors: projectStateErrors(source.rejectedStories) } : {}),
515
567
  }, null, 2));
516
568
  }
517
569
  else {
@@ -523,6 +575,8 @@ async function runProjectEnsure(projectId, opts) {
523
575
  for (const story of boardStatuses)
524
576
  console.log(`${story.id}\t${story.status}`);
525
577
  }
578
+ if (source.rejectedStories.length)
579
+ process.exitCode = 1;
526
580
  return;
527
581
  }
528
582
  // Genesis thread, then the view, then register. Register returns 403
@@ -606,13 +660,74 @@ async function runProjectEnsure(projectId, opts) {
606
660
  channelId: registration.channelId,
607
661
  version: verified.version,
608
662
  stories: verified.stories,
663
+ ...(source.rejectedStories.length ? { ok: false, errors: projectStateErrors(source.rejectedStories) } : {}),
609
664
  };
610
665
  noteCallingSessionBound(payload.projectId, company);
611
666
  if (opts.json) {
612
667
  console.log(JSON.stringify(payload));
668
+ if (source.rejectedStories.length)
669
+ process.exitCode = 1;
613
670
  return;
614
671
  }
615
672
  console.log(`Work mesh: project ${payload.projectId} ensured (thread ${payload.threadId}, channel ${payload.channelId}, ${payload.stories.length} stories)`);
673
+ if (source.rejectedStories.length)
674
+ failProjectStoryStates(source.rejectedStories, false);
675
+ }
676
+ async function runProjectReconcile(projectId, opts) {
677
+ if (!opts.storiesFile?.trim())
678
+ fail("`--stories-file <prd.json>` is required.");
679
+ const source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { allowRejectedStories: true });
680
+ const { token, company } = await withCompany(opts);
681
+ let current;
682
+ try {
683
+ current = await getProjectView(token, company.companyUid, projectId);
684
+ }
685
+ catch (err) {
686
+ if (httpStatus(err) !== 404)
687
+ throw err;
688
+ }
689
+ if (current && (current.companyUid !== company.companyUid || current.projectId !== projectId)) {
690
+ fail("Work mesh project reconcile: Board company or project id did not match the request.");
691
+ }
692
+ const reconciliation = reconcileProjectStories(source.stories, current?.stories ?? []);
693
+ const errors = [
694
+ ...projectStateErrors(source.rejectedStories),
695
+ ...(!current ? [{ code: "PROJECT_BOARD_NOT_FOUND", projectId }] : []),
696
+ ];
697
+ const mismatches = reconciliation.mismatches;
698
+ const clean = mismatches.length === 0 && errors.length === 0;
699
+ if (opts.json) {
700
+ console.log(JSON.stringify({
701
+ ok: clean,
702
+ action: "project.reconcile",
703
+ projectId,
704
+ companyUid: company.companyUid,
705
+ mismatches,
706
+ stateDriftCount: reconciliation.stateDriftCount,
707
+ ledgerStateUnsupported: reconciliation.ledgerStateUnsupported,
708
+ errors,
709
+ }, null, 2));
710
+ }
711
+ else if (clean) {
712
+ const note = reconciliation.ledgerStateUnsupported.length
713
+ ? `; Board does not return ledgerState for ${reconciliation.ledgerStateUnsupported.length} stories yet`
714
+ : "";
715
+ console.log(`Work mesh: project ${projectId} matches ${source.stories.length} ledger stories${note}`);
716
+ }
717
+ else {
718
+ for (const mismatch of mismatches) {
719
+ console.log(`${mismatch.storyId}\tledger=${mismatch.ledgerState ?? "<none>"}\texpected=${mismatch.expectedBoardStatus ?? "<none>"}\tboard=${mismatch.boardStatus ?? "<missing>"}\tboardLedger=${mismatch.boardLedgerState ?? "<unsupported>"}\tstateDrift=${mismatch.stateDrift}\tmissing=${mismatch.missingOnBoard}\textra=${mismatch.extraOnBoard}`);
720
+ }
721
+ if (reconciliation.ledgerStateUnsupported.length) {
722
+ console.log(`Board does not return ledgerState for ${reconciliation.ledgerStateUnsupported.length} stories: ${reconciliation.ledgerStateUnsupported.join(", ")}`);
723
+ }
724
+ if (!current)
725
+ console.error(`PROJECT_BOARD_NOT_FOUND: Board project ${projectId} was not found.`);
726
+ for (const error of source.rejectedStories)
727
+ console.error(`${error.code}: ${error.message}`);
728
+ }
729
+ if (!clean)
730
+ process.exitCode = 1;
616
731
  }
617
732
  async function runPrdSync(opts) {
618
733
  const sessionId = opts.session.trim();
@@ -689,6 +804,8 @@ async function runPrdSync(opts) {
689
804
  });
690
805
  }
691
806
  catch (err) {
807
+ if (err instanceof RejectedProjectStoryStatesError)
808
+ throw err;
692
809
  const reason = err instanceof Error ? err.message : String(err);
693
810
  console.error(`work-mesh: prd-sync ensure failed; emitting anyway (${reason.slice(0, 160)})`);
694
811
  }
@@ -828,8 +945,8 @@ async function runBindProject(target, opts) {
828
945
  }
829
946
  if (!current.companyUid)
830
947
  fail(`Session ${sessionId} has no company. Bind a company first.`);
831
- const { token, company } = await withCompany({ company: opts.company ?? current.companySlug });
832
948
  const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
949
+ const { token, company } = await withCompany({ company: opts.company ?? current.companySlug });
833
950
  await ensureProjectBoard({
834
951
  token,
835
952
  company: {
@@ -2026,6 +2143,13 @@ export function registerMeshCommand(program, deps = {}) {
2026
2143
  .option("--dry-run", "Read the current Board and print computed story statuses without writing")
2027
2144
  .option("--json", "Print {projectId, threadId, channelId, version, stories}")
2028
2145
  .action((projectId, opts) => wrap(() => runProjectEnsure(projectId, opts))());
2146
+ project
2147
+ .command("reconcile <projectId>")
2148
+ .description("Read the project Board and report drift from a PRD ledger")
2149
+ .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
2150
+ .requiredOption("--stories-file <path>", "PRD JSON file with userStories")
2151
+ .option("--json", "Print machine-readable mismatch records")
2152
+ .action((projectId, opts) => wrap(() => runProjectReconcile(projectId, opts))());
2029
2153
  project
2030
2154
  .command("register <projectId>")
2031
2155
  .description("Register an existing Board and bind its project channel; use project ensure to create or repair a Board")
@@ -47,6 +47,9 @@ const WORKTREE_HOOK_SWEEP_BUDGET_MS = 5_000;
47
47
  * bounded, which is the point.
48
48
  */
49
49
  const HOOK_CHECK_TIMEOUT_MS = 60_000;
50
+ // Leave ten seconds inside the desktop's 30-second package-lease wait for
51
+ // process startup and cleanup when reindex runs from a lifecycle hook.
52
+ const HOOK_CHECK_FROM_HOOK_TIMEOUT_MS = 20_000;
50
53
  /** Ceiling override, in milliseconds. Present so tests need not wait a minute. */
51
54
  function hookCheckTimeoutMs() {
52
55
  const raw = process.env.HQ_HOOK_CHECK_TIMEOUT_MS?.trim();
@@ -367,11 +370,11 @@ function hasCommandHook(value) {
367
370
  * an older, unscoped checker — report healthy while the drift only this checker
368
371
  * detects goes unseen.
369
372
  */
370
- function runShippedHookCheck(hqRoot) {
373
+ function runShippedHookCheck(hqRoot, timeoutLimitMs) {
371
374
  const checker = path.join(hqRoot, HOOK_CHECK_RELATIVE_PATH);
372
375
  if (!fs.existsSync(checker))
373
376
  return undefined;
374
- const budgetMs = hookCheckTimeoutMs();
377
+ const budgetMs = Math.min(hookCheckTimeoutMs(), timeoutLimitMs ?? Number.POSITIVE_INFINITY);
375
378
  try {
376
379
  const result = spawnSync('bash', [checker, '--root', hqRoot], {
377
380
  encoding: 'utf8',
@@ -406,7 +409,7 @@ function runShippedHookCheck(hqRoot) {
406
409
  * reindex fires on every Stop / PostToolUse hook, that was the single most
407
410
  * expensive discarded result in the command.
408
411
  */
409
- function inspectHookHealth(hqRoot) {
412
+ function inspectHookHealth(hqRoot, timeoutLimitMs) {
410
413
  const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
411
414
  if (!fs.existsSync(settingsPath)) {
412
415
  return { state: 'extreme', reason: '.claude/settings.json is missing' };
@@ -435,7 +438,7 @@ function inspectHookHealth(hqRoot) {
435
438
  reason: `missing command hook wiring for ${HOOK_EVENTS.filter((event) => !commandEvents.includes(event)).join(', ')}`,
436
439
  };
437
440
  }
438
- const checkerResult = runShippedHookCheck(hqRoot);
441
+ const checkerResult = runShippedHookCheck(hqRoot, timeoutLimitMs);
439
442
  if (checkerResult !== undefined && checkerResult.status !== 0) {
440
443
  return {
441
444
  state: 'minor',
@@ -491,12 +494,12 @@ function runSilently(operation) {
491
494
  * configuration is repaired only after a successful reindex; partial and
492
495
  * malformed configurations remain untouched and receive recovery guidance.
493
496
  */
494
- function repairExtremeHookDrift(hqRoot, allowRepair = true) {
497
+ function repairExtremeHookDrift(hqRoot, allowRepair = true, timeoutLimitMs) {
495
498
  // `hq reindex` can still be invoked from an arbitrary directory. Never turn
496
499
  // an absent settings file there into a rescue attempt.
497
500
  if (!isHqRoot(hqRoot))
498
501
  return;
499
- const health = inspectHookHealth(hqRoot);
502
+ const health = inspectHookHealth(hqRoot, timeoutLimitMs);
500
503
  if (health.state === 'healthy')
501
504
  return;
502
505
  printHookHealthWarning(hqRoot, health.reason);
@@ -510,7 +513,7 @@ function repairExtremeHookDrift(hqRoot, allowRepair = true) {
510
513
  paths: ['.claude'],
511
514
  assumeYes: true,
512
515
  }));
513
- const afterRepair = inspectHookHealth(hqRoot);
516
+ const afterRepair = inspectHookHealth(hqRoot, timeoutLimitMs);
514
517
  if (result.status === 0 && afterRepair.state === 'healthy') {
515
518
  console.log('reindex: repaired HQ hook config');
516
519
  return;
@@ -691,7 +694,7 @@ export function registerReindexCommand(program) {
691
694
  const sweepStartedAt = Date.now();
692
695
  removeStaleHqWorktrees(hqRoot, sweepStartedAt, opts.fromHook ? sweepStartedAt + WORKTREE_HOOK_SWEEP_BUDGET_MS : Number.POSITIVE_INFINITY);
693
696
  const { status } = reindex({ repoRoot: opts.repoRoot });
694
- repairExtremeHookDrift(hqRoot, status === 0);
697
+ repairExtremeHookDrift(hqRoot, status === 0, opts.fromHook ? HOOK_CHECK_FROM_HOOK_TIMEOUT_MS : undefined);
695
698
  if (status === 0) {
696
699
  // Only safe once reindex actually ran: a refused --from-hook reindex
697
700
  // means a sync/rescue holds the shared operation lock and may be