paqad-ai 1.44.0 → 1.44.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # paqad-ai
2
2
 
3
+ ## 1.44.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 91e4798: Fix the Claude Stop hook so it respects the paqad enable/disable flag and can no longer wedge a session in an infinite loop.
8
+
9
+ - **Respects ON/OFF from the real project root.** `verification-completion.mjs` now resolves the project root via `CLAUDE_PROJECT_DIR` / `PAQAD_PROJECT_ROOT` (cwd fallback) like the sibling Stop hooks, instead of raw `process.cwd()`. A project set to `paqad_enable=false` is honored even when the host launched the hook from a subdirectory (previously the disable flag was missed and the OFF project was still blocked).
10
+ - **Bites once, never loops.** Every blocking Stop hook now reads Claude's `stop_hook_active`: the gate still blocks (exit 2) on the first stop so the model gets a turn to fix it, but once it is already inside a Stop-hook continuation it steps aside (surfaces the summary, exits 0). An unresolvable verdict can no longer drive an infinite Stop loop. The git/CI backstop remains the hard, non-bypassable layer.
11
+
3
12
  ## 1.44.0
4
13
 
5
14
  ### Minor Changes
package/dist/cli/index.js CHANGED
@@ -26122,7 +26122,7 @@ init_cancelled_error();
26122
26122
  init_events();
26123
26123
 
26124
26124
  // src/index.ts
26125
- var VERSION = "1.44.0";
26125
+ var VERSION = "1.44.1";
26126
26126
 
26127
26127
  // src/cli/commands/audit.ts
26128
26128
  init_esm_shims();
