@indigoai-us/hq-cli 5.273.0-alpha.0 → 5.274.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 (91) hide show
  1. package/CHANGELOG.md +65 -2
  2. package/README.md +32 -1
  3. package/dist/command-catalog.generated.d.ts +83 -536
  4. package/dist/command-catalog.generated.js +129 -714
  5. package/dist/command-registration-plan.d.ts +12 -0
  6. package/dist/command-registration-plan.js +2 -0
  7. package/dist/commands/cloud.js +40 -0
  8. package/dist/commands/core-checkpoint.js +3 -1
  9. package/dist/commands/core-command-catalog.d.ts +32 -0
  10. package/dist/commands/core-command-catalog.js +58 -0
  11. package/dist/commands/core-ontology.js +9 -4
  12. package/dist/commands/core.d.ts +4 -0
  13. package/dist/commands/core.js +79 -75
  14. package/dist/commands/daemon.d.ts +0 -3
  15. package/dist/commands/daemon.js +1 -173
  16. package/dist/commands/deploy-comments.d.ts +3 -0
  17. package/dist/commands/deploy-comments.js +159 -0
  18. package/dist/commands/lanes-checks.d.ts +0 -1
  19. package/dist/commands/lanes-checks.js +217 -54
  20. package/dist/commands/lanes-hold.js +40 -2
  21. package/dist/commands/lanes-message.js +98 -14
  22. package/dist/commands/lanes-move.d.ts +12 -0
  23. package/dist/commands/lanes-move.js +342 -0
  24. package/dist/commands/lanes-resume.js +3 -1
  25. package/dist/commands/lanes-shared.d.ts +39 -3
  26. package/dist/commands/lanes-shared.js +59 -0
  27. package/dist/commands/lanes.d.ts +3 -5
  28. package/dist/commands/lanes.js +206 -478
  29. package/dist/commands/report.js +2 -14
  30. package/dist/commands/sync-mode.d.ts +0 -11
  31. package/dist/commands/sync-mode.js +0 -30
  32. package/dist/fast-core.js +1 -1
  33. package/dist/{commands/scaffold-fast.d.ts → fast-scaffold.d.ts} +1 -1
  34. package/dist/{commands/scaffold-fast.js → fast-scaffold.js} +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/lib/checkpoint-ontology/apply.d.ts +8 -0
  38. package/dist/lib/checkpoint-ontology/apply.js +33 -0
  39. package/dist/lib/checkpoint-ontology/plan.d.ts +15 -0
  40. package/dist/lib/checkpoint-ontology/plan.js +15 -0
  41. package/dist/lib/daemon/catalog.d.ts +2 -18
  42. package/dist/lib/daemon/catalog.js +2 -18
  43. package/dist/lib/daemon/config.d.ts +0 -6
  44. package/dist/lib/daemon/config.js +2 -15
  45. package/dist/lib/daemon/requests.d.ts +0 -4
  46. package/dist/lib/daemon/requests.js +0 -2
  47. package/dist/lib/daemon/run.js +19 -78
  48. package/dist/lib/daemon/supervisor.d.ts +0 -8
  49. package/dist/lib/daemon/supervisor.js +1 -101
  50. package/dist/lib/deploy-api.d.ts +6 -0
  51. package/dist/lib/deploy-api.js +21 -0
  52. package/dist/lib/doctor/checks/scaffold-cli-floor.d.ts +7 -0
  53. package/dist/lib/doctor/checks/scaffold-cli-floor.js +73 -0
  54. package/dist/lib/doctor/fix/apply.d.ts +17 -2
  55. package/dist/lib/doctor/fix/apply.js +111 -57
  56. package/dist/lib/doctor/fix/json-output.d.ts +9 -1
  57. package/dist/lib/doctor/fix/json-output.js +11 -0
  58. package/dist/lib/doctor/registry.js +2 -0
  59. package/dist/lib/first-sync-next-step.d.ts +37 -0
  60. package/dist/lib/first-sync-next-step.js +189 -0
  61. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  62. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  63. package/dist/lib/lanes/hire-grant-dispatch.d.ts +6 -1
  64. package/dist/lib/lanes/hire-grant-dispatch.js +78 -3
  65. package/dist/lib/lanes/keep-alive.d.ts +3 -2
  66. package/dist/lib/lanes/keep-alive.js +2 -2
  67. package/dist/lib/lanes/merge-command.d.ts +1 -0
  68. package/dist/lib/lanes/merge-command.js +299 -33
  69. package/dist/lib/lanes/orphans.d.ts +7 -0
  70. package/dist/lib/lanes/orphans.js +39 -4
  71. package/dist/lib/lanes/tracker-store.d.ts +2 -18
  72. package/dist/lib/lanes/tracker-store.js +1 -172
  73. package/dist/lib/scaffold-cli-floor.d.ts +9 -0
  74. package/dist/lib/scaffold-cli-floor.js +50 -0
  75. package/dist/main.js +4 -1
  76. package/dist/utils/cli-telemetry.d.ts +7 -1
  77. package/dist/utils/cli-telemetry.js +84 -8
  78. package/dist/utils/contribution-table.d.ts +1 -1
  79. package/dist/utils/scaffold-fast-import-guard.d.ts +3 -0
  80. package/dist/utils/scaffold-fast-import-guard.js +28 -0
  81. package/dist/utils/scaffold-packlist-guard.d.ts +7 -0
  82. package/dist/utils/scaffold-packlist-guard.js +23 -0
  83. package/dist/utils/scaffold-sibling-references.d.ts +21 -0
  84. package/dist/utils/scaffold-sibling-references.js +156 -0
  85. package/package.json +2 -2
  86. package/dist/commands/lanes-import.d.ts +0 -14
  87. package/dist/commands/lanes-import.js +0 -1210
  88. package/dist/commands/lanes-tracker.d.ts +0 -18
  89. package/dist/commands/lanes-tracker.js +0 -452
  90. package/dist/lib/daemon/sync-runner.d.ts +0 -58
  91. package/dist/lib/daemon/sync-runner.js +0 -277
