@indigoai-us/hq-cli 5.273.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 (51) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +32 -1
  3. package/dist/command-catalog.generated.d.ts +72 -0
  4. package/dist/command-catalog.generated.js +92 -0
  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/deploy-comments.d.ts +3 -0
  15. package/dist/commands/deploy-comments.js +159 -0
  16. package/dist/commands/lanes-move.d.ts +12 -0
  17. package/dist/commands/lanes-move.js +342 -0
  18. package/dist/commands/lanes-resume.js +3 -1
  19. package/dist/commands/lanes-shared.d.ts +36 -1
  20. package/dist/commands/lanes-shared.js +59 -0
  21. package/dist/commands/lanes.d.ts +1 -1
  22. package/dist/commands/lanes.js +204 -456
  23. package/dist/commands/report.js +2 -14
  24. package/dist/fast-core.js +1 -1
  25. package/dist/{commands/scaffold-fast.d.ts → fast-scaffold.d.ts} +1 -1
  26. package/dist/{commands/scaffold-fast.js → fast-scaffold.js} +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +2 -2
  29. package/dist/lib/checkpoint-ontology/apply.d.ts +8 -0
  30. package/dist/lib/checkpoint-ontology/apply.js +33 -0
  31. package/dist/lib/checkpoint-ontology/plan.d.ts +15 -0
  32. package/dist/lib/checkpoint-ontology/plan.js +15 -0
  33. package/dist/lib/deploy-api.d.ts +6 -0
  34. package/dist/lib/deploy-api.js +21 -0
  35. package/dist/lib/doctor/fix/apply.d.ts +17 -2
  36. package/dist/lib/doctor/fix/apply.js +111 -57
  37. package/dist/lib/doctor/fix/json-output.d.ts +9 -1
  38. package/dist/lib/doctor/fix/json-output.js +11 -0
  39. package/dist/lib/first-sync-next-step.d.ts +37 -0
  40. package/dist/lib/first-sync-next-step.js +189 -0
  41. package/dist/main.js +4 -1
  42. package/dist/utils/cli-telemetry.d.ts +7 -1
  43. package/dist/utils/cli-telemetry.js +84 -8
  44. package/dist/utils/contribution-table.d.ts +1 -1
  45. package/dist/utils/scaffold-fast-import-guard.d.ts +3 -0
  46. package/dist/utils/scaffold-fast-import-guard.js +28 -0
  47. package/dist/utils/scaffold-packlist-guard.d.ts +7 -0
  48. package/dist/utils/scaffold-packlist-guard.js +23 -0
  49. package/dist/utils/scaffold-sibling-references.d.ts +21 -0
  50. package/dist/utils/scaffold-sibling-references.js +156 -0
  51. package/package.json +1 -1
@@ -5,9 +5,9 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { ensureCognitoToken, } from "../utils/cognito-session.js";
7
7
  import { vaultApiFetch } from "../utils/vault-api.js";
8
+ import { DEPLOY_API_URL, deployApiRequest, deployHeaders } from "../lib/deploy-api.js";
8
9
  import { listReportTemplates } from "../lib/report-templates.js";
9
10
  import { adoptReport, buildReportTarball, conventionReportDir, deployOrgContext, ReportPublishError, plainFailureReason, publishReport, } from "../lib/report-publish.js";
10
- const DEPLOY_API_URL = process.env.HQ_DEPLOY_API_URL ?? "https://api.indigo-hq.com";
11
11
  const REPORT_CHANNEL_STATE_FILE = "report-channels.json";
12
12
  const MAX_REPORT_DISCOVERY_CHANNELS = 50;
13
13
  const JOB_EXECUTION_DISABLED_MESSAGE = "Scheduled jobs are turned off for this company. A company owner can turn them back on.";
@@ -214,22 +214,10 @@ export async function patchReport(token, report, patch) {
214
214
  throw new ReportPublishError("Report update response was incomplete.");
215
215
  return body.report;
216
216
  }
