@indigoai-us/hq-cli 5.98.3 → 5.99.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 (68) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
  3. package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
  4. package/assets/scaffold/core/scripts/hq-session.sh +251 -0
  5. package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
  6. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
  7. package/dist/commands/core.js +25 -5
  8. package/dist/commands/doctor.d.ts +97 -0
  9. package/dist/commands/doctor.js +228 -0
  10. package/dist/commands/scaffold-fast.d.ts +41 -0
  11. package/dist/commands/scaffold-fast.js +57 -0
  12. package/dist/fast-core.d.ts +16 -0
  13. package/dist/fast-core.js +47 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +10 -1
  16. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  17. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  18. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  19. package/dist/lib/doctor/allowed-divergence.js +134 -0
  20. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  21. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  22. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  23. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  24. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  25. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  26. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  27. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  28. package/dist/lib/doctor/compat.d.ts +85 -0
  29. package/dist/lib/doctor/compat.js +102 -0
  30. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  31. package/dist/lib/doctor/deep/classify.js +75 -0
  32. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  33. package/dist/lib/doctor/deep/effects.js +229 -0
  34. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  35. package/dist/lib/doctor/deep/executor.js +369 -0
  36. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  37. package/dist/lib/doctor/deep/parity.js +355 -0
  38. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  39. package/dist/lib/doctor/deep/sandbox.js +572 -0
  40. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  41. package/dist/lib/doctor/fix/apply.js +352 -0
  42. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  43. package/dist/lib/doctor/fix/backup.js +64 -0
  44. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  45. package/dist/lib/doctor/fix/remediation.js +103 -0
  46. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  47. package/dist/lib/doctor/fixtures/discover.js +287 -0
  48. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  49. package/dist/lib/doctor/fixtures/schema.js +248 -0
  50. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  51. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  52. package/dist/lib/doctor/json-output.d.ts +90 -0
  53. package/dist/lib/doctor/json-output.js +76 -0
  54. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  55. package/dist/lib/doctor/payload-shapes.js +275 -0
  56. package/dist/lib/doctor/platform.d.ts +244 -0
  57. package/dist/lib/doctor/platform.js +490 -0
  58. package/dist/lib/doctor/registry.d.ts +49 -0
  59. package/dist/lib/doctor/registry.js +176 -0
  60. package/dist/lib/doctor/report.d.ts +87 -0
  61. package/dist/lib/doctor/report.js +164 -0
  62. package/dist/lib/doctor/types.d.ts +87 -0
  63. package/dist/lib/doctor/types.js +29 -0
  64. package/dist/main.js +6 -0
  65. package/dist/utils/version-check.js +2 -2
  66. package/dist/utils/version-gate.d.ts +1 -1
  67. package/dist/utils/version-gate.js +1 -1
  68. package/package.json +1 -1
