@indigoai-us/hq-cli 5.189.0 → 5.191.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.191.0] — 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - `hq agents provision` now rejects an invalid `--slug` before sending the agent create request.
10
+
11
+ ## [5.190.0] — 2026-09-25
12
+
13
+ ### Added
14
+
15
+ - `hq lanes` validates story references against project `prd.json` files and records
16
+ explicit `--no-story` reasons. When `lanes.story-ledger` is enabled, lane state,
17
+ blockers, questions, and PR links sync back to project stories. Terminal history
18
+ writes are atomic and retry-safe. Stale-lock cleanup reclaims abandoned ownerless
19
+ locks after their directory ages while preserving fresh replacements.
20
+ `hq lanes status --project` summarizes workers, PRs, blockers, questions, and story
21
+ counts.
22
+
23
+ ### Fixed
24
+
25
+ - `hq bot promote` accepts server-signed upload URLs without SSE headers when the bucket supplies encryption. SSE header names are checked case-insensitively, and any supplied encryption must be a complete SSE-KMS pair.
26
+ - Company slug lookup errors now point to the supported `hq sources entities` command.
27
+
28
+ ### Added
29
+
30
+ - QMD native-binding startup failures after sync can emit an actionable warning
31
+ when the `qmd.reindex-startup-warning` hq-flags canary is enabled. The flag
32
+ defaults off, and sync still succeeds when indexing cannot run.
33
+
5
34
  ## [5.189.0] — 2026-09-25
6
35
 
7
36
  ### Added
@@ -70,6 +99,14 @@
70
99
 
71
100
  ### Added
72
101
 
102
+ - `hq lanes` validates story references against project `prd.json` files and records
103
+ explicit `--no-story` reasons. When `lanes.story-ledger` is enabled, lane state,
104
+ blockers, questions, and PR links sync back to project stories. Terminal history
105
+ writes are atomic and retry-safe. Stale-lock cleanup reclaims abandoned ownerless
106
+ locks after their directory ages while preserving fresh replacements.
107
+ `hq lanes status --project` summarizes workers, PRs,
108
+ blockers, questions, and story counts.
109
+
73
110
  - `hq doctor --json` includes stable sync `reasonCode` enums. Client-health reports add `checkId: reasonCode` pairs only when the company-scoped `client-health-sync-reason-codes` hq-flags gate is enabled. `hq doctor --fix --json` reports refresh-sync outcomes without raw error details.
74
111
 
75
112
  - Repair known stale QMD collection paths behind the default-off `qmd.collection-path-repair` hq-flags canary while preserving collection metadata.
