@hasna/hooks 0.6.8 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/index.js +886 -418
- package/bin/serve.js +613 -148
- package/dist/config.d.ts +7 -5
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/index.js +788 -326
- package/dist/openapi.d.ts +216 -0
- package/dist/storage.js +34 -34
- package/hooks/codewith-native-common.test.ts +25 -25
- package/hooks/codewith-native-common.ts +1 -1
- package/hooks/hook-autoformat/package.json +1 -1
- package/hooks/hook-desktopnotify/package.json +1 -1
- package/hooks/hook-envsetup/package.json +1 -1
- package/hooks/hook-knowledge-context/README.md +1 -1
- package/hooks/hook-knowledge-context/src/hook.test.ts +1 -1
- package/hooks/hook-permissionguard/package.json +1 -1
- package/hooks/hook-protectfiles/package.json +1 -1
- package/hooks/hook-spiral-detector/LICENSE +191 -0
- package/hooks/hook-spiral-detector/README.md +23 -0
- package/hooks/hook-spiral-detector/package.json +12 -0
- package/hooks/hook-spiral-detector/src/hook.test.ts +72 -0
- package/hooks/hook-spiral-detector/src/hook.ts +93 -0
- package/hooks/hook-workspace-repos-guard/README.md +4 -3
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +7 -7
- package/hooks/hook-workspace-repos-guard/src/hook.ts +5 -3
- package/hooks/mention-context/src/hasna-mention-context.py +2 -2
- package/hooks/mention-context/src/hasna-mention-warm.py +1 -1
- package/hooks/pre-bash/README.md +1 -1
- package/hooks/prompt-guard/README.md +1 -1
- package/hooks/session-start/README.md +1 -1
- package/hooks/stop-sync/README.md +1 -1
- package/hooks/worktree-guard/README.md +1 -1
- package/package.json +10 -6
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to the Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by the Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding any notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
Copyright 2025 Hasna
|
|
180
|
+
|
|
181
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
182
|
+
you may not use this file except in compliance with the License.
|
|
183
|
+
You may obtain a copy of the License at
|
|
184
|
+
|
|
185
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
186
|
+
|
|
187
|
+
Unless required by applicable law or agreed to in writing, software
|
|
188
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
189
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
190
|
+
See the License for the specific language governing permissions and
|
|
191
|
+
limitations under the License.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# hook-spiral-detector
|
|
2
|
+
|
|
3
|
+
A PostToolUse hook that interrupts Claude Code after five consecutive Bash calls produce the same command, non-zero exit status, and first line of stderr.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
hooks install spiral-detector
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Behavior
|
|
12
|
+
|
|
13
|
+
- Hashes the command and error signature; command text and stderr are not persisted
|
|
14
|
+
- Keeps streaks separate by session
|
|
15
|
+
- Resets the streak after a success, a different failure, or a non-Bash tool call
|
|
16
|
+
- Returns `{ "continue": false }` on the fifth identical failure, stopping the agent loop
|
|
17
|
+
- Fails open if input or state cannot be read or written
|
|
18
|
+
|
|
19
|
+
The threshold is deliberately five: it permits a small number of legitimate retries while interrupting a repeated repair loop early.
|
|
20
|
+
|
|
21
|
+
## Event
|
|
22
|
+
|
|
23
|
+
- **PostToolUse** (all tools, so non-Bash calls can reset the streak)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hook-spiral-detector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interrupts Claude Code sessions after repeated identical command failures",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hook.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"author": "Hasna",
|
|
11
|
+
"license": "Apache-2.0"
|
|
12
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const homes: string[] = [];
|
|
7
|
+
const hookPath = join(import.meta.dir, "hook.ts");
|
|
8
|
+
|
|
9
|
+
async function invoke(home: string, input: object): Promise<Record<string, unknown>> {
|
|
10
|
+
const proc = Bun.spawn(["bun", "run", hookPath], {
|
|
11
|
+
stdin: new Response(JSON.stringify(input)),
|
|
12
|
+
stdout: "pipe",
|
|
13
|
+
stderr: "pipe",
|
|
14
|
+
env: { ...process.env, HOME: home },
|
|
15
|
+
});
|
|
16
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
17
|
+
expect(exitCode).toBe(0);
|
|
18
|
+
return JSON.parse(stdout);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function input(session: string, command = "bun test", exitCode = 1): object {
|
|
22
|
+
return {
|
|
23
|
+
session_id: session,
|
|
24
|
+
tool_name: "Bash",
|
|
25
|
+
tool_input: { command },
|
|
26
|
+
tool_response: { exit_code: exitCode, stderr: "tests failed\nmore details" },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("spiral detector", () => {
|
|
35
|
+
test("interrupts on the fifth identical red signature", async () => {
|
|
36
|
+
const home = mkdtempSync(join(tmpdir(), "spiral-detector-"));
|
|
37
|
+
homes.push(home);
|
|
38
|
+
for (let attempt = 1; attempt < 5; attempt++) {
|
|
39
|
+
expect(await invoke(home, input("session-1"))).toEqual({ continue: true });
|
|
40
|
+
}
|
|
41
|
+
expect(await invoke(home, input("session-1"))).toMatchObject({
|
|
42
|
+
continue: false,
|
|
43
|
+
stopReason: expect.stringContaining("after 5 identical command failures"),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("successes and changed failures reset the streak", async () => {
|
|
48
|
+
const home = mkdtempSync(join(tmpdir(), "spiral-detector-"));
|
|
49
|
+
homes.push(home);
|
|
50
|
+
for (let attempt = 0; attempt < 4; attempt++) await invoke(home, input("session-2"));
|
|
51
|
+
expect(await invoke(home, input("session-2", "bun test", 0))).toEqual({ continue: true });
|
|
52
|
+
for (let attempt = 0; attempt < 4; attempt++) await invoke(home, input("session-2"));
|
|
53
|
+
expect(await invoke(home, input("session-2", "bun run typecheck"))).toEqual({ continue: true });
|
|
54
|
+
for (let attempt = 1; attempt < 5; attempt++) {
|
|
55
|
+
expect(await invoke(home, input("session-2"))).toEqual({ continue: true });
|
|
56
|
+
}
|
|
57
|
+
expect(await invoke(home, input("session-2"))).toMatchObject({ continue: false });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("supports the repository's legacy tool_output field", async () => {
|
|
61
|
+
const home = mkdtempSync(join(tmpdir(), "spiral-detector-"));
|
|
62
|
+
homes.push(home);
|
|
63
|
+
const legacy = {
|
|
64
|
+
session_id: "session-3",
|
|
65
|
+
tool_name: "Bash",
|
|
66
|
+
tool_input: { command: "npm test" },
|
|
67
|
+
tool_output: { exitCode: "2", stderr: "same error" },
|
|
68
|
+
};
|
|
69
|
+
for (let attempt = 1; attempt < 5; attempt++) await invoke(home, legacy);
|
|
70
|
+
expect(await invoke(home, legacy)).toMatchObject({ continue: false });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/** Interrupt a session after five consecutive identical Bash failures. */
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
interface HookInput {
|
|
11
|
+
session_id?: string;
|
|
12
|
+
tool_name?: string;
|
|
13
|
+
tool_input?: Record<string, unknown>;
|
|
14
|
+
tool_response?: unknown;
|
|
15
|
+
tool_output?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface State {
|
|
19
|
+
signature: string;
|
|
20
|
+
count: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const THRESHOLD = 5;
|
|
24
|
+
const STATE_DIR = join(homedir(), ".hasna", "hooks", "state", "spiral-detector");
|
|
25
|
+
|
|
26
|
+
function hash(value: string): string {
|
|
27
|
+
return createHash("sha256").update(value).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function statePath(sessionId: string): string {
|
|
31
|
+
return join(STATE_DIR, `${hash(sessionId)}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readState(path: string): State {
|
|
35
|
+
try {
|
|
36
|
+
const state = JSON.parse(readFileSync(path, "utf8")) as State;
|
|
37
|
+
if (typeof state.signature === "string" && Number.isInteger(state.count) && state.count > 0) return state;
|
|
38
|
+
} catch {}
|
|
39
|
+
return { signature: "", count: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clearState(path: string): void {
|
|
43
|
+
try {
|
|
44
|
+
if (existsSync(path)) unlinkSync(path);
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redSignature(input: HookInput): string | null {
|
|
49
|
+
if (input.tool_name !== "Bash" || typeof input.tool_input?.command !== "string") return null;
|
|
50
|
+
const output = input.tool_response ?? input.tool_output;
|
|
51
|
+
if (!output || typeof output !== "object") return null;
|
|
52
|
+
const record = output as Record<string, unknown>;
|
|
53
|
+
const rawCode = record.exit_code ?? record.exitCode ?? record.code;
|
|
54
|
+
const exitCode = typeof rawCode === "string" ? Number(rawCode) : rawCode;
|
|
55
|
+
if (typeof exitCode !== "number" || !Number.isFinite(exitCode) || exitCode === 0) return null;
|
|
56
|
+
const stderr = typeof record.stderr === "string" ? record.stderr : "";
|
|
57
|
+
const firstLine = stderr.split(/\r?\n/, 1)[0] ?? "";
|
|
58
|
+
return hash(`${hash(input.tool_input.command)}\0${exitCode}\0${firstLine}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function processInput(input: HookInput): { continue: boolean; stopReason?: string } {
|
|
62
|
+
if (!input.session_id) return { continue: true };
|
|
63
|
+
const path = statePath(input.session_id);
|
|
64
|
+
const signature = redSignature(input);
|
|
65
|
+
if (!signature) {
|
|
66
|
+
clearState(path);
|
|
67
|
+
return { continue: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const previous = readState(path);
|
|
71
|
+
const count = previous.signature === signature ? previous.count + 1 : 1;
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
74
|
+
writeFileSync(path, JSON.stringify({ signature, count }));
|
|
75
|
+
} catch {
|
|
76
|
+
return { continue: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return count >= THRESHOLD
|
|
80
|
+
? { continue: false, stopReason: `Spiral detector interrupted the session after ${count} identical command failures. Change the command or underlying state before resuming.` }
|
|
81
|
+
: { continue: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function run(): void {
|
|
85
|
+
try {
|
|
86
|
+
const input = JSON.parse(readFileSync(0, "utf8")) as HookInput;
|
|
87
|
+
console.log(JSON.stringify(processInput(input)));
|
|
88
|
+
} catch {
|
|
89
|
+
console.log(JSON.stringify({ continue: true }));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (import.meta.main) run();
|
|
@@ -35,9 +35,10 @@ it sits under `repos/`; parenthesized command groups
|
|
|
35
35
|
|
|
36
36
|
## Configuration
|
|
37
37
|
|
|
38
|
-
Allowed orgs default to `hasna,hasnaxyz,hasna-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
Allowed orgs default to `hasna,hasnaxyz,hasna-products` and are overridable
|
|
39
|
+
with the `WORKSPACE_REPOS_GUARD_ORGS` env var (comma-separated). Private
|
|
40
|
+
workspace orgs must be added per-install via that var; they are never part
|
|
41
|
+
of the public default. The home directory is resolved with `os.homedir()` —
|
|
41
42
|
never hardcoded.
|
|
42
43
|
|
|
43
44
|
## Failure mode
|
|
@@ -15,8 +15,8 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
|
|
|
15
15
|
return null;
|
|
16
16
|
}describe("hook-workspace-repos-guard", () => {
|
|
17
17
|
describe("resolveAllowedOrgs", () => {
|
|
18
|
-
test("defaults to the
|
|
19
|
-
expect([...resolveAllowedOrgs({})].sort()).toEqual(["hasna", "hasna-
|
|
18
|
+
test("defaults to the three public canonical GitHub orgs", () => {
|
|
19
|
+
expect([...resolveAllowedOrgs({})].sort()).toEqual(["hasna", "hasna-products", "hasnaxyz"]);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
test("parses comma-separated env override, trimming whitespace and empties", () => {
|
|
@@ -31,9 +31,9 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
|
|
|
31
31
|
expect(isBlocked(result)).toBeNull();
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
test("Edit inside
|
|
35
|
-
const result = evaluate(preToolUse("Edit", { file_path: join(REPOS, "
|
|
36
|
-
expect(result.
|
|
34
|
+
test("Edit inside a non-default org is blocked", () => {
|
|
35
|
+
const result = evaluate(preToolUse("Edit", { file_path: join(REPOS, "notanorg", "platform", "docs", "readme.md") }));
|
|
36
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test("MultiEdit with relative file_path resolves against cwd and continues inside allowed org", () => {
|
|
@@ -65,7 +65,7 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
|
|
|
65
65
|
`echo note > ${join(REPOS, "hasna", "apps", "scratch.md")}`,
|
|
66
66
|
`touch ${join(REPOS, "hasna", "apps", "apps", "foo", "x.txt")}`,
|
|
67
67
|
`git clone https://github.com/hasna/foo.git ${join(REPOS, "hasna", "foo")}`,
|
|
68
|
-
`mkdir -p ${join(REPOS, "
|
|
68
|
+
`mkdir -p ${join(REPOS, "hasnaxyz", "iapp-probe", "docs")}`,
|
|
69
69
|
];
|
|
70
70
|
for (const command of commands) {
|
|
71
71
|
const result = evaluate(preToolUse("Bash", { command }));
|
|
@@ -77,7 +77,7 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
|
|
|
77
77
|
const commands = [
|
|
78
78
|
`touch ~/workspace/repos/hasna/apps/foo.md`,
|
|
79
79
|
`echo x > ~/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
|
|
80
|
-
`mkdir -p "${HOME}/workspace/repos/
|
|
80
|
+
`mkdir -p "${HOME}/workspace/repos/hasnaxyz/iapp-probe/notes"`,
|
|
81
81
|
`touch "$HOME"/workspace/repos/hasna/apps/x.txt`,
|
|
82
82
|
`echo x > "\${HOME}"/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
|
|
83
83
|
];
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
* tools are inspected through their `*** Add File:` / `*** Update File:` /
|
|
31
31
|
* `*** Delete File:` markers. Parenthesized command groups are unwrapped.
|
|
32
32
|
*
|
|
33
|
-
* Allowed orgs default to hasna,hasnaxyz,hasna-
|
|
34
|
-
*
|
|
33
|
+
* Allowed orgs default to hasna,hasnaxyz,hasna-products and are overridable
|
|
34
|
+
* with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated). Private
|
|
35
|
+
* workspace orgs must be added per-install via that var; they are never part
|
|
36
|
+
* of the public default.
|
|
35
37
|
* Home is resolved with os.homedir(); never hardcoded. Fail-open on any parse
|
|
36
38
|
* or evaluation error so a guard defect cannot wedge the agent.
|
|
37
39
|
*/
|
|
@@ -51,7 +53,7 @@ const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"])
|
|
|
51
53
|
|
|
52
54
|
const PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
|
|
53
55
|
|
|
54
|
-
const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-
|
|
56
|
+
const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
|
|
55
57
|
|
|
56
58
|
const RULE = "workspace-repos-guard";
|
|
57
59
|
|
|
@@ -721,7 +721,7 @@ def render_section(t):
|
|
|
721
721
|
if proj.get("path"):
|
|
722
722
|
L.append(f" {'':<11} path {sanitize(proj.get('path'), 120)}")
|
|
723
723
|
if isinstance(dups, int) and dups > 1:
|
|
724
|
-
# Real hazard, not trivia: `
|
|
724
|
+
# Real hazard, not trivia: `todos` exists as FIVE project rows
|
|
725
725
|
# with task counters 3/50/58/101/150 across three machine_ids, and
|
|
726
726
|
# the BUG rows are spread over all of them. An agent that files
|
|
727
727
|
# against the wrong row files into a fossil nobody reads.
|
|
@@ -732,7 +732,7 @@ def render_section(t):
|
|
|
732
732
|
|
|
733
733
|
# --- last 3 bugs -------------------------------------------------------
|
|
734
734
|
# Merged across every matching project row, because BUG rows are genuinely
|
|
735
|
-
# spread across the duplicates:
|
|
735
|
+
# spread across the duplicates: todos carries 58 across 5 rows. Scoping
|
|
736
736
|
# to the primary alone would hide real bugs. The scope is stated on the line
|
|
737
737
|
# so the count is never read as belonging to the single project above.
|
|
738
738
|
bugs = t.get("bugs") or []
|
|
@@ -340,7 +340,7 @@ def candidate_names(org, word):
|
|
|
340
340
|
def warm_slow(org, word, tmpdir, projects_cache):
|
|
341
341
|
"""Resolve the todos project for a repo and collect its last 3 BUG rows.
|
|
342
342
|
|
|
343
|
-
Duplicate project rows are the norm, not the exception: `
|
|
343
|
+
Duplicate project rows are the norm, not the exception: `todos` exists
|
|
344
344
|
FOUR times, with 3 / 101 / 58 / 150 task counters on three different
|
|
345
345
|
machine_ids, and the BUG rows are spread across all four (2+7+14+34 = 57).
|
|
346
346
|
Picking by name alone would silently return a 3-task fossil. So the primary
|
package/hooks/pre-bash/README.md
CHANGED
|
@@ -81,7 +81,7 @@ remote relative path cannot be resolved against the local working directory.
|
|
|
81
81
|
|
|
82
82
|
## Install for Codewith
|
|
83
83
|
|
|
84
|
-
Prefer renderer-managed configuration through
|
|
84
|
+
Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
hooks install pre-bash --target codewith
|
|
@@ -6,7 +6,7 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail
|
|
|
6
6
|
|
|
7
7
|
## Install for Codewith
|
|
8
8
|
|
|
9
|
-
Prefer renderer-managed configuration through
|
|
9
|
+
Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
hooks install prompt-guard --target codewith
|
|
@@ -6,7 +6,7 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail
|
|
|
6
6
|
|
|
7
7
|
## Install for Codewith
|
|
8
8
|
|
|
9
|
-
Prefer renderer-managed configuration through
|
|
9
|
+
Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
hooks install session-start --target codewith
|
|
@@ -6,7 +6,7 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail
|
|
|
6
6
|
|
|
7
7
|
## Install for Codewith
|
|
8
8
|
|
|
9
|
-
Prefer renderer-managed configuration through
|
|
9
|
+
Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
hooks install stop-sync --target codewith
|
|
@@ -61,7 +61,7 @@ from a guardrail by a cooperating agent, not a way to reach a shared checkout.
|
|
|
61
61
|
|
|
62
62
|
## Install for Codewith
|
|
63
63
|
|
|
64
|
-
Prefer renderer-managed configuration through
|
|
64
|
+
Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
hooks install worktree-guard --target codewith
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"dashboard:dev": "cd dashboard && bun run dev",
|
|
31
31
|
"dashboard:build": "cd dashboard && bun run build",
|
|
32
32
|
"dashboard:preview": "cd dashboard && bun run preview",
|
|
33
|
-
"postinstall": "mkdir -p $HOME/.hasna/hooks/profiles 2>/dev/null || true"
|
|
33
|
+
"postinstall": "mkdir -p $HOME/.hasna/hooks/profiles 2>/dev/null || true",
|
|
34
|
+
"scan:artifact": "bun scripts/artifact-scan.ts",
|
|
35
|
+
"prepack": "bun run build && bun run scan:artifact"
|
|
34
36
|
},
|
|
35
37
|
"keywords": [
|
|
36
38
|
"claude-code",
|
|
@@ -49,11 +51,12 @@
|
|
|
49
51
|
"@types/pg": "^8.15.6",
|
|
50
52
|
"@types/react": "^18.2.0",
|
|
51
53
|
"typescript": "^5",
|
|
52
|
-
"bun-types": "1.3.14"
|
|
54
|
+
"bun-types": "1.3.14",
|
|
55
|
+
"@hasna/contracts": "0.13.1"
|
|
53
56
|
},
|
|
54
57
|
"dependencies": {
|
|
55
|
-
"@hasna/events": "^0.1.
|
|
56
|
-
"@hasna/secrets": "0.3.
|
|
58
|
+
"@hasna/events": "^0.1.16",
|
|
59
|
+
"@hasna/secrets": "0.3.3",
|
|
57
60
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
58
61
|
"chalk": "^5.3.0",
|
|
59
62
|
"commander": "^12.1.0",
|
|
@@ -64,7 +67,8 @@
|
|
|
64
67
|
"ink-text-input": "^6.0.0",
|
|
65
68
|
"pg": "^8.16.3",
|
|
66
69
|
"react": "^18.2.0",
|
|
67
|
-
"zod": "3.25.76"
|
|
70
|
+
"zod": "3.25.76",
|
|
71
|
+
"@hasna/contracts": "0.13.1"
|
|
68
72
|
},
|
|
69
73
|
"engines": {
|
|
70
74
|
"bun": ">=1.0.0"
|