@@ -20,16 +20,12 @@
20
20
  import type { UnitPlan } from "./catalog.js";
21
21
  import type { MachineType } from "./machine-type.js";
22
22
  import type { ControlRequest } from "./requests.js";
23
- import { syncPassResult } from "./sync-runner.js";
24
23
  export declare const BACKOFF_BASE_MS = 1000;
25
24
  export declare const BACKOFF_MAX_MS = 60000;
26
25
  export declare const HEALTHY_RUN_MS = 60000;
27
26
  export declare const STOP_ESCALATION_MS = 10000;
28
27
  export interface SupervisedChild {
29
28
  pid?: number;
30
- stdout?: {
31
- on(event: "data" | "end", handler: (chunk?: Buffer | string) => void): unknown;
32
- };
33
29
  on(event: "exit", handler: (code: number | null, signal: string | null) => void): unknown;
34
30
  on(event: "error", handler: (err: Error) => void): unknown;
35
31
  kill(signal?: NodeJS.Signals | number): boolean;
@@ -53,8 +49,6 @@ export interface UnitState {
53
49
  lastRunAt?: number;
54
50
  nextRunAt?: number;
55
51
  lastResult?: "ok" | "failed";
56
- lastHeartbeatAt?: string;
57
- lastPassResult?: ReturnType<typeof syncPassResult>;
58
52
  }
59
53
  /** An app that runs the daemon as its own child process instead of an OS unit. */
60
54
  export type HostApp = "desktop";
@@ -85,8 +79,6 @@ export interface SupervisorDeps {
85
79
  clearTimeout?: (handle: unknown) => void;
86
80
  reconcileMs?: number;
87
81
  requestPollMs?: number;
88
- /** Read hq-cloud's lease status after it exits with the lease-conflict code. */
89
- syncOwnerBlockReason?: () => string | undefined;
90
82
  }
91
83
  export interface Supervisor {
92
84
  start(): void;
@@ -17,8 +17,6 @@
17
17
  *
18
18
  * Processes, time, and state output are injected; `run.ts` wires the real ones.
19
19
  */
20
- import { StringDecoder } from "node:string_decoder";
21
- import { SYNC_RUNNER_LEASE_CONFLICT_EXIT, SYNC_RUNNER_LEASE_RETRY_MS, parseSyncRunnerProtocolLine, syncPassResult, } from "./sync-runner.js";
22
20
  export const BACKOFF_BASE_MS = 1_000;
23
21
  export const BACKOFF_MAX_MS = 60_000;
24
22
  export const HEALTHY_RUN_MS = 60_000;
@@ -27,15 +25,7 @@ export function backoffMs(attempt) {
27
25
  return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
28
26
  }
29
27
  function planKey(plan) {
30
- return JSON.stringify([
31
- plan.kind,
32
- plan.restart,
33
- plan.intervalSec,
34
- plan.initialDelaySec,
35
- plan.heartbeatTimeoutMs,
36
- plan.command,
37
- plan.env,
38
- ]);
28
+ return JSON.stringify([plan.kind, plan.restart, plan.intervalSec, plan.initialDelaySec, plan.command, plan.env]);
39
29
  }
40
30
  function errorMessage(err) {
41
31
  return err instanceof Error ? err.message : String(err);
@@ -78,11 +68,6 @@ export function createSupervisor(deps) {
78
68
  clearT(slot.timer);
79
69
  slot.timer = undefined;
80
70
  };
81
- const clearHeartbeatTimer = (slot) => {
82
- if (slot.heartbeatTimer !== undefined)
83
- clearT(slot.heartbeatTimer);
84
- slot.heartbeatTimer = undefined;
85
- };
86
71
  const setBlocked = (slot) => {
87
72
  const block = slot.plan.blocked;
88
73
  slot.state.status = block.state;
@@ -93,7 +78,6 @@ export function createSupervisor(deps) {
93
78
  /** Stop the slot's child (if any), then run `then` once it has exited. */
94
79
  const terminate = (slot, then) => {
95
80
  clearTimer(slot);
96
- clearHeartbeatTimer(slot);
97
81
  const child = slot.child;
98
82
  if (!child) {
99
83
  then?.();
@@ -138,69 +122,6 @@ export function createSupervisor(deps) {
138
122
  const watch = (slot, child) => {
139
123
  live.add(child);
140
124
  let ended = false;
141
- const timeoutMs = slot.plan.heartbeatTimeoutMs;
142
- const decoder = timeoutMs ? new StringDecoder("utf8") : null;
143
- let stdoutRemainder = "";
144
- let lastHeartbeatPersistedAt = now() - 15_000;
145
- const armHeartbeatWatchdog = () => {
146
- if (!timeoutMs)
147
- return;
148
- clearHeartbeatTimer(slot);
149
- slot.heartbeatTimer = setT(() => {
150
- slot.heartbeatTimer = undefined;
151
- if (stopped || slot.child !== child || slot.expectingExit)
152
- return;
153
- deps.log("warn", `${slot.plan.id} emitted no sync protocol heartbeat for ${timeoutMs}ms; restarting it`);
154
- terminate(slot, () => scheduleRestart(slot));
155
- emit();
156
- }, timeoutMs);
157
- };
158
- const acceptProtocolLine = (line) => {
159
- let event;
160
- try {
161
- event = parseSyncRunnerProtocolLine(line);
162
- }
163
- catch (err) {
164
- deps.log("error", `${slot.plan.id} emitted invalid sync protocol output (${Buffer.byteLength(line)} bytes): ${errorMessage(err)}`);
165
- return;
166
- }
167
- if (!event)
168
- return;
169
- const at = now();
170
- slot.state.lastHeartbeatAt = new Date(at).toISOString();
171
- const pass = syncPassResult(event, slot.state.lastHeartbeatAt);
172
- if (pass)
173
- slot.state.lastPassResult = pass;
174
- armHeartbeatWatchdog();
175
- if (pass || at - lastHeartbeatPersistedAt >= 15_000) {
176
- lastHeartbeatPersistedAt = at;
177
- emit();
178
- }
179
- };
180
- if (decoder && child.stdout) {
181
- child.stdout.on("data", (chunk) => {
182
- if (chunk === undefined)
183
- return;
184
- stdoutRemainder += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
185
- let newline = stdoutRemainder.indexOf("\n");
186
- while (newline >= 0) {
187
- acceptProtocolLine(stdoutRemainder.slice(0, newline).trim());
188
- stdoutRemainder = stdoutRemainder.slice(newline + 1);
189
- newline = stdoutRemainder.indexOf("\n");
190
- }
191
- // A malformed or unexpectedly large line is not a heartbeat. Bound
192
- // retained data so a broken child cannot grow daemon memory forever.
193
- if (Buffer.byteLength(stdoutRemainder) > 1024 * 1024)
194
- stdoutRemainder = "";
195
- });
196
- child.stdout.on("end", () => {
197
- stdoutRemainder += decoder.end();
198
- if (stdoutRemainder)
199
- acceptProtocolLine(stdoutRemainder.trim());
200
- stdoutRemainder = "";
201
- });
202
- }
203
- armHeartbeatWatchdog();
204
125
  const onEnd = (code, signal) => {
205
126
  if (ended)
206
127
  return;
@@ -211,7 +132,6 @@ export function createSupervisor(deps) {
211
132
  if (slot.child !== child)
212
133
  return;
213
134
  slot.child = undefined;
214
- clearHeartbeatTimer(slot);
215
135
  if (slot.killTimer !== undefined) {
216
136
  clearT(slot.killTimer);
217
137
  slot.killTimer = undefined;
@@ -239,20 +159,6 @@ export function createSupervisor(deps) {
239
159
  emit();
240
160
  return;
241
161
  }
242
- if (slot.plan.service === "sync" && code === SYNC_RUNNER_LEASE_CONFLICT_EXIT) {
243
- const reason = deps.syncOwnerBlockReason?.() ?? "sync owned by another runner";
244
- slot.state.status = "waiting";
245
- slot.state.reason = reason;
246
- slot.state.nextRunAt = now() + SYNC_RUNNER_LEASE_RETRY_MS;
247
- deps.log("info", `${reason}; retrying in ${SYNC_RUNNER_LEASE_RETRY_MS}ms`);
248
- slot.timer = setT(() => {
249
- slot.timer = undefined;
250
- startLong(slot);
251
- emit();
252
- }, SYNC_RUNNER_LEASE_RETRY_MS);
253
- emit();
254
- return;
255
- }
256
162
  deps.log("warn", `${slot.plan.id} exited (code=${code ?? "null"} signal=${signal ?? "none"})`);
257
163
  if (ranFor >= HEALTHY_RUN_MS)
258
164
  slot.attempts = 0;
@@ -463,11 +369,6 @@ export function createSupervisor(deps) {
463
369
  emit();
464
370
  };
465
371
  const handleRequest = (req) => {
466
- if (req.action === "reconcile") {
467
- deps.log("info", "reconciling daemon configuration (requested)");
468
- reconcile();
469
- return;
470
- }
471
372
  if (req.action === "restart-daemon") {
472
373
  deps.log("info", "restarting the daemon (requested)");
473
374
  if (deps.restartDaemon)
@@ -574,7 +475,6 @@ export function createSupervisor(deps) {
574
475
  clearT(requestTimer);
575
476
  for (const slot of slots.values()) {
576
477
  clearTimer(slot);
577
- clearHeartbeatTimer(slot);
578
478
  slot.child = undefined;
579
479
  if (slot.killTimer !== undefined)
580
480
  clearT(slot.killTimer);
@@ -0,0 +1,6 @@
1
+ /** Shared authenticated request path for the hq-deploy control plane. */
2
+ export declare const DEPLOY_API_URL: string;
3
+ export declare function deployHeaders(token: string, json?: boolean, extra?: Record<string, string>): Record<string, string>;
4
+ export declare function deployApiUrl(route: string): string;
5
+ export declare function deployApiRequest(token: string, route: string, method: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<Response>;
6
+ //# sourceMappingURL=deploy-api.d.ts.map
@@ -0,0 +1,21 @@
1
+ /** Shared authenticated request path for the hq-deploy control plane. */
2
+ export const DEPLOY_API_URL = process.env.HQ_DEPLOY_API_URL?.trim() || "https://api.indigo-hq.com";
3
+ export function deployHeaders(token, json = true, extra = {}) {
4
+ return {
5
+ Authorization: `Bearer ${token}`,
6
+ Accept: "application/json",
7
+ ...(json ? { "Content-Type": "application/json" } : {}),
8
+ ...extra,
9
+ };
10
+ }
11
+ export function deployApiUrl(route) {
12
+ return `${DEPLOY_API_URL.replace(/\/+$/, "")}${route}`;
13
+ }
14
+ export async function deployApiRequest(token, route, method, body, extraHeaders = {}) {
15
+ return fetch(deployApiUrl(route), {
16
+ method,
17
+ headers: deployHeaders(token, body !== undefined, extraHeaders),
18
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
19
+ });
20
+ }
21
+ //# sourceMappingURL=deploy-api.js.map
@@ -0,0 +1,7 @@
1
+ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
2
+ export declare const SCAFFOLD_CLI_FLOOR_FAMILY_ID = "scaffold-cli-floor";
3
+ export declare const SCAFFOLD_CLI_FLOOR_FAMILY_TITLE = "Scaffold CLI compatibility";
4
+ export declare const SCAFFOLD_CLI_FLOOR_CHECK_ID = "scaffold-cli-floor.requires-hq-cli";
5
+ export declare const scaffoldCliFloorFamily: CheckFamily;
6
+ export declare function checkScaffoldCliFloor(context: CheckContext): CheckResult[];
7
+ //# sourceMappingURL=scaffold-cli-floor.d.ts.map
@@ -0,0 +1,73 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as yaml from "js-yaml";
4
+ import { CLI_VERSION } from "../../../cli-version.js";
5
+ import { compareScaffoldCliFloor } from "../../scaffold-cli-floor.js";
6
+ export const SCAFFOLD_CLI_FLOOR_FAMILY_ID = "scaffold-cli-floor";
7
+ export const SCAFFOLD_CLI_FLOOR_FAMILY_TITLE = "Scaffold CLI compatibility";
8
+ export const SCAFFOLD_CLI_FLOOR_CHECK_ID = "scaffold-cli-floor.requires-hq-cli";
9
+ export const scaffoldCliFloorFamily = {
10
+ id: SCAFFOLD_CLI_FLOOR_FAMILY_ID,
11
+ title: SCAFFOLD_CLI_FLOOR_FAMILY_TITLE,
12
+ run: (context) => Promise.resolve(checkScaffoldCliFloor(context)),
13
+ };
14
+ export function checkScaffoldCliFloor(context) {
15
+ const coreYamlPath = path.join(context.hqRoot, "core", "core.yaml");
16
+ if (!fs.existsSync(coreYamlPath)) {
17
+ return [skipped(coreYamlPath, "core/core.yaml is missing")];
18
+ }
19
+ let document;
20
+ try {
21
+ document = yaml.load(fs.readFileSync(coreYamlPath, "utf8"));
22
+ }
23
+ catch {
24
+ return [
25
+ {
26
+ status: "WARN",
27
+ checkId: SCAFFOLD_CLI_FLOOR_CHECK_ID,
28
+ target: coreYamlPath,
29
+ message: "core/core.yaml could not be parsed, so the hq-cli floor was not checked.",
30
+ },
31
+ ];
32
+ }
33
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
34
+ if (document === undefined || document === null) {
35
+ return [skipped(coreYamlPath, "core/core.yaml does not declare requiresHqCli")];
36
+ }
37
+ return [
38
+ {
39
+ status: "WARN",
40
+ checkId: SCAFFOLD_CLI_FLOOR_CHECK_ID,
41
+ target: coreYamlPath,
42
+ message: "core/core.yaml is not a mapping, so the hq-cli floor was not checked.",
43
+ },
44
+ ];
45
+ }
46
+ const required = document.requiresHqCli;
47
+ const comparison = compareScaffoldCliFloor(required, CLI_VERSION);
48
+ if (comparison.status === "skip") {
49
+ return [skipped(coreYamlPath, "core/core.yaml does not declare requiresHqCli")];
50
+ }
51
+ return [
52
+ {
53
+ status: comparison.status === "pass"
54
+ ? "PASS"
55
+ : comparison.status === "fail"
56
+ ? "FAIL"
57
+ : "WARN",
58
+ checkId: SCAFFOLD_CLI_FLOOR_CHECK_ID,
59
+ target: coreYamlPath,
60
+ message: comparison.message,
61
+ ...(comparison.remediation ? { remediation: comparison.remediation } : {}),
62
+ },
63
+ ];
64
+ }
65
+ function skipped(target, reason) {
66
+ return {
67
+ status: "NA",
68
+ checkId: SCAFFOLD_CLI_FLOOR_CHECK_ID,
69
+ target,
70
+ message: `Skipped: ${reason}; no hq-cli floor was checked.`,
71
+ };
72
+ }
73
+ //# sourceMappingURL=scaffold-cli-floor.js.map
@@ -101,6 +101,8 @@ export interface RefreshSyncResult {
101
101
  export declare const REFRESH_SYNC_TIMEOUT_MS = 90000;
102
102
  /** One applied (or attempted) repair, with its post-fix re-check status. */
103
103
  export interface AppliedFix {
104
+ /** One-based repair pass in this `hq doctor --fix` invocation. */
105
+ pass: number;
104
106
  /** The check id of the finding that was repaired. */
105
107
  checkId: string;
106
108
  /** The safe class applied. */
@@ -114,13 +116,24 @@ export interface AppliedFix {
114
116
  /** Present for refresh-sync plans; contains no command output or path data. */
115
117
  refreshSyncOutcome?: RefreshSyncOutcome;
116
118
  }
119
+ /** Backup and restore information for one pass that wrote files. */
120
+ export interface AppliedFixBackup {
121
+ /** One-based repair pass that created this backup. */
122
+ pass: number;
123
+ /** Absolute path of the backup directory. */
124
+ backupDir: string;
125
+ /** Command that restores this pass's pre-write files. */
126
+ restoreCommand: string;
127
+ }
128
+ /** A newly unlocked safe repair can be picked up for at most this many passes. */
129
+ export declare const MAX_FIX_PASSES = 3;
117
130
  /** The outcome of an {@link applyFixes} run. */
118
131
  export interface ApplyFixesResult {
119
- /** 0 on success or a clean no-op; 1 when the run was refused. */
132
+ /** 0 on success, decline, or a clean no-op; 1 when a dirty tree is refused. */
120
133
  exitCode: number;
121
134
  /** Whether any file was written. */
122
135
  wrote: boolean;
123
- /** Why the run wrote nothing, when it did not: refusal reason or null. */
136
+ /** Why no further repairs were applied, when the run stopped early. */
124
137
  refused: "dirty-tree" | "declined" | null;
125
138
  /** How many findings were auto-fixable. */
126
139
  fixableCount: number;
@@ -128,6 +141,8 @@ export interface ApplyFixesResult {
128
141
  backupDir: string | null;
129
142
  /** The restore command, or null when nothing was written. */
130
143
  restoreCommand: string | null;
144
+ /** Every backup made, in pass order. */
145
+ backups: AppliedFixBackup[];
131
146
  /** The applied repairs with their post-fix status. */
132
147
  applied: AppliedFix[];
133
148
  }
@@ -45,6 +45,8 @@ import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
45
45
  export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
46
46
  /** Per-company bound for the default `hq sync pull` repair. */
47
47
  export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
48
+ /** A newly unlocked safe repair can be picked up for at most this many passes. */
49
+ export const MAX_FIX_PASSES = 3;
48
50
  /**
49
51
  * Apply every auto-fixable finding, honouring the dirty-tree refusal, the
50
52
  * preview/confirmation gate, the pre-write backup, and the post-fix re-check.
@@ -62,6 +64,7 @@ export async function applyFixes(options) {
62
64
  fixableCount: 0,
63
65
  backupDir: null,
64
66
  restoreCommand: null,
67
+ backups: [],
65
68
  applied: [],
66
69
  ...over,
67
70
  });
@@ -80,72 +83,123 @@ export async function applyFixes(options) {
80
83
  const collect = options.runChecks ?? defaultRunChecks;
81
84
  const refreshSync = options.refreshSync ?? defaultRefreshSync;
82
85
  const rewriteBotNodePath = options.rewriteBotNodePath ?? defaultRewriteBotNodePath;
83
- // 2. Collect findings and keep only the allowlisted auto-fixable ones.
84
- const findings = await collect(hqRoot);
85
- const fixable = [];
86
- for (const result of findings) {
87
- const rem = deriveRemediation(result);
88
- if (rem && rem.autoFixable)
89
- fixable.push({ result, rem });
90
- }
91
- if (fixable.length === 0) {
92
- write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
93
- return empty();
94
- }
95
- const plans = fixable
96
- .map(({ result, rem }) => planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath))
97
- .filter((plan) => plan !== null);
98
- if (plans.length === 0) {
99
- write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
100
- return empty();
101
- }
102
- // 3. Diff-style preview + confirmation (AC5).
103
- write(`hq doctor --fix will apply ${plans.length} repair${plans.length === 1 ? "" : "s"}:\n\n`);
104
- for (const plan of plans)
105
- write(plan.preview + "\n");
106
- if (!options.yes) {
107
- const confirmed = await Promise.resolve((options.confirm ?? declineByDefault)());
108
- if (!confirmed) {
109
- write("Aborted — no changes written.\n");
110
- return empty({ refused: "declined" });
86
+ // 2. Collect findings, then plan each newly unlocked allowlisted repair.
87
+ // Identity is deliberately the check id + target pair: an unsuccessful
88
+ // repair must not be applied repeatedly in one invocation.
89
+ let findings = await collect(hqRoot);
90
+ const appliedKeys = new Set();
91
+ const planned = [];
92
+ const backups = [];
93
+ let fixableCount = 0;
94
+ let refused = null;
95
+ let lastBackupMs = Number.NEGATIVE_INFINITY;
96
+ const backupClock = () => {
97
+ const suppliedMs = (options.now ?? (() => new Date()))().getTime();
98
+ lastBackupMs = Math.max(suppliedMs, lastBackupMs + 1);
99
+ return new Date(lastBackupMs);
100
+ };
101
+ const buildPlans = (results) => {
102
+ const keysThisPass = new Set();
103
+ const plans = [];
104
+ for (const result of results) {
105
+ const rem = deriveRemediation(result);
106
+ if (!rem?.autoFixable)
107
+ continue;
108
+ const plan = planFix(hqRoot, result, rem, refreshSync, rewriteBotNodePath);
109
+ if (!plan)
110
+ continue;
111
+ const key = JSON.stringify([plan.checkId, plan.target]);
112
+ if (appliedKeys.has(key) || keysThisPass.has(key))
113
+ continue;
114
+ keysThisPass.add(key);
115
+ plans.push(plan);
116
+ }
117
+ return plans;
118
+ };
119
+ for (let pass = 1; pass <= MAX_FIX_PASSES; pass += 1) {
120
+ const plans = buildPlans(findings);
121
+ if (plans.length === 0)
122
+ break;
123
+ fixableCount += plans.length;
124
+ // 3. Preview and require confirmation for every pass unless --yes is set.
125
+ write(`Pass ${pass}/${MAX_FIX_PASSES}: hq doctor --fix will apply ${plans.length} repair${plans.length === 1 ? "" : "s"}:\n\n`);
126
+ for (const plan of plans)
127
+ write(plan.preview + "\n");
128
+ if (!options.yes) {
129
+ const confirmed = await Promise.resolve((options.confirm ?? declineByDefault)());
130
+ if (!confirmed) {
131
+ refused = "declined";
132
+ if (planned.length === 0) {
133
+ write("Aborted — no changes written.\n");
134
+ return empty({ refused, fixableCount });
135
+ }
136
+ write(`Pass ${pass} declined — no further changes written.\n`);
137
+ break;
138
+ }
139
+ }
140
+ // 4. Back up this pass's files before its first write. The clock advances
141
+ // at least one millisecond per backup so fast consecutive passes cannot
142
+ // overwrite an earlier pass's restore point.
143
+ const affected = unique(plans.map((plan) => plan.relpath).filter((rel) => rel.length > 0));
144
+ const backup = affected.length > 0 ? createBackup(hqRoot, affected, backupClock) : null;
145
+ if (backup) {
146
+ backups.push({
147
+ pass,
148
+ backupDir: backup.dir,
149
+ restoreCommand: backup.restoreCommand,
150
+ });
151
+ write(`\nBacked up ${backup.files.length} file${backup.files.length === 1 ? "" : "s"} for pass ${pass} to ${backup.dir}\n`);
152
+ write(`To restore: ${backup.restoreCommand}\n`);
111
153
  }
154
+ // 5. Apply each plan once. Later findings with this check id + target are
155
+ // excluded even if this repair did not make its check pass.
156
+ for (const plan of plans) {
157
+ appliedKeys.add(JSON.stringify([plan.checkId, plan.target]));
158
+ planned.push({ plan, pass });
159
+ await plan.apply();
160
+ }
161
+ // 6. Re-run checks. Newly surfaced repairs are considered by the next loop.
162
+ findings = await collect(hqRoot);
112
163
  }
113
- // 4. Back up every file about to change BEFORE the first write (AC6).
114
- // refresh-sync plans have no hook file; skip the backup pass when nothing
115
- // in the tree is about to be copied.
116
- const affected = unique(plans.map((plan) => plan.relpath).filter((rel) => rel.length > 0));
117
- const backup = affected.length > 0 ? createBackup(hqRoot, affected, options.now) : null;
118
- // 5. Apply. Each plan reads the current on-disk state, so multiple plans that
119
- // touch the same file (two gate ids) compose correctly.
120
- for (const plan of plans)
121
- await plan.apply();
122
- if (backup) {
123
- write(`\nBacked up ${backup.files.length} file${backup.files.length === 1 ? "" : "s"} to ${backup.dir}\n`);
124
- write(`To restore: ${backup.restoreCommand}\n`);
164
+ if (planned.length === 0) {
165
+ write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
166
+ return empty({ refused, fixableCount });
125
167
  }
126
- // 6. Re-run the checks and report the post-fix status of each repair (AC7).
127
- const after = await collect(hqRoot);
128
- const applied = plans.map((plan) => ({
129
- checkId: plan.checkId,
130
- fixClass: plan.fixClass,
131
- target: plan.target,
132
- summary: plan.summary,
133
- postStatus: plan.postStatus(after),
134
- ...(plan.refreshSyncOutcome?.()
135
- ? { refreshSyncOutcome: plan.refreshSyncOutcome() }
136
- : {}),
137
- }));
168
+ // Show each repair's final post-fix status, including work from earlier passes.
169
+ const applied = planned.map(({ plan, pass }) => {
170
+ const refreshSyncOutcome = plan.refreshSyncOutcome?.();
171
+ return {
172
+ pass,
173
+ checkId: plan.checkId,
174
+ fixClass: plan.fixClass,
175
+ target: plan.target,
176
+ summary: plan.summary,
177
+ postStatus: plan.postStatus(findings),
178
+ ...(refreshSyncOutcome ? { refreshSyncOutcome } : {}),
179
+ };
180
+ });
138
181
  write("\nPost-fix status:\n");
139
182
  for (const fix of applied) {
140
- write(` ${fix.postStatus ?? "UNKNOWN"} ${fix.checkId} [${fix.target}]\n`);
183
+ write(` ${fix.postStatus ?? "UNKNOWN"} ${fix.checkId} [${fix.target}] (pass ${fix.pass})\n`);
184
+ }
185
+ // Restore later pass snapshots first. This also returns every file to its
186
+ // pre-run state when multiple passes touched the same path.
187
+ const restoreCommand = backups.length > 0
188
+ ? [...backups].reverse().map(({ restoreCommand: command }) => command).join(" && ")
189
+ : null;
190
+ if (restoreCommand)
191
+ write(`To restore all changes: ${restoreCommand}\n`);
192
+ if (planned.at(-1)?.pass === MAX_FIX_PASSES && buildPlans(findings).length > 0) {
193
+ write(`Stopped after ${MAX_FIX_PASSES} repair passes; new auto-fixable findings remain.\n`);
141
194
  }
142
195
  return {
143
196
  exitCode: 0,
144
197
  wrote: true,
145
- refused: null,
146
- fixableCount: fixable.length,
147
- backupDir: backup?.dir ?? null,
148
- restoreCommand: backup?.restoreCommand ?? null,
198
+ refused,
199
+ fixableCount,
200
+ backupDir: backups[0]?.backupDir ?? null,
201
+ restoreCommand,
202
+ backups,
149
203
  applied,
150
204
  };
151
205
  }
@@ -1,9 +1,16 @@
1
- import type { AppliedFix, ApplyFixesResult } from "./apply.js";
1
+ import type { AppliedFix, AppliedFixBackup, ApplyFixesResult } from "./apply.js";
2
2
  export declare const DOCTOR_FIX_JSON_SCHEMA_VERSION = 1;
3
3
  export interface DoctorFixJsonResult {
4
4
  schemaVersion: number;
5
5
  exitCode: number;
6
6
  refused: ApplyFixesResult["refused"] | "outside-hq-tree";
7
+ applied: {
8
+ checkId: string;
9
+ fixClass: AppliedFix["fixClass"];
10
+ pass: number;
11
+ postStatus: AppliedFix["postStatus"];
12
+ }[];
13
+ backups: Pick<AppliedFixBackup, "pass" | "backupDir" | "restoreCommand">[];
7
14
  refreshSync: {
8
15
  checkId: string;
9
16
  outcome: NonNullable<AppliedFix["refreshSyncOutcome"]>;
@@ -15,6 +22,7 @@ export declare function buildDoctorFixJson(input: {
15
22
  exitCode: number;
16
23
  refused: ApplyFixesResult["refused"] | "outside-hq-tree";
17
24
  applied: readonly AppliedFix[];
25
+ backups?: readonly AppliedFixBackup[];
18
26
  }): DoctorFixJsonResult;
19
27
  export declare function renderDoctorFixJson(input: Parameters<typeof buildDoctorFixJson>[0]): string;
20
28
  //# sourceMappingURL=json-output.d.ts.map
@@ -5,6 +5,17 @@ export function buildDoctorFixJson(input) {
5
5
  schemaVersion: DOCTOR_FIX_JSON_SCHEMA_VERSION,
6
6
  exitCode: input.exitCode,
7
7
  refused: input.refused,
8
+ applied: input.applied.map(({ checkId, fixClass, pass, postStatus }) => ({
9
+ checkId,
10
+ fixClass,
11
+ pass,
12
+ postStatus,
13
+ })),
14
+ backups: (input.backups ?? []).map(({ pass, backupDir, restoreCommand }) => ({
15
+ pass,
16
+ backupDir,
17
+ restoreCommand,
18
+ })),
8
19
  refreshSync: input.applied.flatMap((fix) => fix.fixClass === "refresh-sync" && fix.refreshSyncOutcome
9
20
  ? [
10
21
  {
@@ -23,6 +23,7 @@ import { integrationsFamily } from "./checks/integrations.js";
23
23
  import { syncHealthFamily } from "./checks/sync-health.js";
24
24
  import { workContextFamily } from "./checks/work-context.js";
25
25
  import { botsFamily } from "./checks/bots.js";
26
+ import { scaffoldCliFloorFamily } from "./checks/scaffold-cli-floor.js";
26
27
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
27
28
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
28
29
  /**
@@ -236,6 +237,7 @@ function hasCommandHook(settings, event) {
236
237
  export function createDefaultRegistry() {
237
238
  const registry = new DoctorRegistry();
238
239
  registry.register(hooksFamily);
240
+ registry.register(scaffoldCliFloorFamily);
239
241
  // Fixture coverage (US-007): reports UNTESTED for every registered hook with
240
242
  // no fixture and prints the tested/total coverage line. A separate family so
241
243
  // it needs no change to the hooks tier, per the registry's extensibility
@@ -0,0 +1,37 @@
1
+ export declare const CLI_FIRST_SYNC_NEXT_STEP_FLAG = "cli.first-sync-next-step";
2
+ export declare const FIRST_SYNC_NEXT_STEP_COPY = "Save a file in your HQ folder, then run `hq sync push`.";
3
+ export declare const FIRST_SYNC_CONFIRMATION_COPY = "Your first work sync is complete.";
4
+ export type FirstSyncStepOutcome = "shown" | "followed";
5
+ export declare function shouldShowFirstSyncNextStep(input: {
6
+ flagEnabled: boolean;
7
+ interactive: boolean;
8
+ alreadyShown: boolean;
9
+ alreadyCompleted: boolean;
10
+ }): boolean;
11
+ export declare function shouldConfirmFirstWorkBearingSync(input: {
12
+ flagEnabled: boolean;
13
+ interactive: boolean;
14
+ alreadyCompleted: boolean;
15
+ workBearing: boolean;
16
+ }): boolean;
17
+ export declare function isWorkBearingSyncResult(result: Record<string, unknown>): boolean;
18
+ export declare function isHumanInteractiveCliSession(input: {
19
+ stdoutIsTTY: boolean;
20
+ argv: readonly string[];
21
+ env: NodeJS.ProcessEnv;
22
+ machineIdentity: boolean;
23
+ }): boolean;
24
+ export declare function firstSyncStepMarkerPaths(identity: string, home?: string): {
25
+ shown: string;
26
+ completed: string;
27
+ };
28
+ export declare function claimFirstSyncStepMarker(kind: "shown" | "completed", identity: string, home?: string): boolean;
29
+ export declare function firstSyncStepTelemetryProperties(outcome: FirstSyncStepOutcome): Record<string, unknown>;
30
+ export declare function isFirstSyncNextStepEnabled(): boolean;
31
+ /** Called once after the first completed CLI action, before process finalization. */
32
+ export declare function maybeShowFirstSyncNextStep(): boolean;
33
+ /** Capture the first positive CLI sync result without changing transfer logic. */
34
+ export declare function recordFirstWorkBearingSync(result: Record<string, unknown>): boolean;
35
+ /** Drain outcomes once so each shown/followed event is sent at most once per run. */
36
+ export declare function takeFirstSyncStepOutcomes(): FirstSyncStepOutcome[];
37
+ //# sourceMappingURL=first-sync-next-step.d.ts.map