@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.3

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 (170) hide show
  1. package/README.md +13 -5
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * This is the #1 documented ESM gotcha -- a relative import without `.js`
7
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.
8
+ * load on Read, Write and Edit (Claude Code 2.1.288) but only advise, so
9
+ * this runs as a hook, which blocks.
9
10
  *
10
11
  * Blocks by exiting 2 with a message on stderr (Claude Code convention).
11
12
  */
@@ -63,11 +63,13 @@ async function readStdin() {
63
63
  return Buffer.concat(chunks).toString("utf8");
64
64
  }
65
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.
66
+ // Kept as a duplicated, self-contained block in every hook file rather than
67
+ // imported from a shared helper -- each hook stays a single independent
68
+ // file, which keeps this project's hook count easy to reason about against
69
+ // CLAUDE.md's hook budget. `import.meta.url` is symlink-resolved but
70
+ // `process.argv[1]` is not, so comparing them directly would be false under
71
+ // a symlinked invocation path -- and the guard below would then never run,
72
+ // i.e. silently fail open (exit 0) instead of blocking.
71
73
  function isEntryPoint() {
72
74
  try {
73
75
  return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -131,11 +131,13 @@ export function isSecretWrite(filePath, content) {
131
131
  return reasons;
132
132
  }
133
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.
134
+ // Kept as a duplicated, self-contained block in every hook file rather than
135
+ // imported from a shared helper -- each hook stays a single independent
136
+ // file, which keeps this project's hook count easy to reason about against
137
+ // CLAUDE.md's hook budget. `import.meta.url` is symlink-resolved but
138
+ // `process.argv[1]` is not, so comparing them directly would be false under
139
+ // a symlinked invocation path -- and the guard below would then never run,
140
+ // i.e. silently fail open (exit 0) instead of blocking.
139
141
  function isEntryPoint() {
140
142
  try {
141
143
  return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * UserPromptSubmit: when a prompt looks like change-work, inject a short
4
- * decision-gate reminder as additional context.
4
+ * reminder about branch and PR hygiene as additional context, before the
5
+ * agent starts editing.
5
6
  *
6
- * This is the only UserPromptSubmit hook in the baseline and the only one
7
- * that *injects* context (every other hook communicates via stderr + exit
8
- * code). It surfaces the decisions the `starting-work` skill formalizes --
7
+ * This is the only UserPromptSubmit hook in this project's harness and the
8
+ * only one that *injects* context (every other hook communicates via stderr
9
+ * + exit code). It surfaces the decisions the `starting-work` skill formalizes --
9
10
  * branch and PR target -- up front, so isolation is chosen deliberately
10
11
  * instead of being discovered when `guard-branch-isolation.mjs` blocks a
11
12
  * src/test write on `main`.
@@ -82,11 +83,13 @@ export function buildContext(branch) {
82
83
  ].join("\n");
83
84
  }
84
85
 
85
- // Deliberately inlined in every hook rather than shared: caps.ts counts
86
- // .claude/hooks/*.mjs files against a hard limit, so a helper module would
87
- // cost a hook slot. `import.meta.url` is symlink-resolved but `process.argv[1]`
88
- // is not, so comparing them directly is false under any symlinked path and the
89
- // guard body would never run -- exit 0, i.e. fail open.
86
+ // Kept as a duplicated, self-contained block in every hook file rather than
87
+ // imported from a shared helper -- each hook stays a single independent
88
+ // file, which keeps this project's hook count easy to reason about against
89
+ // CLAUDE.md's hook budget. `import.meta.url` is symlink-resolved but
90
+ // `process.argv[1]` is not, so comparing them directly would be false under
91
+ // a symlinked invocation path -- and the guard below would then never run,
92
+ // i.e. silently fail open (exit 0) instead of blocking.
90
93
  function isEntryPoint() {
91
94
  try {
92
95
  return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -23,12 +23,28 @@
23
23
  * On any failure it exits 2 with a concise stderr summary, which Claude Code
24
24
  * surfaces back to the model as advisory feedback. The edit has already been
25
25
  * applied -- this is a nudge, not a hard gate.
26
+ *
27
+ * Worktree correctness: `CLAUDE_PROJECT_DIR` is pinned to the session's
28
+ * ORIGINAL project root and stays there even after the session moves into a
29
+ * worktree with `EnterWorktree` (see code.claude.com/docs/en/worktrees,
30
+ * "Hook paths don't follow the worktree"). Resolving every path against it
31
+ * would run every check below against the wrong tree -- or, worse, silently
32
+ * skip the file, since a worktree lives at `<projectDir>/.claude/worktrees/
33
+ * <name>/` and a projectDir-relative path into it starts with `.claude/`.
34
+ * Instead this hook asks git which working tree actually contains the
35
+ * edited file (`resolveVerifyRoot`, the same "resolve from the file, not the
36
+ * session" approach `guard-branch-isolation.mjs` already uses for the same
37
+ * reason) and scopes every step to that root.
26
38
  */
27
39
  import process from "node:process";
28
- import path from "node:path";
29
- import fs from "node:fs";
30
- import { spawnSync } from "node:child_process";
31
- import { isProtectedPath } from "../../bin/lib/protected-paths.mjs";
40
+ import path, { dirname, resolve } from "node:path";
41
+ import fs, { existsSync, realpathSync } from "node:fs";
42
+ import { execFileSync, spawnSync } from "node:child_process";
43
+ import { fileURLToPath } from "node:url";
44
+ import {
45
+ canonicalize,
46
+ isProtectedPath,
47
+ } from "../../bin/lib/protected-paths.mjs";
32
48
 
33
49
  async function readStdin() {
34
50
  const chunks = [];
@@ -36,115 +52,277 @@ async function readStdin() {
36
52
  return Buffer.concat(chunks).toString("utf8");
37
53
  }
38
54
 
39
- const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
40
-
41
- const raw = await readStdin();
42
- let input;
43
- try {
44
- input = JSON.parse(raw);
45
- } catch {
46
- process.exit(0);
55
+ /**
56
+ * Returns a git runner that executes every command with `git -C dir` --
57
+ * same pattern as `guard-branch-isolation.mjs`'s `defaultGitFor`, kept as
58
+ * its own copy here rather than a shared import: each hook stays a single
59
+ * independent file, which keeps this project's hook count easy to reason
60
+ * about against CLAUDE.md's hook budget.
61
+ *
62
+ * @param {string} dir Absolute directory path.
63
+ * @returns {(args: string[]) => string}
64
+ */
65
+ export function defaultGitFor(dir) {
66
+ return function git(args) {
67
+ try {
68
+ return execFileSync("git", ["-C", dir, ...args], {
69
+ encoding: "utf8",
70
+ // Don't let git's own stderr leak to this process's stderr (it
71
+ // would otherwise print its "not a git repository" line even for
72
+ // the ordinary, silent case below) -- captured and inspected
73
+ // instead. `LC_ALL`/`LANGUAGE` pin git's message to English so the
74
+ // pattern match below doesn't depend on the caller's locale.
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ env: { ...process.env, LC_ALL: "C", LANGUAGE: "C" },
77
+ }).trim();
78
+ } catch (cause) {
79
+ // "Not a git repository (or any of the parent directories)" is the
80
+ // expected, silent case (any directory outside version control) --
81
+ // everything else (git missing, EACCES, a `dubious ownership`
82
+ // refusal, a corrupt worktree pointer, which prints a DIFFERENT "not
83
+ // a git repository: <path>" form with no "(or any ...)" suffix) is a
84
+ // real problem whose only symptom would otherwise be a confusing
85
+ // silent fall-back to CLAUDE_PROJECT_DIR, so it gets one stderr line
86
+ // instead. The pattern is deliberately narrow: matching bare "not a
87
+ // git repository" would also swallow the corrupt-pointer case, which
88
+ // is exactly the case this hint exists to surface.
89
+ const message = String(cause?.stderr || cause?.message || "").trim();
90
+ if (!/not a git repository \(or any /i.test(message)) {
91
+ process.stderr.write(
92
+ `post-edit-verify: git lookup failed in \`${dir}\` (${message.split("\n")[0]}); ` +
93
+ "falling back to CLAUDE_PROJECT_DIR.\n",
94
+ );
95
+ }
96
+ return "";
97
+ }
98
+ };
47
99
  }
48
100
 
49
- const filePath = input.tool_input?.file_path;
50
- if (typeof filePath !== "string" || filePath.length === 0) process.exit(0);
51
-
52
- const abs = path.isAbsolute(filePath)
53
- ? filePath
54
- : path.resolve(projectDir, filePath);
55
- const rel = path.relative(projectDir, abs).split(path.sep).join("/");
56
-
57
- // Only TypeScript sources; never generated declarations or the hooks themselves.
58
- if (!/\.(ts|mts|cts)$/.test(rel) || /\.d\.ts$/.test(rel)) process.exit(0);
59
- if (
60
- rel.startsWith("..") ||
61
- rel.includes("node_modules/") ||
62
- /(^|\/)dist\//.test(rel) ||
63
- rel.startsWith(".claude/")
101
+ /**
102
+ * Resolves the git working-tree root that actually contains `absFile` --
103
+ * the worktree root when the file lives inside a linked worktree, the main
104
+ * checkout root otherwise -- falling back to `fallback` when `absFile` is
105
+ * outside any git repository (or git isn't available).
106
+ *
107
+ * Walks up to the nearest EXISTING ancestor of `absFile` before shelling
108
+ * out, the same way `guard-branch-isolation.mjs` does: a Write creating a
109
+ * brand-new nested directory means `git -C <that dir>` would otherwise fail
110
+ * outright ("cannot change to ... No such file or directory").
111
+ *
112
+ * @param {string} absFile Absolute path to the edited file.
113
+ * @param {string} fallback Used when no git root can be resolved.
114
+ * @param {(dir: string) => (args: string[]) => string} [gitFactory]
115
+ * @returns {string}
116
+ */
117
+ export function resolveVerifyRoot(
118
+ absFile,
119
+ fallback,
120
+ gitFactory = defaultGitFor,
64
121
  ) {
65
- process.exit(0);
122
+ let probeDir = dirname(resolve(absFile));
123
+ while (!existsSync(probeDir)) {
124
+ const parent = dirname(probeDir);
125
+ if (parent === probeDir) break; // filesystem root; give up climbing
126
+ probeDir = parent;
127
+ }
128
+ const git = gitFactory(probeDir);
129
+ const root = git(["rev-parse", "--show-toplevel"]);
130
+ return root === "" ? fallback : root;
66
131
  }
67
132
 
68
- if (!isProtectedPath(rel)) process.exit(0);
69
-
70
- // Walk up to the nearest package.json = the owning package root (the
71
- // project root itself in a flat single-package layout).
72
- function findPackageDir(startDir) {
73
- let dir = startDir;
74
- while (dir.startsWith(projectDir)) {
75
- if (fs.existsSync(path.join(dir, "package.json"))) return dir;
76
- const parent = path.dirname(dir);
77
- if (parent === dir) break;
78
- dir = parent;
133
+ // Kept as a duplicated, self-contained block rather than a shared helper --
134
+ // see the comment on `defaultGitFor` above. `import.meta.url` is
135
+ // symlink-resolved by Node's ESM loader but `process.argv[1]` is not, so
136
+ // comparing them directly would be false under a symlinked invocation path,
137
+ // and this guard exists precisely so the pure helpers above stay importable
138
+ // (for testing) without the script's side-effecting body running too.
139
+ function isEntryPoint() {
140
+ try {
141
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
142
+ } catch {
143
+ return false;
79
144
  }
80
- return undefined;
81
145
  }
82
146
 
83
- const pkgDir = findPackageDir(path.dirname(abs));
84
- if (pkgDir === undefined) process.exit(0);
85
-
86
- function run(cmd, args) {
87
- const res = spawnSync(cmd, args, {
88
- cwd: projectDir,
89
- encoding: "utf8",
90
- env: process.env,
91
- });
92
- // If the tool can't even be spawned (e.g. pnpm missing), skip silently
93
- // rather than emit a misleading failure.
94
- if (res.error) return undefined;
95
- return res;
96
- }
147
+ if (isEntryPoint()) {
148
+ const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
97
149
 
98
- const failures = [];
150
+ const raw = await readStdin();
151
+ let input;
152
+ try {
153
+ input = JSON.parse(raw);
154
+ } catch {
155
+ process.exit(0);
156
+ }
99
157
 
100
- // 1. Format the edited file (best-effort; a parse error is itself signal).
101
- const fmt = run("pnpm", ["exec", "prettier", "--write", abs]);
102
- if (fmt && fmt.status !== 0) {
103
- failures.push(`prettier:\n${(fmt.stderr || fmt.stdout || "").trim()}`);
104
- }
158
+ const filePath = input.tool_input?.file_path;
159
+ if (typeof filePath !== "string" || filePath.length === 0) process.exit(0);
105
160
 
106
- // 2. Lint the edited file (single file; flat config resolves from repo root
107
- // and honours its own `ignores`, so no per-package wrapper is needed).
108
- // Report-only (no --fix) so the root cause is addressed, not masked.
109
- const lint = run("pnpm", ["exec", "eslint", abs]);
110
- if (lint && lint.status !== 0) {
111
- failures.push(`eslint:\n${(lint.stdout || lint.stderr || "").trim()}`);
112
- }
161
+ // A relative file_path is resolved against the hook's `cwd` input field
162
+ // when present -- unlike CLAUDE_PROJECT_DIR, `cwd` follows the session
163
+ // into a worktree (see the worktree-correctness note above) -- falling
164
+ // back to projectDir otherwise.
165
+ const baseDir =
166
+ typeof input.cwd === "string" && input.cwd.length > 0
167
+ ? input.cwd
168
+ : projectDir;
169
+ // Canonicalized (case-correct, symlinks resolved) so it's in the SAME form
170
+ // `resolveVerifyRoot` returns (`git rev-parse --show-toplevel` already
171
+ // resolves symlinks) -- comparing an un-resolved `abs` against a resolved
172
+ // `root` would make `path.relative` produce a bogus `../..` on any
173
+ // symlinked project path (macOS's `/tmp` -> `/private/tmp`, `/var` ->
174
+ // `/private/var`, a symlinked home directory), which is exactly the
175
+ // silent-skip failure mode this file exists to close.
176
+ const abs = canonicalize(
177
+ path.isAbsolute(filePath) ? filePath : path.resolve(baseDir, filePath),
178
+ );
113
179
 
114
- // 3. Type-check the owning package.
115
- const tc = run("pnpm", ["-C", pkgDir, "typecheck"]);
116
- if (tc && tc.status !== 0) {
117
- failures.push(`typecheck:\n${(tc.stdout || tc.stderr || "").trim()}`);
118
- }
180
+ const root = resolveVerifyRoot(abs, canonicalize(projectDir));
181
+ const rel = path.relative(root, abs).split(path.sep).join("/");
119
182
 
120
- // 4. Run only the tests related to the edited file.
121
- const vt = run("pnpm", ["exec", "vitest", "related", abs, "--run"]);
122
- if (vt && vt.status !== 0) {
123
- failures.push(`vitest related:\n${(vt.stdout || vt.stderr || "").trim()}`);
124
- }
183
+ // Only TypeScript sources; never generated declarations.
184
+ if (!/\.(ts|mts|cts)$/.test(rel) || /\.d\.ts$/.test(rel)) process.exit(0);
185
+ if (
186
+ rel.startsWith("..") ||
187
+ rel.includes("node_modules/") ||
188
+ /(^|\/)dist\//.test(rel)
189
+ ) {
190
+ process.exit(0);
191
+ }
192
+
193
+ if (!isProtectedPath(rel)) process.exit(0);
125
194
 
126
- // 5. When a src/ file is implemented/updated, also lint the package's tests/
127
- // directory to surface stale eslint-disable directives that became unused
128
- // once the implementation landed.
129
- if (/(^|\/)src\//.test(rel)) {
130
- const testsDir = path.join(pkgDir, "tests");
131
- if (fs.existsSync(testsDir)) {
132
- const testLint = run("pnpm", ["exec", "eslint", testsDir]);
133
- if (testLint && testLint.status !== 0) {
134
- failures.push(
135
- `eslint (tests/ -- scanned because a src/ file was edited; fix the file(s) listed below):\n${(testLint.stdout || testLint.stderr || "").trim()}`,
136
- );
195
+ // Walk up to the nearest package.json = the owning package root (the
196
+ // worktree/project root itself in a flat single-package layout). Bounded
197
+ // by `root`, not `projectDir` -- if this project is itself checked out
198
+ // inside a larger git repository, `root` (the git toplevel) can sit ABOVE
199
+ // the actual package; walking up from the edited file finds the real
200
+ // owning package first regardless.
201
+ function findPackageDir(startDir) {
202
+ let dir = startDir;
203
+ while (dir.startsWith(root)) {
204
+ if (fs.existsSync(path.join(dir, "package.json"))) return dir;
205
+ const parent = path.dirname(dir);
206
+ if (parent === dir) break;
207
+ dir = parent;
137
208
  }
209
+ return undefined;
138
210
  }
139
- }
140
211
 
141
- if (failures.length > 0) {
142
- process.stderr.write(
143
- `post-edit-verify found issues in \`${rel}\` (package: ` +
144
- `${path.relative(projectDir, pkgDir) || "."}). Address these before ` +
145
- `moving on:\n\n${failures.join("\n\n")}\n`,
146
- );
147
- process.exit(2);
148
- }
212
+ const pkgDir = findPackageDir(path.dirname(abs));
213
+ if (pkgDir === undefined) process.exit(0);
149
214
 
150
- process.exit(0);
215
+ // A worktree (or nested checkout) with no dependencies installed yet would
216
+ // fail every step below for a reason that has nothing to do with the edit.
217
+ // Checked against `pkgDir`, not `root`: those differ whenever this project
218
+ // is a subdirectory of a larger repo, and `root`'s own node_modules (if it
219
+ // even has one) says nothing about whether THIS package can resolve its
220
+ // tools. Exits 2 (not 0) so this reaches the model the same way a real
221
+ // failure does -- "install dependencies" is actionable feedback, and a
222
+ // silent exit 0 here would just be a quieter version of the bug this file
223
+ // exists to fix.
224
+ if (!fs.existsSync(path.join(pkgDir, "node_modules"))) {
225
+ process.stderr.write(
226
+ `post-edit-verify: no node_modules under \`${pkgDir}\` -- run ` +
227
+ "`pnpm install` there before this edit can be verified.\n",
228
+ );
229
+ process.exit(2);
230
+ }
231
+
232
+ let toolMissing = false;
233
+
234
+ function run(cmd, args) {
235
+ const res = spawnSync(cmd, args, {
236
+ cwd: root,
237
+ encoding: "utf8",
238
+ env: process.env,
239
+ // `spawnSync`'s default 1 MiB `maxBuffer` can be exceeded by a real
240
+ // failure's own output (a large `tsc -b --force` or `eslint tests/`
241
+ // error list) -- raised well above what any of this hook's steps
242
+ // realistically emit so a genuine failure's output is never itself
243
+ // the reason it gets treated as unreportable.
244
+ maxBuffer: 16 * 1024 * 1024,
245
+ });
246
+ // Only a missing binary (e.g. pnpm itself absent) skips silently -- any
247
+ // other `res.error` (a killed process, `maxBuffer` exceeded, ...) is a
248
+ // real failure and must be reported, not swallowed as if the step never
249
+ // ran.
250
+ if (res.error?.code === "ENOENT") {
251
+ toolMissing = true;
252
+ return undefined;
253
+ }
254
+ if (res.error) {
255
+ const why = `${cmd} ${args.join(" ")} failed: ${res.error.message}\n`;
256
+ // Prepended to BOTH streams, not just stderr: every failure below is
257
+ // reported via `(x.stdout || x.stderr)`, so a step whose partial
258
+ // stdout is non-empty would otherwise hide this cause behind it.
259
+ return {
260
+ status: 1,
261
+ stdout: why + (res.stdout ?? ""),
262
+ stderr: why + (res.stderr ?? ""),
263
+ };
264
+ }
265
+ return res;
266
+ }
267
+
268
+ const failures = [];
269
+
270
+ // 1. Format the edited file (best-effort; a parse error is itself signal).
271
+ const fmt = run("pnpm", ["exec", "prettier", "--write", abs]);
272
+ if (fmt && fmt.status !== 0) {
273
+ failures.push(`prettier:\n${(fmt.stderr || fmt.stdout || "").trim()}`);
274
+ }
275
+
276
+ // 2. Lint the edited file (single file; flat config resolves from repo root
277
+ // and honours its own `ignores`, so no per-package wrapper is needed).
278
+ // Report-only (no --fix) so the root cause is addressed, not masked.
279
+ const lint = run("pnpm", ["exec", "eslint", abs]);
280
+ if (lint && lint.status !== 0) {
281
+ failures.push(`eslint:\n${(lint.stdout || lint.stderr || "").trim()}`);
282
+ }
283
+
284
+ // 3. Type-check the owning package.
285
+ const tc = run("pnpm", ["-C", pkgDir, "typecheck"]);
286
+ if (tc && tc.status !== 0) {
287
+ failures.push(`typecheck:\n${(tc.stdout || tc.stderr || "").trim()}`);
288
+ }
289
+
290
+ // 4. Run only the tests related to the edited file.
291
+ const vt = run("pnpm", ["exec", "vitest", "related", abs, "--run"]);
292
+ if (vt && vt.status !== 0) {
293
+ failures.push(`vitest related:\n${(vt.stdout || vt.stderr || "").trim()}`);
294
+ }
295
+
296
+ // 5. When a src/ file is implemented/updated, also lint the package's tests/
297
+ // directory to surface stale eslint-disable directives that became unused
298
+ // once the implementation landed.
299
+ if (/(^|\/)src\//.test(rel)) {
300
+ const testsDir = path.join(pkgDir, "tests");
301
+ if (fs.existsSync(testsDir)) {
302
+ const testLint = run("pnpm", ["exec", "eslint", testsDir]);
303
+ if (testLint && testLint.status !== 0) {
304
+ failures.push(
305
+ `eslint (tests/ -- scanned because a src/ file was edited; fix the file(s) listed below):\n${(testLint.stdout || testLint.stderr || "").trim()}`,
306
+ );
307
+ }
308
+ }
309
+ }
310
+
311
+ if (failures.length > 0) {
312
+ process.stderr.write(
313
+ `post-edit-verify found issues in \`${rel}\` (package: ` +
314
+ `${path.relative(root, pkgDir) || "."}). Address these before ` +
315
+ `moving on:\n\n${failures.join("\n\n")}\n`,
316
+ );
317
+ process.exit(2);
318
+ }
319
+
320
+ if (toolMissing) {
321
+ process.stderr.write(
322
+ "post-edit-verify: `pnpm` was not found on PATH -- checks skipped.\n",
323
+ );
324
+ process.exit(2);
325
+ }
326
+
327
+ process.exit(0);
328
+ }
@@ -99,6 +99,13 @@ contract (a documented behavior, an interface to satisfy):
99
99
  - **A `SubagentStop`/`PreToolUse` hook flagging a dispatch is a prompt to
100
100
  verify, not a replacement for verifying.** Treat its stderr reminder as a
101
101
  signal to check state yourself.
102
+ - **A hook that visibly fails to fire may be an Enterprise-managed override,
103
+ not a bug in the hook.** If `guard-hub-src-writes.mjs`/
104
+ `guard-branch-isolation.mjs` let a top-level `src/`/`tests/` write through
105
+ unblocked, check `/status`'s "Setting sources" line for a managed source
106
+ before debugging the hook script itself -- a managed `allowManagedHooksOnly`
107
+ policy (CLAUDE.md's "Agent Operating Model") disables project hooks
108
+ outright, with no repo-visible signal that it's active.
102
109
  - **A templated dispatch prompt needs a per-target assumption check, not
103
110
  just a per-target file-list check.** Verify the template's implicit
104
111
  assumptions against each target's own docs/tests before dispatch, not
@@ -99,8 +99,8 @@ throw "a string";
99
99
  fail `build` with TS9010. Any exported-type change needs both.
100
100
  - **A test that deliberately avoids importing from `src` can strand an
101
101
  export and fail `pnpm knip`** — keep both a hand-authored table and an
102
- import for projection identity. `knip` is not gated in `pre-push` by
103
- default — run it yourself after touching any export.
102
+ import for projection identity. `knip` is a `lint`-group step, so `pre-push`
103
+ gates it — run it yourself after touching any export to catch it sooner.
104
104
  - **eslint runs in-loop** (prettier → eslint → typecheck → vitest) —
105
105
  resolve findings as you write, don't defer to a later `pnpm lint` pass.
106
106
  - **Thread `now` as an injectable parameter on a time-dependent guard**
@@ -25,6 +25,11 @@
25
25
  "type": "command",
26
26
  "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-double-background.mjs\"",
27
27
  "timeout": 30
28
+ },
29
+ {
30
+ "type": "command",
31
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-hub-src-writes.mjs\"",
32
+ "timeout": 30
28
33
  }
29
34
  ]
30
35
  },