@@ -6868,6 +6868,9 @@ export declare const COMMAND_CATALOG: readonly [{
6868
6868
  }, {
6869
6869
  readonly flags: "--story <id>";
6870
6870
  readonly description: "Story id from the project's prd.json";
6871
+ }, {
6872
+ readonly flags: "--no-story <reason>";
6873
+ readonly description: "Create a lane without a story and record why";
6871
6874
  }, {
6872
6875
  readonly flags: "--additional-story <id>";
6873
6876
  readonly description: "Additional story carried by this lane; repeatable and discouraged when active";
@@ -6998,7 +7001,28 @@ export declare const COMMAND_CATALOG: readonly [{
6998
7001
  readonly description: "Filter by senior ref";
6999
7002
  }, {
7000
7003
  readonly flags: "--json";
7001
- readonly description: "JSON on stdout (always)";
7004
+ readonly description: "Emit JSON on stdout (default)";
7005
+ }, {
7006
+ readonly flags: "--human";
7007
+ readonly description: "Emit human-readable output";
7008
+ }];
7009
+ readonly subcommands: readonly [];
7010
+ }, {
7011
+ readonly name: "status";
7012
+ readonly description: "Summarize project stories, lanes, PRs, blockers, and pending questions";
7013
+ readonly aliases: readonly [];
7014
+ readonly hidden: false;
7015
+ readonly usage: "[options]";
7016
+ readonly arguments: readonly [];
7017
+ readonly options: readonly [{
7018
+ readonly flags: "--project <slug>";
7019
+ readonly description: "HQ project slug";
7020
+ }, {
7021
+ readonly flags: "--company <slug|uid>";
7022
+ readonly description: "Company project scope; omit for a personal project";
7023
+ }, {
7024
+ readonly flags: "--json";
7025
+ readonly description: "Emit JSON on stdout";
7002
7026
  }];
7003
7027
  readonly subcommands: readonly [];
7004
7028
  }, {
@@ -8874,6 +8874,10 @@ export const COMMAND_CATALOG = [
8874
8874
  "flags": "--story <id>",
8875
8875
  "description": "Story id from the project's prd.json"
8876
8876
  },
8877
+ {
8878
+ "flags": "--no-story <reason>",
8879
+ "description": "Create a lane without a story and record why"
8880
+ },
8877
8881
  {
8878
8882
  "flags": "--additional-story <id>",
8879
8883
  "description": "Additional story carried by this lane; repeatable and discouraged when active"
@@ -9044,7 +9048,34 @@ export const COMMAND_CATALOG = [
9044
9048
  },
9045
9049
  {
9046
9050
  "flags": "--json",
9047
- "description": "JSON on stdout (always)"
9051
+ "description": "Emit JSON on stdout (default)"
9052
+ },
9053
+ {
9054
+ "flags": "--human",
9055
+ "description": "Emit human-readable output"
9056
+ }
9057
+ ],
9058
+ "subcommands": []
9059
+ },
9060
+ {
9061
+ "name": "status",
9062
+ "description": "Summarize project stories, lanes, PRs, blockers, and pending questions",
9063
+ "aliases": [],
9064
+ "hidden": false,
9065
+ "usage": "[options]",
9066
+ "arguments": [],
9067
+ "options": [
9068
+ {
9069
+ "flags": "--project <slug>",
9070
+ "description": "HQ project slug"
9071
+ },
9072
+ {
9073
+ "flags": "--company <slug|uid>",
9074
+ "description": "Company project scope; omit for a personal project"
9075
+ },
9076
+ {
9077
+ "flags": "--json",
9078
+ "description": "Emit JSON on stdout"
9048
9079
  }
9049
9080
  ],
9050
9081
  "subcommands": []
@@ -55,6 +55,8 @@ export const VALID_PROVIDERS = new Set(["codex", "grok", "claude", "agents-v2"])
55
55
  export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
56
56
  /** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
57
57
  export const VALID_AGENT_SIZE_KEYS = new Set(["basic", "power", "dev"]);
58
+ // Keep cloud agent create slugs aligned with hq-pro-agents POST /v1/agents.
59
+ const AGENT_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/;
58
60
  const CLAUDE_PROVISION_COMMAND = "hq agents provision <name> --provider agents-v2 --model claude-opus-5-5";
59
61
  const CLAUDE_API_KEY_ARGS = "--auth-mode apiKey --api-key-env <VAR>";
60
62
  function claudeProvisionGuidance() {
@@ -1281,6 +1283,12 @@ export function registerAgentsCommand(program) {
1281
1283
  .addHelpText("after", `\n${claudeProvisionGuidance()}\n`)
1282
1284
  .action(async function (name, opts) {
1283
1285
  try {
1286
+ const slug = opts.slug ?? slugifyAgentName(name);
1287
+ if (!AGENT_SLUG_PATTERN.test(slug)) {
1288
+ console.error(chalk.red("Agent slug must start with a lowercase letter or digit, contain only lowercase letters, digits, and hyphens, and be at most 63 characters. " +
1289
+ "Update --slug or the agent name."));
1290
+ process.exit(1);
1291
+ }
1284
1292
  // Both of these previously fell back to their default on an
1285
1293
  // unrecognised value, silently provisioning a billable box the operator
1286
1294
  // did not ask for. They now exit(1) instead — see parseEnumOption.
@@ -1341,7 +1349,6 @@ export function registerAgentsCommand(program) {
1341
1349
  // Every path confirms hq-pro's company-specific quote. Omission still
1342
1350
  // leaves the POST size implicit, preserving the server-side default.
1343
1351
  confirmAgentCreateQuoteOrExit(quotedSize, opts.yes, opts.json);
1344
- const slug = opts.slug ?? slugifyAgentName(name);
1345
1352
  try {
1346
1353
  const result = await provisionAgent(token, {
1347
1354
  companyUid,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `hq lanes` — worker lane management and decision queue (spec §5–§7, §10–§11).
3
3
  *
4
- * Existing commands print JSON on stdout and take no interactive input, except
4
+ * Existing commands print JSON on stdout by default and take no interactive input, except
5
5
  * `accounts login`, which hands the terminal to the vendor's own sign-in.
6
6
  * `watch` and the monitor reminder are the line-oriented text surfaces.
7
7
  */
@@ -80,6 +80,8 @@ export interface LanesCommandDeps {
80
80
  lanePlacementEnabled?: () => boolean;
81
81
  /** Test seam for the default-off lane hire-grants rollout gate. */
82
82
  laneHireGrantsEnabled?: (company?: LaneCompanyRef | null) => boolean | Promise<boolean>;
83
+ /** Test seam for the default-off story ledger flag. */
84
+ storyLedgerEnabled?: () => boolean;
83
85
  /** Test seam for the SSM-only Outpost owner grant path. */
84
86
  isSsmOwnerDispatch?: () => boolean;
85
87
  /** Test seam for changing a lane between pre-lock and locked grant reads. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `hq lanes` — worker lane management and decision queue (spec §5–§7, §10–§11).
3
3
  *
4
- * Existing commands print JSON on stdout and take no interactive input, except
4
+ * Existing commands print JSON on stdout by default and take no interactive input, except
5
5
  * `accounts login`, which hands the terminal to the vendor's own sign-in.
6
6
  * `watch` and the monitor reminder are the line-oriented text surfaces.
7
7
  */
@@ -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 { Option } from "commander";
13
14
  import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
14
15
  import { ensureCognitoToken } from "../utils/cognito-session.js";
15
16
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
@@ -29,7 +30,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
29
30
  import { openMeshTransport } from "../lib/mesh/client.js";
30
31
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
31
32
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
32
- import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
33
+ import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
33
34
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
34
35
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
35
36
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
@@ -257,6 +258,7 @@ function resolveDeps(deps = {}) {
257
258
  fetchText: deps.fetchText ?? defaultFetchText,
258
259
  lanePlacementEnabled: deps.lanePlacementEnabled ?? defaultLanePlacementEnabled,
259
260
  laneHireGrantsEnabled: deps.laneHireGrantsEnabled ?? defaultLaneHireGrantsEnabled,
261
+ storyLedgerEnabled: deps.storyLedgerEnabled ?? laneStoryLedgerEnabled,
260
262
  isSsmOwnerDispatch: deps.isSsmOwnerDispatch ?? (() => isSsmRunCommandProcess()),
261
263
  withGrantHireLocks: deps.withGrantHireLocks ?? withLaneHireLocks,
262
264
  laneClaudeAccountBalanceEnabled: deps.laneClaudeAccountBalanceEnabled ?? defaultLaneClaudeAccountBalanceEnabled,
@@ -2112,18 +2114,28 @@ async function runCreate(ctx, opts) {
2112
2114
  const meshCompanyFlag = opts.meshCompany?.trim();
2113
2115
  const project = opts.project?.trim();
2114
2116
  const story = opts.story?.trim();
2117
+ const noStoryReason = opts.noStory?.trim();
2115
2118
  const additionalStories = (opts.additionalStory ?? []).map((value) => value.trim());
2116
2119
  const briefFile = opts.briefFile?.trim();
2117
2120
  if (!project) {
2118
2121
  throw new LanesError("missing_project", "--project is required", {});
2119
2122
  }
2120
- if (!story) {
2123
+ if (opts.story !== undefined && opts.noStory !== undefined) {
2124
+ throw new LanesError("story_selection_conflict", "Pass either --story or --no-story, not both", {});
2125
+ }
2126
+ if (opts.noStory !== undefined && !noStoryReason) {
2127
+ throw new LanesError("missing_no_story_reason", "--no-story requires a non-empty reason", {});
2128
+ }
2129
+ if (!story && !noStoryReason) {
2121
2130
  throw new LanesError("missing_story", "--story is required", {});
2122
2131
  }
2132
+ if (noStoryReason && additionalStories.length > 0) {
2133
+ throw new LanesError("story_selection_conflict", "--additional-story requires --story and cannot be used with --no-story", {});
2134
+ }
2123
2135
  if (additionalStories.some((value) => value.length === 0)) {
2124
2136
  throw new LanesError("missing_story", "--additional-story values must be non-empty", {});
2125
2137
  }
2126
- const storyIds = [story, ...additionalStories];
2138
+ const storyIds = story ? [story, ...additionalStories] : [];
2127
2139
  if (new Set(storyIds).size !== storyIds.length) {
2128
2140
  throw new LanesError("duplicate_story", "A lane cannot carry the same project/story more than once", {
2129
2141
  project_id: project,
@@ -2174,6 +2186,9 @@ async function runCreate(ctx, opts) {
2174
2186
  throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
2175
2187
  }
2176
2188
  // A no-company lane must not call company resolution (openMeshTransport).
2189
+ if (storyIds.length > 0 && ctx.storyLedgerEnabled()) {
2190
+ validateProjectStories(hqRoot, project, storyIds, company?.slug);
2191
+ }
2177
2192
  let meshCompany = company;
2178
2193
  if (meshCompanyFlag) {
2179
2194
  const resolvedMeshCompany = await ctx.resolveCompany(meshCompanyFlag);
@@ -2189,7 +2204,7 @@ async function runCreate(ctx, opts) {
2189
2204
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
2190
2205
  assertSameTenant(company, senior, seniorCompany);
2191
2206
  const createStoryWarnings = [];
2192
- if (meshCompany) {
2207
+ if (meshCompany && storyIds.length > 0) {
2193
2208
  const warning = await storyBoardWarning(ctx, meshCompany, project, storyIds);
2194
2209
  if (warning !== undefined)
2195
2210
  createStoryWarnings.push(warning);
@@ -2630,7 +2645,8 @@ async function runCreate(ctx, opts) {
2630
2645
  ? { mesh_company: meshCompany }
2631
2646
  : {}),
2632
2647
  project_id: project,
2633
- story_id: story,
2648
+ story_id: story ?? "",
2649
+ ...(noStoryReason ? { no_story_reason: noStoryReason } : {}),
2634
2650
  stories: storyIds.map((storyId) => ({
2635
2651
  project_id: project,
2636
2652
  story_id: storyId,
@@ -3416,6 +3432,9 @@ async function runPickup(ctx, opts) {
3416
3432
  });
3417
3433
  }
3418
3434
  async function runList(ctx, opts) {
3435
+ if (opts.json && opts.human) {
3436
+ throw new LanesError("conflicting_output_format", "Use either --json or --human for hq lanes list", { json: true, human: true });
3437
+ }
3419
3438
  const hqRoot = ctx.hqRoot();
3420
3439
  const state = opts.state ? parseLaneState(opts.state) : undefined;
3421
3440
  const senior = opts.senior ? parseSeniorRef(opts.senior) : undefined;
@@ -3430,9 +3449,11 @@ async function runList(ctx, opts) {
3430
3449
  .filter((lane) => {
3431
3450
  if (state && lane.state !== state)
3432
3451
  return false;
3433
- if ((opts.project || opts.story) &&
3434
- !laneStories(lane).some((story) => (!opts.project || story.project_id === opts.project) &&
3435
- (!opts.story || story.story_id === opts.story))) {
3452
+ const carriesFilter = laneStories(lane).some((story) => (!opts.project || story.project_id === opts.project) &&
3453
+ (!opts.story || story.story_id === opts.story));
3454
+ const matchingNoStory = !opts.story && opts.project !== undefined &&
3455
+ lane.no_story_reason !== undefined && lane.project_id === opts.project;
3456
+ if ((opts.project || opts.story) && !carriesFilter && !matchingNoStory) {
3436
3457
  return false;
3437
3458
  }
3438
3459
  if (company && !recordMatchesCompany(lane, company)) {
@@ -3465,6 +3486,9 @@ async function runList(ctx, opts) {
3465
3486
  return {
3466
3487
  ...lane,
3467
3488
  ...hireByLane.get(lane.lane_id),
3489
+ stories: laneStories(lane),
3490
+ story_ids: laneStories(lane).map((story) => story.story_id),
3491
+ no_story_reason: lane.no_story_reason ?? null,
3468
3492
  ...(remoteQueued
3469
3493
  ? { state: "remote", remote_state: lane.state, queued_reason: undefined }
3470
3494
  : {}),
@@ -3486,12 +3510,74 @@ async function runList(ctx, opts) {
3486
3510
  const host_capacity = ctx.lanePlacementEnabled()
3487
3511
  ? (await loadHostCapacitySnapshots(ctx, hqRoot)).entries
3488
3512
  : undefined;
3489
- printJson({
3513
+ const result = {
3490
3514
  ok: true,
3491
3515
  action: "list",
3492
3516
  lanes,
3493
3517
  ...(host_capacity !== undefined ? { host_capacity } : {}),
3518
+ };
3519
+ if (opts.human)
3520
+ console.log(formatLaneListHuman(lanes));
3521
+ else
3522
+ printJson(result);
3523
+ }
3524
+ function formatLaneListHuman(lanes) {
3525
+ if (lanes.length === 0)
3526
+ return "No lanes.";
3527
+ return lanes.map((lane) => {
3528
+ const ids = lane.story_ids;
3529
+ const storyLabel = ids.length > 0
3530
+ ? "stories: " + ids.join(", ")
3531
+ : "no story: " + (lane.no_story_reason ?? "reason not recorded");
3532
+ return lane.lane_id + " " + lane.state + " " + lane.provider + " " + lane.project_id +
3533
+ " " + storyLabel;
3534
+ }).join("\n");
3535
+ }
3536
+ async function runStatus(ctx, opts) {
3537
+ if (!ctx.storyLedgerEnabled()) {
3538
+ throw new LanesError("story_ledger_disabled", "hq lanes status requires the lanes.story-ledger flag", {
3539
+ flag: "lanes.story-ledger",
3540
+ });
3541
+ }
3542
+ const hqRoot = ctx.hqRoot();
3543
+ const company = opts.company ? await ctx.resolveCompany(opts.company) : undefined;
3544
+ const lanes = loadAllLanes(hqRoot).filter((lane) => lane.company?.slug === company?.slug);
3545
+ const pending = new Map();
3546
+ for (const lane of lanes) {
3547
+ const refs = [];
3548
+ for (const questionId of lane.pending_question_ids ?? []) {
3549
+ const question = readQuestion(hqRoot, questionId);
3550
+ const sameCompany = question.company === null
3551
+ ? lane.company === null
3552
+ : lane.company !== null &&
3553
+ question.company.uid === lane.company.uid &&
3554
+ question.company.slug === lane.company.slug;
3555
+ if (question.lane_id !== lane.lane_id || !sameCompany) {
3556
+ throw new LanesError("story_ledger_question_mismatch", `Pending question ${questionId} does not belong to lane ${lane.lane_id}`, { lane_id: lane.lane_id, question_id: questionId, question_lane_id: question.lane_id });
3557
+ }
3558
+ if (question.status === "pending") {
3559
+ refs.push({
3560
+ question_id: question.question_id,
3561
+ project_id: question.project_id,
3562
+ story_id: question.story_id,
3563
+ });
3564
+ }
3565
+ }
3566
+ pending.set(lane.lane_id, refs);
3567
+ }
3568
+ const snapshot = buildLaneStatusSnapshot({
3569
+ hqRoot,
3570
+ projectId: opts.project,
3571
+ ...(company ? { companySlug: company.slug } : {}),
3572
+ ...(company ? { companyUid: company.uid } : {}),
3573
+ lanes,
3574
+ generatedAt: ctx.now().toISOString(),
3575
+ pendingQuestions: pending,
3494
3576
  });
3577
+ if (opts.json)
3578
+ printJson(snapshot);
3579
+ else
3580
+ console.log(formatLaneStatusHuman(snapshot));
3495
3581
  }
3496
3582
  async function runCapacity(ctx) {
3497
3583
  const hqRoot = ctx.hqRoot();
@@ -4736,6 +4822,12 @@ async function runMessage(ctx, laneId, opts) {
4736
4822
  const at = ctx.now().toISOString();
4737
4823
  const projectId = opts.project?.trim() || lane.project_id;
4738
4824
  const storyId = opts.story?.trim() || lane.story_id;
4825
+ if (opts.story !== undefined && !opts.story.trim()) {
4826
+ throw new LanesError("missing_story", "--story must be non-empty", {});
4827
+ }
4828
+ if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
4829
+ validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
4830
+ }
4739
4831
  const dropboxMessage = {
4740
4832
  at,
4741
4833
  message_id: messageId,
@@ -4889,6 +4981,7 @@ async function runMessage(ctx, laneId, opts) {
4889
4981
  ...current,
4890
4982
  project_id: projectId,
4891
4983
  story_id: storyId,
4984
+ no_story_reason: storyId ? undefined : current.no_story_reason,
4892
4985
  stories: laneStory(current, projectId, storyId)
4893
4986
  ? laneStories(current)
4894
4987
  : [
@@ -5190,6 +5283,12 @@ async function runResume(ctx, laneId, opts) {
5190
5283
  const at = ctx.now().toISOString();
5191
5284
  const projectId = opts.project?.trim() || lane.project_id;
5192
5285
  const storyId = opts.story?.trim() || lane.story_id;
5286
+ if (opts.story !== undefined && !opts.story.trim()) {
5287
+ throw new LanesError("missing_story", "--story must be non-empty", {});
5288
+ }
5289
+ if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
5290
+ validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
5291
+ }
5193
5292
  const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
5194
5293
  const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
5195
5294
  let requestedMeshCompany;
@@ -5370,6 +5469,7 @@ async function runResume(ctx, laneId, opts) {
5370
5469
  ...current,
5371
5470
  project_id: projectId,
5372
5471
  story_id: storyId,
5472
+ no_story_reason: storyId ? undefined : current.no_story_reason,
5373
5473
  stories: laneStory(current, projectId, storyId)
5374
5474
  ? laneStories(current)
5375
5475
  : [
@@ -5460,8 +5560,7 @@ function addCarriedStory(lane, projectId, storyId, at) {
5460
5560
  }
5461
5561
  return {
5462
5562
  ...lane,
5463
- project_id: projectId,
5464
- story_id: storyId,
5563
+ ...(lane.story_id.trim() ? {} : { project_id: projectId, story_id: storyId }),
5465
5564
  stories: [
5466
5565
  ...laneStories(lane),
5467
5566
  { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
@@ -5521,7 +5620,7 @@ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status,
5521
5620
  const company = lane.mesh_company ?? lane.company;
5522
5621
  if (!company)
5523
5622
  return;
5524
- const ready = await ensureStoryThread(ctx, lane, projectId);
5623
+ const ready = await ensureStoryThread(ctx, lane, projectId, storyId);
5525
5624
  if (!ready.thread_id)
5526
5625
  return;
5527
5626
  await emitLaneMeshEvent(ctx, company, ready.thread_id, event, {
@@ -5593,6 +5692,9 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
5593
5692
  const normalizedStoryId = storyId.trim();
5594
5693
  if (!normalizedStoryId)
5595
5694
  throw new LanesError("missing_story", "<story-id> is required", {});
5695
+ if (ctx.storyLedgerEnabled()) {
5696
+ validateProjectStories(hqRoot, projectId, [normalizedStoryId], lane.company?.slug);
5697
+ }
5596
5698
  const company = lane.mesh_company ?? lane.company;
5597
5699
  const warnings = [];
5598
5700
  if (company) {
@@ -5608,7 +5710,11 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
5608
5710
  const before = lane;
5609
5711
  const next = casUpdateLane(hqRoot, laneId, (current) => {
5610
5712
  assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
5611
- return addCarriedStory(current, projectId, normalizedStoryId, at);
5713
+ const added = addCarriedStory(current, projectId, normalizedStoryId, at);
5714
+ return {
5715
+ ...added,
5716
+ no_story_reason: undefined,
5717
+ };
5612
5718
  });
5613
5719
  const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
5614
5720
  if (transitionWarning !== undefined)
@@ -6953,13 +7059,16 @@ export function registerLanesCommand(parent, deps = {}) {
6953
7059
  .command("lanes", { hidden: true })
6954
7060
  .description("Create, supervise, and steer worker lanes; routed decision queue");
6955
7061
  registerOutpostPrunerCommand(lanes);
7062
+ const noStoryOption = new Option("--no-story <reason>", "Create a lane without a story and record why");
7063
+ noStoryOption.attributeName = () => "noStory";
6956
7064
  lanes
6957
7065
  .command("create")
6958
7066
  .description("Register a lane, run admission, and return immediately")
6959
7067
  .option("--company <slug|uid>", "Company slug or cloud uid; omit for a core/personal lane")
6960
7068
  .option("--mesh-company <slug|uid>", "Explicit Work Mesh company for a personal lane; membership is verified")
6961
7069
  .requiredOption("--project <slug>", "HQ project slug")
6962
- .requiredOption("--story <id>", "Story id from the project's prd.json")
7070
+ .option("--story <id>", "Story id from the project's prd.json")
7071
+ .addOption(noStoryOption)
6963
7072
  .option("--additional-story <id>", "Additional story carried by this lane; repeatable and discouraged when active", collectRepeatable)
6964
7073
  .requiredOption("--brief-file <path>", "Path to the lane brief")
6965
7074
  .option("--worker <name>", "HQ worker or reusable profile id")
@@ -7009,10 +7118,20 @@ export function registerLanesCommand(parent, deps = {}) {
7009
7118
  .option("--story <id>", "Filter by story")
7010
7119
  .option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
7011
7120
  .option("--senior <ref>", "Filter by senior ref")
7012
- .option("--json", "JSON on stdout (always)")
7121
+ .option("--json", "Emit JSON on stdout (default)")
7122
+ .option("--human", "Emit human-readable output")
7013
7123
  .action(wrap(async (opts) => {
7014
7124
  await runList(ctx, opts);
7015
7125
  }));
7126
+ lanes
7127
+ .command("status")
7128
+ .description("Summarize project stories, lanes, PRs, blockers, and pending questions")
7129
+ .requiredOption("--project <slug>", "HQ project slug")
7130
+ .option("--company <slug|uid>", "Company project scope; omit for a personal project")
7131
+ .option("--json", "Emit JSON on stdout")
7132
+ .action(wrap(async (opts) => {
7133
+ await runStatus(ctx, opts);
7134
+ }));
7016
7135
  lanes
7017
7136
  .command("capacity")
7018
7137
  .description("Report this host's lane capacity by provider and account")
@@ -7,6 +7,22 @@ function assertState(state, bundle, companyUid, operationId) {
7
7
  state.phase !== "preparing")
8
8
  throw new Error("Promotion preparation returned a different identity or phase");
9
9
  }
10
+ function hasValidEncryptionHeaders(headers) {
11
+ const valuesFor = (name) => Object.entries(headers)
12
+ .filter(([key]) => key.toLowerCase() === name)
13
+ .map(([, value]) => value);
14
+ const encryptionValues = valuesFor("x-amz-server-side-encryption");
15
+ const keyIdValues = valuesFor("x-amz-server-side-encryption-aws-kms-key-id");
16
+ // Multiple differently-cased copies are ambiguous, even when their values
17
+ // happen to match. Accept one exact SSE-KMS pair or no effective SSE values.
18
+ if (encryptionValues.length > 1 || keyIdValues.length > 1)
19
+ return false;
20
+ const encryption = encryptionValues[0] ?? "";
21
+ const keyId = keyIdValues[0] ?? "";
22
+ if (!encryption.trim() && !keyId.trim())
23
+ return true;
24
+ return encryption === "aws:kms" && Boolean(keyId.trim());
25
+ }
10
26
  /** Only the presigned object request receives these bytes. Owner authorization
11
27
  * is handled by BotApi and is never forwarded to S3 or a redirect destination.
12
28
  */
@@ -15,8 +31,7 @@ async function uploadObject(upload, bytes, fetchImpl) {
15
31
  if (url.protocol !== "https:" || url.username || url.password || url.port ||
16
32
  !/^[a-z0-9.-]+\.s3(?:[.-][a-z0-9-]+)?\.amazonaws\.com$/.test(url.hostname) ||
17
33
  upload.method !== "PUT" || upload.bytes !== bytes.length || upload.sha256 !== sha(bytes) ||
18
- upload.headers["x-amz-server-side-encryption"] !== "aws:kms" ||
19
- !upload.headers["x-amz-server-side-encryption-aws-kms-key-id"] ||
34
+ !hasValidEncryptionHeaders(upload.headers) ||
20
35
  upload.headers["if-none-match"] !== "*" ||
21
36
  upload.headers["x-amz-checksum-sha256"] !== Buffer.from(upload.sha256, "hex").toString("base64") ||
22
37
  Object.keys(upload.headers).some(key => /^(authorization|cookie)$/i.test(key))) {
@@ -1,6 +1,8 @@
1
1
  import { type EmbedLockDependencies } from "../search-index/embed-lock.js";
2
2
  import { type LoadGateDependencies } from "../search-index/load-gate.js";
3
3
  import { type UtilityIo } from "./common.js";
4
+ export declare const QMD_REINDEX_STARTUP_WARNING_FLAG = "qmd.reindex-startup-warning";
5
+ export declare const QMD_REINDEX_STARTUP_WARNING_FLAG_FALLBACK = false;
4
6
  export type QmdReindexOptions = UtilityIo & {
5
7
  cwd?: string;
6
8
  /** Test seams; default to the real search-index implementations. */
@@ -17,6 +19,8 @@ export type QmdReindexOptions = UtilityIo & {
17
19
  embedLockDependencies?: EmbedLockDependencies;
18
20
  /** Test seams for the host load gate consulted while the embed lock is held. */
19
21
  loadGateDependencies?: LoadGateDependencies;
22
+ /** Test seam for the hq-flags-gated warning on qmd native startup failures. */
23
+ startupFailureWarningsEnabled?: () => boolean;
20
24
  };
21
25
  export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
22
26
  //# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
@@ -15,7 +15,14 @@ import { reconcileCollections, resolveQmdBin, resolveQmdHome, runQmd } from "../
15
15
  import { applyIndexSizeLimit } from "../search-index/max-doc-bytes.js";
16
16
  import { defaultEmbedLockDependencies, runWithEmbedLock, } from "../search-index/embed-lock.js";
17
17
  import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from "../search-index/load-gate.js";
18
+ import { resolveProcessFlagGate } from "../flag-registry.js";
19
+ import { isQmdNativeBindingError, QMD_NATIVE_BINDING_REMEDY, } from "../../utils/qmd-native-binding-error.js";
18
20
  import { ioFor, line } from "./common.js";
21
+ export const QMD_REINDEX_STARTUP_WARNING_FLAG = "qmd.reindex-startup-warning";
22
+ export const QMD_REINDEX_STARTUP_WARNING_FLAG_FALLBACK = false;
23
+ function qmdStartupFailureWarningsEnabled() {
24
+ return resolveProcessFlagGate(QMD_REINDEX_STARTUP_WARNING_FLAG, { fallback: QMD_REINDEX_STARTUP_WARNING_FLAG_FALLBACK }, () => QMD_REINDEX_STARTUP_WARNING_FLAG_FALLBACK);
25
+ }
19
26
  export function qmdReindexAfterSync(args = [], options = {}) {
20
27
  let hqRoot = "";
21
28
  let embed = false;
@@ -42,6 +49,15 @@ export function qmdReindexAfterSync(args = [], options = {}) {
42
49
  const run = options.run ?? ((argv, opts) => runQmd(argv, opts));
43
50
  const sizeLimit = options.sizeLimit ?? ((root) => applyIndexSizeLimit(root));
44
51
  const { stderr } = ioFor(options);
52
+ let startupFailureWarningShown = false;
53
+ const warnOnNativeStartupFailure = (error) => {
54
+ if (startupFailureWarningShown ||
55
+ !isQmdNativeBindingError(error) ||
56
+ !(options.startupFailureWarningsEnabled ?? qmdStartupFailureWarningsEnabled)())
57
+ return;
58
+ startupFailureWarningShown = true;
59
+ line(stderr, `hq: search index was not updated after sync; ${QMD_NATIVE_BINDING_REMEDY}`);
60
+ };
45
61
  // Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
46
62
  try {
47
63
  reconcile(hqRoot, { bin });
@@ -62,7 +78,8 @@ export function qmdReindexAfterSync(args = [], options = {}) {
62
78
  try {
63
79
  run(["update"], { bin, cwd: hqRoot });
64
80
  }
65
- catch {
81
+ catch (error) {
82
+ warnOnNativeStartupFailure(error);
66
83
  /* never fail a sync on index freshness */
67
84
  }
68
85
  if (embed) {
@@ -91,6 +108,7 @@ export function qmdReindexAfterSync(args = [], options = {}) {
91
108
  catch (error) {
92
109
  if (error instanceof LoadGateConfigurationError)
93
110
  line(stderr, `hq: ${error.message}`);
111
+ warnOnNativeStartupFailure(error);
94
112
  /* embeddings are deferred-cost and optional */
95
113
  }
96
114
  }
@@ -16,6 +16,8 @@ export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFT
16
16
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
17
17
  export { resolveLanesHqRoot } from "./hq-root.js";
18
18
  export type { LanesHqRootOpts } from "./hq-root.js";
19
+ export { LANE_STORY_LEDGER_FLAG, buildLaneStatusSnapshot, formatLaneStatusHuman, laneStoryLedgerEnabled, resolveProjectStoryLedger, syncLaneToProjectStoryLedger, validateProjectStories, } from "./story-ledger.js";
20
+ export type { LaneStatusSnapshot, ProjectStoryLedger, StoryLedgerWriteDeps } from "./story-ledger.js";
19
21
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
20
22
  export type { OutpostDispatchResult, OutpostPickupPayload, } from "./outpost-dispatch.js";
21
23
  export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
@@ -10,6 +10,7 @@ export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HIS
10
10
  export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
11
11
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
12
12
  export { resolveLanesHqRoot } from "./hq-root.js";
13
+ export { LANE_STORY_LEDGER_FLAG, buildLaneStatusSnapshot, formatLaneStatusHuman, laneStoryLedgerEnabled, resolveProjectStoryLedger, syncLaneToProjectStoryLedger, validateProjectStories, } from "./story-ledger.js";
13
14
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
14
15
  export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
15
16
  export { DEFAULT_LANE_WATCH_INTERVAL_MS, MAX_LANE_WATCH_INTERVAL_MS, MAX_WATCHED_LANES, MAX_WATCH_OUTPUT_LINES, runLaneWatch, } from "./watch.js";