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 +9 -0
- package/dist/cli/index.js +1 -1
- package/dist/index.js +3 -3
- package/package.json +1 -1
- package/runtime/hooks/capability-gate.mjs +45 -12
- package/runtime/hooks/lib/loop-guard.mjs +32 -0
- package/runtime/hooks/verification-completion.mjs +11 -1
- package/runtime/scripts/verify-backstop.mjs +15 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
41902
|
+
var VERSION = "1.44.1";
|
|
41903
41903
|
function getFrameworkName() {
|
|
41904
41904
|
return "paqad-ai";
|
|
41905
41905
|
}
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
process.stdin.
|
|
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
|
-
|
|
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.
|
|
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);
|