@indigoai-us/hq-cli 5.274.1 → 5.276.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 (77) hide show
  1. package/CHANGELOG.md +88 -4
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
  5. package/dist/command-catalog.generated.d.ts +34 -1
  6. package/dist/command-catalog.generated.js +43 -1
  7. package/dist/commands/cloud.js +22 -9
  8. package/dist/commands/company.js +66 -3
  9. package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
  10. package/dist/commands/core-checkpoint-gate-setting.js +163 -0
  11. package/dist/commands/core-checkpoint.d.ts +4 -2
  12. package/dist/commands/core-checkpoint.js +34 -3
  13. package/dist/commands/core-command-catalog.d.ts +4 -2
  14. package/dist/commands/core-command-catalog.js +5 -2
  15. package/dist/commands/core-ontology.js +3 -3
  16. package/dist/commands/core.js +3 -1
  17. package/dist/commands/daemon.d.ts +1 -0
  18. package/dist/commands/daemon.js +9 -3
  19. package/dist/commands/feedback.d.ts +6 -0
  20. package/dist/commands/feedback.js +86 -37
  21. package/dist/commands/lanes.js +33 -11
  22. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  23. package/dist/commands/run-feature-telemetry.js +6 -0
  24. package/dist/commands/run.js +3 -2
  25. package/dist/commands/secrets.js +7 -1
  26. package/dist/commands/whoami.d.ts +4 -6
  27. package/dist/commands/whoami.js +39 -8
  28. package/dist/fast-core.d.ts +6 -7
  29. package/dist/fast-core.js +22 -8
  30. package/dist/fast-scaffold.d.ts +16 -7
  31. package/dist/fast-scaffold.js +13 -8
  32. package/dist/index.js +16 -1
  33. package/dist/lib/billing/plan-lock.d.ts +3 -1
  34. package/dist/lib/billing/plan-lock.js +7 -1
  35. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  36. package/dist/lib/checkpoint-ontology/company.js +15 -7
  37. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  38. package/dist/lib/checkpoint-ontology/plan.js +53 -4
  39. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  40. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  41. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  42. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  43. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  44. package/dist/lib/core-utils/timeout-guard.js +28 -13
  45. package/dist/lib/daemon/catalog.d.ts +7 -0
  46. package/dist/lib/daemon/catalog.js +6 -0
  47. package/dist/lib/daemon/flag.d.ts +23 -0
  48. package/dist/lib/daemon/flag.js +57 -0
  49. package/dist/lib/daemon/install.js +6 -1
  50. package/dist/lib/daemon/remote-control.d.ts +6 -0
  51. package/dist/lib/daemon/remote-control.js +12 -0
  52. package/dist/lib/daemon/run.js +115 -2
  53. package/dist/lib/daemon/succession.d.ts +116 -0
  54. package/dist/lib/daemon/succession.js +262 -0
  55. package/dist/lib/daemon/supervisor.d.ts +34 -1
  56. package/dist/lib/daemon/supervisor.js +190 -23
  57. package/dist/lib/flag-registry.d.ts +12 -7
  58. package/dist/lib/flag-registry.js +66 -75
  59. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  60. package/dist/lib/flag-snapshot-file.js +91 -0
  61. package/dist/lib/lanes/host-capacity.js +7 -1
  62. package/dist/lib/lanes/models.d.ts +2 -0
  63. package/dist/lib/lanes/models.js +6 -1
  64. package/dist/lib/lanes/reconcile.js +20 -4
  65. package/dist/lib/process-flag-company-context.d.ts +8 -0
  66. package/dist/lib/process-flag-company-context.js +67 -0
  67. package/dist/main.js +3 -1
  68. package/dist/native-fast-route.d.ts +3 -0
  69. package/dist/native-fast-route.js +39 -0
  70. package/dist/utils/cli-telemetry.d.ts +26 -2
  71. package/dist/utils/cli-telemetry.js +178 -3
  72. package/dist/utils/hq-root-resolver.d.ts +13 -0
  73. package/dist/utils/hq-root-resolver.js +103 -0
  74. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  75. package/dist/utils/vault-api.d.ts +8 -0
  76. package/dist/utils/vault-api.js +13 -1
  77. package/package.json +1 -1
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `hq core checkpoint-gate-setting` — keeps the local cache that lets a
3
+ * company owner turn the checkpoint Stop gate off for their company (CO-58).
4
+ *
5
+ * The owner sets `companySettings.checkpointGateEnabled` on hq-pro
6
+ * (`hq company settings set --checkpoint-gate-enabled false`, or the console
7
+ * Settings page). The Stop gate script never calls the network: it reads
8
+ * `~/.hq/cache/checkpoint-gate/<slug>.json` and, when that is missing or older
9
+ * than ten minutes, starts this command detached with `--refresh`.
10
+ *
11
+ * `--refresh` reads the setting through the member-readable
12
+ * `GET /company-settings/checkpoint-gate` route and writes the cache only while
13
+ * the hq-flag `checkpoint.gate.company-setting` is on for the company. With the
14
+ * flag off it removes the cache, so the gate keeps today's behaviour. Any
15
+ * failure (no login, API unreachable, timeout) leaves the cache as it was: the
16
+ * gate then uses the last known value, or today's behaviour when there is none.
17
+ * Only the named company's setting is ever read.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
23
+ import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
24
+ import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
25
+ import { describeCoreCommand } from "./core-command-catalog.js";
26
+ export const CHECKPOINT_GATE_SETTING_FLAG = "checkpoint.gate.company-setting";
27
+ /** The refresh gives up after this long; the gate keeps the cached value. */
28
+ export const CHECKPOINT_GATE_REFRESH_TIMEOUT_MS = 3_000;
29
+ /** Same character set the gate script accepts; anything else is refused. */
30
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
31
+ export function assertCompanySlug(slug) {
32
+ if (!SLUG_RE.test(slug) || slug === "personal") {
33
+ throw new Error(`checkpoint-gate-setting: '${slug}' is not a company slug`);
34
+ }
35
+ }
36
+ export function checkpointGateCacheDir(home = os.homedir()) {
37
+ return path.join(home, ".hq", "cache", "checkpoint-gate");
38
+ }
39
+ export function checkpointGateCachePath(slug, home = os.homedir()) {
40
+ return path.join(checkpointGateCacheDir(home), `${slug}.json`);
41
+ }
42
+ /**
43
+ * `readLanesRegistryFlag` resolves a failed registry read to `false`. The
44
+ * refresh must not treat that as "flag off" and delete a valid cache, so a
45
+ * reported failure becomes null (unknown).
46
+ */
47
+ export async function readCheckpointGateSettingFlag(slug, companyUid) {
48
+ let failed = false;
49
+ const enabled = await readLanesRegistryFlag(CHECKPOINT_GATE_SETTING_FLAG, {
50
+ company: { slug, uid: companyUid },
51
+ reportFailure: () => {
52
+ failed = true;
53
+ },
54
+ });
55
+ return failed ? null : enabled;
56
+ }
57
+ async function defaultFetchSetting(token, companyUid, signal) {
58
+ const res = await vaultApiFetch({
59
+ token,
60
+ path: "/company-settings/checkpoint-gate",
61
+ query: { companyUid },
62
+ signal,
63
+ });
64
+ if (!res.ok)
65
+ throw new Error(`GET /company-settings/checkpoint-gate returned ${res.status}`);
66
+ const body = (await res.json());
67
+ if (typeof body.checkpointGateEnabled !== "boolean") {
68
+ throw new Error("GET /company-settings/checkpoint-gate returned no boolean checkpointGateEnabled");
69
+ }
70
+ return body.checkpointGateEnabled;
71
+ }
72
+ function writeCacheAtomic(file, entry) {
73
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
74
+ const tmp = `${file}.${process.pid}.tmp`;
75
+ fs.writeFileSync(tmp, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
76
+ fs.renameSync(tmp, file);
77
+ }
78
+ function withTimeout(work, ms) {
79
+ const controller = new AbortController();
80
+ let timer;
81
+ const timeout = new Promise((_, reject) => {
82
+ timer = setTimeout(() => {
83
+ controller.abort();
84
+ reject(new Error(`timed out after ${ms} ms`));
85
+ }, ms);
86
+ });
87
+ return Promise.race([work(controller.signal), timeout]).finally(() => clearTimeout(timer));
88
+ }
89
+ /**
90
+ * Refresh one company's cache entry. Never throws for a network or auth
91
+ * failure: those return `kept` and leave the cache untouched.
92
+ */
93
+ export async function refreshCheckpointGateSetting(slug, deps = {}) {
94
+ assertCompanySlug(slug);
95
+ const file = checkpointGateCachePath(slug, deps.home);
96
+ // Never interactive: a detached refresh must not open a browser login.
97
+ const getToken = deps.getToken ?? (() => ensureCognitoToken({ interactive: false }));
98
+ const resolveCompanyUid = deps.resolveCompanyUid ?? ((token, companySlug) => getEntityUid(token, { companySlug }));
99
+ const fetchSetting = deps.fetchSetting ?? defaultFetchSetting;
100
+ const readFlag = deps.readFlag ?? readCheckpointGateSettingFlag;
101
+ // One timeout covers every network step, the flag read included.
102
+ let fetched;
103
+ try {
104
+ fetched = await withTimeout(async (signal) => {
105
+ const token = await getToken();
106
+ const uid = await resolveCompanyUid(token, slug);
107
+ const enabled = await fetchSetting(token, uid, signal);
108
+ return { enabled, flag: await readFlag(slug, uid) };
109
+ }, deps.timeoutMs ?? CHECKPOINT_GATE_REFRESH_TIMEOUT_MS);
110
+ }
111
+ catch (error) {
112
+ return { status: "kept", company: slug, reason: error instanceof Error ? error.message : String(error) };
113
+ }
114
+ if (fetched.flag === null) {
115
+ return { status: "kept", company: slug, reason: `could not read ${CHECKPOINT_GATE_SETTING_FLAG}` };
116
+ }
117
+ if (!fetched.flag) {
118
+ fs.rmSync(file, { force: true });
119
+ return { status: "removed", company: slug, reason: "flag_off" };
120
+ }
121
+ const now = (deps.now?.() ?? new Date()).getTime();
122
+ writeCacheAtomic(file, { company: slug, checkpointGateEnabled: fetched.enabled, fetched_at: Math.floor(now / 1000) });
123
+ return { status: "written", company: slug, checkpointGateEnabled: fetched.enabled };
124
+ }
125
+ export function describeRefreshOutcome(outcome) {
126
+ switch (outcome.status) {
127
+ case "written":
128
+ return `checkpoint gate for ${outcome.company}: ${outcome.checkpointGateEnabled ? "on" : "off"} (cached)`;
129
+ case "removed":
130
+ return `checkpoint gate for ${outcome.company}: company setting not in use (${CHECKPOINT_GATE_SETTING_FLAG} is off); cache cleared`;
131
+ case "kept":
132
+ return `checkpoint gate for ${outcome.company}: could not refresh (${outcome.reason}); cache unchanged`;
133
+ }
134
+ }
135
+ export function registerCoreCheckpointGateSettingCommand(core) {
136
+ const command = core
137
+ .command("checkpoint-gate-setting")
138
+ .description("Refresh the local cache of a company's checkpoint gate setting (used by the Stop gate)")
139
+ .requiredOption("--company <slug>", "company whose setting to read")
140
+ .option("--refresh", "read the setting from HQ and update the local cache")
141
+ .option("--json", "print the result as JSON")
142
+ .action(async (options) => {
143
+ try {
144
+ assertCompanySlug(options.company);
145
+ if (!options.refresh) {
146
+ const file = checkpointGateCachePath(options.company);
147
+ const cached = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
148
+ process.stdout.write(`${options.json ? JSON.stringify({ company: options.company, cached }) : cached ? `checkpoint gate for ${options.company}: ${cached.checkpointGateEnabled ? "on" : "off"} (cached at ${new Date(cached.fetched_at * 1000).toISOString()})` : `checkpoint gate for ${options.company}: no cached setting`}\n`);
149
+ return;
150
+ }
151
+ const outcome = await refreshCheckpointGateSetting(options.company);
152
+ process.stdout.write(`${options.json ? JSON.stringify(outcome) : describeRefreshOutcome(outcome)}\n`);
153
+ // A timed-out request can keep sockets open; the answer is already out.
154
+ process.exit(0);
155
+ }
156
+ catch (error) {
157
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
158
+ process.exit(1);
159
+ }
160
+ });
161
+ describeCoreCommand(command, { execution: "native", root: "cwd" });
162
+ }
163
+ //# sourceMappingURL=core-checkpoint-gate-setting.js.map
@@ -45,8 +45,10 @@ export declare const SIBLING_PROMPT_TEMPLATE = "You are the HQ checkpoint siblin
45
45
  * Rendered into the sibling prompt only when the payload names a company. The
46
46
  * flag check happens inside `hq core ontology plan`, in the sibling, because
47
47
  * the checkpoint parent performs no network I/O. Only this run's own payload
48
- * gets the step; claimed queue payloads are covered by the next run, since the
49
- * plan works from file hashes rather than from the payload's file list.
48
+ * gets the step. A queued payload's company files are recorded as queued
49
+ * touches when it is queued (CO-59), and a later plan for that company takes
50
+ * them while checkpoint.ontology.queued-touches is on; a run with no company
51
+ * of its own gets the step for the company whose touches waited longest.
50
52
  */
51
53
  export declare const SIBLING_ONTOLOGY_STEP = "5b. ONTOLOGY (this run's own payload only; skip it for claimed queue payloads).\n Run: hq core ontology plan --run-dir \"<runDir>\" --hq-root \"<hqRoot>\"\n If it prints \"ontology: skipped\", do nothing more for this step. Otherwise\n read <runDir>/ontology-plan.json, follow its \"instructions\" exactly, and\n write <runDir>/ontology-candidates.json. Read only the files and line\n ranges the plan lists. Then run:\n hq core ontology apply --run-dir \"<runDir>\" --hq-root \"<hqRoot>\"\n Never write under the company's ontology/ folder yourself, and never edit\n payload.json or ontology-plan.json. Copy the command's one-line output into\n the report. If either command fails, record the error and continue.\n";
52
54
  export declare function renderSiblingPrompt(runDir: string, payloadPath: string, options?: {
@@ -17,6 +17,7 @@ import { toHqStateWriteError } from "../utils/hq-state-write-error.js";
17
17
  import { checkpointStampWriteErrorFor, isCheckpointStampWriteError, } from "../utils/checkpoint-stamp-write-error.js";
18
18
  import { readBackendPreference } from "../lib/checkpoint-ontology/plan.js";
19
19
  import { resolveOntologyCompany } from "../lib/checkpoint-ontology/company.js";
20
+ import { markBorrowed, oldestQueuedCompany, recordQueuedTouches } from "../lib/checkpoint-ontology/queued-touches.js";
20
21
  import { describeCoreCommand } from "./core-command-catalog.js";
21
22
  const DEFAULT_TRIGGER = "stop-gate";
22
23
  const BACKENDS = new Set(["auto", "claude", "codex", "grok", "none"]);
@@ -189,8 +190,10 @@ report instead of guessing.
189
190
  * Rendered into the sibling prompt only when the payload names a company. The
190
191
  * flag check happens inside `hq core ontology plan`, in the sibling, because
191
192
  * the checkpoint parent performs no network I/O. Only this run's own payload
192
- * gets the step; claimed queue payloads are covered by the next run, since the
193
- * plan works from file hashes rather than from the payload's file list.
193
+ * gets the step. A queued payload's company files are recorded as queued
194
+ * touches when it is queued (CO-59), and a later plan for that company takes
195
+ * them while checkpoint.ontology.queued-touches is on; a run with no company
196
+ * of its own gets the step for the company whose touches waited longest.
194
197
  */
195
198
  export const SIBLING_ONTOLOGY_STEP = `5b. ONTOLOGY (this run's own payload only; skip it for claimed queue payloads).
196
199
  Run: hq core ontology plan --run-dir "<runDir>" --hq-root "<hqRoot>"
@@ -1001,15 +1004,43 @@ function startSibling(liveRoot, input, threadPath, backend) {
1001
1004
  fs.mkdirSync(siblingRoot, { recursive: true });
1002
1005
  if (activePid !== null) {
1003
1006
  const depth = appendPending(pendingPath, payload);
1007
+ try {
1008
+ recordQueuedTouches(liveRoot, payload.company, payload.files, new Date());
1009
+ }
1010
+ catch {
1011
+ // Queued touches only help a later ontology plan; they must never fail
1012
+ // the checkpoint itself.
1013
+ }
1004
1014
  printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
1005
1015
  return;
1006
1016
  }
1007
1017
  const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
1008
1018
  fs.mkdirSync(runDir, { recursive: true });
1009
1019
  payload.pending_payloads = claimPending(pendingPath, path.join(runDir, "pending-claimed.jsonl"));
1020
+ if (!payload.company) {
1021
+ let queued;
1022
+ try {
1023
+ queued = oldestQueuedCompany(liveRoot);
1024
+ }
1025
+ catch {
1026
+ queued = null;
1027
+ }
1028
+ if (queued) {
1029
+ payload.ontology_company = queued;
1030
+ try {
1031
+ markBorrowed(liveRoot, queued, new Date());
1032
+ }
1033
+ catch {
1034
+ // A borrowed-company marker must never fail the checkpoint.
1035
+ }
1036
+ }
1037
+ }
1010
1038
  const payloadPath = path.join(runDir, "payload.json");
1011
1039
  fs.writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
1012
- const prompt = renderSiblingPrompt(runDir, payloadPath, { company: payload.company, hqRoot: liveRoot });
1040
+ const prompt = renderSiblingPrompt(runDir, payloadPath, {
1041
+ company: payload.company ?? payload.ontology_company ?? null,
1042
+ hqRoot: liveRoot,
1043
+ });
1013
1044
  fs.writeFileSync(path.join(runDir, "prompt.md"), prompt);
1014
1045
  const logPath = path.join(runDir, "run.log");
1015
1046
  const logFd = fs.openSync(logPath, "a");
@@ -22,11 +22,13 @@ export declare function describeCoreCommand(command: Command, metadata: CoreComm
22
22
  export declare function registeredCoreCommandListing(core: Command): CoreCommandListingEntry[];
23
23
  export declare function fastScaffoldListingMismatches(listing: readonly CoreCommandListingEntry[], fastScripts: readonly {
24
24
  name: string;
25
- asset: string;
25
+ kind: "shell" | "native";
26
+ asset?: string;
26
27
  }[]): string[];
27
28
  export declare function assertFastScaffoldListingParity(listing: readonly CoreCommandListingEntry[], fastScripts: readonly {
28
29
  name: string;
29
- asset: string;
30
+ kind: "shell" | "native";
31
+ asset?: string;
30
32
  }[]): void;
31
33
  export {};
32
34
  //# sourceMappingURL=core-command-catalog.d.ts.map
@@ -43,8 +43,11 @@ export function fastScaffoldListingMismatches(listing, fastScripts) {
43
43
  if (!listed) {
44
44
  mismatches.push(`${fast.name} is missing from hq core commands`);
45
45
  }
46
- else if (listed.asset !== fast.asset) {
47
- mismatches.push(`${fast.name} lists ${listed.asset ?? "no asset"}, fast path uses ${fast.asset}`);
46
+ else if (fast.kind === "shell" && listed.asset !== fast.asset) {
47
+ mismatches.push(`${fast.name} lists ${listed.asset ?? "no asset"}, fast path uses ${fast.asset ?? "no asset"}`);
48
+ }
49
+ else if (fast.kind === "native" && (listed.execution !== "native" || listed.asset)) {
50
+ mismatches.push(`${fast.name} is native in the fast path but not in the command catalog`);
48
51
  }
49
52
  }
50
53
  return mismatches;
@@ -12,7 +12,7 @@ import * as path from "node:path";
12
12
  import { resolveLiveRoot } from "../utils/hq-roots.js";
13
13
  import { describeCoreCommand } from "./core-command-catalog.js";
14
14
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
15
- import { MULTI_WRITER_FLAG, planOntologyRun, } from "../lib/checkpoint-ontology/plan.js";
15
+ import { MULTI_WRITER_FLAG, planOntologyRun, payloadOntologyCompany, } from "../lib/checkpoint-ontology/plan.js";
16
16
  import { applyOntologyRun, ApplyError } from "../lib/checkpoint-ontology/apply.js";
17
17
  import { resolveOntologyCompany, WriteBoundsError } from "../lib/checkpoint-ontology/company.js";
18
18
  import { findDuplicateGroups, summarizeMergeBack, } from "../lib/checkpoint-ontology/multi-writer.js";
@@ -85,8 +85,8 @@ export async function runOntologyPlan(hqRoot, runDirOption, deps = {}) {
85
85
  }
86
86
  export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
87
87
  const runDir = requireRunDir(runDirOption);
88
- const company = readPayload(runDir).company;
89
- if (typeof company !== "string" || company === "") {
88
+ const company = payloadOntologyCompany(readPayload(runDir)).slug;
89
+ if (company === null) {
90
90
  throw new OntologyUsageError("ontology: the run payload names no company");
91
91
  }
92
92
  const report = applyOntologyRun({ hqRoot, runDir, company, now: deps.now?.() });
@@ -31,6 +31,7 @@
31
31
  import { Option } from "commander";
32
32
  import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
33
33
  import { registerCoreOntologyCommand } from "./core-ontology.js";
34
+ import { registerCoreCheckpointGateSettingCommand } from "./core-checkpoint-gate-setting.js";
34
35
  import { resolveLiveRoot } from "../utils/hq-roots.js";
35
36
  import { interpreterFor, runBundledScript } from "../utils/run-bundled-script.js";
36
37
  import { renderIndexTarget } from "../lib/index-render/index.js";
@@ -286,6 +287,7 @@ export function registerCoreCommands(program) {
286
287
  // hosts native TypeScript plumbing when a scaffold contract needs it.
287
288
  registerCoreCheckpointCommand(core);
288
289
  registerCoreOntologyCommand(core);
290
+ registerCoreCheckpointGateSettingCommand(core);
289
291
  // These command names intentionally own native implementations. Their
290
292
  // similarly named scaffold assets are forwarders, so sending them through
291
293
  // runEntry would recurse back into this command forever.
@@ -459,7 +461,7 @@ export function registerCoreCommands(program) {
459
461
  const chunks = [];
460
462
  for await (const chunk of process.stdin)
461
463
  chunks.push(chunk);
462
- const code = timeoutGuardCommand(Buffer.concat(chunks).toString("utf8"));
464
+ const code = await timeoutGuardCommand(Buffer.concat(chunks).toString("utf8"));
463
465
  if (code !== 0)
464
466
  process.exit(code);
465
467
  });
@@ -41,6 +41,7 @@ export interface DaemonCommandDeps {
41
41
  windowsUser?: string;
42
42
  windowsHeadlessLauncher?: string;
43
43
  systemUnitDir?: string;
44
+ systemRecordPath?: string;
44
45
  }
45
46
  /**
46
47
  * The HQ folder install records: --hq-root, else the CLI's shared resolution
@@ -96,6 +96,7 @@ async function loadDefaults(over) {
96
96
  windowsUser: over.windowsUser ?? (platform === "win32" ? mesh.resolveWindowsUser(env) : ""),
97
97
  windowsHeadlessLauncher: over.windowsHeadlessLauncher ?? (platform === "win32" ? mesh.resolveWindowsHeadlessLauncher(env) : undefined),
98
98
  systemUnitDir: over.systemUnitDir ?? SYSTEM_UNIT_DIR,
99
+ systemRecordPath: over.systemRecordPath ?? path.join(SYSTEM_ADOPTED_ROOT, "adopted.json"),
99
100
  };
100
101
  }
101
102
  function detect(d, flag, config) {
@@ -250,7 +251,7 @@ function migrateDeps(d, paths) {
250
251
  recordPath: path.join(paths.root, "adopted.json"),
251
252
  // Box system units: root-owned, outside the login user's daemon folder (which handOver chowns to that user).
252
253
  systemAdoptedDir: path.join(SYSTEM_ADOPTED_ROOT, "adopted"),
253
- systemRecordPath: path.join(SYSTEM_ADOPTED_ROOT, "adopted.json"),
254
+ systemRecordPath: d.systemRecordPath ?? path.join(SYSTEM_ADOPTED_ROOT, "adopted.json"),
254
255
  systemUnitDir: d.systemUnitDir ?? SYSTEM_UNIT_DIR,
255
256
  };
256
257
  }
@@ -489,8 +490,13 @@ function binaryFingerprint(bin) {
489
490
  }
490
491
  }
491
492
  async function runTmuxService(d, opts) {
492
- const { runRemoteControlHost } = await import("../lib/daemon/remote-control.js");
493
+ const { installFingerprint, runRemoteControlHost } = await import("../lib/daemon/remote-control.js");
493
494
  const { findOnPath } = await import("../lib/daemon/run.js");
495
+ const { resolveRunningInstall } = await import("../utils/version-gate.js");
496
+ // The daemon keeps this host running across a CLI update, so a new hq-cli
497
+ // install also counts as an update and restarts the session once it is quiet.
498
+ const cliRoot = resolveRunningInstall().packageRoot;
499
+ const watched = cliRoot ? [opts.watch, path.join(cliRoot, "package.json")] : [opts.watch];
494
500
  const hqRoot = d.env.HQ_ROOT?.trim();
495
501
  if (!hqRoot)
496
502
  throw new DaemonCommandError(`${opts.label} needs HQ_ROOT (the daemon sets it from the saved HQ folder)`);
@@ -508,7 +514,7 @@ async function runTmuxService(d, opts) {
508
514
  cwd: hqRoot,
509
515
  argv: opts.argv,
510
516
  label: opts.label,
511
- binaryFingerprint: () => binaryFingerprint(opts.watch),
517
+ binaryFingerprint: () => installFingerprint(watched, binaryFingerprint),
512
518
  now: () => Date.now(),
513
519
  run: d.run,
514
520
  pollMs: 5_000,
@@ -3,6 +3,8 @@ import { type DiagnosticsBlob } from "../utils/feedback-diagnostics.js";
3
3
  import { collectFeedbackLogs, feedbackLogsEnabled } from "../utils/feedback-logs.js";
4
4
  import { type LogBundleRef } from "../utils/feedback-log-bundle.js";
5
5
  export declare const BODY_MAX_BYTES: number;
6
+ /** Validate fields that must be valid before feedback attachments are uploaded. */
7
+ export declare function validateFeedbackContent(title: string, body: string): void;
6
8
  /**
7
9
  * Mirror of the server's `MAX_BODY_BYTES` in hq-pro
8
10
  * `src/vault-service/handlers/feedback.ts`, which rejects the WHOLE request
@@ -36,6 +38,10 @@ export interface FeedbackSubmitOptions {
36
38
  * stays a summary and this carries the rest.
37
39
  */
38
40
  logBundle?: LogBundleRef;
41
+ /** Diagnostics collected before uploads by the command path. */
42
+ diagnostics?: DiagnosticsBlob;
43
+ /** Precomputed setting so environment lookup happens before attachments upload. */
44
+ logsEnabled?: boolean;
39
45
  }
40
46
  export declare function readBodyFile(bodyFile: string, stdin?: NodeJS.ReadableStream): Promise<string>;
41
47
  /**
@@ -1,12 +1,25 @@
1
1
  import * as fs from "node:fs";
2
2
  import chalk from "chalk";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
- import { vaultApiFetch } from "../utils/vault-api.js";
4
+ import { assertVaultApiRequestSupported, vaultApiFetch, } from "../utils/vault-api.js";
5
5
  import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
6
6
  import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
7
7
  import { FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MIN_USEFUL_BYTES, collectFeedbackLogs, feedbackLogsEnabled, } from "../utils/feedback-logs.js";
8
8
  import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
9
9
  export const BODY_MAX_BYTES = 64 * 1024;
10
+ /** Validate fields that must be valid before feedback attachments are uploaded. */
11
+ export function validateFeedbackContent(title, body) {
12
+ if (title.trim().length === 0) {
13
+ throw new Error("title must not be empty. Provide a short, non-whitespace title via --title.");
14
+ }
15
+ if (body.trim().length === 0) {
16
+ throw new Error("body must not be empty. Provide at least one non-whitespace character.");
17
+ }
18
+ const bodyBytes = Buffer.byteLength(body, "utf8");
19
+ if (bodyBytes > BODY_MAX_BYTES) {
20
+ throw new Error(`Body exceeds 64 KiB limit (${bodyBytes} bytes). Reduce the body size before submitting.`);
21
+ }
22
+ }
10
23
  /**
11
24
  * Mirror of the server's `MAX_BODY_BYTES` in hq-pro
12
25
  * `src/vault-service/handlers/feedback.ts`, which rejects the WHOLE request
@@ -16,12 +29,36 @@ export const BODY_MAX_BYTES = 64 * 1024;
16
29
  * logs are budgeted against whatever headroom is left between them.
17
30
  */
18
31
  export const REQUEST_MAX_BYTES = 64 * 1024;
32
+ /** Reject JSON envelopes that hq-pro would refuse with HTTP 413. */
33
+ function validateFeedbackRequestSize(requestBody) {
34
+ const requestBytes = Buffer.byteLength(JSON.stringify(requestBody), "utf8");
35
+ if (requestBytes > REQUEST_MAX_BYTES) {
36
+ throw new Error(`Request exceeds 64 KiB limit (${requestBytes} bytes). Reduce the feedback content before submitting.`);
37
+ }
38
+ }
19
39
  /**
20
40
  * Headroom held back for the `"logs":{...}` JSON envelope itself — its keys,
21
41
  * braces, and the escaping of the tail text. The final size is re-measured
22
42
  * after attaching, so this reserve only needs to be roughly right.
23
43
  */
24
44
  const LOGS_ENVELOPE_RESERVE_BYTES = 1024;
45
+ function buildFeedbackRequestBody(opts) {
46
+ const requestBody = {
47
+ type: opts.type,
48
+ title: opts.title,
49
+ body: opts.body,
50
+ diagnostics: opts.diagnostics,
51
+ };
52
+ if (opts.company) {
53
+ requestBody.company = opts.company;
54
+ }
55
+ if (opts.screenshots && opts.screenshots.length > 0) {
56
+ requestBody.screenshots = opts.screenshots;
57
+ }
58
+ if (opts.logBundle)
59
+ requestBody.logBundle = opts.logBundle;
60
+ return requestBody;
61
+ }
25
62
  export async function readBodyFile(bodyFile, stdin) {
26
63
  if (bodyFile === "-") {
27
64
  const stream = stdin ?? process.stdin;
@@ -79,48 +116,32 @@ export function attachDebugLogs(requestBody, diagnostics, enabled, deps = {}) {
79
116
  }
80
117
  }
81
118
  export async function submitFeedback(opts) {
82
- // Validate the title locally, symmetric with the body check below. Commander's
83
- // `requiredOption("--title")` only requires the flag to be PRESENT — an empty
84
- // or whitespace-only value (`--title ""`, or a title the /hq-bug skill derived
85
- // to nothing) passes the flag check, then the server rejects it with a 400
86
- // "title (non-empty string) is required" that floods Sentry as a context-free
87
- // warning (HQ-AB). Catch it here so the caller gets a clear, actionable error
88
- // and the bad request never reaches the server.
89
- if (opts.title.trim().length === 0) {
90
- throw new Error("title must not be empty. Provide a short, non-whitespace title via --title.");
91
- }
92
- if (opts.body.trim().length === 0) {
93
- throw new Error("body must not be empty. Provide at least one non-whitespace character.");
94
- }
95
- const bodyBytes = Buffer.byteLength(opts.body, "utf8");
96
- if (bodyBytes > BODY_MAX_BYTES) {
97
- throw new Error(`Body exceeds 64 KiB limit (${bodyBytes} bytes). Reduce the body size before submitting.`);
98
- }
99
- const diagnostics = collectDiagnostics();
100
- const requestBody = {
119
+ // Keep submitFeedback safe for direct callers as well as the command path,
120
+ // which invokes the same validator before uploading any attachments.
121
+ validateFeedbackContent(opts.title, opts.body);
122
+ const diagnostics = opts.diagnostics ?? collectDiagnostics();
123
+ const requestBody = buildFeedbackRequestBody({
101
124
  type: opts.type,
102
125
  title: opts.title,
103
126
  body: opts.body,
127
+ company: opts.company,
104
128
  diagnostics,
105
- };
106
- if (opts.company) {
107
- requestBody.company = opts.company;
108
- }
109
- if (opts.screenshots && opts.screenshots.length > 0) {
110
- requestBody.screenshots = opts.screenshots;
111
- }
112
- // The bundle reference is four small fields, so unlike the inline logs below
113
- // it needs no budgeting — but it is added BEFORE attachDebugLogs so those
114
- // bytes are counted against the inline budget rather than overrunning it.
115
- if (opts.logBundle) {
116
- requestBody.logBundle = opts.logBundle;
117
- }
129
+ screenshots: opts.screenshots,
130
+ logBundle: opts.logBundle,
131
+ });
132
+ // Direct callers can supply attachment references; ensure the complete base
133
+ // envelope still fits before adding optional inline logs or making a request.
134
+ validateFeedbackRequestSize(requestBody);
118
135
  // Debug logs are attached LAST and sized against the headroom the rest of
119
136
  // the request left behind. The server 413s the whole body over 64 KiB, so a
120
137
  // fixed log budget would turn a previously-working large-body submission
121
138
  // into a hard failure. Measuring the real serialized envelope — the exact
122
139
  // bytes vaultApiFetch will send — is what makes that impossible.
123
- attachDebugLogs(requestBody, diagnostics, opts.includeLogs !== false);
140
+ attachDebugLogs(requestBody, diagnostics, opts.includeLogs !== false, {
141
+ envEnabled: opts.logsEnabled === undefined
142
+ ? feedbackLogsEnabled
143
+ : () => opts.logsEnabled === true,
144
+ });
124
145
  const res = await vaultApiFetch({
125
146
  token: opts.token,
126
147
  path: "/v1/feedback",
@@ -155,8 +176,34 @@ function registerSubcommand(feedbackCmd, type) {
155
176
  try {
156
177
  const token = await ensureCognitoToken({ interactive: false });
157
178
  const body = await readBodyFile(opts.bodyFile);
158
- // Validate + upload screenshots (direct-to-S3 via presigned PUT)
159
- // before submitting, so the row references uploaded objects.
179
+ validateFeedbackContent(opts.title, body);
180
+ // Resolve every required local input before creating S3 objects. These
181
+ // diagnostics are passed through so submitFeedback does not collect
182
+ // them again after attachments have been uploaded.
183
+ const diagnostics = collectDiagnostics();
184
+ const logsEnabled = opts.logs !== false && feedbackLogsEnabled();
185
+ // Validate the serialized server request envelope before screenshots
186
+ // or the log bundle can create remote objects. Attachment references
187
+ // are added and checked again inside submitFeedback after upload.
188
+ validateFeedbackRequestSize(buildFeedbackRequestBody({
189
+ type,
190
+ title: opts.title,
191
+ body,
192
+ company: opts.company,
193
+ diagnostics,
194
+ }));
195
+ // vaultApiFetch can reject an hqk_ token or an invalid API base URL
196
+ // locally before fetch. Check the mandatory POST, and the screenshot
197
+ // presign when requested, before any upload can create an orphan.
198
+ assertVaultApiRequestSupported({ token, path: "/v1/feedback" });
199
+ if ((opts.screenshot ?? []).length > 0) {
200
+ assertVaultApiRequestSupported({
201
+ token,
202
+ path: "/v1/feedback/screenshots/presign",
203
+ });
204
+ }
205
+ // Upload screenshots (direct-to-S3 via presigned PUT) before the
206
+ // submission, so the row references uploaded objects.
160
207
  const screenshots = await uploadScreenshots({
161
208
  paths: opts.screenshot ?? [],
162
209
  token,
@@ -167,7 +214,7 @@ function registerSubcommand(feedbackCmd, type) {
167
214
  // this resolves undefined and the report still carries inline logs.
168
215
  const logBundle = await uploadLogBundle({
169
216
  token,
170
- enabled: opts.logs !== false && feedbackLogsEnabled(),
217
+ enabled: logsEnabled,
171
218
  });
172
219
  const result = await submitFeedback({
173
220
  type,
@@ -178,6 +225,8 @@ function registerSubcommand(feedbackCmd, type) {
178
225
  screenshots,
179
226
  includeLogs: opts.logs !== false,
180
227
  logBundle,
228
+ diagnostics,
229
+ logsEnabled,
181
230
  });
182
231
  console.log(`Submitted: ${result.id}`);
183
232
  }