@@ -0,0 +1,352 @@
1
+ /**
2
+ * `hq doctor --fix` — apply the allowlisted safe repairs (US-011).
3
+ *
4
+ * Read-by-default is the doctor's whole safety posture, so the write path is
5
+ * wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
6
+ *
7
+ * 1. Dirty-tree refusal. If the working tree has uncommitted changes under
8
+ * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
+ * unless `--force`, so a repair can never be tangled up with unrelated
10
+ * in-flight edits to the security layer.
11
+ * 2. Allowlist only. It repairs exactly three classes — restore an execute
12
+ * bit, add a hook id to the gate profiles it is missing from, re-register a
13
+ * script present on disk — and NEVER rewrites a hook body or deletes a file.
14
+ * Classification is owned by {@link deriveRemediation}; a content-drift
15
+ * finding is manual-only and simply never appears in the fixable set.
16
+ * 3. Preview + confirmation. Every change is shown diff-style and requires
17
+ * confirmation, with `--yes` for non-interactive use.
18
+ * 4. Backup first. Before any write, the affected files are copied under
19
+ * `workspace/doctor/backups/<timestamp>/` and the restore command is
20
+ * printed on completion.
21
+ * 5. Re-verify. After applying, the affected checks are re-run and their
22
+ * post-fix status reported, so the user sees the real result rather than an
23
+ * assumed success.
24
+ *
25
+ * The applier resolves nothing about the host: the caller passes the HQ root
26
+ * (the command resolves it once), keeping this module decoupled from the CLI and
27
+ * trivially testable against a fake tree.
28
+ */
29
+ import { spawnSync } from "node:child_process";
30
+ import * as fs from "node:fs";
31
+ import * as path from "node:path";
32
+ import { createDefaultRegistry } from "../registry.js";
33
+ import { flattenFamilies } from "../report.js";
34
+ import { gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
35
+ import { createBackup } from "./backup.js";
36
+ import { deriveRemediation } from "./remediation.js";
37
+ /** The tree subtrees whose uncommitted changes block a `--fix` run. */
38
+ export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
39
+ /**
40
+ * Apply every auto-fixable finding, honouring the dirty-tree refusal, the
41
+ * preview/confirmation gate, the pre-write backup, and the post-fix re-check.
42
+ * Returns a structured result rather than throwing so both the CLI and tests can
43
+ * assert on it.
44
+ */
45
+ export async function applyFixes(options) {
46
+ const write = options.stdout ?? ((chunk) => void process.stdout.write(chunk));
47
+ const writeErr = options.stderr ?? ((chunk) => void process.stderr.write(chunk));
48
+ const hqRoot = options.hqRoot;
49
+ const empty = (over = {}) => ({
50
+ exitCode: 0,
51
+ wrote: false,
52
+ refused: null,
53
+ fixableCount: 0,
54
+ backupDir: null,
55
+ restoreCommand: null,
56
+ applied: [],
57
+ ...over,
58
+ });
59
+ // 1. Dirty-tree refusal (AC8). Never write over uncommitted security-layer
60
+ // edits unless the user forces it.
61
+ if (!options.force) {
62
+ const dirty = (options.dirtyCheck ?? uncommittedHookConfigChanges)(hqRoot);
63
+ if (dirty.length > 0) {
64
+ writeErr(`hq doctor --fix: refusing to run — uncommitted changes under ` +
65
+ `${HOOK_CONFIG_DIRS.join(", ")}:\n` +
66
+ dirty.map((line) => ` ${line}`).join("\n") +
67
+ `\nCommit or stash them, or re-run with --force.\n`);
68
+ return empty({ exitCode: 1, refused: "dirty-tree" });
69
+ }
70
+ }
71
+ // 2. Collect findings and keep only the allowlisted auto-fixable ones.
72
+ const findings = await runChecks(hqRoot);
73
+ const fixable = [];
74
+ for (const result of findings) {
75
+ const rem = deriveRemediation(result);
76
+ if (rem && rem.autoFixable)
77
+ fixable.push({ result, rem });
78
+ }
79
+ if (fixable.length === 0) {
80
+ write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
81
+ return empty();
82
+ }
83
+ const plans = fixable
84
+ .map(({ result, rem }) => planFix(hqRoot, result, rem))
85
+ .filter((plan) => plan !== null);
86
+ if (plans.length === 0) {
87
+ write("hq doctor --fix: nothing to repair — no auto-fixable findings.\n");
88
+ return empty();
89
+ }
90
+ // 3. Diff-style preview + confirmation (AC5).
91
+ write(`hq doctor --fix will apply ${plans.length} repair${plans.length === 1 ? "" : "s"}:\n\n`);
92
+ for (const plan of plans)
93
+ write(plan.preview + "\n");
94
+ if (!options.yes) {
95
+ const confirmed = await Promise.resolve((options.confirm ?? declineByDefault)());
96
+ if (!confirmed) {
97
+ write("Aborted — no changes written.\n");
98
+ return empty({ refused: "declined" });
99
+ }
100
+ }
101
+ // 4. Back up every file about to change BEFORE the first write (AC6).
102
+ const affected = unique(plans.map((plan) => plan.relpath));
103
+ const backup = createBackup(hqRoot, affected, options.now);
104
+ // 5. Apply. Each plan reads the current on-disk state, so multiple plans that
105
+ // touch the same file (two gate ids) compose correctly.
106
+ for (const plan of plans)
107
+ plan.apply();
108
+ write(`\nBacked up ${backup.files.length} file${backup.files.length === 1 ? "" : "s"} to ${backup.dir}\n`);
109
+ write(`To restore: ${backup.restoreCommand}\n`);
110
+ // 6. Re-run the checks and report the post-fix status of each repair (AC7).
111
+ const after = await runChecks(hqRoot);
112
+ const applied = plans.map((plan) => ({
113
+ checkId: plan.checkId,
114
+ fixClass: plan.fixClass,
115
+ target: plan.target,
116
+ summary: plan.summary,
117
+ postStatus: plan.postStatus(after),
118
+ }));
119
+ write("\nPost-fix status:\n");
120
+ for (const fix of applied) {
121
+ write(` ${fix.postStatus ?? "UNKNOWN"} ${fix.checkId} [${fix.target}]\n`);
122
+ }
123
+ return {
124
+ exitCode: 0,
125
+ wrote: true,
126
+ refused: null,
127
+ fixableCount: fixable.length,
128
+ backupDir: backup.dir,
129
+ restoreCommand: backup.restoreCommand,
130
+ applied,
131
+ };
132
+ }
133
+ /** Run the default (read-only) check registry and flatten to a result list. */
134
+ async function runChecks(hqRoot) {
135
+ const context = { hqRoot, platform: { id: "unknown" } };
136
+ const families = await createDefaultRegistry().run(context);
137
+ return flattenFamilies(families);
138
+ }
139
+ // --- Dirty-tree probe ----------------------------------------------------------
140
+ /**
141
+ * The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
142
+ * each. Returns [] when the tree is clean OR when `hqRoot` is not a git repo
143
+ * (git exits non-zero): with no VCS there is no in-flight change to protect, and
144
+ * the backup still covers the write.
145
+ */
146
+ export function uncommittedHookConfigChanges(hqRoot) {
147
+ const result = spawnSync("git", ["-C", hqRoot, "status", "--porcelain", "--", ...HOOK_CONFIG_DIRS], { encoding: "utf8" });
148
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") {
149
+ return [];
150
+ }
151
+ return result.stdout
152
+ .split("\n")
153
+ .map((line) => line.trim())
154
+ .filter((line) => line.length > 0);
155
+ }
156
+ // --- Fix planning --------------------------------------------------------------
157
+ /** Build the concrete plan for one auto-fixable finding, or null if unplannable. */
158
+ function planFix(hqRoot, result, rem) {
159
+ switch (rem.fixClass) {
160
+ case "executable-bit":
161
+ return planExecutableBit(hqRoot, result, rem);
162
+ case "gate-profile":
163
+ return planGateProfile(hqRoot, result, rem);
164
+ case "register-hook":
165
+ return planRegisterHook(hqRoot, result, rem);
166
+ default:
167
+ return null;
168
+ }
169
+ }
170
+ /** Restore the execute bit on a hook script. */
171
+ function planExecutableBit(hqRoot, result, rem) {
172
+ const abs = rem.fixTarget;
173
+ if (!abs)
174
+ return null;
175
+ let beforeMode;
176
+ try {
177
+ beforeMode = fs.statSync(abs).mode & 0o777;
178
+ }
179
+ catch {
180
+ return null; // Script vanished between check and fix; skip rather than fail.
181
+ }
182
+ const afterMode = beforeMode | 0o111;
183
+ const relpath = toRelpath(hqRoot, abs);
184
+ return {
185
+ checkId: result.checkId,
186
+ fixClass: "executable-bit",
187
+ target: abs,
188
+ relpath,
189
+ preview: ` ${relpath}\n` +
190
+ ` - mode ${octal(beforeMode)}\n` +
191
+ ` + mode ${octal(afterMode)} (restore executable bit)`,
192
+ summary: `chmod +x ${relpath}`,
193
+ apply: () => fs.chmodSync(abs, afterMode),
194
+ postStatus: (results) => statusForPath(results, abs),
195
+ };
196
+ }
197
+ /** Add a gated hook id to the `hook-gate.sh` profiles it is missing from. */
198
+ function planGateProfile(hqRoot, result, rem) {
199
+ const hookId = rem.fixTarget;
200
+ if (!hookId)
201
+ return null;
202
+ const gatePath = path.join(hqRoot, ".claude", "hooks", "hook-gate.sh");
203
+ let source;
204
+ try {
205
+ source = fs.readFileSync(gatePath, "utf8");
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ const missing = gateMembership(hookId, parseHookGateProfiles(source)).missing;
211
+ if (missing.length === 0)
212
+ return null;
213
+ const relpath = toRelpath(hqRoot, gatePath);
214
+ const preview = ` ${relpath}\n` +
215
+ missing
216
+ .map((profile) => ` + is_in_${profile}_profile: allow "${hookId}" (return 0)`)
217
+ .join("\n");
218
+ return {
219
+ checkId: result.checkId,
220
+ fixClass: "gate-profile",
221
+ target: hookId,
222
+ relpath,
223
+ preview,
224
+ summary: `add ${hookId} to ${missing.join(", ")} in hook-gate.sh`,
225
+ apply: () => {
226
+ const current = fs.readFileSync(gatePath, "utf8");
227
+ const updated = addHookIdToGateProfiles(current, hookId, missing);
228
+ fs.writeFileSync(gatePath, updated);
229
+ },
230
+ postStatus: (results) => statusForTarget(results, ".gate-profiles", hookId),
231
+ };
232
+ }
233
+ /** Re-register a script that is present on disk but wired in no settings file. */
234
+ function planRegisterHook(hqRoot, result, rem) {
235
+ const scriptAbs = rem.fixTarget;
236
+ if (!scriptAbs)
237
+ return null;
238
+ const settingsPath = path.join(hqRoot, ".claude", "settings.json");
239
+ const hookId = path.basename(scriptAbs).replace(/\.sh$/, "");
240
+ const event = "PreToolUse";
241
+ const command = `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/hook-gate.sh" ${hookId} ` +
242
+ `"$CLAUDE_PROJECT_DIR/.claude/hooks/${hookId}.sh"`;
243
+ const relpath = toRelpath(hqRoot, settingsPath);
244
+ return {
245
+ checkId: result.checkId,
246
+ fixClass: "register-hook",
247
+ target: scriptAbs,
248
+ relpath,
249
+ preview: ` ${relpath}\n` +
250
+ ` + hooks.${event}: register ${hookId} via hook-gate.sh\n` +
251
+ ` ${command}`,
252
+ summary: `register ${hookId} on ${event}`,
253
+ apply: () => {
254
+ const settings = readJson(settingsPath) ?? {};
255
+ const updated = registerHookInSettings(settings, event, command);
256
+ fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
257
+ },
258
+ // After registration the script becomes a wired, executable file, reported
259
+ // by the Claude wiring tier's `…script` PASS keyed on the same path.
260
+ postStatus: (results) => statusForPath(results, scriptAbs),
261
+ };
262
+ }
263
+ // --- Shell / JSON edit primitives ---------------------------------------------
264
+ /**
265
+ * Insert a new `case` arm allowlisting `hookId` (leading to `return 0`) at the
266
+ * top of each named profile function's `case "$1" in` block. Additive: it never
267
+ * rewrites an existing arm, so an id already present in another profile is
268
+ * untouched and the parser recognises the new arm exactly as it would a
269
+ * hand-written one. A profile whose function or case block cannot be located is
270
+ * left unchanged rather than corrupted.
271
+ */
272
+ export function addHookIdToGateProfiles(source, hookId, profiles) {
273
+ let output = source;
274
+ for (const profile of profiles) {
275
+ output = insertProfileArm(output, profile, hookId);
276
+ }
277
+ return output;
278
+ }
279
+ function insertProfileArm(source, profile, hookId) {
280
+ const fnMatch = new RegExp(`is_in_${profile}_profile\\s*\\(\\)\\s*\\{`).exec(source);
281
+ if (!fnMatch)
282
+ return source;
283
+ const afterFn = fnMatch.index + fnMatch[0].length;
284
+ const region = source.slice(afterFn);
285
+ const caseMatch = /case\s+"\$(?:1|HOOK_ID)"\s+in[^\n]*\n/.exec(region);
286
+ if (!caseMatch)
287
+ return source;
288
+ const insertAt = afterFn + caseMatch.index + caseMatch[0].length;
289
+ const arm = ` ${hookId})\n return 0\n ;;\n`;
290
+ return source.slice(0, insertAt) + arm + source.slice(insertAt);
291
+ }
292
+ /**
293
+ * Append a `type: command` hook registration to `settings.hooks[event]`,
294
+ * creating the `hooks` map and the event array as needed. Tolerant of a
295
+ * non-object `hooks` value — it is replaced rather than trusted.
296
+ */
297
+ export function registerHookInSettings(settings, event, command) {
298
+ const root = settings && typeof settings === "object"
299
+ ? { ...settings }
300
+ : {};
301
+ const hooksValue = root.hooks;
302
+ const hooks = hooksValue && typeof hooksValue === "object"
303
+ ? { ...hooksValue }
304
+ : {};
305
+ const existing = Array.isArray(hooks[event])
306
+ ? [...hooks[event]]
307
+ : [];
308
+ existing.push({ hooks: [{ type: "command", command, timeout: 5 }] });
309
+ hooks[event] = existing;
310
+ root.hooks = hooks;
311
+ return root;
312
+ }
313
+ // --- Post-fix status lookup ----------------------------------------------------
314
+ /** The status of the result whose `target` equals `absPath`, preferring PASS. */
315
+ function statusForPath(results, absPath) {
316
+ const matches = results.filter((result) => result.target === absPath);
317
+ const pass = matches.find((result) => result.status === "PASS");
318
+ return (pass ?? matches[0])?.status ?? null;
319
+ }
320
+ /** The status of the result matching a check-id suffix and target. */
321
+ function statusForTarget(results, checkIdSuffix, target) {
322
+ const match = results.find((result) => result.checkId.endsWith(checkIdSuffix) && result.target === target);
323
+ return match?.status ?? null;
324
+ }
325
+ // --- Small helpers -------------------------------------------------------------
326
+ /** The default interactive confirmation: decline unless a TTY says yes. */
327
+ function declineByDefault() {
328
+ if (!process.stdin.isTTY)
329
+ return false;
330
+ // A real TTY prompt is handled by the command layer; the library default is
331
+ // conservative — never write without an explicit yes.
332
+ return false;
333
+ }
334
+ function readJson(file) {
335
+ try {
336
+ return JSON.parse(fs.readFileSync(file, "utf8"));
337
+ }
338
+ catch {
339
+ return null;
340
+ }
341
+ }
342
+ function toRelpath(hqRoot, abs) {
343
+ const rel = path.relative(hqRoot, abs);
344
+ return rel === "" ? abs : rel;
345
+ }
346
+ function octal(mode) {
347
+ return "0" + (mode & 0o777).toString(8).padStart(3, "0");
348
+ }
349
+ function unique(values) {
350
+ return [...new Set(values)];
351
+ }
352
+ //# sourceMappingURL=apply.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pre-write backups for `hq doctor --fix` (US-011).
3
+ *
4
+ * `--fix` writes to safety-hook configuration, so every run that changes a file
5
+ * first copies the ORIGINAL under `workspace/doctor/backups/<timestamp>/`,
6
+ * preserving the file's relative path so a restore is unambiguous, and hands
7
+ * back a single restore command the applier prints on completion. This is one of
8
+ * the load-bearing guardrails from the PRD: a diagnostic tool that edits the
9
+ * security layer must always leave the user a one-command way back.
10
+ *
11
+ * The module is pure filesystem work with an injectable clock, so the applier's
12
+ * tests get a deterministic backup directory name without racing the wall clock.
13
+ */
14
+ /** Where backups live under the resolved HQ root. */
15
+ export declare const BACKUP_RELDIR: string;
16
+ /** The result of a backup pass. */
17
+ export interface BackupResult {
18
+ /** Absolute path of the timestamped backup directory. */
19
+ dir: string;
20
+ /** Relative paths (to the HQ root) actually copied, in input order. */
21
+ files: string[];
22
+ /** A single shell command that restores every backed-up file in place. */
23
+ restoreCommand: string;
24
+ }
25
+ /**
26
+ * A filesystem-safe backup directory name derived from an instant, e.g.
27
+ * `2026-08-11T08-30-00-000Z`. Colons and dots — illegal or awkward in paths —
28
+ * are replaced with dashes; the value stays sortable and unique per millisecond.
29
+ */
30
+ export declare function backupTimestamp(now?: Date): string;
31
+ /**
32
+ * Copy each of `relpaths` (relative to `hqRoot`) into a fresh timestamped backup
33
+ * directory, preserving both the relative path structure and the file mode.
34
+ * Paths that do not exist on disk are skipped rather than failing the run — the
35
+ * applier only lists files it is about to write, but a missing one must not
36
+ * abort a backup pass. Returns the directory, the copied set, and a restore
37
+ * command.
38
+ */
39
+ export declare function createBackup(hqRoot: string, relpaths: readonly string[], now?: () => Date): BackupResult;
40
+ //# sourceMappingURL=backup.d.ts.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pre-write backups for `hq doctor --fix` (US-011).
3
+ *
4
+ * `--fix` writes to safety-hook configuration, so every run that changes a file
5
+ * first copies the ORIGINAL under `workspace/doctor/backups/<timestamp>/`,
6
+ * preserving the file's relative path so a restore is unambiguous, and hands
7
+ * back a single restore command the applier prints on completion. This is one of
8
+ * the load-bearing guardrails from the PRD: a diagnostic tool that edits the
9
+ * security layer must always leave the user a one-command way back.
10
+ *
11
+ * The module is pure filesystem work with an injectable clock, so the applier's
12
+ * tests get a deterministic backup directory name without racing the wall clock.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ /** Where backups live under the resolved HQ root. */
17
+ export const BACKUP_RELDIR = path.join("workspace", "doctor", "backups");
18
+ /**
19
+ * A filesystem-safe backup directory name derived from an instant, e.g.
20
+ * `2026-08-11T08-30-00-000Z`. Colons and dots — illegal or awkward in paths —
21
+ * are replaced with dashes; the value stays sortable and unique per millisecond.
22
+ */
23
+ export function backupTimestamp(now = new Date()) {
24
+ return now.toISOString().replace(/[:.]/g, "-");
25
+ }
26
+ /**
27
+ * Copy each of `relpaths` (relative to `hqRoot`) into a fresh timestamped backup
28
+ * directory, preserving both the relative path structure and the file mode.
29
+ * Paths that do not exist on disk are skipped rather than failing the run — the
30
+ * applier only lists files it is about to write, but a missing one must not
31
+ * abort a backup pass. Returns the directory, the copied set, and a restore
32
+ * command.
33
+ */
34
+ export function createBackup(hqRoot, relpaths, now = () => new Date()) {
35
+ const dir = path.join(hqRoot, BACKUP_RELDIR, backupTimestamp(now()));
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const files = [];
38
+ for (const rel of relpaths) {
39
+ const src = path.join(hqRoot, rel);
40
+ let mode;
41
+ try {
42
+ mode = fs.statSync(src).mode & 0o777;
43
+ }
44
+ catch {
45
+ continue; // Not on disk — nothing to back up.
46
+ }
47
+ const dest = path.join(dir, rel);
48
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
49
+ fs.copyFileSync(src, dest);
50
+ try {
51
+ fs.chmodSync(dest, mode);
52
+ }
53
+ catch {
54
+ // Best-effort mode preservation; the content copy already succeeded.
55
+ }
56
+ files.push(rel);
57
+ }
58
+ // `cp -a <dir>/. <hqRoot>/` copies the backup contents — including the leading
59
+ // dot-directories (.claude/.codex/.grok) — back over the tree, restoring both
60
+ // content and mode.
61
+ const restoreCommand = `cp -a "${dir}/." "${hqRoot}/"`;
62
+ return { dir, files, restoreCommand };
63
+ }
64
+ //# sourceMappingURL=backup.js.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Structured remediation for every doctor finding (US-011).
3
+ *
4
+ * The wiring/parity tiers (US-004/US-005) already attach a one-line
5
+ * `remediation` string to each finding. `--fix` needs more than prose: it needs
6
+ * to know, mechanically, whether a finding is one of the allowlisted SAFE repair
7
+ * classes, and if so exactly which file or hook id to act on. This module is the
8
+ * single classifier that turns a {@link CheckResult} into that structured shape.
9
+ *
10
+ * The three — and only three — auto-fixable classes are a deliberate, security
11
+ * -reviewed allowlist (see the PRD decision record):
12
+ *
13
+ * - `executable-bit` — restore the execute bit on a hook script that exists
14
+ * but lost `+x`. Detected by the shared `chmod +x …`
15
+ * remediation the Claude, Codex, and core-event exec-bit
16
+ * findings all emit.
17
+ * - `gate-profile` — add a gated hook id to the `hook-gate.sh` profile
18
+ * allowlists it is missing from. Detected by the
19
+ * `…gate-profiles` FAIL from the Claude wiring tier.
20
+ * - `register-hook` — re-register a script that is present on disk but wired
21
+ * nowhere. Detected by the `…orphan` WARN.
22
+ *
23
+ * EVERYTHING else — content drift between platform copies, a missing script, a
24
+ * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
25
+ * allowed-divergence entry — is `autoFixable: false` (manual-only). `--fix` must
26
+ * never rewrite a hook body or create/delete a file, so those cases carry the
27
+ * human remediation and nothing the applier will act on. A test pins that a
28
+ * content-drift finding is manual-only, because that boundary is load-bearing:
29
+ * deciding which of two diverged copies is correct needs human judgement.
30
+ */
31
+ import type { CheckResult } from "../types.js";
32
+ /** The allowlisted safe repair classes `--fix` is permitted to apply. */
33
+ export type FixClass = "executable-bit" | "gate-profile" | "register-hook";
34
+ /**
35
+ * A finding's structured remediation. `autoFixable`, `action`, and `command`
36
+ * are the `--json` surface (AC1); `fixTarget`/`fixClass` are the internal handle
37
+ * `--fix` acts on and are not part of the JSON contract.
38
+ */
39
+ export interface Remediation {
40
+ /** Whether `--fix` is allowed to repair this finding mechanically. */
41
+ autoFixable: boolean;
42
+ /** Plain-language description of the exact repair. */
43
+ action: string;
44
+ /** The equivalent shell command a user could run by hand, or "" when none. */
45
+ command: string;
46
+ /** The safe class when {@link autoFixable}, else null (manual-only). */
47
+ fixClass: FixClass | null;
48
+ /**
49
+ * The concrete file path (exec-bit, register-hook) or hook id (gate-profile)
50
+ * the auto-fix operates on. Present only when {@link autoFixable}. Internal —
51
+ * deliberately omitted from the `--json` document.
52
+ */
53
+ fixTarget?: string;
54
+ }
55
+ /**
56
+ * Extract the target path from a `chmod +x <path>` command, stripping one layer
57
+ * of surrounding single or double quotes. Returns "" when the string is not a
58
+ * chmod-exec command.
59
+ */
60
+ export declare function chmodTargetPath(command: string | undefined): string;
61
+ /**
62
+ * Classify a finding into its structured remediation, or null when the result
63
+ * is not a finding at all (only FAIL and WARN describe an actionable problem;
64
+ * PASS/NA/UNTESTED/UNKNOWN/KNOWN-DEFECT carry nothing to remediate).
65
+ *
66
+ * The classification keys off the shared remediation-string shape and the check
67
+ * id SUFFIX rather than a full id, so it survives a family renaming its id
68
+ * prefix and never mistakes one tier's ids for another's.
69
+ */
70
+ export declare function deriveRemediation(result: CheckResult): Remediation | null;
71
+ //# sourceMappingURL=remediation.d.ts.map
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Structured remediation for every doctor finding (US-011).
3
+ *
4
+ * The wiring/parity tiers (US-004/US-005) already attach a one-line
5
+ * `remediation` string to each finding. `--fix` needs more than prose: it needs
6
+ * to know, mechanically, whether a finding is one of the allowlisted SAFE repair
7
+ * classes, and if so exactly which file or hook id to act on. This module is the
8
+ * single classifier that turns a {@link CheckResult} into that structured shape.
9
+ *
10
+ * The three — and only three — auto-fixable classes are a deliberate, security
11
+ * -reviewed allowlist (see the PRD decision record):
12
+ *
13
+ * - `executable-bit` — restore the execute bit on a hook script that exists
14
+ * but lost `+x`. Detected by the shared `chmod +x …`
15
+ * remediation the Claude, Codex, and core-event exec-bit
16
+ * findings all emit.
17
+ * - `gate-profile` — add a gated hook id to the `hook-gate.sh` profile
18
+ * allowlists it is missing from. Detected by the
19
+ * `…gate-profiles` FAIL from the Claude wiring tier.
20
+ * - `register-hook` — re-register a script that is present on disk but wired
21
+ * nowhere. Detected by the `…orphan` WARN.
22
+ *
23
+ * EVERYTHING else — content drift between platform copies, a missing script, a
24
+ * missing Codex counterpart, an unquoted `$CLAUDE_PROJECT_DIR`, a stale
25
+ * allowed-divergence entry — is `autoFixable: false` (manual-only). `--fix` must
26
+ * never rewrite a hook body or create/delete a file, so those cases carry the
27
+ * human remediation and nothing the applier will act on. A test pins that a
28
+ * content-drift finding is manual-only, because that boundary is load-bearing:
29
+ * deciding which of two diverged copies is correct needs human judgement.
30
+ */
31
+ /** Matches an exec-bit remediation command, quoted or not: `chmod +x <path>`. */
32
+ const CHMOD_EXEC = /^chmod\s+\+x\s+(.+)$/;
33
+ /**
34
+ * Extract the target path from a `chmod +x <path>` command, stripping one layer
35
+ * of surrounding single or double quotes. Returns "" when the string is not a
36
+ * chmod-exec command.
37
+ */
38
+ export function chmodTargetPath(command) {
39
+ if (!command)
40
+ return "";
41
+ const match = CHMOD_EXEC.exec(command.trim());
42
+ if (!match)
43
+ return "";
44
+ return match[1].trim().replace(/^["']/, "").replace(/["']$/, "");
45
+ }
46
+ /**
47
+ * Classify a finding into its structured remediation, or null when the result
48
+ * is not a finding at all (only FAIL and WARN describe an actionable problem;
49
+ * PASS/NA/UNTESTED/UNKNOWN/KNOWN-DEFECT carry nothing to remediate).
50
+ *
51
+ * The classification keys off the shared remediation-string shape and the check
52
+ * id SUFFIX rather than a full id, so it survives a family renaming its id
53
+ * prefix and never mistakes one tier's ids for another's.
54
+ */
55
+ export function deriveRemediation(result) {
56
+ if (result.status !== "FAIL" && result.status !== "WARN")
57
+ return null;
58
+ const { checkId, remediation } = result;
59
+ const target = result.target ?? "";
60
+ // executable-bit — the Claude, Codex, and core-event exec-bit findings all
61
+ // emit a `chmod +x <path>` remediation. Take the path from the command, not
62
+ // from `target`, because the Codex tier reports the hook id in `target`.
63
+ const chmodPath = chmodTargetPath(remediation);
64
+ if (chmodPath) {
65
+ return {
66
+ autoFixable: true,
67
+ fixClass: "executable-bit",
68
+ fixTarget: chmodPath,
69
+ action: `Restore the executable bit on ${chmodPath}.`,
70
+ command: `chmod +x "${chmodPath}"`,
71
+ };
72
+ }
73
+ // gate-profile — a gated hook id present in some but not all three profiles.
74
+ if (checkId.endsWith(".gate-profiles") && result.status === "FAIL" && target) {
75
+ return {
76
+ autoFixable: true,
77
+ fixClass: "gate-profile",
78
+ fixTarget: target,
79
+ action: `Add ${target} to the hook-gate.sh profile allowlists it is missing from.`,
80
+ command: remediation ?? `Add ${target} to the missing hook-gate.sh profiles.`,
81
+ };
82
+ }
83
+ // register-hook — a script on disk that no settings file references.
84
+ if (checkId.endsWith(".orphan") && target) {
85
+ return {
86
+ autoFixable: true,
87
+ fixClass: "register-hook",
88
+ fixTarget: target,
89
+ action: `Re-register ${target} in .claude/settings.json.`,
90
+ command: remediation ?? `Register ${target} in .claude/settings.json.`,
91
+ };
92
+ }
93
+ // Manual-only: content drift, missing scripts/counterparts, unquoted
94
+ // expansions, stale allowed-divergence entries, invalid settings, etc. `--fix`
95
+ // never touches these.
96
+ return {
97
+ autoFixable: false,
98
+ fixClass: null,
99
+ action: remediation ?? result.message,
100
+ command: remediation ?? "",
101
+ };
102
+ }
103
+ //# sourceMappingURL=remediation.js.map