@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.4
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 +574 -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 +21 -13
- package/dist/packs.js +241 -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 +44 -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 +295 -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 +41 -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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Gitleaks
|
|
2
|
+
|
|
3
|
+
# What this does: scans for secrets (API keys, tokens, private keys) using
|
|
4
|
+
# gitleaks/gitleaks-action. On a push to the release branch it scans only
|
|
5
|
+
# the pushed commits; on a pull request it scans the PR's commits; the
|
|
6
|
+
# weekly schedule and manual dispatch scan the full history. Not a required
|
|
7
|
+
# check by default -- see this pack's adoptNotes.
|
|
8
|
+
#
|
|
9
|
+
# gitleaks-action v3 needs a free GITLEAKS_LICENSE for an organization-owned
|
|
10
|
+
# repository (gitleaks/gitleaks-action's own README) -- if this repository
|
|
11
|
+
# accepts pull requests from forks, that secret is unavailable on a
|
|
12
|
+
# fork-originated run and this job fails cleanly rather than scanning; a
|
|
13
|
+
# collaborators-only PR policy avoids that case entirely.
|
|
14
|
+
on:
|
|
15
|
+
push:
|
|
16
|
+
branches: [main]
|
|
17
|
+
pull_request:
|
|
18
|
+
branches: [main]
|
|
19
|
+
schedule:
|
|
20
|
+
- cron: "0 5 * * 1" # every Monday, full-history scan
|
|
21
|
+
workflow_dispatch:
|
|
22
|
+
|
|
23
|
+
permissions: {}
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
27
|
+
cancel-in-progress: true
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
scan:
|
|
31
|
+
name: Gitleaks
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
timeout-minutes: 10
|
|
34
|
+
permissions:
|
|
35
|
+
contents: read
|
|
36
|
+
pull-requests: read
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
39
|
+
with:
|
|
40
|
+
persist-credentials: false
|
|
41
|
+
fetch-depth: 0 # gitleaks needs full history to compute the commit range it scans
|
|
42
|
+
|
|
43
|
+
- uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
|
|
44
|
+
env:
|
|
45
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
46
|
+
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
|
47
|
+
# Pinned above the action's own stale built-in default; Dependabot
|
|
48
|
+
# doesn't track this, so bump it by hand periodically.
|
|
49
|
+
GITLEAKS_VERSION: "8.30.1"
|
|
50
|
+
GITLEAKS_CONFIG: .gitleaks.toml
|
|
51
|
+
# Avoids needing pull-requests: write for a PR comment.
|
|
52
|
+
GITLEAKS_ENABLE_COMMENTS: "false"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: Scorecard
|
|
2
|
+
|
|
3
|
+
# What this does: runs OpenSSF Scorecard, a tool that scores a repository's
|
|
4
|
+
# supply-chain security practices (branch protection, pinned dependencies,
|
|
5
|
+
# signed releases, and similar checks) and can publish a badge from the
|
|
6
|
+
# result. It runs weekly, on every push to the release branch, and on
|
|
7
|
+
# manual dispatch -- not on every PR, since supply-chain posture doesn't
|
|
8
|
+
# change per-commit the way tests do. Read-only: this workflow only
|
|
9
|
+
# inspects the repo and uploads a report, it never writes to it.
|
|
10
|
+
on:
|
|
11
|
+
push:
|
|
12
|
+
branches: [main]
|
|
13
|
+
schedule:
|
|
14
|
+
- cron: "30 4 * * 1" # every Monday
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
|
|
17
|
+
permissions: read-all
|
|
18
|
+
|
|
19
|
+
concurrency:
|
|
20
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
21
|
+
cancel-in-progress: true
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
analysis:
|
|
25
|
+
name: Scorecard analysis
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
timeout-minutes: 20
|
|
28
|
+
permissions:
|
|
29
|
+
# Required by ossf/scorecard-action's own documented minimum.
|
|
30
|
+
security-events: write # to upload SARIF results
|
|
31
|
+
id-token: write # to publish results and get a badge (publish_results: true)
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
34
|
+
with:
|
|
35
|
+
persist-credentials: false
|
|
36
|
+
|
|
37
|
+
- uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
|
38
|
+
with:
|
|
39
|
+
results_file: results.sarif
|
|
40
|
+
results_format: sarif
|
|
41
|
+
# `publish_results: true` is what makes a README badge resolve,
|
|
42
|
+
# and needs the repository to actually be public -- see this
|
|
43
|
+
# pack's adoptNotes. Set this `false` for a private repository.
|
|
44
|
+
publish_results: true
|
|
45
|
+
|
|
46
|
+
- uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
|
47
|
+
with:
|
|
48
|
+
sarif_file: results.sarif
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"name": "supply-chain",
|
|
4
|
+
"description": "Secret scanning and an OpenSSF Scorecard run for any project hosted on GitHub, published or not: gitleaks.yml (gitleaks/gitleaks-action on push, pull request, weekly and manual dispatch) with a .gitleaks.toml, and scorecard.yml (ossf/scorecard-action, weekly, results published as code-scanning alerts). Pure file drop: no hooks, no settings, no scripts, no verify step. Read-only workflows, not required checks by default.",
|
|
5
|
+
"modes": ["fresh", "adopt"],
|
|
6
|
+
"budget": {
|
|
7
|
+
"agents": 0,
|
|
8
|
+
"skills": 0,
|
|
9
|
+
"hooks": 0,
|
|
10
|
+
"workflows": 2,
|
|
11
|
+
"scripts": 0
|
|
12
|
+
},
|
|
13
|
+
"wiring": {
|
|
14
|
+
"settings": {},
|
|
15
|
+
"packageScripts": {},
|
|
16
|
+
"verifySteps": []
|
|
17
|
+
},
|
|
18
|
+
"adoptNotes": "An adopted project that already has its own .github/workflows/gitleaks.yml or scorecard.yml surfaces as an ordinary file conflict -- keep the existing one if it already works, since this pack's version would just replace a configured setup with its generic defaults. gitleaks.yml needs a free GITLEAKS_LICENSE repo secret for an organization-owned repository (gitleaks-action's own requirement); scorecard.yml assumes a public repository (`publish_results: true` needs one) -- set it `false` for a private repo. Both are read-only and not required checks by default; add either to branch protection once it has a clean run. The SPDX/REUSE license-header gate is not part of this pack: it lives in the fresh-only `publishing` pack, because it fails an established project's `pnpm verify` until a one-time backfill touches every file. The two workflows ship without an SPDX header on purpose (a header would carry a project-name token that stays literal in an adopt-mode install); a project that also installs `publishing` gets the headers from that pack's `check-license-headers.mjs --fix` backfill."
|
|
19
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart (startup|resume): installs dependencies into a freshly
|
|
4
|
+
* created git worktree automatically, so the first edit made there doesn't
|
|
5
|
+
* hit `post-edit-verify.mjs`'s own "no node_modules" exit-2 guard.
|
|
6
|
+
*
|
|
7
|
+
* This is a BACKSTOP, not the primary install path. It only fires on
|
|
8
|
+
* `claude --worktree`/a resumed session already inside a worktree --
|
|
9
|
+
* `SessionStart` does not fire on a mid-session `EnterWorktree`
|
|
10
|
+
* (code.claude.com/docs/en/worktrees: "SessionStart ... does not fire on a
|
|
11
|
+
* mid-session EnterWorktree"), which is the more common way a worktree gets
|
|
12
|
+
* created in this project (the `working-in-worktrees` skill's start mode
|
|
13
|
+
* runs `pnpm install` itself for exactly that reason). Both paths converge
|
|
14
|
+
* on the same guarantee: by the time any edit happens, dependencies exist.
|
|
15
|
+
*
|
|
16
|
+
* Does nothing unless ALL of these hold:
|
|
17
|
+
* - `cwd` is a LINKED worktree (its `--git-dir` differs from its
|
|
18
|
+
* `--git-common-dir` -- a plain checkout's are identical).
|
|
19
|
+
* - `node_modules` is not a symlink (if the project sets
|
|
20
|
+
* `worktree.symlinkDirectories: ["node_modules"]`, installing here
|
|
21
|
+
* would write through the symlink into the MAIN checkout's own
|
|
22
|
+
* node_modules, which is not this hook's job to do).
|
|
23
|
+
* - A lockfile is present (nothing to install against otherwise).
|
|
24
|
+
* - `node_modules/.modules.yaml` (pnpm's own install marker) is missing.
|
|
25
|
+
*
|
|
26
|
+
* Always exits 0 -- this is advisory infrastructure, never a gate. A git
|
|
27
|
+
* failure that isn't "not a git repository" still gets a stderr hint rather
|
|
28
|
+
* than silent failure -- see `defaultGitFor`.
|
|
29
|
+
*/
|
|
30
|
+
import process from "node:process";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import {
|
|
33
|
+
existsSync,
|
|
34
|
+
lstatSync,
|
|
35
|
+
openSync,
|
|
36
|
+
closeSync,
|
|
37
|
+
rmSync,
|
|
38
|
+
realpathSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import { canonicalize } from "../../bin/lib/protected-paths.mjs";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Same pattern as `guard-worktree-only.mjs`'s `defaultGitFor`, but this
|
|
46
|
+
* hook is advisory (a SessionStart backstop), not an enforcement guard, so
|
|
47
|
+
* an unexpected git failure stays fail-OPEN -- it just gets a stderr hint
|
|
48
|
+
* instead of silently doing nothing, the same posture `post-edit-verify.mjs`
|
|
49
|
+
* takes for the identical reason.
|
|
50
|
+
*/
|
|
51
|
+
export function defaultGitFor(dir) {
|
|
52
|
+
return function git(args) {
|
|
53
|
+
try {
|
|
54
|
+
return execFileSync("git", ["-C", dir, ...args], {
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
env: { ...process.env, LC_ALL: "C", LANGUAGE: "C" },
|
|
58
|
+
}).trim();
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
const message = String(cause?.stderr || cause?.message || "").trim();
|
|
61
|
+
if (!/not a git repository \(or any /i.test(message)) {
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
`ensure-worktree-deps: git lookup failed in \`${dir}\` (${message.split("\n")[0]}); ` +
|
|
64
|
+
"skipping the dependency-install check.\n",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** See `guard-worktree-only.mjs`'s identical helper for why this matters. */
|
|
73
|
+
function gitAbsPath(git, boundDir, args) {
|
|
74
|
+
const raw = git(args);
|
|
75
|
+
if (raw === "") return "";
|
|
76
|
+
return canonicalize(path.resolve(boundDir, raw));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolves `root`'s own `--git-dir` ONCE, used both to decide whether it's
|
|
81
|
+
* a linked worktree and as the lock file's parent directory (unique per
|
|
82
|
+
* worktree, since a linked worktree's `.git` is a file pointing at
|
|
83
|
+
* `<mainRepoRoot>/.git/worktrees/<name>`).
|
|
84
|
+
*
|
|
85
|
+
* @param {string} root
|
|
86
|
+
* @param {(dir: string) => (args: string[]) => string} [gitFactory]
|
|
87
|
+
* @returns {{ gitDir: string; isLinked: boolean }}
|
|
88
|
+
*/
|
|
89
|
+
function resolveWorktreeGitDir(root, gitFactory) {
|
|
90
|
+
const git = gitFactory(root);
|
|
91
|
+
const gitDir = gitAbsPath(git, root, ["rev-parse", "--git-dir"]);
|
|
92
|
+
if (gitDir === "") return { gitDir: "", isLinked: false };
|
|
93
|
+
const commonDir = gitAbsPath(git, root, ["rev-parse", "--git-common-dir"]);
|
|
94
|
+
return { gitDir, isLinked: gitDir !== commonDir };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True when `dir` is the root of a LINKED git worktree (not the main
|
|
99
|
+
* checkout, and not a plain non-git directory).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} dir
|
|
102
|
+
* @param {(dir: string) => (args: string[]) => string} [gitFactory]
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function isLinkedWorktree(dir, gitFactory = defaultGitFor) {
|
|
106
|
+
return resolveWorktreeGitDir(dir, gitFactory).isLinked;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `.git` for a linked worktree is a FILE, not a directory -- `git rev-parse
|
|
111
|
+
* --git-dir` resolves it to the real, unique-per-worktree directory
|
|
112
|
+
* `<mainRepoRoot>/.git/worktrees/<name>`, a safe, collision-free lock
|
|
113
|
+
* location. Returns "" when git can't resolve one at all (not a repo, or an
|
|
114
|
+
* unexpected failure `defaultGitFor` already reported).
|
|
115
|
+
*
|
|
116
|
+
* @param {string} root
|
|
117
|
+
* @param {(dir: string) => (args: string[]) => string} [gitFactory]
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
export function gitDirFor(root, gitFactory = defaultGitFor) {
|
|
121
|
+
return resolveWorktreeGitDir(root, gitFactory).gitDir;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* True when dependencies should be installed in `root`: no existing pnpm
|
|
126
|
+
* install marker, a lockfile is present, and `node_modules` (if it exists
|
|
127
|
+
* at all) is a real directory, not a symlink. Checks `lstatSync` directly
|
|
128
|
+
* rather than `existsSync` + `lstatSync` -- `existsSync` follows a symlink
|
|
129
|
+
* and reports `false` for a DANGLING one, which would skip the very check
|
|
130
|
+
* meant to catch it and let the install proceed through the broken link.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} root
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
export function needsInstall(root) {
|
|
136
|
+
const nodeModules = path.join(root, "node_modules");
|
|
137
|
+
const stat = lstatSync(nodeModules, { throwIfNoEntry: false });
|
|
138
|
+
if (stat !== undefined && stat.isSymbolicLink()) return false;
|
|
139
|
+
const marker = path.join(nodeModules, ".modules.yaml");
|
|
140
|
+
if (existsSync(marker)) return false;
|
|
141
|
+
return existsSync(path.join(root, "pnpm-lock.yaml"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Runs `pnpm install --frozen-lockfile --prefer-offline` in `root`,
|
|
146
|
+
* serialized against a concurrent SessionStart/subagent hitting the same
|
|
147
|
+
* worktree via a lock file opened with the `wx` flag (fails if it already
|
|
148
|
+
* exists -- "create exclusively", no separate lock library required). The
|
|
149
|
+
* lock is ALWAYS removed once the attempt finishes, success or failure: the
|
|
150
|
+
* only thing it protects against is two installs running at the exact same
|
|
151
|
+
* moment, not a retry across time, and a lock left behind after a failed
|
|
152
|
+
* install would report "another session is installing" forever afterward
|
|
153
|
+
* with no way to clear it short of a manual `rm`.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} root
|
|
156
|
+
* @returns {{ installed: boolean; message: string }}
|
|
157
|
+
*/
|
|
158
|
+
export function installIfNeeded(root) {
|
|
159
|
+
if (!needsInstall(root)) {
|
|
160
|
+
return { installed: false, message: "" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const gitDir = gitDirFor(root);
|
|
164
|
+
if (gitDir === "") {
|
|
165
|
+
return {
|
|
166
|
+
installed: false,
|
|
167
|
+
message:
|
|
168
|
+
"ensure-worktree-deps: could not resolve this worktree's git " +
|
|
169
|
+
"directory -- run `pnpm install` here by hand.",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const lockPath = path.join(gitDir, "worktree-deps.lock");
|
|
174
|
+
let fd;
|
|
175
|
+
try {
|
|
176
|
+
fd = openSync(lockPath, "wx");
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
if (cause?.code === "EEXIST") {
|
|
179
|
+
return {
|
|
180
|
+
installed: false,
|
|
181
|
+
message:
|
|
182
|
+
`ensure-worktree-deps: lock \`${lockPath}\` exists -- another ` +
|
|
183
|
+
"session is already installing here, or a previous attempt was " +
|
|
184
|
+
"interrupted before it could clean up (remove the lock and run " +
|
|
185
|
+
"`pnpm install` by hand if so).",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
installed: false,
|
|
190
|
+
message:
|
|
191
|
+
`ensure-worktree-deps: could not create the install lock at ` +
|
|
192
|
+
`\`${lockPath}\` (${cause?.code ?? cause?.message}).`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const res = spawnSync(
|
|
198
|
+
"pnpm",
|
|
199
|
+
["install", "--frozen-lockfile", "--prefer-offline"],
|
|
200
|
+
{
|
|
201
|
+
cwd: root,
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
// A chatty install's combined output can exceed the default 1 MiB
|
|
204
|
+
// buffer, which would otherwise report an unrelated ENOBUFS failure
|
|
205
|
+
// instead of the real one.
|
|
206
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
if (res.error) {
|
|
210
|
+
return {
|
|
211
|
+
installed: false,
|
|
212
|
+
message: `ensure-worktree-deps: could not run pnpm install (${res.error.message}).`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (res.status !== 0) {
|
|
216
|
+
const cause =
|
|
217
|
+
res.status === null
|
|
218
|
+
? `killed by ${res.signal}`
|
|
219
|
+
: `exit code ${res.status}`;
|
|
220
|
+
// Both streams, not just one -- pnpm's real failure can land on
|
|
221
|
+
// either, and a chatty install's combined output is capped so a
|
|
222
|
+
// large log doesn't blow past whatever a hook's context budget is.
|
|
223
|
+
const combined = [res.stdout, res.stderr]
|
|
224
|
+
.filter((s) => typeof s === "string" && s.length > 0)
|
|
225
|
+
.join("\n")
|
|
226
|
+
.trim()
|
|
227
|
+
.slice(-4000);
|
|
228
|
+
return {
|
|
229
|
+
installed: false,
|
|
230
|
+
message:
|
|
231
|
+
`ensure-worktree-deps: \`pnpm install\` failed in this worktree ` +
|
|
232
|
+
`(${cause}) -- run it by hand:\n${combined}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
installed: true,
|
|
237
|
+
message: "ensure-worktree-deps: installed dependencies in this worktree.",
|
|
238
|
+
};
|
|
239
|
+
} finally {
|
|
240
|
+
closeSync(fd);
|
|
241
|
+
rmSync(lockPath, { force: true });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isEntryPoint() {
|
|
246
|
+
try {
|
|
247
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (isEntryPoint()) {
|
|
254
|
+
const chunks = [];
|
|
255
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
256
|
+
let input;
|
|
257
|
+
try {
|
|
258
|
+
input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
259
|
+
} catch {
|
|
260
|
+
process.exit(0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const root = typeof input?.cwd === "string" ? input.cwd : "";
|
|
264
|
+
if (root === "" || !isLinkedWorktree(root)) process.exit(0);
|
|
265
|
+
|
|
266
|
+
const { installed, message } = installIfNeeded(root);
|
|
267
|
+
if (message === "") process.exit(0);
|
|
268
|
+
|
|
269
|
+
const reminder =
|
|
270
|
+
"Reminder: this project requires all src/tests development inside a " +
|
|
271
|
+
"worktree -- run the TDD pipeline sequentially in this same worktree " +
|
|
272
|
+
"(no per-spoke isolation), and never `git stash` here (the stash stack " +
|
|
273
|
+
"is shared across every worktree of this repository).";
|
|
274
|
+
|
|
275
|
+
const output = {
|
|
276
|
+
hookSpecificOutput: {
|
|
277
|
+
hookEventName: "SessionStart",
|
|
278
|
+
additionalContext: installed ? `${message}\n\n${reminder}` : message,
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
process.stdout.write(JSON.stringify(output));
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse guard (Write|Edit): blocks a src/tests write outside a linked
|
|
4
|
+
* git worktree under `.claude/worktrees/` -- for ANY caller (hub or writer
|
|
5
|
+
* spoke), on ANY branch. This is stricter than the two guards it sits
|
|
6
|
+
* alongside:
|
|
7
|
+
*
|
|
8
|
+
* - `guard-branch-isolation.mjs` only blocks while `HEAD` is `main`.
|
|
9
|
+
* - `guard-hub-src-writes.mjs` only blocks the hub; a writer spoke passes.
|
|
10
|
+
*
|
|
11
|
+
* Installing this pack means every src/tests write -- feature branch or
|
|
12
|
+
* `main`, hub or spoke -- must happen inside a worktree this project
|
|
13
|
+
* creates under `.claude/worktrees/`. A sibling worktree made with a plain
|
|
14
|
+
* `git worktree add ../foo` is deliberately NOT enough: it also closes the
|
|
15
|
+
* gap `bin/lib/protected-paths.mjs`'s own doc comment names -- `isProtectedPath`
|
|
16
|
+
* only ever compares one absolute path's PREFIX against `projectDir`, so a
|
|
17
|
+
* checkout that lives entirely outside `projectDir` can never be recognised
|
|
18
|
+
* as protected by that function alone, no matter where it sits.
|
|
19
|
+
*
|
|
20
|
+
* Detection: a linked worktree's `git rev-parse --git-dir` differs from its
|
|
21
|
+
* `git rev-parse --git-common-dir` (a plain checkout's are identical). The
|
|
22
|
+
* worktree only counts as sanctioned when its own toplevel sits under
|
|
23
|
+
* `<mainRepoRoot>/.claude/worktrees/` -- `mainRepoRoot` being the parent of
|
|
24
|
+
* `--git-common-dir` (a linked worktree's common dir is always
|
|
25
|
+
* `<mainRepoRoot>/.git`).
|
|
26
|
+
*
|
|
27
|
+
* `isProtectedPath` is ALWAYS scoped to `toplevel` (the checkout that
|
|
28
|
+
* actually contains the file -- a worktree's own root, not the main repo's
|
|
29
|
+
* root), never called bare -- an unscoped call treats any absolute path
|
|
30
|
+
* with a literal `/src/` or `/tests/` segment as protected, which would
|
|
31
|
+
* wrongly block every write in a completely unrelated repository cloned
|
|
32
|
+
* at, say, `~/src/other-project`. `guard-branch-isolation.mjs` avoids this
|
|
33
|
+
* the same way (its own header comment names the same hazard). Scoping to
|
|
34
|
+
* `mainRepoRoot` instead of `toplevel` was tried and is WRONG: a sibling
|
|
35
|
+
* worktree's own root is not a path-prefix of `mainRepoRoot` at all, so
|
|
36
|
+
* that scoping silently reopens the sibling-worktree gap this guard exists
|
|
37
|
+
* to close -- see `shouldBlock`'s own comment for the reasoning.
|
|
38
|
+
*
|
|
39
|
+
* A git lookup failure for a reason OTHER than "not a git repository" (git
|
|
40
|
+
* missing, EACCES, a corrupt worktree pointer) is NOT treated as "allow" --
|
|
41
|
+
* unlike an advisory hook (`post-edit-verify.mjs`, which stays informative
|
|
42
|
+
* on a git failure since it's a nudge, not a gate), this is an ENFORCEMENT
|
|
43
|
+
* guard, so an ambiguous git state fails CLOSED (blocks, with a diagnostic)
|
|
44
|
+
* rather than silently turning enforcement off.
|
|
45
|
+
*
|
|
46
|
+
* Blocks by exiting 2 with a self-contained message -- hooks run in
|
|
47
|
+
* parallel with no defined order, so this message can't assume any other
|
|
48
|
+
* guard's message was seen first.
|
|
49
|
+
*/
|
|
50
|
+
import process from "node:process";
|
|
51
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
52
|
+
import { execFileSync } from "node:child_process";
|
|
53
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
import {
|
|
56
|
+
canonicalize,
|
|
57
|
+
isProtectedPath,
|
|
58
|
+
} from "../../bin/lib/protected-paths.mjs";
|
|
59
|
+
|
|
60
|
+
/** Thrown by the git runner for any failure that ISN'T "not a git repository". */
|
|
61
|
+
export class GitLookupError extends Error {}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns a git runner bound to `git -C dir`. Distinguishes the expected,
|
|
65
|
+
* silent case (`dir` genuinely isn't in a git repository -- returns `""`)
|
|
66
|
+
* from every other failure (git missing, EACCES, a `dubious ownership`
|
|
67
|
+
* refusal, a corrupt worktree pointer, which prints a DIFFERENT "not a git
|
|
68
|
+
* repository: <path>" form with no "(or any ...)" suffix): those THROW
|
|
69
|
+
* `GitLookupError` rather than returning `""`, so the caller can fail
|
|
70
|
+
* closed instead of silently allowing.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} dir Absolute directory path.
|
|
73
|
+
* @returns {(args: string[]) => string}
|
|
74
|
+
*/
|
|
75
|
+
export function defaultGitFor(dir) {
|
|
76
|
+
return function git(args) {
|
|
77
|
+
try {
|
|
78
|
+
return execFileSync("git", ["-C", dir, ...args], {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
// Don't let git's own stderr leak to this process's stderr.
|
|
81
|
+
// `LC_ALL`/`LANGUAGE` pin git's message to English so the pattern
|
|
82
|
+
// match below doesn't depend on the caller's locale.
|
|
83
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
84
|
+
env: { ...process.env, LC_ALL: "C", LANGUAGE: "C" },
|
|
85
|
+
}).trim();
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
const message = String(cause?.stderr || cause?.message || "").trim();
|
|
88
|
+
if (/not a git repository \(or any /i.test(message)) return "";
|
|
89
|
+
throw new GitLookupError(message.split("\n")[0], { cause });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `git rev-parse --git-dir`/`--git-common-dir` return a path RELATIVE TO
|
|
96
|
+
* THE `-C` DIRECTORY whenever that directory is inside the working tree --
|
|
97
|
+
* only `--show-toplevel` is documented to always return absolute. Resolved
|
|
98
|
+
* against `boundDir`, then canonicalized (symlinks resolved) so a `boundDir`
|
|
99
|
+
* reached through a symlink (macOS's `/tmp` -> `/private/tmp`) doesn't make
|
|
100
|
+
* an already-absolute, already-canonical git output compare unequal to a
|
|
101
|
+
* resolved-but-not-canonicalized one -- exactly the class of bug this
|
|
102
|
+
* pack's own `post-edit-verify.mjs`-adjacent fix (PR1) closed for the same
|
|
103
|
+
* reason.
|
|
104
|
+
*
|
|
105
|
+
* @param {(args: string[]) => string} git
|
|
106
|
+
* @param {string} boundDir
|
|
107
|
+
* @param {string[]} args
|
|
108
|
+
* @returns {string} Canonicalized absolute path, or "" if the command
|
|
109
|
+
* returned "" (the expected not-a-repo case; a real failure THROWS
|
|
110
|
+
* instead, see `defaultGitFor`).
|
|
111
|
+
*/
|
|
112
|
+
function gitAbsPath(git, boundDir, args) {
|
|
113
|
+
const raw = git(args);
|
|
114
|
+
if (raw === "") return "";
|
|
115
|
+
return canonicalize(resolve(boundDir, raw));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Pure decision function -- exported for unit testing. Can throw
|
|
120
|
+
* `GitLookupError` (propagated from `git`) when git itself fails
|
|
121
|
+
* unexpectedly; the caller decides what that means (see `isEntryPoint`
|
|
122
|
+
* body below: fail closed).
|
|
123
|
+
*
|
|
124
|
+
* @param {string} filePath The file_path from the tool_input payload.
|
|
125
|
+
* @param {(args: string[]) => string} git A git runner bound to `boundDir`
|
|
126
|
+
* (see `defaultGitFor`).
|
|
127
|
+
* @param {string} boundDir The same directory `git` was bound to -- the
|
|
128
|
+
* nearest existing ancestor of `filePath`.
|
|
129
|
+
* @returns {boolean} true = block, false = allow.
|
|
130
|
+
*/
|
|
131
|
+
export function shouldBlock(filePath, git, boundDir) {
|
|
132
|
+
const gitDir = gitAbsPath(git, boundDir, ["rev-parse", "--git-dir"]);
|
|
133
|
+
if (gitDir === "") return false; // not in a git repo; nothing to enforce
|
|
134
|
+
|
|
135
|
+
const commonDir = gitAbsPath(git, boundDir, [
|
|
136
|
+
"rev-parse",
|
|
137
|
+
"--git-common-dir",
|
|
138
|
+
]);
|
|
139
|
+
const isLinkedWorktree = gitDir !== commonDir;
|
|
140
|
+
// The checkout that actually CONTAINS `filePath` -- the worktree's own
|
|
141
|
+
// root for a linked worktree, the same as `mainRepoRoot` below otherwise.
|
|
142
|
+
const toplevel = canonicalize(git(["rev-parse", "--show-toplevel"])); // always absolute
|
|
143
|
+
|
|
144
|
+
// Scoped to the file's OWN checkout root (`toplevel`), never called bare
|
|
145
|
+
// and never scoped to `mainRepoRoot` (the ORIGINAL main checkout's root,
|
|
146
|
+
// computed below) -- both matter for different reasons. An unscoped
|
|
147
|
+
// isProtectedPath(filePath) would treat ANY absolute path containing a
|
|
148
|
+
// literal "/src/" or "/tests/" segment as protected, including a
|
|
149
|
+
// completely unrelated repository the session happens to touch. Scoping
|
|
150
|
+
// to `mainRepoRoot` INSTEAD of `toplevel` would be equally wrong the
|
|
151
|
+
// other way: a sibling worktree's own root is not a path-prefix of
|
|
152
|
+
// `mainRepoRoot` at all (it typically lives in a sibling directory), so
|
|
153
|
+
// that scoping would wrongly report its real src/ files as "not
|
|
154
|
+
// protected" and silently reopen the exact sibling-worktree gap this
|
|
155
|
+
// guard exists to close -- confirmed by a regression test built for
|
|
156
|
+
// exactly this case.
|
|
157
|
+
if (!isProtectedPath(canonicalize(filePath), toplevel)) return false;
|
|
158
|
+
|
|
159
|
+
if (!isLinkedWorktree) return true; // the main checkout itself; always blocked
|
|
160
|
+
|
|
161
|
+
const mainRepoRoot = dirname(commonDir); // a linked worktree's common-dir is always <mainRepoRoot>/.git
|
|
162
|
+
const sanctionedPrefix = resolve(mainRepoRoot, ".claude", "worktrees");
|
|
163
|
+
const rel = relative(sanctionedPrefix, toplevel);
|
|
164
|
+
// Inside sanctionedPrefix iff `rel` doesn't escape upward via a literal
|
|
165
|
+
// ".." segment and isn't an absolute path (which `relative()` returns
|
|
166
|
+
// instead of a ".."-prefixed one on Windows when the two paths are on
|
|
167
|
+
// different drives). `rel === ""` (the sanctioned directory ITSELF, not a
|
|
168
|
+
// named worktree under it) is correctly treated as NOT inside -- nothing
|
|
169
|
+
// should ever write directly into `.claude/worktrees/` itself.
|
|
170
|
+
const insideSanctioned =
|
|
171
|
+
rel !== "" &&
|
|
172
|
+
rel !== ".." &&
|
|
173
|
+
!rel.startsWith(`..${sep}`) &&
|
|
174
|
+
!isAbsolute(rel);
|
|
175
|
+
return !insideSanctioned;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Kept as a duplicated, self-contained block rather than a shared helper --
|
|
179
|
+
// see `post-edit-verify.mjs`'s equivalent comment. `import.meta.url` is
|
|
180
|
+
// symlink-resolved by Node's ESM loader but `process.argv[1]` is not.
|
|
181
|
+
function isEntryPoint() {
|
|
182
|
+
try {
|
|
183
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (isEntryPoint()) {
|
|
190
|
+
const chunks = [];
|
|
191
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
192
|
+
let input;
|
|
193
|
+
try {
|
|
194
|
+
input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
195
|
+
} catch {
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const filePath = input?.tool_input?.file_path ?? "";
|
|
200
|
+
if (typeof filePath !== "string" || filePath.length === 0) process.exit(0);
|
|
201
|
+
|
|
202
|
+
// Cheap, unscoped pre-check first -- this can only produce a false
|
|
203
|
+
// NEGATIVE (an obviously-unrelated path skips the git shell-out
|
|
204
|
+
// entirely), never a false positive, so it's safe ahead of the properly
|
|
205
|
+
// scoped check inside `shouldBlock`.
|
|
206
|
+
if (!isProtectedPath(filePath)) process.exit(0);
|
|
207
|
+
|
|
208
|
+
// Bind git to the nearest EXISTING ancestor of the write target -- a
|
|
209
|
+
// Write creating a brand-new nested directory means `git -C <that dir>`
|
|
210
|
+
// would otherwise fail outright.
|
|
211
|
+
let probeDir = dirname(resolve(filePath));
|
|
212
|
+
while (!existsSync(probeDir)) {
|
|
213
|
+
const parent = dirname(probeDir);
|
|
214
|
+
if (parent === probeDir) break;
|
|
215
|
+
probeDir = parent;
|
|
216
|
+
}
|
|
217
|
+
const git = defaultGitFor(probeDir);
|
|
218
|
+
|
|
219
|
+
let blocked;
|
|
220
|
+
let lookupFailure;
|
|
221
|
+
try {
|
|
222
|
+
blocked = shouldBlock(filePath, git, probeDir);
|
|
223
|
+
} catch (cause) {
|
|
224
|
+
if (cause instanceof GitLookupError) {
|
|
225
|
+
blocked = true;
|
|
226
|
+
lookupFailure = cause.message;
|
|
227
|
+
} else {
|
|
228
|
+
throw cause; // truly unexpected -- surface as a visible hook crash, never silent
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!blocked) process.exit(0);
|
|
233
|
+
|
|
234
|
+
process.stderr.write(
|
|
235
|
+
lookupFailure !== undefined
|
|
236
|
+
? `guard-worktree-only: Blocked: git lookup failed for \`${filePath}\` ` +
|
|
237
|
+
`(${lookupFailure}) -- refusing the write rather than allowing it ` +
|
|
238
|
+
"with unknown worktree state.\n"
|
|
239
|
+
: `guard-worktree-only: Blocked: refusing to write \`${filePath}\` outside a ` +
|
|
240
|
+
"linked worktree under `.claude/worktrees/`. This project requires every " +
|
|
241
|
+
"src/tests change to happen inside a worktree -- run the " +
|
|
242
|
+
"`working-in-worktrees` skill's start mode first.\n",
|
|
243
|
+
);
|
|
244
|
+
process.exit(2);
|
|
245
|
+
}
|