@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,61 @@
1
+ /**
2
+ * Classify a hook — via the fixture that guards it — as a PURE GUARD or a
3
+ * SIDE-EFFECTING hook (US-008).
4
+ *
5
+ * `hq doctor --deep-test` proves that a blocking hook actually blocks by firing
6
+ * it with crafted inputs through the real `hook-gate.sh` path and reading the
7
+ * verdict. That is only sound for hooks whose entire observable behaviour is a
8
+ * decision — block, allow, or a stderr pattern — and which produce no filesystem
9
+ * or git effects when they run. Those are PURE GUARDS, and they are the only
10
+ * hooks US-008 executes.
11
+ *
12
+ * A hook that writes files or makes commits (autocommit, checkpoint, journal,
13
+ * reindex, …) cannot be verified by exit code and MUST NOT be fired against
14
+ * anything but a throwaway, effect-asserting sandbox. That is US-009's job, so
15
+ * this module marks such hooks SIDE-EFFECTING and the executor skips them; the
16
+ * classification is exported so US-009 can pick exactly those up.
17
+ *
18
+ * The current fixture schema (US-007) can only express decision outcomes
19
+ * (`block` / `allow` / `stderr`), so the operative signal here is the hook id's
20
+ * family: the four side-effecting families US-009 enumerates are matched by a
21
+ * marker substring, and everything else is a pure guard. When the schema later
22
+ * grows effect-based expectations, {@link classifyFixture} is where a fixture
23
+ * carrying one would additionally be classed side-effecting — the seam is kept
24
+ * here so the executor never has to know the difference.
25
+ */
26
+ import type { HookFixture } from "../fixtures/schema.js";
27
+ /** A hook's deep-test class: verifiable by verdict, or effect-bearing. */
28
+ export type HookClass = "pure-guard" | "side-effecting";
29
+ /**
30
+ * Substrings that mark a hook id as belonging to a side-effecting family. This
31
+ * is the seed set US-009 names — autocommit, checkpoint, journal, reindex — and
32
+ * is deliberately matched as a case-insensitive substring so every member of a
33
+ * family is caught (`hq-autocommit`, `auto-checkpoint-precompact`,
34
+ * `journal-due`, …) without enumerating each id. US-009 extends this list as it
35
+ * brings more side-effecting hooks under sandboxed execution.
36
+ */
37
+ export declare const SIDE_EFFECTING_ID_MARKERS: readonly string[];
38
+ /** A classification verdict together with the reason behind it. */
39
+ export interface Classification {
40
+ /** Whether the hook is a pure guard or side-effecting. */
41
+ hookClass: HookClass;
42
+ /** Human-readable reason, for messages and debugging. */
43
+ reason: string;
44
+ }
45
+ /**
46
+ * Classify a bare hook id. Side-effecting when the id contains one of
47
+ * {@link SIDE_EFFECTING_ID_MARKERS} (case-insensitive), pure guard otherwise.
48
+ */
49
+ export declare function classifyHookId(hookId: string): Classification;
50
+ /**
51
+ * Classify the hook a fixture guards. Currently a function of the hook id alone
52
+ * (see the module comment); the fixture is taken by value so a future
53
+ * effect-based expectation can be folded into the verdict here without changing
54
+ * any caller.
55
+ */
56
+ export declare function classifyFixture(fixture: HookFixture): Classification;
57
+ /** Whether a fixture's hook is a pure guard — the ones `--deep-test` executes. */
58
+ export declare function isPureGuard(fixture: HookFixture): boolean;
59
+ /** Whether a hook id names a side-effecting hook (US-009's execution surface). */
60
+ export declare function isSideEffectingHookId(hookId: string): boolean;
61
+ //# sourceMappingURL=classify.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Classify a hook — via the fixture that guards it — as a PURE GUARD or a
3
+ * SIDE-EFFECTING hook (US-008).
4
+ *
5
+ * `hq doctor --deep-test` proves that a blocking hook actually blocks by firing
6
+ * it with crafted inputs through the real `hook-gate.sh` path and reading the
7
+ * verdict. That is only sound for hooks whose entire observable behaviour is a
8
+ * decision — block, allow, or a stderr pattern — and which produce no filesystem
9
+ * or git effects when they run. Those are PURE GUARDS, and they are the only
10
+ * hooks US-008 executes.
11
+ *
12
+ * A hook that writes files or makes commits (autocommit, checkpoint, journal,
13
+ * reindex, …) cannot be verified by exit code and MUST NOT be fired against
14
+ * anything but a throwaway, effect-asserting sandbox. That is US-009's job, so
15
+ * this module marks such hooks SIDE-EFFECTING and the executor skips them; the
16
+ * classification is exported so US-009 can pick exactly those up.
17
+ *
18
+ * The current fixture schema (US-007) can only express decision outcomes
19
+ * (`block` / `allow` / `stderr`), so the operative signal here is the hook id's
20
+ * family: the four side-effecting families US-009 enumerates are matched by a
21
+ * marker substring, and everything else is a pure guard. When the schema later
22
+ * grows effect-based expectations, {@link classifyFixture} is where a fixture
23
+ * carrying one would additionally be classed side-effecting — the seam is kept
24
+ * here so the executor never has to know the difference.
25
+ */
26
+ /**
27
+ * Substrings that mark a hook id as belonging to a side-effecting family. This
28
+ * is the seed set US-009 names — autocommit, checkpoint, journal, reindex — and
29
+ * is deliberately matched as a case-insensitive substring so every member of a
30
+ * family is caught (`hq-autocommit`, `auto-checkpoint-precompact`,
31
+ * `journal-due`, …) without enumerating each id. US-009 extends this list as it
32
+ * brings more side-effecting hooks under sandboxed execution.
33
+ */
34
+ export const SIDE_EFFECTING_ID_MARKERS = [
35
+ "autocommit",
36
+ "checkpoint",
37
+ "journal",
38
+ "reindex",
39
+ ];
40
+ /**
41
+ * Classify a bare hook id. Side-effecting when the id contains one of
42
+ * {@link SIDE_EFFECTING_ID_MARKERS} (case-insensitive), pure guard otherwise.
43
+ */
44
+ export function classifyHookId(hookId) {
45
+ const lower = hookId.toLowerCase();
46
+ const marker = SIDE_EFFECTING_ID_MARKERS.find((m) => lower.includes(m));
47
+ if (marker) {
48
+ return {
49
+ hookClass: "side-effecting",
50
+ reason: `hook id "${hookId}" matches the side-effecting family marker "${marker}"; deep execution is deferred to the sandboxed effect tier`,
51
+ };
52
+ }
53
+ return {
54
+ hookClass: "pure-guard",
55
+ reason: `hook id "${hookId}" is a decision-only guard with no known filesystem or git side effects`,
56
+ };
57
+ }
58
+ /**
59
+ * Classify the hook a fixture guards. Currently a function of the hook id alone
60
+ * (see the module comment); the fixture is taken by value so a future
61
+ * effect-based expectation can be folded into the verdict here without changing
62
+ * any caller.
63
+ */
64
+ export function classifyFixture(fixture) {
65
+ return classifyHookId(fixture.hookId);
66
+ }
67
+ /** Whether a fixture's hook is a pure guard — the ones `--deep-test` executes. */
68
+ export function isPureGuard(fixture) {
69
+ return classifyFixture(fixture).hookClass === "pure-guard";
70
+ }
71
+ /** Whether a hook id names a side-effecting hook (US-009's execution surface). */
72
+ export function isSideEffectingHookId(hookId) {
73
+ return classifyHookId(hookId).hookClass === "side-effecting";
74
+ }
75
+ //# sourceMappingURL=classify.js.map
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Observable-effect expectations for side-effecting hooks (US-009).
3
+ *
4
+ * A pure guard (US-008) is fully described by a decision — block, allow, or a
5
+ * stderr pattern — so its fixture cases assert exit-code outcomes. A hook that
6
+ * WRITES FILES or MAKES COMMITS (autocommit, checkpoint, journal, reindex, …)
7
+ * cannot be verified that way: a `git commit` hook that exits 0 tells you
8
+ * nothing about whether a commit was actually made. So its fixture cases assert
9
+ * OBSERVABLE EFFECTS instead — this module owns that data contract and the
10
+ * before/after evaluation that decides whether an effect happened.
11
+ *
12
+ * The three effect kinds the PRD names, and nothing more (the schema grows
13
+ * deliberately, one kind at a time):
14
+ *
15
+ * - `fileCreated: <path>` — a file appeared at <path> that was not there
16
+ * before the hook ran.
17
+ * - `commitCreated` — the sandbox git repo has more commits after the
18
+ * hook than before.
19
+ * - `fileUnmodified: <path>` — a named file's bytes are identical before and
20
+ * after (the negative assertion: the hook did NOT
21
+ * touch something it must leave alone).
22
+ *
23
+ * Evaluation is a strict before/after diff so it cannot be fooled by a file that
24
+ * merely already existed: {@link captureEffectBaseline} snapshots exactly what
25
+ * each expectation needs (git commit count, file existence, file hashes) BEFORE
26
+ * the hook runs, and {@link evaluateEffects} compares against the same paths
27
+ * afterwards. All git access goes through an injectable {@link GitProbe} so the
28
+ * evaluation is unit-testable and a non-git sandbox degrades to a null count
29
+ * rather than throwing.
30
+ *
31
+ * This module is pure data + comparison; the sandbox that seeds the tree and
32
+ * runs the hook is sandbox.ts. It reuses only type-level helpers from the
33
+ * fixture schema (US-007) and never mutates anything but is handed paths to read.
34
+ */
35
+ /**
36
+ * A single observable effect a side-effecting hook's case expects. Exactly one
37
+ * of the three kinds the PRD enumerates.
38
+ */
39
+ export type EffectExpectation = {
40
+ kind: "file-created";
41
+ path: string;
42
+ } | {
43
+ kind: "commit-created";
44
+ } | {
45
+ kind: "file-unmodified";
46
+ path: string;
47
+ };
48
+ /**
49
+ * Parse a fixture case's `effects:` field into a list of {@link
50
+ * EffectExpectation}. Returns the list, or an error string naming what was
51
+ * wrong (never throws). A side-effecting case MUST declare at least one effect —
52
+ * an empty or absent list is an error, because a case that asserts no effect
53
+ * proves nothing.
54
+ *
55
+ * Accepted YAML shapes per entry (each entry names exactly one effect):
56
+ * - `{ fileCreated: "<path>" }`
57
+ * - `{ commitCreated: true }` (any truthy value, or the bare string
58
+ * `"commitCreated"`)
59
+ * - `{ fileUnmodified: "<path>" }`
60
+ */
61
+ export declare function parseEffectExpectations(raw: unknown): EffectExpectation[] | string;
62
+ /** Human phrase for an effect, used in result messages. */
63
+ export declare function describeEffect(effect: EffectExpectation): string;
64
+ /**
65
+ * The minimal git surface the effect evaluation needs. Injectable so the
66
+ * comparison logic is unit-testable without a real repo, and so a non-git
67
+ * sandbox returns `null` (an honest "cannot tell") rather than throwing.
68
+ */
69
+ export interface GitProbe {
70
+ /** Number of commits reachable from HEAD, or null when there is no repo/HEAD. */
71
+ commitCount(root: string): number | null;
72
+ }
73
+ /** The real probe: `git rev-list --count HEAD` in `root`. */
74
+ export declare const defaultGitProbe: GitProbe;
75
+ /**
76
+ * A snapshot of exactly the state the declared effects need to compare against,
77
+ * taken BEFORE the hook runs. Everything is captured up front so the evaluation
78
+ * is a pure diff and cannot be fooled by pre-existing state.
79
+ */
80
+ export interface EffectBaseline {
81
+ /** Commits reachable from HEAD before the run, or null when not a git repo. */
82
+ commitCount: number | null;
83
+ /** Per `file-created` path: did it exist before the run. */
84
+ existedBefore: Map<string, boolean>;
85
+ /** Per `file-unmodified` path: its content hash before the run (null if absent). */
86
+ hashBefore: Map<string, string | null>;
87
+ }
88
+ /**
89
+ * Snapshot the tree state the given expectations will be compared against. Reads
90
+ * only; touches nothing.
91
+ */
92
+ export declare function captureEffectBaseline(root: string, expectations: readonly EffectExpectation[], git?: GitProbe): EffectBaseline;
93
+ /** The outcome of checking one expectation against the post-run tree. */
94
+ export interface EffectEvaluation {
95
+ /** The expectation that was checked. */
96
+ expectation: EffectExpectation;
97
+ /** Whether the observed after-state satisfied the expectation. */
98
+ met: boolean;
99
+ /** Human detail of what was observed, for result messages. */
100
+ detail: string;
101
+ }
102
+ /**
103
+ * Compare the post-run tree against the baseline for every expectation. Pure and
104
+ * read-only; the returned list is in the same order as `expectations`.
105
+ */
106
+ export declare function evaluateEffects(root: string, expectations: readonly EffectExpectation[], baseline: EffectBaseline, git?: GitProbe): EffectEvaluation[];
107
+ //# sourceMappingURL=effects.d.ts.map
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Observable-effect expectations for side-effecting hooks (US-009).
3
+ *
4
+ * A pure guard (US-008) is fully described by a decision — block, allow, or a
5
+ * stderr pattern — so its fixture cases assert exit-code outcomes. A hook that
6
+ * WRITES FILES or MAKES COMMITS (autocommit, checkpoint, journal, reindex, …)
7
+ * cannot be verified that way: a `git commit` hook that exits 0 tells you
8
+ * nothing about whether a commit was actually made. So its fixture cases assert
9
+ * OBSERVABLE EFFECTS instead — this module owns that data contract and the
10
+ * before/after evaluation that decides whether an effect happened.
11
+ *
12
+ * The three effect kinds the PRD names, and nothing more (the schema grows
13
+ * deliberately, one kind at a time):
14
+ *
15
+ * - `fileCreated: <path>` — a file appeared at <path> that was not there
16
+ * before the hook ran.
17
+ * - `commitCreated` — the sandbox git repo has more commits after the
18
+ * hook than before.
19
+ * - `fileUnmodified: <path>` — a named file's bytes are identical before and
20
+ * after (the negative assertion: the hook did NOT
21
+ * touch something it must leave alone).
22
+ *
23
+ * Evaluation is a strict before/after diff so it cannot be fooled by a file that
24
+ * merely already existed: {@link captureEffectBaseline} snapshots exactly what
25
+ * each expectation needs (git commit count, file existence, file hashes) BEFORE
26
+ * the hook runs, and {@link evaluateEffects} compares against the same paths
27
+ * afterwards. All git access goes through an injectable {@link GitProbe} so the
28
+ * evaluation is unit-testable and a non-git sandbox degrades to a null count
29
+ * rather than throwing.
30
+ *
31
+ * This module is pure data + comparison; the sandbox that seeds the tree and
32
+ * runs the hook is sandbox.ts. It reuses only type-level helpers from the
33
+ * fixture schema (US-007) and never mutates anything but is handed paths to read.
34
+ */
35
+ import { spawnSync } from "node:child_process";
36
+ import { createHash } from "node:crypto";
37
+ import * as fs from "node:fs";
38
+ import * as path from "node:path";
39
+ /**
40
+ * Parse a fixture case's `effects:` field into a list of {@link
41
+ * EffectExpectation}. Returns the list, or an error string naming what was
42
+ * wrong (never throws). A side-effecting case MUST declare at least one effect —
43
+ * an empty or absent list is an error, because a case that asserts no effect
44
+ * proves nothing.
45
+ *
46
+ * Accepted YAML shapes per entry (each entry names exactly one effect):
47
+ * - `{ fileCreated: "<path>" }`
48
+ * - `{ commitCreated: true }` (any truthy value, or the bare string
49
+ * `"commitCreated"`)
50
+ * - `{ fileUnmodified: "<path>" }`
51
+ */
52
+ export function parseEffectExpectations(raw) {
53
+ if (!Array.isArray(raw)) {
54
+ return "`effects` must be a non-empty list of effect expectations";
55
+ }
56
+ if (raw.length === 0) {
57
+ return "`effects` is empty; a side-effecting case must assert at least one observable effect";
58
+ }
59
+ const out = [];
60
+ for (let i = 0; i < raw.length; i++) {
61
+ const parsed = parseOneEffect(raw[i], i);
62
+ if (typeof parsed === "string")
63
+ return parsed;
64
+ out.push(parsed);
65
+ }
66
+ return out;
67
+ }
68
+ function parseOneEffect(raw, index) {
69
+ const where = `effect #${index + 1}`;
70
+ // Bare-string forms: only the no-argument `commitCreated` supports it.
71
+ if (raw === "commitCreated")
72
+ return { kind: "commit-created" };
73
+ if (!isRecord(raw)) {
74
+ return `${where} must be a mapping such as { fileCreated: <path> }, { commitCreated: true }, or { fileUnmodified: <path> }`;
75
+ }
76
+ if ("fileCreated" in raw) {
77
+ const p = raw.fileCreated;
78
+ if (typeof p !== "string" || p.trim() === "") {
79
+ return `${where}: fileCreated must be a non-empty path string`;
80
+ }
81
+ return { kind: "file-created", path: p };
82
+ }
83
+ if ("fileUnmodified" in raw) {
84
+ const p = raw.fileUnmodified;
85
+ if (typeof p !== "string" || p.trim() === "") {
86
+ return `${where}: fileUnmodified must be a non-empty path string`;
87
+ }
88
+ return { kind: "file-unmodified", path: p };
89
+ }
90
+ if ("commitCreated" in raw) {
91
+ // Any truthy value means "expect a commit"; a false value is a no-op that
92
+ // would assert nothing, so it is rejected rather than silently dropped.
93
+ if (raw.commitCreated)
94
+ return { kind: "commit-created" };
95
+ return `${where}: commitCreated must be true (drop the entry to assert nothing)`;
96
+ }
97
+ return `${where} names no known effect (expected fileCreated, commitCreated, or fileUnmodified)`;
98
+ }
99
+ /** Human phrase for an effect, used in result messages. */
100
+ export function describeEffect(effect) {
101
+ switch (effect.kind) {
102
+ case "file-created":
103
+ return `a file created at ${effect.path}`;
104
+ case "commit-created":
105
+ return "a git commit created";
106
+ case "file-unmodified":
107
+ return `${effect.path} left unmodified`;
108
+ }
109
+ }
110
+ /** The real probe: `git rev-list --count HEAD` in `root`. */
111
+ export const defaultGitProbe = {
112
+ commitCount(root) {
113
+ const res = spawnSync("git", ["rev-list", "--count", "HEAD"], {
114
+ cwd: root,
115
+ encoding: "utf8",
116
+ });
117
+ if (res.status !== 0)
118
+ return null;
119
+ const n = Number.parseInt((res.stdout ?? "").trim(), 10);
120
+ return Number.isFinite(n) ? n : null;
121
+ },
122
+ };
123
+ /**
124
+ * Snapshot the tree state the given expectations will be compared against. Reads
125
+ * only; touches nothing.
126
+ */
127
+ export function captureEffectBaseline(root, expectations, git = defaultGitProbe) {
128
+ const existedBefore = new Map();
129
+ const hashBefore = new Map();
130
+ let needsCommitCount = false;
131
+ for (const effect of expectations) {
132
+ switch (effect.kind) {
133
+ case "file-created":
134
+ existedBefore.set(effect.path, fs.existsSync(path.join(root, effect.path)));
135
+ break;
136
+ case "file-unmodified":
137
+ hashBefore.set(effect.path, hashFile(path.join(root, effect.path)));
138
+ break;
139
+ case "commit-created":
140
+ needsCommitCount = true;
141
+ break;
142
+ }
143
+ }
144
+ return {
145
+ commitCount: needsCommitCount ? git.commitCount(root) : null,
146
+ existedBefore,
147
+ hashBefore,
148
+ };
149
+ }
150
+ /**
151
+ * Compare the post-run tree against the baseline for every expectation. Pure and
152
+ * read-only; the returned list is in the same order as `expectations`.
153
+ */
154
+ export function evaluateEffects(root, expectations, baseline, git = defaultGitProbe) {
155
+ return expectations.map((expectation) => evaluateOne(root, expectation, baseline, git));
156
+ }
157
+ function evaluateOne(root, expectation, baseline, git) {
158
+ switch (expectation.kind) {
159
+ case "file-created": {
160
+ const abs = path.join(root, expectation.path);
161
+ const existedBefore = baseline.existedBefore.get(expectation.path) ?? false;
162
+ const existsAfter = fs.existsSync(abs);
163
+ if (existedBefore) {
164
+ return {
165
+ expectation,
166
+ met: false,
167
+ detail: `${expectation.path} already existed before the run, so its presence is not a created effect`,
168
+ };
169
+ }
170
+ return {
171
+ expectation,
172
+ met: existsAfter,
173
+ detail: existsAfter
174
+ ? `${expectation.path} was created`
175
+ : `${expectation.path} does not exist after the run`,
176
+ };
177
+ }
178
+ case "file-unmodified": {
179
+ const abs = path.join(root, expectation.path);
180
+ const before = baseline.hashBefore.get(expectation.path) ?? null;
181
+ const after = hashFile(abs);
182
+ const unchanged = before === after;
183
+ return {
184
+ expectation,
185
+ met: unchanged,
186
+ detail: unchanged
187
+ ? `${expectation.path} was left unmodified`
188
+ : before === null
189
+ ? `${expectation.path} was created by the run but was expected to be untouched`
190
+ : after === null
191
+ ? `${expectation.path} was deleted by the run but was expected to be untouched`
192
+ : `${expectation.path} was modified by the run`,
193
+ };
194
+ }
195
+ case "commit-created": {
196
+ const after = git.commitCount(root);
197
+ const before = baseline.commitCount;
198
+ if (before === null || after === null) {
199
+ return {
200
+ expectation,
201
+ met: false,
202
+ detail: "commit count could not be read (the sandbox is not a git repository)",
203
+ };
204
+ }
205
+ const made = after - before;
206
+ return {
207
+ expectation,
208
+ met: made > 0,
209
+ detail: made > 0
210
+ ? `${made} commit${made === 1 ? "" : "s"} were created`
211
+ : "no new commit was created",
212
+ };
213
+ }
214
+ }
215
+ }
216
+ // --- low-level helpers ---------------------------------------------------------
217
+ /** sha256 of a file's bytes, or null when the file is absent / unreadable. */
218
+ function hashFile(abs) {
219
+ try {
220
+ return createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ function isRecord(value) {
227
+ return typeof value === "object" && value !== null && !Array.isArray(value);
228
+ }
229
+ //# sourceMappingURL=effects.js.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `--deep-test` execution for pure guard hooks (US-008).
3
+ *
4
+ * Default `hq doctor` proves a hook is *wired*. Deep test proves it *works*: for
5
+ * every pure-guard fixture (US-007 schema, {@link isPureGuard} classification)
6
+ * it replays each case through the REAL `hook-gate.sh` and checks the verdict
7
+ * against the fixture's expectation. Four rules make this trustworthy rather
8
+ * than theatre:
9
+ *
10
+ * 1. Route through the gate, never the hook directly. A hook can pass direct
11
+ * invocation and still silently no-op under the active gate profile — the
12
+ * documented `block-hq-root-git-mutation` incident did exactly that. So
13
+ * each case runs `hook-gate.sh <id> <script>` with a payload on stdin, and
14
+ * under ALL THREE profiles (minimal, standard, strict). A profile whose
15
+ * outcome differs from the expectation is a FAIL naming that profile: an
16
+ * expected block that no-ops to exit 0 under standard is caught here and
17
+ * nowhere else.
18
+ *
19
+ * 2. Sandbox, never the live tree. The gate and hooks are copied into a
20
+ * throwaway temp tree and every case executes there, so a diagnosis can
21
+ * never mutate the user's real setup. A test asserts the live tree is
22
+ * byte-identical before and after a run.
23
+ *
24
+ * 3. Time-bound every case. A hook that hangs must fail its case, not the
25
+ * command — each execution is spawned with a per-case timeout and a
26
+ * timeout is reported as FAIL, never left to block the run.
27
+ *
28
+ * 4. Never fire a hook in default mode. This module runs only when the caller
29
+ * asks for it (the command wires it behind `--deep-test`); the default
30
+ * registry does not include it. A test asserts no hook process is spawned
31
+ * without the flag.
32
+ *
33
+ * Expectation matching and the `expectedFailure` rule are owned by
34
+ * {@link outcomeMatches} / {@link classifyCaseStatus} in the fixture schema, so
35
+ * a case outcome means the same thing here as in the coverage tier. The Claude
36
+ * block protocol (a non-zero gate exit is a block) is the only one this story
37
+ * handles; Grok's stdout-`deny` protocol is US-010's parity replay.
38
+ */
39
+ import type { CheckContext, CheckResult } from "../types.js";
40
+ import { type GateProfile } from "../hook-gate-profiles.js";
41
+ /** The id of the deep-test family the command appends under `--deep-test`. */
42
+ export declare const DEEP_FAMILY_ID = "deep";
43
+ /** Human title of the deep-test family. */
44
+ export declare const DEEP_FAMILY_TITLE = "Deep guard-hook tests";
45
+ /** Default per-case time bound. Deep test has no latency target, but no case
46
+ * may hang the command, so every execution is capped. */
47
+ export declare const DEFAULT_PER_CASE_TIMEOUT_MS = 10000;
48
+ /**
49
+ * The tree entries copied into the sandbox. Deliberately a curated set, not the
50
+ * whole tree: the live `.claude/` carries worktrees and session state that can
51
+ * run to hundreds of megabytes, whereas the gate only needs the hook scripts,
52
+ * the settings a hook might read, the Codex/Grok mirrors, and `core/` (where the
53
+ * gate finds `hook-lib.sh` and hooks find their helpers). Missing entries are
54
+ * skipped, so a partial tree still sandboxes cleanly.
55
+ */
56
+ export declare const SANDBOX_COPY_RELPATHS: readonly string[];
57
+ /** One gate execution's inputs. */
58
+ export interface GateRunInput {
59
+ /** Absolute path to the sandboxed `hook-gate.sh`. */
60
+ gatePath: string;
61
+ /** Absolute path to the sandboxed hook script the gate delegates to. */
62
+ hookScriptPath: string;
63
+ /** The gated hook id (the gate's first argument). */
64
+ hookId: string;
65
+ /** The gate profile to run under, via `HQ_HOOK_PROFILE`. */
66
+ profile: GateProfile;
67
+ /** The JSON payload piped to the gate's stdin. */
68
+ payload: string;
69
+ /** Working directory (the sandbox root) the gate runs in. */
70
+ cwd: string;
71
+ /** The per-case time bound in milliseconds. */
72
+ timeoutMs: number;
73
+ }
74
+ /** One gate execution's observed outcome. */
75
+ export interface GateRunResult {
76
+ /** True when the execution exceeded {@link GateRunInput.timeoutMs}. */
77
+ timedOut: boolean;
78
+ /** Gate exit code, or null when it did not exit normally (timeout/signal). */
79
+ exitCode: number | null;
80
+ /** Captured stderr (empty string when none). */
81
+ stderr: string;
82
+ }
83
+ /** Executes one gate invocation. Injectable so the aggregation is unit-testable. */
84
+ export type GateRunner = (input: GateRunInput) => GateRunResult;
85
+ /**
86
+ * The real runner: spawn `bash hook-gate.sh <id> <script>` with the payload on
87
+ * stdin, `HQ_HOOK_PROFILE` set, and a hard timeout. A timeout is detected by the
88
+ * `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
89
+ * a hook cannot ignore the bound.
90
+ */
91
+ export declare const defaultGateRunner: GateRunner;
92
+ /** Options for {@link runDeepGuardTests}. All are injectable for testing. */
93
+ export interface DeepTestOptions {
94
+ /** Per-case time bound. Default: {@link DEFAULT_PER_CASE_TIMEOUT_MS}. */
95
+ perCaseTimeoutMs?: number;
96
+ /** Tree entries copied into the sandbox. Default: {@link SANDBOX_COPY_RELPATHS}. */
97
+ sandboxRelpaths?: readonly string[];
98
+ /** Gate runner. Default: {@link defaultGateRunner}. */
99
+ runner?: GateRunner;
100
+ /** Leave the sandbox on disk (debugging). Default: false — it is removed. */
101
+ keepSandbox?: boolean;
102
+ }
103
+ /** Remove every sandbox still on disk. Safe to call repeatedly. */
104
+ export declare function cleanupAllDeepSandboxes(): void;
105
+ /**
106
+ * Execute every pure-guard fixture's cases through the real gate under all three
107
+ * profiles and return the per-case results. Read-only against the live tree;
108
+ * all execution happens in a sandbox that is removed before returning. Returns
109
+ * an empty list — creating no sandbox — when there is nothing to run.
110
+ */
111
+ export declare function runDeepGuardTests(context: CheckContext, options?: DeepTestOptions): Promise<CheckResult[]>;
112
+ //# sourceMappingURL=executor.d.ts.map