@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.
- package/README.md +13 -5
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /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
|
-
*
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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
|
-
*
|
|
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
|
|
7
|
-
* that *injects* context (every other hook communicates via stderr
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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 {
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
const
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
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
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
process.
|
|
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
|
-
|
|
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
|
|
103
|
-
|
|
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
|
},
|