@indigoai-us/hq-cli 5.98.3 → 5.99.1
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 +43 -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-checkpoint.js +11 -3
- package/dist/commands/core.js +60 -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/core-utils/soft-timeout.d.ts +55 -0
- package/dist/lib/core-utils/soft-timeout.js +205 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
- package/dist/lib/core-utils/timeout-guard.js +207 -0
- 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/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 +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime probe (US-006): did HQ's hooks actually FIRE in this session?
|
|
3
|
+
*
|
|
4
|
+
* Every other hooks check answers "is the configuration correct?". This one
|
|
5
|
+
* answers the question that correct configuration cannot: "did the host actually
|
|
6
|
+
* dispatch the hooks it loaded?". `personal-context.md` and
|
|
7
|
+
* `core/docs/hq/HOOKS-NOT-FIRING.md` document that the Claude Code **app** and
|
|
8
|
+
* **SDK** runtimes load `.claude/settings.json` correctly and then never
|
|
9
|
+
* dispatch a single command hook — so a tree that passes every wiring check can
|
|
10
|
+
* still have zero live enforcement. Only evidence that a hook *ran* can catch
|
|
11
|
+
* that, and the one durable trace a real dispatch leaves is the policy-trigger
|
|
12
|
+
* ledger under `workspace/orchestrator/policy-trigger-state/`.
|
|
13
|
+
*
|
|
14
|
+
* ## Two layers, on purpose
|
|
15
|
+
*
|
|
16
|
+
* 1. {@link reproduceCheckHqHooks} is a faithful TypeScript port of
|
|
17
|
+
* `core/scripts/check-hq-hooks.sh --require-ledger`: same settings/wiring
|
|
18
|
+
* checks, same ledger rule, same overall pass/fail. It is the piece US-012
|
|
19
|
+
* reduces that shell script to a wrapper over, and the piece the agreement
|
|
20
|
+
* test pins against the live script. Its verdict is a plain boolean, exactly
|
|
21
|
+
* like the script's exit code.
|
|
22
|
+
*
|
|
23
|
+
* 2. {@link checkRuntimeProbe} turns that raw verdict into an honest,
|
|
24
|
+
* platform-aware doctor result. The raw script FAILs on any missing ledger;
|
|
25
|
+
* the doctor is more discerning, because a missing ledger means different
|
|
26
|
+
* things on different hosts:
|
|
27
|
+
* - app / SDK → FAIL: those hosts never dispatch, so a missing ledger is
|
|
28
|
+
* proof of no enforcement (AC3).
|
|
29
|
+
* - unknown → UNKNOWN: the host could not be identified, so live
|
|
30
|
+
* enforcement cannot be claimed either way (AC4) — never PASS, never a
|
|
31
|
+
* confident FAIL.
|
|
32
|
+
* - CLI / Codex / Grok → UNTESTED: those hosts DO dispatch, so an empty
|
|
33
|
+
* ledger just means nothing has triggered a policy hook yet this
|
|
34
|
+
* session. "Wired but not yet exercised" is UNTESTED, not a failure.
|
|
35
|
+
*
|
|
36
|
+
* The probe is strictly read-only: it stats and reads files and NEVER writes to
|
|
37
|
+
* the ledger or mutates session state (AC6). Reading it can never manufacture the
|
|
38
|
+
* very evidence it is looking for.
|
|
39
|
+
*/
|
|
40
|
+
import type { CheckContext, CheckResult } from "../types.js";
|
|
41
|
+
/** Common id prefix for every result the runtime probe emits. */
|
|
42
|
+
export declare const RUNTIME_PROBE_PREFIX = "hooks.runtime";
|
|
43
|
+
/**
|
|
44
|
+
* The ledger directory, relative to the HQ root. A hook that fires through the
|
|
45
|
+
* policy-trigger path appends a `<session-id>.txt` file here; its presence is the
|
|
46
|
+
* one durable proof that dispatch happened. Kept in exact parity with
|
|
47
|
+
* `check-hq-hooks.sh` (`LEDGER_DIR="$HQ_ROOT/workspace/orchestrator/policy-trigger-state"`).
|
|
48
|
+
*/
|
|
49
|
+
export declare const POLICY_TRIGGER_LEDGER_RELPATH = "workspace/orchestrator/policy-trigger-state";
|
|
50
|
+
/** The two lifecycle events `check-hq-hooks.sh` requires a command hook on. */
|
|
51
|
+
export declare const REQUIRED_HOOK_EVENTS: readonly ["SessionStart", "PreToolUse"];
|
|
52
|
+
/** Whether the ledger was found. Mirrors the script's `present`/`missing`. */
|
|
53
|
+
export type LedgerState = "present" | "missing";
|
|
54
|
+
/** Options for {@link reproduceCheckHqHooks}. */
|
|
55
|
+
export interface ReproduceOptions {
|
|
56
|
+
/** Absolute HQ tree root to inspect. */
|
|
57
|
+
hqRoot: string;
|
|
58
|
+
/**
|
|
59
|
+
* Scope the ledger check to this exact session (the `--session-id` flag). When
|
|
60
|
+
* set, only `<sessionId>.txt` counts; an older session's ledger cannot stand in
|
|
61
|
+
* for the current runtime. When absent, any ledger file suffices, exactly as
|
|
62
|
+
* the script behaves without `--session-id`.
|
|
63
|
+
*/
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The reproduced verdict of `check-hq-hooks.sh --require-ledger`. `ok` is the
|
|
68
|
+
* boolean twin of the script's exit code: true ⇔ exit 0, false ⇔ exit non-zero.
|
|
69
|
+
*/
|
|
70
|
+
export interface HookLoadReproduction {
|
|
71
|
+
/** True iff the script would exit 0 (no issues, ledger present). */
|
|
72
|
+
ok: boolean;
|
|
73
|
+
/** Whether the (optionally session-scoped) ledger was found. */
|
|
74
|
+
ledgerState: LedgerState;
|
|
75
|
+
/** The human-readable issues, mirroring the script's `ISSUES` array. */
|
|
76
|
+
issues: string[];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Faithfully reproduce `core/scripts/check-hq-hooks.sh --require-ledger` in
|
|
80
|
+
* TypeScript. The checks, in the script's order:
|
|
81
|
+
*
|
|
82
|
+
* 1. `.claude/settings.json` present and valid JSON.
|
|
83
|
+
* 2. A `type: "command"` hook on both SessionStart and PreToolUse.
|
|
84
|
+
* 3. No hook command word-splits `$CLAUDE_PROJECT_DIR` (the quote-aware scan
|
|
85
|
+
* shared with the Claude wiring tier), and every script a command actually
|
|
86
|
+
* runs exists on disk.
|
|
87
|
+
* 4. The optional `.claude/settings.local.json` overlay, when present, is valid
|
|
88
|
+
* JSON and passes the same command scan.
|
|
89
|
+
* 5. The policy-trigger ledger exists (session-scoped when `sessionId` is set).
|
|
90
|
+
*
|
|
91
|
+
* Never throws: an unreadable or malformed input becomes an issue, not an
|
|
92
|
+
* exception, so the probe degrades exactly like the defensively-written script.
|
|
93
|
+
*/
|
|
94
|
+
export declare function reproduceCheckHqHooks(opts: ReproduceOptions): HookLoadReproduction;
|
|
95
|
+
/**
|
|
96
|
+
* The runtime-probe check family entry. Reproduces the script verdict, then
|
|
97
|
+
* renders it as a single platform-aware doctor result. See the module header for
|
|
98
|
+
* the per-platform status rationale.
|
|
99
|
+
*/
|
|
100
|
+
export declare function checkRuntimeProbe(context: CheckContext): CheckResult[];
|
|
101
|
+
//# sourceMappingURL=runtime-probe.d.ts.map
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime probe (US-006): did HQ's hooks actually FIRE in this session?
|
|
3
|
+
*
|
|
4
|
+
* Every other hooks check answers "is the configuration correct?". This one
|
|
5
|
+
* answers the question that correct configuration cannot: "did the host actually
|
|
6
|
+
* dispatch the hooks it loaded?". `personal-context.md` and
|
|
7
|
+
* `core/docs/hq/HOOKS-NOT-FIRING.md` document that the Claude Code **app** and
|
|
8
|
+
* **SDK** runtimes load `.claude/settings.json` correctly and then never
|
|
9
|
+
* dispatch a single command hook — so a tree that passes every wiring check can
|
|
10
|
+
* still have zero live enforcement. Only evidence that a hook *ran* can catch
|
|
11
|
+
* that, and the one durable trace a real dispatch leaves is the policy-trigger
|
|
12
|
+
* ledger under `workspace/orchestrator/policy-trigger-state/`.
|
|
13
|
+
*
|
|
14
|
+
* ## Two layers, on purpose
|
|
15
|
+
*
|
|
16
|
+
* 1. {@link reproduceCheckHqHooks} is a faithful TypeScript port of
|
|
17
|
+
* `core/scripts/check-hq-hooks.sh --require-ledger`: same settings/wiring
|
|
18
|
+
* checks, same ledger rule, same overall pass/fail. It is the piece US-012
|
|
19
|
+
* reduces that shell script to a wrapper over, and the piece the agreement
|
|
20
|
+
* test pins against the live script. Its verdict is a plain boolean, exactly
|
|
21
|
+
* like the script's exit code.
|
|
22
|
+
*
|
|
23
|
+
* 2. {@link checkRuntimeProbe} turns that raw verdict into an honest,
|
|
24
|
+
* platform-aware doctor result. The raw script FAILs on any missing ledger;
|
|
25
|
+
* the doctor is more discerning, because a missing ledger means different
|
|
26
|
+
* things on different hosts:
|
|
27
|
+
* - app / SDK → FAIL: those hosts never dispatch, so a missing ledger is
|
|
28
|
+
* proof of no enforcement (AC3).
|
|
29
|
+
* - unknown → UNKNOWN: the host could not be identified, so live
|
|
30
|
+
* enforcement cannot be claimed either way (AC4) — never PASS, never a
|
|
31
|
+
* confident FAIL.
|
|
32
|
+
* - CLI / Codex / Grok → UNTESTED: those hosts DO dispatch, so an empty
|
|
33
|
+
* ledger just means nothing has triggered a policy hook yet this
|
|
34
|
+
* session. "Wired but not yet exercised" is UNTESTED, not a failure.
|
|
35
|
+
*
|
|
36
|
+
* The probe is strictly read-only: it stats and reads files and NEVER writes to
|
|
37
|
+
* the ledger or mutates session state (AC6). Reading it can never manufacture the
|
|
38
|
+
* very evidence it is looking for.
|
|
39
|
+
*/
|
|
40
|
+
import * as fs from "node:fs";
|
|
41
|
+
import * as path from "node:path";
|
|
42
|
+
import { scanHookCommand } from "./claude-wiring.js";
|
|
43
|
+
/** Common id prefix for every result the runtime probe emits. */
|
|
44
|
+
export const RUNTIME_PROBE_PREFIX = "hooks.runtime";
|
|
45
|
+
/**
|
|
46
|
+
* The ledger directory, relative to the HQ root. A hook that fires through the
|
|
47
|
+
* policy-trigger path appends a `<session-id>.txt` file here; its presence is the
|
|
48
|
+
* one durable proof that dispatch happened. Kept in exact parity with
|
|
49
|
+
* `check-hq-hooks.sh` (`LEDGER_DIR="$HQ_ROOT/workspace/orchestrator/policy-trigger-state"`).
|
|
50
|
+
*/
|
|
51
|
+
export const POLICY_TRIGGER_LEDGER_RELPATH = "workspace/orchestrator/policy-trigger-state";
|
|
52
|
+
/** The two lifecycle events `check-hq-hooks.sh` requires a command hook on. */
|
|
53
|
+
export const REQUIRED_HOOK_EVENTS = ["SessionStart", "PreToolUse"];
|
|
54
|
+
/**
|
|
55
|
+
* Faithfully reproduce `core/scripts/check-hq-hooks.sh --require-ledger` in
|
|
56
|
+
* TypeScript. The checks, in the script's order:
|
|
57
|
+
*
|
|
58
|
+
* 1. `.claude/settings.json` present and valid JSON.
|
|
59
|
+
* 2. A `type: "command"` hook on both SessionStart and PreToolUse.
|
|
60
|
+
* 3. No hook command word-splits `$CLAUDE_PROJECT_DIR` (the quote-aware scan
|
|
61
|
+
* shared with the Claude wiring tier), and every script a command actually
|
|
62
|
+
* runs exists on disk.
|
|
63
|
+
* 4. The optional `.claude/settings.local.json` overlay, when present, is valid
|
|
64
|
+
* JSON and passes the same command scan.
|
|
65
|
+
* 5. The policy-trigger ledger exists (session-scoped when `sessionId` is set).
|
|
66
|
+
*
|
|
67
|
+
* Never throws: an unreadable or malformed input becomes an issue, not an
|
|
68
|
+
* exception, so the probe degrades exactly like the defensively-written script.
|
|
69
|
+
*/
|
|
70
|
+
export function reproduceCheckHqHooks(opts) {
|
|
71
|
+
const { hqRoot, sessionId } = opts;
|
|
72
|
+
const issues = [];
|
|
73
|
+
const rootHasSpace = /\s/.test(hqRoot);
|
|
74
|
+
const settingsPath = path.join(hqRoot, ".claude", "settings.json");
|
|
75
|
+
const localPath = path.join(hqRoot, ".claude", "settings.local.json");
|
|
76
|
+
// --- 1 + 2 + 3: the shipped settings file -----------------------------------
|
|
77
|
+
const base = readJson(settingsPath);
|
|
78
|
+
if (!base.present) {
|
|
79
|
+
issues.push(".claude/settings.json is missing");
|
|
80
|
+
}
|
|
81
|
+
else if (base.invalid) {
|
|
82
|
+
issues.push(".claude/settings.json is not valid JSON");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
for (const event of REQUIRED_HOOK_EVENTS) {
|
|
86
|
+
if (!hasCommandHook(base.value, event)) {
|
|
87
|
+
issues.push(`${event} has no command hook in .claude/settings.json`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
scanSettingsFile(base.value, ".claude/settings.json", hqRoot, rootHasSpace, issues);
|
|
91
|
+
}
|
|
92
|
+
// --- 4: the optional local overlay ------------------------------------------
|
|
93
|
+
// Its absence is never an issue, but when present Claude Code merges its hooks
|
|
94
|
+
// in, so a command hiding there fails a spaced root exactly like a shipped one.
|
|
95
|
+
const local = readJson(localPath);
|
|
96
|
+
if (local.present) {
|
|
97
|
+
if (local.invalid) {
|
|
98
|
+
issues.push(".claude/settings.local.json is not valid JSON");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
scanSettingsFile(local.value, ".claude/settings.local.json", hqRoot, rootHasSpace, issues);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// --- 5: the policy-trigger ledger -------------------------------------------
|
|
105
|
+
const ledgerState = resolveLedgerState(hqRoot, sessionId);
|
|
106
|
+
if (ledgerState === "missing") {
|
|
107
|
+
issues.push(sessionId
|
|
108
|
+
? `policy-trigger ledger was not found for session ${sessionId} under ${POLICY_TRIGGER_LEDGER_RELPATH}`
|
|
109
|
+
: `policy-trigger ledger was not found under ${POLICY_TRIGGER_LEDGER_RELPATH}`);
|
|
110
|
+
}
|
|
111
|
+
return { ok: issues.length === 0, ledgerState, issues };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The runtime-probe check family entry. Reproduces the script verdict, then
|
|
115
|
+
* renders it as a single platform-aware doctor result. See the module header for
|
|
116
|
+
* the per-platform status rationale.
|
|
117
|
+
*/
|
|
118
|
+
export function checkRuntimeProbe(context) {
|
|
119
|
+
const hqRoot = context.hqRoot;
|
|
120
|
+
const sessionId = context.sessionId;
|
|
121
|
+
const platformId = context.platform?.id ?? "unknown";
|
|
122
|
+
const checkId = `${RUNTIME_PROBE_PREFIX}.enforcement`;
|
|
123
|
+
const target = POLICY_TRIGGER_LEDGER_RELPATH;
|
|
124
|
+
const scope = sessionId ? ` for session ${sessionId}` : "";
|
|
125
|
+
const repro = reproduceCheckHqHooks({ hqRoot, sessionId });
|
|
126
|
+
// Unknown host: the label cannot be trusted, so live enforcement cannot be
|
|
127
|
+
// verified in either direction. UNKNOWN (never PASS or FAIL) is the honest
|
|
128
|
+
// verdict and, per the exit-code contract, still draws the user's attention.
|
|
129
|
+
if (platformId === "unknown") {
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
status: "UNKNOWN",
|
|
133
|
+
checkId,
|
|
134
|
+
target,
|
|
135
|
+
message: `Host platform is unknown, so whether hooks actually dispatched this ` +
|
|
136
|
+
`session cannot be verified — the policy-trigger ledger is ${repro.ledgerState}${scope}. ` +
|
|
137
|
+
`Not reporting hook enforcement as working.`,
|
|
138
|
+
remediation: `Run hq doctor from the terminal Claude Code CLI (pass --session-id on ` +
|
|
139
|
+
`an app/SDK host) for a definitive runtime verdict.`,
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
// Ledger present ⇒ a hook demonstrably fired this session ⇒ enforcement is live.
|
|
144
|
+
if (repro.ledgerState === "present") {
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
status: "PASS",
|
|
148
|
+
checkId,
|
|
149
|
+
target,
|
|
150
|
+
message: `Hook dispatch was observed this session: the policy-trigger ledger has an entry${scope}.`,
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
// Ledger missing on a non-dispatching host is proof, not ambiguity: the app and
|
|
155
|
+
// SDK runtimes load hook config and never fire it. This is the one failure the
|
|
156
|
+
// whole probe exists to surface.
|
|
157
|
+
if (platformId === "claude-code-app" || platformId === "claude-code-sdk") {
|
|
158
|
+
const host = platformId === "claude-code-app" ? "Claude Code app" : "Claude Code SDK";
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
status: "FAIL",
|
|
162
|
+
checkId,
|
|
163
|
+
target,
|
|
164
|
+
message: `Hook enforcement is NOT observed: no policy-trigger ledger entry exists${scope} and this host ` +
|
|
165
|
+
`is the ${host} runtime, which does not dispatch command hooks. Correct-looking hook ` +
|
|
166
|
+
`configuration is not working enforcement here.`,
|
|
167
|
+
remediation: `Use the terminal Claude Code CLI for hook-enforced work (see ` +
|
|
168
|
+
`core/docs/hq/HOOKS-NOT-FIRING.md). Verify with: ` +
|
|
169
|
+
`bash core/scripts/check-hq-hooks.sh --root "$PWD" --require-ledger.`,
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
// Dispatching host (CLI / Codex / Grok) with an empty ledger: hooks are wired
|
|
174
|
+
// and this host does fire them, but nothing has triggered a policy hook yet
|
|
175
|
+
// this session. Wired-but-not-yet-exercised is UNTESTED, never a failure.
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
status: "UNTESTED",
|
|
179
|
+
checkId,
|
|
180
|
+
target,
|
|
181
|
+
message: `Hooks are wired but dispatch has not been observed yet: the policy-trigger ledger has no ` +
|
|
182
|
+
`entry${scope}. This host (${platformId}) does dispatch hooks, so trigger a policy-gated action ` +
|
|
183
|
+
`and re-check to confirm live enforcement.`,
|
|
184
|
+
remediation: `After a real action, verify with: bash core/scripts/check-hq-hooks.sh --root "$PWD" ` +
|
|
185
|
+
`--require-ledger${sessionId ? ` --session-id ${sessionId}` : ""}.`,
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
// --- ledger ------------------------------------------------------------------
|
|
190
|
+
/**
|
|
191
|
+
* Resolve whether the ledger counts as present. Session-scoped: only
|
|
192
|
+
* `<sessionId>.txt` counts. Unscoped: any `*.txt` anywhere under the ledger
|
|
193
|
+
* directory counts, matching the script's recursive `find -type f -name '*.txt'`.
|
|
194
|
+
*/
|
|
195
|
+
function resolveLedgerState(hqRoot, sessionId) {
|
|
196
|
+
const ledgerDir = path.join(hqRoot, ...POLICY_TRIGGER_LEDGER_RELPATH.split("/"));
|
|
197
|
+
if (sessionId) {
|
|
198
|
+
return isFile(path.join(ledgerDir, `${sessionId}.txt`))
|
|
199
|
+
? "present"
|
|
200
|
+
: "missing";
|
|
201
|
+
}
|
|
202
|
+
return ledgerDirHasTxt(ledgerDir) ? "present" : "missing";
|
|
203
|
+
}
|
|
204
|
+
/** True when any `*.txt` file exists anywhere under `dir` (recursive). */
|
|
205
|
+
function ledgerDirHasTxt(dir) {
|
|
206
|
+
let entries;
|
|
207
|
+
try {
|
|
208
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
const full = path.join(dir, entry.name);
|
|
215
|
+
if (entry.isDirectory()) {
|
|
216
|
+
if (ledgerDirHasTxt(full))
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
else if (entry.isFile() && entry.name.endsWith(".txt")) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
// --- settings inspection -----------------------------------------------------
|
|
226
|
+
/**
|
|
227
|
+
* Scan every command hook in one parsed settings object: count the commands that
|
|
228
|
+
* word-split `$CLAUDE_PROJECT_DIR`, and check that every script a command
|
|
229
|
+
* actually runs exists on disk. Appends to `issues`, mirroring the script's
|
|
230
|
+
* `scan_hook_commands`.
|
|
231
|
+
*/
|
|
232
|
+
function scanSettingsFile(settings, label, hqRoot, rootHasSpace, issues) {
|
|
233
|
+
const commands = enumerateCommands(settings);
|
|
234
|
+
const scans = commands.map((command) => scanHookCommand(command));
|
|
235
|
+
const unquoted = scans.filter((scan) => scan.unquotedProjectDir).length;
|
|
236
|
+
if (unquoted > 0) {
|
|
237
|
+
issues.push(rootHasSpace
|
|
238
|
+
? `${unquoted} hook command(s) in ${label} reference $CLAUDE_PROJECT_DIR without quotes and this HQ root contains a space, so /bin/sh splits the path and every one of those hooks is failing right now: ${hqRoot}`
|
|
239
|
+
: `${unquoted} hook command(s) in ${label} reference $CLAUDE_PROJECT_DIR without quotes; they break on any install path containing a space`);
|
|
240
|
+
}
|
|
241
|
+
const required = new Set();
|
|
242
|
+
for (const scan of scans) {
|
|
243
|
+
for (const rp of scan.requiredRelpaths)
|
|
244
|
+
required.add(rp);
|
|
245
|
+
}
|
|
246
|
+
for (const rp of [...required].sort()) {
|
|
247
|
+
if (!fs.existsSync(path.join(hqRoot, rp))) {
|
|
248
|
+
issues.push(`a hook command in ${label} runs a script that does not exist: ${rp}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Whether `settings.hooks[event]` carries at least one non-empty `type:
|
|
254
|
+
* "command"` hook. Tolerant of every malformed shape — a non-conforming branch
|
|
255
|
+
* simply does not count.
|
|
256
|
+
*/
|
|
257
|
+
function hasCommandHook(settings, event) {
|
|
258
|
+
const entries = hookEntriesFor(settings, event);
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
const inner = entry?.hooks;
|
|
261
|
+
if (!Array.isArray(inner))
|
|
262
|
+
continue;
|
|
263
|
+
for (const item of inner) {
|
|
264
|
+
const hook = item;
|
|
265
|
+
if (hook?.type === "command" &&
|
|
266
|
+
typeof hook.command === "string" &&
|
|
267
|
+
hook.command.length > 0) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
/** Every non-empty command string across every lifecycle event in `settings`. */
|
|
275
|
+
function enumerateCommands(settings) {
|
|
276
|
+
const out = [];
|
|
277
|
+
const hooks = hooksObject(settings);
|
|
278
|
+
if (!hooks)
|
|
279
|
+
return out;
|
|
280
|
+
for (const entries of Object.values(hooks)) {
|
|
281
|
+
if (!Array.isArray(entries))
|
|
282
|
+
continue;
|
|
283
|
+
for (const entry of entries) {
|
|
284
|
+
const inner = entry?.hooks;
|
|
285
|
+
if (!Array.isArray(inner))
|
|
286
|
+
continue;
|
|
287
|
+
for (const item of inner) {
|
|
288
|
+
const hook = item;
|
|
289
|
+
if (hook?.type === "command" &&
|
|
290
|
+
typeof hook.command === "string" &&
|
|
291
|
+
hook.command.length > 0) {
|
|
292
|
+
out.push(hook.command);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
function hookEntriesFor(settings, event) {
|
|
300
|
+
const hooks = hooksObject(settings);
|
|
301
|
+
const entries = hooks?.[event];
|
|
302
|
+
return Array.isArray(entries) ? entries : [];
|
|
303
|
+
}
|
|
304
|
+
function hooksObject(settings) {
|
|
305
|
+
if (!settings || typeof settings !== "object")
|
|
306
|
+
return null;
|
|
307
|
+
const hooks = settings.hooks;
|
|
308
|
+
if (!hooks || typeof hooks !== "object")
|
|
309
|
+
return null;
|
|
310
|
+
return hooks;
|
|
311
|
+
}
|
|
312
|
+
function readJson(file) {
|
|
313
|
+
let raw;
|
|
314
|
+
try {
|
|
315
|
+
raw = fs.readFileSync(file, "utf8");
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return { present: false };
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return { present: true, value: JSON.parse(raw) };
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return { present: true, invalid: true };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function isFile(file) {
|
|
328
|
+
try {
|
|
329
|
+
return fs.statSync(file).isFile();
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
//# sourceMappingURL=runtime-probe.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-hq-hooks.sh ⇄ `hq doctor` compatibility (US-012).
|
|
3
|
+
*
|
|
4
|
+
* US-012 reduces `core/scripts/check-hq-hooks.sh` to a thin wrapper over
|
|
5
|
+
* `hq doctor`, so HQ has ONE answer to "are my hooks working" instead of four
|
|
6
|
+
* partially-overlapping tools. The wrapper must, however, keep its narrow
|
|
7
|
+
* contract intact (AC1): the same PASS/FAIL header, the same
|
|
8
|
+
* `HQ runtime enforcement: OBSERVED / NOT OBSERVED` line the app/SDK safety
|
|
9
|
+
* instruction in `personal-context.md` greps for, and the same 0/2 exit codes —
|
|
10
|
+
* and it must degrade to its inline implementation when the `hq` CLI is absent
|
|
11
|
+
* or too old (AC2), because it exists precisely to run when the toolchain is
|
|
12
|
+
* suspect.
|
|
13
|
+
*
|
|
14
|
+
* The wrapper therefore does NOT adopt the full `hq doctor` verdict: the doctor
|
|
15
|
+
* is a strict superset (Codex/Grok parity, three-profile membership, exec bits,
|
|
16
|
+
* fixture coverage) and reports the known Codex drift as FAIL on a healthy tree,
|
|
17
|
+
* which would break every existing check-hq-hooks caller. Instead it maps a
|
|
18
|
+
* *scoped* slice of the doctor's JSON — exactly the checks check-hq-hooks
|
|
19
|
+
* originally performed (settings load + the policy-trigger ledger) — back onto
|
|
20
|
+
* its own contract.
|
|
21
|
+
*
|
|
22
|
+
* This module is that scope and mapping, as one tested TypeScript reference. The
|
|
23
|
+
* shell wrapper mirrors it in bash (`jq` over `hq doctor --json`); the agreement
|
|
24
|
+
* test (`compat.test.ts`) pins the real wrapper's exit code and output against
|
|
25
|
+
* both this reference and the live `hq doctor` on healthy and broken fake trees.
|
|
26
|
+
* It is the same "two implementations, one contract, pinned by a test" pattern
|
|
27
|
+
* that `reproduceCheckHqHooks` (US-006) uses in the other direction.
|
|
28
|
+
*/
|
|
29
|
+
import type { DoctorJsonDocument } from "./json-output.js";
|
|
30
|
+
/** Exit code check-hq-hooks.sh returns on success. Preserved by the wrapper. */
|
|
31
|
+
export declare const CHECK_HQ_HOOKS_PASS_EXIT = 0;
|
|
32
|
+
/** Exit code check-hq-hooks.sh returns on a health failure. Preserved. */
|
|
33
|
+
export declare const CHECK_HQ_HOOKS_FAIL_EXIT = 2;
|
|
34
|
+
/** Exit code check-hq-hooks.sh returns on a usage error. Preserved. */
|
|
35
|
+
export declare const CHECK_HQ_HOOKS_USAGE_EXIT = 64;
|
|
36
|
+
/** The runtime-probe result id: whether hooks actually fired (the ledger). */
|
|
37
|
+
export declare const RUNTIME_ENFORCEMENT_CHECK_ID = "hooks.runtime.enforcement";
|
|
38
|
+
/**
|
|
39
|
+
* The `hq doctor` check ids that correspond to check-hq-hooks.sh's *settings
|
|
40
|
+
* load* scope — the subset of the hooks family the wrapper is allowed to fail
|
|
41
|
+
* on. Deliberately narrow: it covers a missing/invalid settings file, an
|
|
42
|
+
* unquoted `$CLAUDE_PROJECT_DIR` that word-splits on a spaced root, and a hook
|
|
43
|
+
* command that runs a script which is not on disk — and nothing else. The many
|
|
44
|
+
* richer doctor findings (executable bits, orphaned scripts, three-profile
|
|
45
|
+
* membership, Codex/Grok parity, fixture coverage) are intentionally OUT of
|
|
46
|
+
* scope, so the wrapper stays byte-for-byte compatible with callers that only
|
|
47
|
+
* ever cared whether the hook configuration could load.
|
|
48
|
+
*/
|
|
49
|
+
export declare const CHECK_HQ_HOOKS_SETTINGS_SCOPE: readonly string[];
|
|
50
|
+
/**
|
|
51
|
+
* Whether hook dispatch was observed this session. Mirrors the wrapper's
|
|
52
|
+
* `HQ runtime enforcement: …` line. `NOT CHECKED` is the default when
|
|
53
|
+
* `--require-ledger` was not passed (the wrapper prints "ledger: not checked").
|
|
54
|
+
*/
|
|
55
|
+
export type RuntimeObservation = "OBSERVED" | "NOT OBSERVED" | "NOT CHECKED";
|
|
56
|
+
/** The verdict the wrapper renders, derived from a scoped `hq doctor` run. */
|
|
57
|
+
export interface CheckHqHooksVerdict {
|
|
58
|
+
/** True iff the wrapper exits 0 (no in-scope failure, ledger observed when required). */
|
|
59
|
+
ok: boolean;
|
|
60
|
+
/** The exit code the wrapper returns: 0 on pass, 2 on failure. */
|
|
61
|
+
exitCode: number;
|
|
62
|
+
/** The `HQ hook health: …` header value. */
|
|
63
|
+
hookHealth: "PASS" | "FAIL";
|
|
64
|
+
/** The `HQ runtime enforcement: …` observation. */
|
|
65
|
+
runtime: RuntimeObservation;
|
|
66
|
+
/** The failing messages, in the wrapper's ` - <message>` bullet form. */
|
|
67
|
+
issues: string[];
|
|
68
|
+
}
|
|
69
|
+
/** Options for {@link deriveCheckHqHooksVerdict}. */
|
|
70
|
+
export interface DeriveVerdictOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Whether `--require-ledger` (or `--session-id`, which implies it) was passed.
|
|
73
|
+
* When true, an unobserved policy-trigger ledger is a failure, exactly as the
|
|
74
|
+
* inline script treats a missing ledger — regardless of platform.
|
|
75
|
+
*/
|
|
76
|
+
requireLedger?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Derive the check-hq-hooks.sh verdict from a `hq doctor --json` document,
|
|
80
|
+
* scoped to check-hq-hooks' original concerns. This is the exact logic the
|
|
81
|
+
* shell wrapper reproduces with `jq`; keeping it here as one tested function is
|
|
82
|
+
* what lets `compat.test.ts` prove the wrapper and `hq doctor` agree.
|
|
83
|
+
*/
|
|
84
|
+
export declare function deriveCheckHqHooksVerdict(doc: DoctorJsonDocument, options?: DeriveVerdictOptions): CheckHqHooksVerdict;
|
|
85
|
+
//# sourceMappingURL=compat.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-hq-hooks.sh ⇄ `hq doctor` compatibility (US-012).
|
|
3
|
+
*
|
|
4
|
+
* US-012 reduces `core/scripts/check-hq-hooks.sh` to a thin wrapper over
|
|
5
|
+
* `hq doctor`, so HQ has ONE answer to "are my hooks working" instead of four
|
|
6
|
+
* partially-overlapping tools. The wrapper must, however, keep its narrow
|
|
7
|
+
* contract intact (AC1): the same PASS/FAIL header, the same
|
|
8
|
+
* `HQ runtime enforcement: OBSERVED / NOT OBSERVED` line the app/SDK safety
|
|
9
|
+
* instruction in `personal-context.md` greps for, and the same 0/2 exit codes —
|
|
10
|
+
* and it must degrade to its inline implementation when the `hq` CLI is absent
|
|
11
|
+
* or too old (AC2), because it exists precisely to run when the toolchain is
|
|
12
|
+
* suspect.
|
|
13
|
+
*
|
|
14
|
+
* The wrapper therefore does NOT adopt the full `hq doctor` verdict: the doctor
|
|
15
|
+
* is a strict superset (Codex/Grok parity, three-profile membership, exec bits,
|
|
16
|
+
* fixture coverage) and reports the known Codex drift as FAIL on a healthy tree,
|
|
17
|
+
* which would break every existing check-hq-hooks caller. Instead it maps a
|
|
18
|
+
* *scoped* slice of the doctor's JSON — exactly the checks check-hq-hooks
|
|
19
|
+
* originally performed (settings load + the policy-trigger ledger) — back onto
|
|
20
|
+
* its own contract.
|
|
21
|
+
*
|
|
22
|
+
* This module is that scope and mapping, as one tested TypeScript reference. The
|
|
23
|
+
* shell wrapper mirrors it in bash (`jq` over `hq doctor --json`); the agreement
|
|
24
|
+
* test (`compat.test.ts`) pins the real wrapper's exit code and output against
|
|
25
|
+
* both this reference and the live `hq doctor` on healthy and broken fake trees.
|
|
26
|
+
* It is the same "two implementations, one contract, pinned by a test" pattern
|
|
27
|
+
* that `reproduceCheckHqHooks` (US-006) uses in the other direction.
|
|
28
|
+
*/
|
|
29
|
+
/** Exit code check-hq-hooks.sh returns on success. Preserved by the wrapper. */
|
|
30
|
+
export const CHECK_HQ_HOOKS_PASS_EXIT = 0;
|
|
31
|
+
/** Exit code check-hq-hooks.sh returns on a health failure. Preserved. */
|
|
32
|
+
export const CHECK_HQ_HOOKS_FAIL_EXIT = 2;
|
|
33
|
+
/** Exit code check-hq-hooks.sh returns on a usage error. Preserved. */
|
|
34
|
+
export const CHECK_HQ_HOOKS_USAGE_EXIT = 64;
|
|
35
|
+
/** The runtime-probe result id: whether hooks actually fired (the ledger). */
|
|
36
|
+
export const RUNTIME_ENFORCEMENT_CHECK_ID = "hooks.runtime.enforcement";
|
|
37
|
+
/**
|
|
38
|
+
* The `hq doctor` check ids that correspond to check-hq-hooks.sh's *settings
|
|
39
|
+
* load* scope — the subset of the hooks family the wrapper is allowed to fail
|
|
40
|
+
* on. Deliberately narrow: it covers a missing/invalid settings file, an
|
|
41
|
+
* unquoted `$CLAUDE_PROJECT_DIR` that word-splits on a spaced root, and a hook
|
|
42
|
+
* command that runs a script which is not on disk — and nothing else. The many
|
|
43
|
+
* richer doctor findings (executable bits, orphaned scripts, three-profile
|
|
44
|
+
* membership, Codex/Grok parity, fixture coverage) are intentionally OUT of
|
|
45
|
+
* scope, so the wrapper stays byte-for-byte compatible with callers that only
|
|
46
|
+
* ever cared whether the hook configuration could load.
|
|
47
|
+
*/
|
|
48
|
+
export const CHECK_HQ_HOOKS_SETTINGS_SCOPE = [
|
|
49
|
+
"hooks.settings-present",
|
|
50
|
+
"hooks.settings-valid-json",
|
|
51
|
+
"hooks.claude.settings-local-valid-json",
|
|
52
|
+
"hooks.claude.unquoted-project-dir",
|
|
53
|
+
"hooks.claude.script-missing",
|
|
54
|
+
];
|
|
55
|
+
/** The doctor statuses that count as a failure for check-hq-hooks' purposes. */
|
|
56
|
+
const FAILING_STATUSES = new Set([
|
|
57
|
+
"FAIL",
|
|
58
|
+
"UNKNOWN",
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Derive the check-hq-hooks.sh verdict from a `hq doctor --json` document,
|
|
62
|
+
* scoped to check-hq-hooks' original concerns. This is the exact logic the
|
|
63
|
+
* shell wrapper reproduces with `jq`; keeping it here as one tested function is
|
|
64
|
+
* what lets `compat.test.ts` prove the wrapper and `hq doctor` agree.
|
|
65
|
+
*/
|
|
66
|
+
export function deriveCheckHqHooksVerdict(doc, options = {}) {
|
|
67
|
+
const requireLedger = options.requireLedger === true;
|
|
68
|
+
const scope = new Set(CHECK_HQ_HOOKS_SETTINGS_SCOPE);
|
|
69
|
+
const messages = [];
|
|
70
|
+
// Settings-load concern: any in-scope result that FAILs or is UNKNOWN.
|
|
71
|
+
for (const result of doc.results) {
|
|
72
|
+
if (scope.has(result.checkId) && FAILING_STATUSES.has(result.status)) {
|
|
73
|
+
messages.push(result.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Runtime/ledger concern: only checked under --require-ledger. The runtime
|
|
77
|
+
// probe reports PASS only when a hook demonstrably fired this session; any
|
|
78
|
+
// other status (UNTESTED on a dispatching host, FAIL on app/SDK, UNKNOWN on an
|
|
79
|
+
// unidentifiable host) means dispatch was not observed — which is a failure
|
|
80
|
+
// for --require-ledger, matching the inline script's "fail on a missing
|
|
81
|
+
// ledger" contract.
|
|
82
|
+
const runtimeResult = doc.results.find((result) => result.checkId === RUNTIME_ENFORCEMENT_CHECK_ID);
|
|
83
|
+
const runtime = observeRuntime(runtimeResult?.status, requireLedger);
|
|
84
|
+
if (requireLedger && runtime !== "OBSERVED") {
|
|
85
|
+
messages.push(runtimeResult?.message ??
|
|
86
|
+
"policy-trigger ledger was not found under workspace/orchestrator/policy-trigger-state");
|
|
87
|
+
}
|
|
88
|
+
const ok = messages.length === 0;
|
|
89
|
+
return {
|
|
90
|
+
ok,
|
|
91
|
+
exitCode: ok ? CHECK_HQ_HOOKS_PASS_EXIT : CHECK_HQ_HOOKS_FAIL_EXIT,
|
|
92
|
+
hookHealth: ok ? "PASS" : "FAIL",
|
|
93
|
+
runtime,
|
|
94
|
+
issues: messages.map((message) => ` - ${message}`),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function observeRuntime(status, requireLedger) {
|
|
98
|
+
if (!requireLedger)
|
|
99
|
+
return "NOT CHECKED";
|
|
100
|
+
return status === "PASS" ? "OBSERVED" : "NOT OBSERVED";
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=compat.js.map
|