@indigoai-us/hq-cli 5.98.2 → 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.
- package/CHANGELOG.md +52 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
- package/assets/scaffold/core/scripts/hq-session.sh +251 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
- package/dist/commands/core.js +25 -5
- package/dist/commands/doctor.d.ts +97 -0
- package/dist/commands/doctor.js +228 -0
- package/dist/commands/scaffold-fast.d.ts +41 -0
- package/dist/commands/scaffold-fast.js +57 -0
- package/dist/fast-core.d.ts +16 -0
- package/dist/fast-core.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
- package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
- package/dist/lib/doctor/allowed-divergence.js +134 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
- package/dist/lib/doctor/checks/claude-wiring.js +524 -0
- package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
- package/dist/lib/doctor/checks/codex-wiring.js +376 -0
- package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
- package/dist/lib/doctor/checks/grok-wiring.js +186 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
- package/dist/lib/doctor/checks/runtime-probe.js +335 -0
- package/dist/lib/doctor/compat.d.ts +85 -0
- package/dist/lib/doctor/compat.js +102 -0
- package/dist/lib/doctor/deep/classify.d.ts +61 -0
- package/dist/lib/doctor/deep/classify.js +75 -0
- package/dist/lib/doctor/deep/effects.d.ts +107 -0
- package/dist/lib/doctor/deep/effects.js +229 -0
- package/dist/lib/doctor/deep/executor.d.ts +112 -0
- package/dist/lib/doctor/deep/executor.js +369 -0
- package/dist/lib/doctor/deep/parity.d.ts +129 -0
- package/dist/lib/doctor/deep/parity.js +355 -0
- package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
- package/dist/lib/doctor/deep/sandbox.js +572 -0
- package/dist/lib/doctor/fix/apply.d.ts +119 -0
- package/dist/lib/doctor/fix/apply.js +352 -0
- package/dist/lib/doctor/fix/backup.d.ts +40 -0
- package/dist/lib/doctor/fix/backup.js +64 -0
- package/dist/lib/doctor/fix/remediation.d.ts +71 -0
- package/dist/lib/doctor/fix/remediation.js +103 -0
- package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
- package/dist/lib/doctor/fixtures/discover.js +287 -0
- package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
- package/dist/lib/doctor/fixtures/schema.js +248 -0
- package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
- package/dist/lib/doctor/hook-gate-profiles.js +107 -0
- package/dist/lib/doctor/json-output.d.ts +90 -0
- package/dist/lib/doctor/json-output.js +76 -0
- package/dist/lib/doctor/payload-shapes.d.ts +170 -0
- package/dist/lib/doctor/payload-shapes.js +275 -0
- package/dist/lib/doctor/platform.d.ts +244 -0
- package/dist/lib/doctor/platform.js +490 -0
- package/dist/lib/doctor/registry.d.ts +49 -0
- package/dist/lib/doctor/registry.js +176 -0
- package/dist/lib/doctor/report.d.ts +87 -0
- package/dist/lib/doctor/report.js +164 -0
- package/dist/lib/doctor/types.d.ts +87 -0
- package/dist/lib/doctor/types.js +29 -0
- package/dist/main.js +6 -0
- package/dist/utils/hook-trust.d.ts +10 -13
- package/dist/utils/hook-trust.js +148 -27
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +1 -1
- package/package.json +2 -2
|
@@ -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
|