@monte3l/groundwork 0.0.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 (151) hide show
  1. package/README.md +23 -0
  2. package/bin/m3l-groundwork.mjs +10 -0
  3. package/dist/assets.d.ts +20 -0
  4. package/dist/assets.js +79 -0
  5. package/dist/caps.d.ts +25 -0
  6. package/dist/caps.js +69 -0
  7. package/dist/conflicts.d.ts +12 -0
  8. package/dist/conflicts.js +77 -0
  9. package/dist/emit.d.ts +7 -0
  10. package/dist/emit.js +42 -0
  11. package/dist/git.d.ts +3 -0
  12. package/dist/git.js +9 -0
  13. package/dist/harness/conformance.d.ts +20 -0
  14. package/dist/harness/conformance.js +18 -0
  15. package/dist/harness/frontmatter.d.ts +38 -0
  16. package/dist/harness/frontmatter.js +204 -0
  17. package/dist/harness/grade.d.ts +4 -0
  18. package/dist/harness/grade.js +105 -0
  19. package/dist/harness/rules.d.ts +55 -0
  20. package/dist/harness/rules.js +580 -0
  21. package/dist/harness/types.d.ts +32 -0
  22. package/dist/harness/types.js +9 -0
  23. package/dist/inventory.d.ts +63 -0
  24. package/dist/inventory.js +66 -0
  25. package/dist/jsonc.d.ts +14 -0
  26. package/dist/jsonc.js +83 -0
  27. package/dist/main.d.ts +24 -0
  28. package/dist/main.js +297 -0
  29. package/dist/merge-json.d.ts +74 -0
  30. package/dist/merge-json.js +135 -0
  31. package/dist/mode.d.ts +19 -0
  32. package/dist/mode.js +53 -0
  33. package/dist/packs.d.ts +61 -0
  34. package/dist/packs.js +186 -0
  35. package/dist/plugin.d.ts +23 -0
  36. package/dist/plugin.js +79 -0
  37. package/dist/report.d.ts +4 -0
  38. package/dist/report.js +323 -0
  39. package/dist/survey/fs-walk.d.ts +14 -0
  40. package/dist/survey/fs-walk.js +60 -0
  41. package/dist/survey/survey-docs.d.ts +4 -0
  42. package/dist/survey/survey-docs.js +69 -0
  43. package/dist/survey/survey-harness.d.ts +4 -0
  44. package/dist/survey/survey-harness.js +121 -0
  45. package/dist/survey/survey-shape.d.ts +4 -0
  46. package/dist/survey/survey-shape.js +182 -0
  47. package/dist/survey/survey-toolchain.d.ts +4 -0
  48. package/dist/survey/survey-toolchain.js +217 -0
  49. package/dist/survey/survey.d.ts +5 -0
  50. package/dist/survey/survey.js +21 -0
  51. package/dist/survey/types.d.ts +117 -0
  52. package/dist/survey/types.js +8 -0
  53. package/dist/tokens.d.ts +13 -0
  54. package/dist/tokens.js +13 -0
  55. package/dist/toolchain/conformance.d.ts +20 -0
  56. package/dist/toolchain/conformance.js +30 -0
  57. package/dist/toolchain/grade.d.ts +4 -0
  58. package/dist/toolchain/grade.js +244 -0
  59. package/dist/toolchain/rules.d.ts +118 -0
  60. package/dist/toolchain/rules.js +706 -0
  61. package/dist/toolchain/tsconfig-chain.d.ts +36 -0
  62. package/dist/toolchain/tsconfig-chain.js +116 -0
  63. package/dist/toolchain/types.d.ts +27 -0
  64. package/dist/toolchain/types.js +9 -0
  65. package/package.json +59 -0
  66. package/plugin/skills/customize/SKILL.md +305 -0
  67. package/plugin/src/domain-map.ts +134 -0
  68. package/plugin/src/index.ts +4 -0
  69. package/plugin/src/kind-facet-map.ts +174 -0
  70. package/plugin/src/pack-map.ts +65 -0
  71. package/templates/core/.claude/agents/Explore.md +43 -0
  72. package/templates/core/.claude/agents/code-implementer.md +258 -0
  73. package/templates/core/.claude/agents/code-reviewer.md +163 -0
  74. package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
  75. package/templates/core/.claude/agents/test-author.md +211 -0
  76. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
  77. package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
  78. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
  79. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
  80. package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
  81. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
  82. package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
  83. package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
  84. package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
  85. package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
  86. package/templates/core/.claude/rules/agent-dispatch.md +121 -0
  87. package/templates/core/.claude/rules/refactoring.md +52 -0
  88. package/templates/core/.claude/rules/src.md +114 -0
  89. package/templates/core/.claude/rules/tests.md +129 -0
  90. package/templates/core/.claude/settings.json +111 -0
  91. package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
  92. package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
  93. package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
  94. package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
  95. package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
  96. package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
  97. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
  98. package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
  99. package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
  100. package/templates/core/.github/workflows/ci.yml +123 -0
  101. package/templates/core/.github/workflows/dependency-review.yml +26 -0
  102. package/templates/core/.github/workflows/security-audit.yml +54 -0
  103. package/templates/core/.node-version +1 -0
  104. package/templates/core/.prettierignore +5 -0
  105. package/templates/core/.prettierrc.json +4 -0
  106. package/templates/core/CLAUDE.md +127 -0
  107. package/templates/core/README.md +24 -0
  108. package/templates/core/_gitignore +19 -0
  109. package/templates/core/_npmrc +1 -0
  110. package/templates/core/bin/check-exports.mjs +92 -0
  111. package/templates/core/bin/check-harness.mjs +27 -0
  112. package/templates/core/bin/check-node-version.mjs +51 -0
  113. package/templates/core/bin/check-toolchain.mjs +20 -0
  114. package/templates/core/bin/lib/agent-roster.mjs +8 -0
  115. package/templates/core/bin/lib/frontmatter.mjs +210 -0
  116. package/templates/core/bin/lib/harness-rules.mjs +916 -0
  117. package/templates/core/bin/lib/protected-paths.mjs +23 -0
  118. package/templates/core/bin/lib/report.mjs +56 -0
  119. package/templates/core/bin/lib/signed-range.mjs +178 -0
  120. package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
  121. package/templates/core/bin/lib/verify-steps.mjs +131 -0
  122. package/templates/core/bin/lib/verify-steps.packs.json +1 -0
  123. package/templates/core/bin/lint-commit.mjs +50 -0
  124. package/templates/core/bin/strip-claude-trailers.mjs +25 -0
  125. package/templates/core/bin/verify.mjs +64 -0
  126. package/templates/core/commitlint.config.js +11 -0
  127. package/templates/core/docs/research/harness-refresh.md +27 -0
  128. package/templates/core/docs/research/typescript-refresh.md +32 -0
  129. package/templates/core/eslint.config.js +105 -0
  130. package/templates/core/knip.json +6 -0
  131. package/templates/core/lefthook.yml +39 -0
  132. package/templates/core/package.json +58 -0
  133. package/templates/core/pnpm-workspace.yaml +13 -0
  134. package/templates/core/src/index.ts +12 -0
  135. package/templates/core/tests/index.test.ts +8 -0
  136. package/templates/core/tsconfig.base.json +36 -0
  137. package/templates/core/tsconfig.build.json +10 -0
  138. package/templates/core/tsconfig.json +11 -0
  139. package/templates/core/vitest.config.ts +32 -0
  140. package/templates/packs/README.md +81 -0
  141. package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
  142. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
  143. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
  144. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
  145. package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
  146. package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
  147. package/templates/packs/harness-extras/pack.json +65 -0
  148. package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
  149. package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
  150. package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
  151. package/templates/packs/statusline/pack.json +31 -0
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard (Bash): block a command that combines `run_in_background:
4
+ * true` with a shell-level detach construct (`nohup`, `disown`, or a trailing
5
+ * `&`) in the same call.
6
+ *
7
+ * The two mechanisms both try to survive process/session churn, but stacking
8
+ * them produces a false "completed" report instead of a working one: the
9
+ * harness's own background-job tracking loses track of a process it never
10
+ * actually owns (the shell already detached it), so a poll against the
11
+ * harness-tracked job reports done while the real work is either still
12
+ * running unobserved or was silently killed along with the polling wrapper.
13
+ * Pick exactly one detachment mechanism per call: `run_in_background: true`
14
+ * alone (the harness tracks it), or a plain foreground command wrapped in
15
+ * `nohup <cmd> > <log> 2>&1 & disown` with the polling done separately
16
+ * against the raw PID (`kill -0 $PID`) -- never both on the same invocation.
17
+ *
18
+ * Fail-open on everything ambiguous: a malformed payload, a missing/
19
+ * non-boolean `run_in_background`, or a command with no detach construct all
20
+ * exit 0. This is a DENYLIST of the three known detach constructs, not a
21
+ * strict parse of the shell grammar -- extend `hasShellDetach` as new gaps
22
+ * are found rather than reaching for a real shell parser.
23
+ */
24
+ import process from "node:process";
25
+ import { realpathSync } from "node:fs";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ /**
29
+ * Does `command` contain a shell-level detach construct: `nohup`, `disown`,
30
+ * or a trailing background `&`?
31
+ *
32
+ * Both checks are anchored to avoid matching ordinary argument text, not
33
+ * just shell operators:
34
+ *
35
+ * - `nohup`/`disown` only count in COMMAND POSITION -- the start of the
36
+ * whole string, or right after a command separator (`;`, `&`, `|`, `(`).
37
+ * An unanchored `\bnohup\b` would also fire on `grep -n nohup file.txt`
38
+ * (searching FOR the word) -- matching argument text, not an invocation.
39
+ * - the bare `&` check excludes `&&` (logical AND), both fd-duplication
40
+ * redirect spellings -- `2>&1` (a `&` immediately preceded by `>`) and
41
+ * `&>`/`&>>` (bash's combined-redirect shorthand for `> file 2>&1`) -- and
42
+ * requires the `&` to sit at a command boundary (end-of-command or
43
+ * followed by whitespace), so an embedded query-string `&`
44
+ * (`...100&page=2`) isn't misread as backgrounding.
45
+ *
46
+ * @param {string} command
47
+ * @returns {boolean}
48
+ */
49
+ export function hasShellDetach(command) {
50
+ if (/(?:^|[;&|(])\s*(?:nohup|disown)\b/.test(command)) return true;
51
+ return /(?<![&>])&(?![&>\S])/.test(command);
52
+ }
53
+
54
+ /**
55
+ * Pure decision function -- exported for unit testing. Every ambiguous or
56
+ * non-applicable input returns `false` (allow), and only one confirmed
57
+ * verdict returns `true` (block).
58
+ *
59
+ * @param {string} command
60
+ * @param {unknown} runInBackground raw `tool_input.run_in_background` value
61
+ * @returns {boolean} true = block, false = allow
62
+ */
63
+ export function shouldBlockDoubleBackground(command, runInBackground) {
64
+ if (runInBackground !== true) return false;
65
+ if (typeof command !== "string" || command.length === 0) return false;
66
+ return hasShellDetach(command);
67
+ }
68
+
69
+ async function readStdin() {
70
+ const chunks = [];
71
+ for await (const chunk of process.stdin) chunks.push(chunk);
72
+ return Buffer.concat(chunks).toString("utf8");
73
+ }
74
+
75
+ // Deliberately inlined in every hook rather than shared: caps.ts counts
76
+ // .claude/hooks/*.mjs files against a hard limit, so a helper module would
77
+ // cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
78
+ // is not, so comparing them directly is false under any symlinked path and the
79
+ // guard body would never run -- exit 0, i.e. fail open.
80
+ function isEntryPoint() {
81
+ try {
82
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ // Only run when invoked directly, not when imported for testing.
89
+ if (isEntryPoint()) {
90
+ const raw = await readStdin();
91
+ let input;
92
+ try {
93
+ input = JSON.parse(raw);
94
+ } catch {
95
+ process.exit(0);
96
+ }
97
+
98
+ const command = input.tool_input?.command;
99
+ const runInBackground = input.tool_input?.run_in_background;
100
+
101
+ if (!shouldBlockDoubleBackground(command, runInBackground)) process.exit(0);
102
+
103
+ process.stderr.write(`\
104
+ [guard-double-background] Blocked: this command combines \`run_in_background:
105
+ true\` with a shell-level detach construct (\`nohup\`, \`disown\`, or a
106
+ trailing \`&\`). Stacking both risks a false "completed" report -- the
107
+ harness loses track of a process the shell already detached. Pick exactly one:
108
+ - \`run_in_background: true\` alone, polled via TaskOutput/Monitor, or
109
+ - a foreground \`nohup <cmd> > <log> 2>&1 & disown\`, polled separately
110
+ against the raw PID (\`kill -0 $PID\`) -- with run_in_background left false.
111
+ `);
112
+ process.exit(2);
113
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard (Bash): block a `git push` issued through the agent's Bash
4
+ * tool when any outgoing commit is unsigned or has an invalid signature.
5
+ *
6
+ * This is the FIRST Bash-matcher hook in the baseline -- every other
7
+ * PreToolUse hook inspects `tool_input.file_path`; this one inspects
8
+ * `tool_input.command`.
9
+ *
10
+ * Opt-in, not always-on: `signingEnabled()` (bin/lib/signed-range.mjs) checks
11
+ * whether THIS machine's git is configured to sign commits at all
12
+ * (`commit.gpgsign`). A project whose author hasn't set up commit signing
13
+ * gets no enforcement here -- the alternative (enforcing by default) would
14
+ * hard-block every push on a machine with no GPG/SSH signing key configured,
15
+ * which is not a safe default for a freshly bootstrapped project. Turn
16
+ * signing on (`git config commit.gpgsign true`) and this guard turns on with
17
+ * it, no other configuration step.
18
+ *
19
+ * Fail-open by design (matching every sibling hook): a malformed payload, a
20
+ * non-push command, signing not enabled, or a git failure all exit 0. The
21
+ * literal-signature verdict is the only thing that blocks (exit 2). Pair
22
+ * this with your remote's own "require signed commits" branch-protection
23
+ * setting for the authoritative, unbypassable layer -- this hook is only the
24
+ * earlier, local catch.
25
+ */
26
+ import process from "node:process";
27
+ import { realpathSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
29
+ import {
30
+ parseGitPush,
31
+ outgoingCommits,
32
+ unsignedCommits,
33
+ signingEnabled,
34
+ } from "../../bin/lib/signed-range.mjs";
35
+
36
+ async function readStdin() {
37
+ const chunks = [];
38
+ for await (const chunk of process.stdin) chunks.push(chunk);
39
+ return Buffer.concat(chunks).toString("utf8");
40
+ }
41
+
42
+ // Deliberately inlined in every hook rather than shared: caps.ts counts
43
+ // .claude/hooks/*.mjs files against a hard limit, so a helper module would
44
+ // cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
45
+ // is not, so comparing them directly is false under any symlinked path and the
46
+ // guard body would never run -- exit 0, i.e. fail open.
47
+ function isEntryPoint() {
48
+ try {
49
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // Only run when invoked directly, not when imported for testing.
56
+ if (isEntryPoint()) {
57
+ const raw = await readStdin();
58
+ let input;
59
+ try {
60
+ input = JSON.parse(raw);
61
+ } catch {
62
+ process.exit(0);
63
+ }
64
+
65
+ const command = input.tool_input?.command;
66
+ const { isPush, dryRun } = parseGitPush(
67
+ typeof command === "string" ? command : "",
68
+ );
69
+ if (!isPush || dryRun) process.exit(0);
70
+ if (!signingEnabled()) process.exit(0);
71
+
72
+ let bad;
73
+ try {
74
+ bad = unsignedCommits(outgoingCommits());
75
+ } catch {
76
+ process.exit(0); // cannot determine range -> defer to remote branch protection
77
+ }
78
+ if (bad.length === 0) process.exit(0);
79
+
80
+ process.stderr.write(`\
81
+ [guard-git-push-signed] Blocked: refusing to push unsigned/unverified commits.
82
+ ${bad.map(({ sha, code }) => ` - ${sha.slice(0, 12)} (%G? = ${code})`).join("\n")}
83
+
84
+ commit.gpgsign is enabled on this machine, so every commit pushed to the
85
+ remote must carry a valid signature. Re-sign the range, e.g.:
86
+ git rebase --exec 'git commit --amend --no-edit -S' origin/main
87
+ Then retry the push.
88
+ `);
89
+ process.exit(2);
90
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard (Write|Edit): blocks hub-authored writes into guarded
4
+ * source and test paths on ANY branch.
5
+ *
6
+ * Problem: `guard-branch-isolation.mjs` only fires while `HEAD` is `main`.
7
+ * On a feature branch nothing else stops the hub itself from writing
8
+ * directly into a guarded path instead of dispatching the write to a writer
9
+ * spoke, as CLAUDE.md's Agent Operating Model requires.
10
+ *
11
+ * The seam: the PreToolUse payload carries a top-level `agent_type` field
12
+ * when the tool call fires inside a subagent context. The field is absent
13
+ * (or empty) for hub-level calls, and contains the subagent's name for
14
+ * spoke calls.
15
+ *
16
+ * The decision: block when BOTH conditions hold:
17
+ * (a) the target path is a guarded source/test path, AND
18
+ * (b) `agent_type` is NOT the name of an authorised writer spoke
19
+ * (`code-implementer` or `test-author`, per WRITER_SPOKES in
20
+ * bin/lib/agent-roster.mjs).
21
+ *
22
+ * Hub calls (absent/empty agent_type) and non-writer subagents are treated
23
+ * identically -- both are blocked from guarded paths. Writer spokes are
24
+ * allowed through. All other paths are allowed through unconditionally.
25
+ *
26
+ * Fail-open: an unparseable payload or missing file_path exits 0 so a
27
+ * malformed hook input never wedges the session.
28
+ */
29
+ import process from "node:process";
30
+ import { realpathSync } from "node:fs";
31
+ import { fileURLToPath } from "node:url";
32
+ import { isProtectedPath } from "../../bin/lib/protected-paths.mjs";
33
+ import { WRITER_SPOKES } from "../../bin/lib/agent-roster.mjs";
34
+
35
+ /**
36
+ * Pure decision function -- exported for unit testing.
37
+ *
38
+ * @param {string | undefined} filePath The file_path from the tool_input payload.
39
+ * @param {unknown} agentType The top-level agent_type from the payload.
40
+ * @returns {boolean} true = block, false = allow.
41
+ */
42
+ export function shouldBlockHubSrcWrite(filePath, agentType) {
43
+ if (!filePath || typeof filePath !== "string") return false;
44
+ if (!isProtectedPath(filePath)) return false;
45
+ if (
46
+ typeof agentType === "string" &&
47
+ agentType.length > 0 &&
48
+ WRITER_SPOKES.has(agentType)
49
+ ) {
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ // Deliberately inlined in every hook rather than shared: caps.ts counts
56
+ // .claude/hooks/*.mjs files against a hard limit, so a helper module would
57
+ // cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
58
+ // is not, so comparing them directly is false under any symlinked path and the
59
+ // guard body would never run -- exit 0, i.e. fail open.
60
+ function isEntryPoint() {
61
+ try {
62
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Only run when invoked directly, not when imported for testing.
69
+ if (isEntryPoint()) {
70
+ const chunks = [];
71
+ for await (const chunk of process.stdin) chunks.push(chunk);
72
+ let input;
73
+ try {
74
+ input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
75
+ } catch {
76
+ process.exit(0);
77
+ }
78
+ const filePath = input.tool_input?.file_path ?? "";
79
+ const agentType = input.agent_type;
80
+ if (!shouldBlockHubSrcWrite(filePath, agentType)) process.exit(0);
81
+ process.stderr.write(
82
+ "guard-hub-src-writes: Hub-authored write to a guarded path detected.\n" +
83
+ ` Path: ${filePath}\n` +
84
+ " Dispatch the write to 'code-implementer' (src/**) or 'test-author' (tests/**) instead.\n" +
85
+ " See: CLAUDE.md's Agent Operating Model.\n",
86
+ );
87
+ process.exit(2);
88
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard (Write|Edit): blocks writing a relative import that is
4
+ * missing the `.js` extension.
5
+ *
6
+ * This is the #1 documented ESM gotcha -- a relative import without `.js`
7
+ * type-checks but fails to resolve at runtime in Node. Path-scoped rules
8
+ * inject on Read, not reliably at Write, so this runs as a hook.
9
+ *
10
+ * Blocks by exiting 2 with a message on stderr (Claude Code convention).
11
+ */
12
+ import process from "node:process";
13
+
14
+ const ALLOWED = [".js", ".mjs", ".cjs", ".json", ".node"];
15
+
16
+ /** Extract the content being written from the tool input. */
17
+ function contentToCheck(input) {
18
+ const ti = input.tool_input ?? {};
19
+ // Write -> content; Edit -> new_string (the text being introduced).
20
+ return [ti.content, ti.new_string].filter((s) => typeof s === "string");
21
+ }
22
+
23
+ /** Find relative import/export specifiers missing an allowed extension. */
24
+ function offendingSpecifiers(source) {
25
+ const offenders = [];
26
+ // Matches: from "./x", from '../y', import("./z"), require("./w")
27
+ const re = /\b(?:from|import|require)\s*\(?\s*["'](\.\.?\/[^"']*)["']/g;
28
+ let m;
29
+ while ((m = re.exec(source)) !== null) {
30
+ const spec = m[1];
31
+ const hasAllowed = ALLOWED.some((ext) => spec.endsWith(ext));
32
+ if (!hasAllowed) offenders.push(spec);
33
+ }
34
+ return offenders;
35
+ }
36
+
37
+ async function readStdin() {
38
+ const chunks = [];
39
+ for await (const chunk of process.stdin) chunks.push(chunk);
40
+ return Buffer.concat(chunks).toString("utf8");
41
+ }
42
+
43
+ const raw = await readStdin();
44
+ let input;
45
+ try {
46
+ input = JSON.parse(raw);
47
+ } catch {
48
+ process.exit(0); // Not our concern; let the call through.
49
+ }
50
+
51
+ const filePath = input.tool_input?.file_path ?? "";
52
+ if (!/\.(ts|tsx|mts|cts)$/.test(filePath)) process.exit(0);
53
+
54
+ const offenders = contentToCheck(input).flatMap(offendingSpecifiers);
55
+ if (offenders.length > 0) {
56
+ const unique = [...new Set(offenders)];
57
+ process.stderr.write(
58
+ `Blocked: relative import(s) missing the required \`.js\` extension ` +
59
+ `(ESM + NodeNext will fail to resolve at runtime):\n` +
60
+ unique.map((s) => ` - "${s}" -> "${s}.js"`).join("\n") +
61
+ `\nAdd the \`.js\` extension to every relative import.\n`,
62
+ );
63
+ process.exit(2);
64
+ }
65
+
66
+ process.exit(0);
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard (Write|Edit): blocks CommonJS constructs in TypeScript
4
+ * source. This project is ESM only ("type": "module"); `require`,
5
+ * `module.exports`, `exports.`, `__dirname`, and `__filename` are forbidden.
6
+ *
7
+ * Blocks by exiting 2 with a message on stderr.
8
+ */
9
+ import process from "node:process";
10
+ import { realpathSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const PATTERNS = [
14
+ { re: /\brequire\s*\(/, label: "require(...)" },
15
+ { re: /\bmodule\.exports\b/, label: "module.exports" },
16
+ // A bare \b also fires inside kebab-case filenames like
17
+ // "check-doc-exports.mjs" (hyphen -> letter is a word-boundary
18
+ // transition). Excluding a preceding identifier char or hyphen keeps real
19
+ // `exports.foo = ...` assignments blocked while letting mentions of any
20
+ // "*-exports.<ext>" bin script through.
21
+ { re: /(?<![\w-])exports\.[A-Za-z_$]/, label: "exports.<name>" },
22
+ { re: /\b__dirname\b/, label: "__dirname" },
23
+ { re: /\b__filename\b/, label: "__filename" },
24
+ ];
25
+
26
+ const SOURCE_EXT_RE = /\.(ts|tsx|mts|cts|js|mjs)$/;
27
+
28
+ /**
29
+ * True when `filePath` is TS/JS source this guard should inspect -- not a
30
+ * config file, doc, or a file under this hook's own directory.
31
+ *
32
+ * @param {string} filePath
33
+ * @returns {boolean}
34
+ */
35
+ export function isGuardedFilePath(filePath) {
36
+ if (!SOURCE_EXT_RE.test(filePath)) return false;
37
+ // Normalize separators first: Windows paths use "\", and a literal "/"
38
+ // check silently never matches there, leaving this hook unable to exempt
39
+ // its own directory on that platform.
40
+ if (filePath.replace(/\\/g, "/").includes("/.claude/hooks/")) return false;
41
+ return true;
42
+ }
43
+
44
+ /**
45
+ * Scan `content` for forbidden CommonJS constructs. Returns a human-readable
46
+ * label per match (empty array when clean).
47
+ *
48
+ * @param {string} content
49
+ * @returns {string[]}
50
+ */
51
+ export function findCommonJsHits(content) {
52
+ return PATTERNS.filter((p) => p.re.test(content)).map((p) => p.label);
53
+ }
54
+
55
+ function contentToCheck(input) {
56
+ const ti = input.tool_input ?? {};
57
+ return [ti.content, ti.new_string].filter((s) => typeof s === "string");
58
+ }
59
+
60
+ async function readStdin() {
61
+ const chunks = [];
62
+ for await (const chunk of process.stdin) chunks.push(chunk);
63
+ return Buffer.concat(chunks).toString("utf8");
64
+ }
65
+
66
+ // Deliberately inlined in every hook rather than shared: caps.ts counts
67
+ // .claude/hooks/*.mjs files against a hard limit, so a helper module would
68
+ // cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
69
+ // is not, so comparing them directly is false under any symlinked path and the
70
+ // guard body would never run -- exit 0, i.e. fail open.
71
+ function isEntryPoint() {
72
+ try {
73
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ // Main execution -- only run when invoked directly, not when imported for testing.
80
+ if (isEntryPoint()) {
81
+ const raw = await readStdin();
82
+ let input;
83
+ try {
84
+ input = JSON.parse(raw);
85
+ } catch {
86
+ process.exit(0);
87
+ }
88
+
89
+ const filePath = input.tool_input?.file_path ?? "";
90
+ if (!isGuardedFilePath(filePath)) process.exit(0);
91
+
92
+ const source = contentToCheck(input).join("\n");
93
+ const hits = findCommonJsHits(source);
94
+ if (hits.length > 0) {
95
+ process.stderr.write(
96
+ `Blocked: CommonJS construct(s) found (this project is ESM only):\n` +
97
+ hits.map((h) => ` - ${h}`).join("\n") +
98
+ `\nUse ESM equivalents: import/export, import.meta.url, ` +
99
+ `fileURLToPath(import.meta.url).\n`,
100
+ );
101
+ process.exit(2);
102
+ }
103
+
104
+ process.exit(0);
105
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard (Write|Edit): protects tool-owned artifacts.
4
+ *
5
+ * - `dist/**` is tsc output and must never be hand-edited.
6
+ * - `coverage/**` is the vitest v8 coverage report and must never be
7
+ * hand-edited.
8
+ *
9
+ * Add another generated-output directory to PROTECTED_DIR_NAMES below if
10
+ * this project introduces one (e.g. a bundler's own output dir).
11
+ *
12
+ * Blocks by exiting 2 with a message on stderr.
13
+ */
14
+ import process from "node:process";
15
+
16
+ const PROTECTED_DIR_NAMES = ["dist", "coverage"];
17
+
18
+ async function readStdin() {
19
+ const chunks = [];
20
+ for await (const chunk of process.stdin) chunks.push(chunk);
21
+ return Buffer.concat(chunks).toString("utf8");
22
+ }
23
+
24
+ const raw = await readStdin();
25
+ let input;
26
+ try {
27
+ input = JSON.parse(raw);
28
+ } catch {
29
+ process.exit(0);
30
+ }
31
+
32
+ const filePath = input.tool_input?.file_path ?? "";
33
+
34
+ const hit = PROTECTED_DIR_NAMES.find((name) =>
35
+ new RegExp(`(^|/)${name}/`).test(filePath),
36
+ );
37
+ if (hit) {
38
+ process.stderr.write(
39
+ `Blocked: \`${hit}/\` is generated output and must never be ` +
40
+ `hand-edited. Change the source and rebuild/retest instead.\n`,
41
+ );
42
+ process.exit(2);
43
+ }
44
+
45
+ process.exit(0);
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse blocking (Write|Edit): refuse to write a real secret to disk.
4
+ *
5
+ * CLAUDE.md's Security section requires that no secret or token ever land in
6
+ * source, tests, or fixtures. A CI secret-scanning step is the backstop;
7
+ * this hook is the earlier, write-time block so a credential never reaches
8
+ * the working tree -- where it would linger in git reflog/objects even
9
+ * after a fix.
10
+ *
11
+ * Two independent triggers, both hard-block (exit 2):
12
+ * 1. Path: a dotenv file (`.env`, `.env.local`, ...) -- these are
13
+ * gitignored secret stores and should never be authored by the agent.
14
+ * Template variants (`.env.example`/`.sample`/`.template`/`.dist`) are
15
+ * allowed.
16
+ * 2. Content: a known secret-key assignment carrying a *real* value, or a
17
+ * recognised high-entropy token/private-key literal. References and
18
+ * placeholders (`${{ secrets.X }}`, `process.env.X`, `<your-token>`, ...)
19
+ * are NOT flagged -- the point is to catch values, not names.
20
+ *
21
+ * Detection is deliberately conservative: prefix-anchored token shapes and
22
+ * placeholder exclusion keep false positives off legitimate config/docs
23
+ * (e.g. `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` in a workflow).
24
+ */
25
+ import process from "node:process";
26
+ import { realpathSync } from "node:fs";
27
+ import path from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ async function readStdin() {
31
+ const chunks = [];
32
+ for await (const chunk of process.stdin) chunks.push(chunk);
33
+ return Buffer.concat(chunks).toString("utf8");
34
+ }
35
+
36
+ /**
37
+ * Env-var-name prefixes/exact names whose assigned *value* is a secret we
38
+ * must never persist. Project-specific keys can be appended here.
39
+ */
40
+ export const SECRET_KEYS = [
41
+ "NPM_TOKEN",
42
+ "GITHUB_TOKEN",
43
+ "GH_TOKEN",
44
+ "AWS_SECRET_ACCESS_KEY",
45
+ "AWS_ACCESS_KEY_ID",
46
+ "AWS_SESSION_TOKEN",
47
+ ];
48
+
49
+ // High-signal literal shapes -- a match is almost certainly a real credential.
50
+ const TOKEN_LITERALS = [
51
+ /\bghp_[A-Za-z0-9]{36}\b/, // GitHub personal access token
52
+ /\bgithub_pat_[A-Za-z0-9_]{22,}\b/, // GitHub fine-grained PAT
53
+ /\bnpm_[A-Za-z0-9]{36}\b/, // npm automation token
54
+ /\bAKIA[0-9A-Z]{16}\b/, // AWS access key id
55
+ /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, // PEM private key
56
+ ];
57
+
58
+ /**
59
+ * A value is a placeholder/reference, not a real secret, when it defers to an
60
+ * env var or CI secret, or is an obvious dummy. Such values must not be flagged.
61
+ *
62
+ * @param {string} value
63
+ * @returns {boolean}
64
+ */
65
+ function isPlaceholderValue(value) {
66
+ const v = value.trim().replace(/^["']|["']$/g, "");
67
+ if (v.length === 0) return true;
68
+ if (/\$\{|\$\(|process\.env|secrets\.|env\./.test(v)) return true; // reference
69
+ if (/^<.*>$/.test(v)) return true; // <your-token>
70
+ if (
71
+ /(example|placeholder|changeme|your[-_]|xxx+|dummy|fake|redacted)/i.test(v)
72
+ )
73
+ return true;
74
+ // Too short / low-entropy to be a real token.
75
+ return v.length < 16;
76
+ }
77
+
78
+ /**
79
+ * True when the basename is a real dotenv file (not a committed template).
80
+ *
81
+ * @param {string} filePath
82
+ * @returns {boolean}
83
+ */
84
+ export function isEnvFilePath(filePath) {
85
+ const base = path.basename(filePath);
86
+ if (!/^\.env(\.|$)/.test(base)) return false;
87
+ return !/\.(example|sample|template|dist)$/.test(base);
88
+ }
89
+
90
+ /**
91
+ * Scan `content` for secret-key assignments carrying a real value or for a
92
+ * recognised token/private-key literal. Returns a human-readable reason per
93
+ * match (empty array when clean).
94
+ *
95
+ * @param {string} content
96
+ * @returns {string[]}
97
+ */
98
+ export function findSecrets(content) {
99
+ const hits = [];
100
+
101
+ for (const key of SECRET_KEYS) {
102
+ // KEY = value or KEY: value (env/yaml/json-ish), value runs to EOL.
103
+ const re = new RegExp(`\\b${key}\\b\\s*[:=]\\s*(.+)`, "g");
104
+ for (const m of content.matchAll(re)) {
105
+ if (!isPlaceholderValue(m[1]))
106
+ hits.push(`${key} assigned a literal value`);
107
+ }
108
+ }
109
+
110
+ for (const re of TOKEN_LITERALS) {
111
+ if (re.test(content))
112
+ hits.push(`recognised token/key literal (${re.source})`);
113
+ }
114
+
115
+ return hits;
116
+ }
117
+
118
+ /**
119
+ * Combined verdict for a write. Returns the block reasons (empty when allowed).
120
+ *
121
+ * @param {string} filePath
122
+ * @param {string} content
123
+ * @returns {string[]}
124
+ */
125
+ export function isSecretWrite(filePath, content) {
126
+ const reasons = [];
127
+ if (isEnvFilePath(filePath)) {
128
+ reasons.push(`writing a dotenv secret file (${path.basename(filePath)})`);
129
+ }
130
+ reasons.push(...findSecrets(content ?? ""));
131
+ return reasons;
132
+ }
133
+
134
+ // Deliberately inlined in every hook rather than shared: caps.ts counts
135
+ // .claude/hooks/*.mjs files against a hard limit, so a helper module would
136
+ // cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
137
+ // is not, so comparing them directly is false under any symlinked path and the
138
+ // guard body would never run -- exit 0, i.e. fail open.
139
+ function isEntryPoint() {
140
+ try {
141
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ // Main execution -- only run when invoked directly, not when imported for testing.
148
+ if (isEntryPoint()) {
149
+ const raw = await readStdin();
150
+ let input;
151
+ try {
152
+ input = JSON.parse(raw);
153
+ } catch {
154
+ process.exit(0);
155
+ }
156
+
157
+ const filePath = input.tool_input?.file_path ?? "";
158
+ if (typeof filePath !== "string" || filePath.length === 0) process.exit(0);
159
+
160
+ // Content by field presence (Write -> content, Edit -> new_string), matching
161
+ // the sibling-hook convention so the guard stays live if tool_name is absent.
162
+ const ti = input.tool_input ?? {};
163
+ const content =
164
+ typeof ti.content === "string"
165
+ ? ti.content
166
+ : typeof ti.new_string === "string"
167
+ ? ti.new_string
168
+ : "";
169
+
170
+ const reasons = isSecretWrite(filePath, content);
171
+ if (reasons.length === 0) process.exit(0);
172
+
173
+ process.stderr.write(`\
174
+ [guard-secret-writes] Blocked write to ${filePath}:
175
+ ${reasons.map((r) => ` - ${r}`).join("\n")}
176
+
177
+ Secrets and tokens must never be written to the working tree -- they persist in
178
+ git objects/reflog even after removal. Use a CI secret or an env var reference
179
+ instead of a literal value, and keep real dotenv files out of the repo (.env*
180
+ is gitignored; author .env.example with placeholders).
181
+ `);
182
+ process.exit(2);
183
+ }