@monte3l/groundwork 0.1.0-next.1 → 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 +16 -8
  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
@@ -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,2 @@
1
+ [extend]
2
+ useDefault = true
@@ -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
+ }