package/dist/index.js CHANGED
@@ -22780,7 +22780,7 @@ var memoizedReport;
22780
22780
  function getEngineVersionReport() {
22781
22781
  if (memoizedReport === void 0) {
22782
22782
  memoizedReport = Object.freeze({
22783
- engineVersion: normalizeEngineVersion("1.44.0"),
22783
+ engineVersion: normalizeEngineVersion("1.44.1"),
22784
22784
  minConsumerVersion: MIN_CONSUMER_VERSION,
22785
22785
  deprecatedAsOf: DEPRECATED_AS_OF
22786
22786
  });
@@ -39165,7 +39165,7 @@ function formatVerdictSummary(input2) {
39165
39165
 
39166
39166
  // src/verification/repository/run-repository-verification.ts
39167
39167
  function verifierVersion() {
39168
- return true ? "1.44.0" : "0.0.0-dev";
39168
+ return true ? "1.44.1" : "0.0.0-dev";
39169
39169
  }
39170
39170
  function backstopGates() {
39171
39171
  return [
@@ -41899,7 +41899,7 @@ var WorkflowEngine = class {
41899
41899
  };
41900
41900
 
41901
41901
  // src/index.ts
41902
- var VERSION = "1.44.0";
41902
+ var VERSION = "1.44.1";
41903
41903
  function getFrameworkName() {
41904
41904
  return "paqad-ai";
41905
41905
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paqad-ai",
3
- "version": "1.44.0",
3
+ "version": "1.44.1",
4
4
  "description": "Spec-driven development framework — AI agents that think before they type",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,11 +20,13 @@
20
20
  // capability currently folded into the seam (rule-scripts); as F6 folds in
21
21
  // stages/decision-pause/delivery, this pre-check generalises per-capability.
22
22
 
23
- import { existsSync } from 'node:fs';
23
+ import { existsSync, realpathSync } from 'node:fs';
24
24
  import { join } from 'node:path';
25
25
  import process from 'node:process';
26
+ import { pathToFileURL } from 'node:url';
26
27
 
27
28
  import { isPaqadDisabled, readFlooredMode, resolveProjectRoot } from './lib/paqad-disabled.mjs';
29
+ import { stopHookActiveFromStdin } from './lib/loop-guard.mjs';
28
30
 
29
31
  // Mirrors PATHS.RULE_SCRIPT_MAP — kept in sync by hand (runtime mjs has no dist).
30
32
  const RULE_SCRIPT_MAP_REL = 'docs/instructions/rules/rule-script-map.yml';
@@ -90,7 +92,7 @@ function parsePayload(input) {
90
92
  }
91
93
  }
92
94
 
93
- async function main(input) {
95
+ export async function main(input, seam = SEAM) {
94
96
  try {
95
97
  const projectRoot = resolveProjectRoot();
96
98
  if (isPaqadDisabled(projectRoot)) return 0;
@@ -104,11 +106,24 @@ async function main(input) {
104
106
 
105
107
  const result = await runCapabilityGate({
106
108
  projectRoot,
107
- seam: SEAM,
109
+ seam,
108
110
  payload: parsePayload(input),
109
111
  });
110
112
  if (result.block) {
111
113
  process.stderr.write(`${result.summary}\n`);
114
+ // Loop guard (fix #2) — the completion (Stop) seam is the only one the host
115
+ // re-runs after forcing a continuation. When Claude marks this Stop as a
116
+ // continuation of a prior block (`stop_hook_active`), the gate has already
117
+ // bitten once, so blocking again would loop on an unresolvable outcome:
118
+ // surface the summary (above) but exit non-blocking. The pre-mutation seam
119
+ // is a PreToolUse deny, not a Stop loop, so it always keeps its teeth. git/CI
120
+ // remain the hard, non-bypassable layer.
121
+ if (seam === 'completion' && stopHookActiveFromStdin(input)) {
122
+ process.stderr.write(
123
+ '[paqad] already surfaced this turn — not blocking again so the session can end (git/CI remains the hard gate).\n',
124
+ );
125
+ return 0;
126
+ }
112
127
  return 2;
113
128
  }
114
129
  if (result.summary) {
@@ -123,12 +138,30 @@ async function main(input) {
123
138
  }
124
139
 
125
140
  // Drain stdin (the host pipes the tool/Stop payload) then enforce. The payload is
126
- // parsed lazily inside main() and only the decision-pause self-arm reads it.
127
- let input = '';
128
- process.stdin.on('data', (chunk) => {
129
- input += chunk;
130
- });
131
- process.stdin.on('end', () => {
132
- main(input).then((code) => process.exit(code));
133
- });
134
- process.stdin.resume();
141
+ // parsed lazily inside main() and only the decision-pause self-arm reads it. Guarded
142
+ // so importing this module for tests runs nothing. The guard resolves the entry
143
+ // path with realpathSync: the host invokes this hook through the symlinked install
144
+ // (~/.paqad-ai/current → the package runtime), and macOS also aliases /tmp →
145
+ // /private/tmp, so a raw argv[1] compare would MISS the match and silently no-op
146
+ // the whole gate. Comparing realpaths is symlink-safe.
147
+ if (isDirectEntry()) {
148
+ let input = '';
149
+ process.stdin.on('data', (chunk) => {
150
+ input += chunk;
151
+ });
152
+ process.stdin.on('end', () => {
153
+ main(input).then((code) => process.exit(code));
154
+ });
155
+ process.stdin.resume();
156
+ }
157
+
158
+ /** True when this module is the process entry point, symlink-safe (see above). */
159
+ function isDirectEntry() {
160
+ const entry = process.argv[1];
161
+ if (!entry) return false;
162
+ try {
163
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
@@ -0,0 +1,32 @@
1
+ // loop-guard.mjs — the Stop-hook loop breaker (fix #2).
2
+ //
3
+ // A blocking Stop hook (exit 2) makes the host re-prompt the model and run the
4
+ // hook again. When the block is on a condition the model cannot resolve in-session
5
+ // (a missing canonical doc, an unrecordable stage), that re-prompt loops forever.
6
+ //
7
+ // The host tells us when we are already inside such a continuation: Claude sets
8
+ // `stop_hook_active: true` on the Stop payload once a prior Stop hook forced the
9
+ // turn. Every blocking Stop hook reads this and, when set, degrades from a hard
10
+ // block to an advisory — the gate still bites ONCE (surfacing the problem and
11
+ // giving the model a turn to fix it), then steps aside so the session can end.
12
+ // The non-bypassable teeth remain at the git/CI backstop, exactly as the hook
13
+ // header comments already promise.
14
+ //
15
+ // Dependency-free and side-effect-free (importing runs nothing) so it is trivially
16
+ // unit-testable and safe to load on every Stop.
17
+
18
+ /**
19
+ * Best-effort `stop_hook_active` from the host Stop-hook stdin payload. True only
20
+ * when the host explicitly marks this Stop as a continuation of a prior Stop-hook
21
+ * block. Absent / non-JSON / malformed ⇒ false (treat as the first stop, so the
22
+ * gate keeps its teeth).
23
+ */
24
+ export function stopHookActiveFromStdin(stdin) {
25
+ try {
26
+ const parsed = JSON.parse(stdin);
27
+ return parsed?.stop_hook_active === true;
28
+ } catch {
29
+ // Not JSON / no field — treat as the first stop.
30
+ return false;
31
+ }
32
+ }
@@ -13,6 +13,8 @@
13
13
  import process from 'node:process';
14
14
 
15
15
  import { sessionIdFromStdin } from './lib/context-seam-emit.mjs';
16
+ import { resolveProjectRoot } from './lib/paqad-disabled.mjs';
17
+ import { stopHookActiveFromStdin } from './lib/loop-guard.mjs';
16
18
  import { runVerificationBackstop } from '../scripts/verify-backstop.mjs';
17
19
 
18
20
  // Read the Stop-hook JSON payload: the host (Claude) puts the session_id here, and
@@ -33,8 +35,16 @@ async function main() {
33
35
  const code = await runVerificationBackstop({
34
36
  origin: 'hook-completion',
35
37
  softFail: true,
36
- projectRoot: process.cwd(),
38
+ // Fix #1 — resolve the project root the host is operating on (CLAUDE_PROJECT_DIR
39
+ // / PAQAD_PROJECT_ROOT, cwd fallback) via the shared helper, matching the sibling
40
+ // Stop hooks. Raw process.cwd() missed the disable flag stored in the project's
41
+ // .paqad/.config whenever the host launched the hook from a subdirectory, so an
42
+ // OFF project still got blocked.
43
+ projectRoot: resolveProjectRoot(),
37
44
  hostSessionId: sessionIdFromStdin(input),
45
+ // Fix #2 — pass Claude's `stop_hook_active` so a block downgrades to advisory
46
+ // once we are already inside a Stop-hook continuation loop (see loop-guard.mjs).
47
+ loopActive: stopHookActiveFromStdin(input),
38
48
  });
39
49
  process.exit(code);
40
50
  }
@@ -50,6 +50,7 @@ export async function runVerificationBackstop({
50
50
  hostSessionId,
51
51
  stdout,
52
52
  stderr,
53
+ loopActive,
53
54
  }) {
54
55
  const out = stdout ?? process.stdout;
55
56
  const err = stderr ?? process.stderr;
@@ -84,8 +85,21 @@ export async function runVerificationBackstop({
84
85
  // Stop-hook contract: on a block (exit 2) the host surfaces only STDERR to
85
86
  // the model — STDOUT is transcript-only. Writing the verdict to stdout made
86
87
  // the block invisible ("No stderr output"). Mirror capability-gate.mjs so the
87
- // failing verdict summary reaches the model. Exit code (2) is unchanged.
88
+ // failing verdict summary reaches the model.
88
89
  err.write(`${verdict.summary}\n`);
90
+ // Loop guard (fix #2) — when the host says we are already inside a Stop-hook
91
+ // continuation (`loopActive`, set only by the completion hook from Claude's
92
+ // `stop_hook_active`), the gate has already bitten once this turn. Blocking
93
+ // again would loop forever on an unresolvable verdict, so we step aside: the
94
+ // summary is surfaced (above) but the exit is a non-blocking 0. git/CI stay the
95
+ // hard, non-bypassable layer. Never reached by the git/CI backstop, which
96
+ // never sets loopActive.
97
+ if (loopActive) {
98
+ err.write(
99
+ '[paqad] already surfaced this turn — not blocking again so the session can end (git/CI remains the hard gate).\n',
100
+ );
101
+ return 0;
102
+ }
89
103
  return 2;
90
104
  } catch (error) {
91
105
  const message = error instanceof Error ? error.message : String(error);