217
- function deployHeaders(token, json = true, extra = {}) {
218
- return {
219
- Authorization: `Bearer ${token}`,
220
- Accept: "application/json",
221
- ...(json ? { "Content-Type": "application/json" } : {}),
222
- ...extra,
223
- };
224
- }
225
217
  async function deployJson(token, route, method, body, extraHeaders = {}) {
226
218
  let res;
227
219
  try {
228
- res = await fetch(`${DEPLOY_API_URL.replace(/\/+$/, "")}${route}`, {
229
- method,
230
- headers: deployHeaders(token, body !== undefined, extraHeaders),
231
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
232
- });
220
+ res = await deployApiRequest(token, route, method, body, extraHeaders);
233
221
  }
234
222
  catch {
235
223
  throw new ReportPublishError("Could not reach hq-deploy.");
package/dist/fast-core.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { resolveLiveRoot } from "./utils/hq-roots.js";
11
11
  import { runBundledScript } from "./utils/run-bundled-script.js";
12
- import { parseFastCore } from "./commands/scaffold-fast.js";
12
+ import { parseFastCore } from "./fast-scaffold.js";
13
13
  /**
14
14
  * Run the fast-path scaffold script named in `argv` and exit with its code.
15
15
  * Only call when {@link isFastCoreRequest} is true; a non-match returns without
@@ -38,4 +38,4 @@ export type ParsedFastCore = {
38
38
  export declare function parseFastCore(argv: readonly string[]): ParsedFastCore | null;
39
39
  /** Whether this invocation is a fast-path scaffold dispatch. */
40
40
  export declare function isFastCoreRequest(argv: readonly string[]): boolean;
41
- //# sourceMappingURL=scaffold-fast.d.ts.map
41
+ //# sourceMappingURL=fast-scaffold.d.ts.map
@@ -54,4 +54,4 @@ export function parseFastCore(argv) {
54
54
  export function isFastCoreRequest(argv) {
55
55
  return parseFastCore(argv) !== null;
56
56
  }
57
- //# sourceMappingURL=scaffold-fast.js.map
57
+ //# sourceMappingURL=fast-scaffold.js.map
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import "./node-preflight.js";
3
3
  import "./node-network-compat.js";
4
4
  import { isVersionRequest } from "./version-request.js";
5
- import { isFastCoreRequest } from "./commands/scaffold-fast.js";
5
+ import { isFastCoreRequest } from "./fast-scaffold.js";
6
6
  export declare const __test__: {
7
7
  isVersionRequest: typeof isVersionRequest;
8
8
  isFastCoreRequest: typeof isFastCoreRequest;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import "./node-network-compat.js";
6
6
  import { isVersionRequest, wantsLabelledVersionReport } from "./version-request.js";
7
7
  // Dependency-light: a pure parser + table, no command graph. Safe to load on
8
8
  // every path (including --version) without reintroducing the heavy startup.
9
- import { isFastCoreRequest } from "./commands/scaffold-fast.js";
9
+ import { isFastCoreRequest } from "./fast-scaffold.js";
10
10
  // Also dependency-light: registers a process-level rejection boundary; its only
11
11
  // heavy import (main.js) is lazy and reached only when a rejection fires.
12
12
  import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
@@ -33,7 +33,7 @@ if (isVersionRequest(process.argv)) {
33
33
  else if (isFastCoreRequest(process.argv)) {
34
34
  // Hot relocated plumbing (`hq core hq-session`, `hq core checkpoint-stop-gate`)
35
35
  // dispatches straight to the bundled-script runner, so it never pays for the
36
- // ~60-module command graph main.ts imports. See src/commands/scaffold-fast.ts.
36
+ // ~60-module command graph main.ts imports. See src/fast-scaffold.ts.
37
37
  void import("./fast-core.js").then(({ runFastCore }) => runFastCore(process.argv));
38
38
  }
39
39
  else {
@@ -105,6 +105,14 @@ export declare class ApplyError extends Error {
105
105
  }
106
106
  /** Validate one raw candidate. Returns the candidate or a rejection reason. */
107
107
  export declare function parseCandidate(raw: unknown): Candidate | string;
108
+ /**
109
+ * checkpoint.ontology.named-evidence: true when `evidence` holds one of
110
+ * `names` as a word, matching case. A name inside a path, domain, handle, tag
111
+ * or identifier does not count: `indigoai-us/hq`, `getindigo.ai`,
112
+ * `C:\\Indigo\\hq`, `scope: company:indigo`, `Indigo:internal`, `@indigo`,
113
+ * `` `indigo` ``.
114
+ */
115
+ export declare function evidenceNames(evidence: string, names: string[]): boolean;
108
116
  type Resolution = {
109
117
  tier: 1;
110
118
  target: string;
@@ -63,6 +63,25 @@ export function parseCandidate(raw) {
63
63
  evidence: typeof c.evidence === "string" ? c.evidence : "",
64
64
  };
65
65
  }
66
+ /**
67
+ * checkpoint.ontology.named-evidence: true when `evidence` holds one of
68
+ * `names` as a word, matching case. A name inside a path, domain, handle, tag
69
+ * or identifier does not count: `indigoai-us/hq`, `getindigo.ai`,
70
+ * `C:\\Indigo\\hq`, `scope: company:indigo`, `Indigo:internal`, `@indigo`,
71
+ * `` `indigo` ``.
72
+ */
73
+ export function evidenceNames(evidence, names) {
74
+ for (const raw of names) {
75
+ const name = raw.trim();
76
+ if (!name || !evidence)
77
+ continue;
78
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ const word = new RegExp(`(?<![\\p{L}\\p{N}_\\-./\\\\@:\`#])${escaped}(?![\\p{L}\\p{N}_\\-/\\\\@\`]|[.:#][\\p{L}\\p{N}])`, "u");
80
+ if (word.test(evidence))
81
+ return true;
82
+ }
83
+ return false;
84
+ }
66
85
  /** Design section 5.3, tiers 1 to 4. */
67
86
  export function resolveCandidate(index, type, canonical) {
68
87
  const normalized = normalizeEntityName(canonical, type);
@@ -281,6 +300,9 @@ export function applyOntologyRun(options) {
281
300
  if (types.size > 1)
282
301
  crossTypeNames.add(name);
283
302
  }
303
+ // checkpoint.ontology.named-evidence: the company's own company entity.
304
+ const ownCompanyRel = `ontology/entities/company/${company.slug}.md`;
305
+ const isOwnCompany = (entity, rel) => entity.type === "company" && (rel === ownCompanyRel || crossTypeKey(entity.canonical_name) === crossTypeKey(company.slug));
284
306
  const inputs = liveInputs(options.hqRoot, company, plan);
285
307
  const blocked = new Set();
286
308
  const updates = new Map();
@@ -339,6 +361,13 @@ export function applyOntologyRun(options) {
339
361
  note(report.rejected, "ambiguous_type");
340
362
  continue;
341
363
  }
364
+ if (plan.named_evidence === true &&
365
+ input.kind === "knowledge" &&
366
+ isOwnCompany(linked, resolved.target) &&
367
+ !evidenceNames(candidate.evidence, [candidate.name, ...candidate.aliases])) {
368
+ note(report.rejected, "unnamed_in_evidence");
369
+ continue;
370
+ }
342
371
  report.matched += 1;
343
372
  report.links.push({ input: candidate.input, action: "link", type: linked.type, canonical_name: linked.canonical_name, path: resolved.target });
344
373
  if (resolved.tier === 1 && resolved.duplicates.length > 0) {
@@ -379,6 +408,10 @@ export function applyOntologyRun(options) {
379
408
  ? [...(index.canonical.get(entityKey("project", normalizeEntityName(canonical, "project"))) ?? [])]
380
409
  : [];
381
410
  if (projects.length === 1) {
411
+ if (plan.named_evidence === true && !evidenceNames(candidate.evidence, [candidate.name, ...candidate.aliases])) {
412
+ note(report.rejected, "unnamed_in_evidence");
413
+ continue;
414
+ }
382
415
  const linked = index.byRel.get(projects[0]);
383
416
  report.matched += 1;
384
417
  report.links.push({ input: candidate.input, action: "link", type: linked.type, canonical_name: linked.canonical_name, path: projects[0] });
@@ -81,6 +81,19 @@ export declare const SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG = "checkpoint.ontology.sk
81
81
  * to 0.709 at precision 0.747 to 0.731 (61 of the 63 added links are HQ).
82
82
  */
83
83
  export declare const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-project-link";
84
+ /**
85
+ * A knowledge link to the company's own company entity, or a
86
+ * concept-project-link redirect, needs evidence that names the entity as a
87
+ * word: the candidate name or one of its aliases, not inside a path, domain,
88
+ * handle, tag or identifier (`indigoai-us/hq`, `getindigo.ai`,
89
+ * `scope: company:indigo`). The plan carries `named_evidence: true`, and apply
90
+ * rejects such a link as `unnamed_in_evidence`. CO-57: on the six CO-52 runs,
91
+ * 67 of 143 false positives were links to company/indigo and project/HQ, most
92
+ * on evidence that only held the name inside an identifier; a replay of this
93
+ * rule raised precision on the concept-project-link arm from 0.705 to 0.750
94
+ * and lost no gold link.
95
+ */
96
+ export declare const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
84
97
  export declare const PLAN_FILE = "ontology-plan.json";
85
98
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
86
99
  export declare const REPORT_FILE = "ontology-report.json";
@@ -153,6 +166,8 @@ export interface OntologyPlan {
153
166
  skip_ambiguous_concept_links?: true;
154
167
  /** Set by checkpoint.ontology.concept-project-link: apply links a knowledge concept with an ambiguous alias to the one project of that canonical name. */
155
168
  concept_project_link?: true;
169
+ /** Set by checkpoint.ontology.named-evidence: apply rejects a knowledge link to the company's own entity, or a concept-project-link redirect, whose evidence does not name it as a word. */
170
+ named_evidence?: true;
156
171
  inputs: PlanInput[];
157
172
  /** The session's summary, decisions and learnings, for the `session` input. */
158
173
  session_text: string | null;
@@ -85,6 +85,19 @@ export const SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG = "checkpoint.ontology.skip-ambig
85
85
  * to 0.709 at precision 0.747 to 0.731 (61 of the 63 added links are HQ).
86
86
  */
87
87
  export const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-project-link";
88
+ /**
89
+ * A knowledge link to the company's own company entity, or a
90
+ * concept-project-link redirect, needs evidence that names the entity as a
91
+ * word: the candidate name or one of its aliases, not inside a path, domain,
92
+ * handle, tag or identifier (`indigoai-us/hq`, `getindigo.ai`,
93
+ * `scope: company:indigo`). The plan carries `named_evidence: true`, and apply
94
+ * rejects such a link as `unnamed_in_evidence`. CO-57: on the six CO-52 runs,
95
+ * 67 of 143 false positives were links to company/indigo and project/HQ, most
96
+ * on evidence that only held the name inside an identifier; a replay of this
97
+ * rule raised precision on the concept-project-link arm from 0.705 to 0.750
98
+ * and lost no gold link.
99
+ */
100
+ export const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
88
101
  export const PLAN_FILE = "ontology-plan.json";
89
102
  export const CANDIDATES_FILE = "ontology-candidates.json";
90
103
  export const REPORT_FILE = "ontology-report.json";
@@ -264,6 +277,7 @@ export async function planOntologyRun(options) {
264
277
  const firstNamePeople = await options.readFlag(FIRST_NAME_PEOPLE_FLAG, company);
265
278
  const skipAmbiguousConceptLinks = await options.readFlag(SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG, company);
266
279
  const conceptProjectLink = await options.readFlag(CONCEPT_PROJECT_LINK_FLAG, company);
280
+ const namedEvidence = await options.readFlag(NAMED_EVIDENCE_FLAG, company);
267
281
  const touched = sessionTouched(hqRoot, company, payload.files);
268
282
  // A first run takes its boundary from the entity tree, so the ledger is
269
283
  // refreshed before merge-back moves any `last_updated` forward.
@@ -366,6 +380,7 @@ export async function planOntologyRun(options) {
366
380
  ...(firstNamePeople ? { first_name_people: true } : {}),
367
381
  ...(skipAmbiguousConceptLinks ? { skip_ambiguous_concept_links: true } : {}),
368
382
  ...(conceptProjectLink ? { concept_project_link: true } : {}),
383
+ ...(namedEvidence ? { named_evidence: true } : {}),
369
384
  inputs,
370
385
  session_text: sessionText,
371
386
  pending_after_run: remaining,
@@ -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
@@ -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
  {
@@ -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