@monte3l/groundwork 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/bin/m3l-groundwork.mjs +10 -0
  3. package/dist/assets.d.ts +20 -0
  4. package/dist/assets.js +79 -0
  5. package/dist/caps.d.ts +25 -0
  6. package/dist/caps.js +69 -0
  7. package/dist/conflicts.d.ts +12 -0
  8. package/dist/conflicts.js +77 -0
  9. package/dist/emit.d.ts +7 -0
  10. package/dist/emit.js +42 -0
  11. package/dist/git.d.ts +3 -0
  12. package/dist/git.js +9 -0
  13. package/dist/harness/conformance.d.ts +20 -0
  14. package/dist/harness/conformance.js +18 -0
  15. package/dist/harness/frontmatter.d.ts +38 -0
  16. package/dist/harness/frontmatter.js +204 -0
  17. package/dist/harness/grade.d.ts +4 -0
  18. package/dist/harness/grade.js +105 -0
  19. package/dist/harness/rules.d.ts +55 -0
  20. package/dist/harness/rules.js +580 -0
  21. package/dist/harness/types.d.ts +32 -0
  22. package/dist/harness/types.js +9 -0
  23. package/dist/inventory.d.ts +63 -0
  24. package/dist/inventory.js +66 -0
  25. package/dist/jsonc.d.ts +14 -0
  26. package/dist/jsonc.js +83 -0
  27. package/dist/main.d.ts +24 -0
  28. package/dist/main.js +297 -0
  29. package/dist/merge-json.d.ts +74 -0
  30. package/dist/merge-json.js +135 -0
  31. package/dist/mode.d.ts +19 -0
  32. package/dist/mode.js +53 -0
  33. package/dist/packs.d.ts +61 -0
  34. package/dist/packs.js +186 -0
  35. package/dist/plugin.d.ts +23 -0
  36. package/dist/plugin.js +79 -0
  37. package/dist/report.d.ts +4 -0
  38. package/dist/report.js +323 -0
  39. package/dist/survey/fs-walk.d.ts +14 -0
  40. package/dist/survey/fs-walk.js +60 -0
  41. package/dist/survey/survey-docs.d.ts +4 -0
  42. package/dist/survey/survey-docs.js +69 -0
  43. package/dist/survey/survey-harness.d.ts +4 -0
  44. package/dist/survey/survey-harness.js +121 -0
  45. package/dist/survey/survey-shape.d.ts +4 -0
  46. package/dist/survey/survey-shape.js +182 -0
  47. package/dist/survey/survey-toolchain.d.ts +4 -0
  48. package/dist/survey/survey-toolchain.js +217 -0
  49. package/dist/survey/survey.d.ts +5 -0
  50. package/dist/survey/survey.js +21 -0
  51. package/dist/survey/types.d.ts +117 -0
  52. package/dist/survey/types.js +8 -0
  53. package/dist/tokens.d.ts +13 -0
  54. package/dist/tokens.js +13 -0
  55. package/dist/toolchain/conformance.d.ts +20 -0
  56. package/dist/toolchain/conformance.js +30 -0
  57. package/dist/toolchain/grade.d.ts +4 -0
  58. package/dist/toolchain/grade.js +244 -0
  59. package/dist/toolchain/rules.d.ts +118 -0
  60. package/dist/toolchain/rules.js +706 -0
  61. package/dist/toolchain/tsconfig-chain.d.ts +36 -0
  62. package/dist/toolchain/tsconfig-chain.js +116 -0
  63. package/dist/toolchain/types.d.ts +27 -0
  64. package/dist/toolchain/types.js +9 -0
  65. package/package.json +59 -0
  66. package/plugin/skills/customize/SKILL.md +305 -0
  67. package/plugin/src/domain-map.ts +134 -0
  68. package/plugin/src/index.ts +4 -0
  69. package/plugin/src/kind-facet-map.ts +174 -0
  70. package/plugin/src/pack-map.ts +65 -0
  71. package/templates/core/.claude/agents/Explore.md +43 -0
  72. package/templates/core/.claude/agents/code-implementer.md +258 -0
  73. package/templates/core/.claude/agents/code-reviewer.md +163 -0
  74. package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
  75. package/templates/core/.claude/agents/test-author.md +211 -0
  76. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
  77. package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
  78. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
  79. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
  80. package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
  81. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
  82. package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
  83. package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
  84. package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
  85. package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
  86. package/templates/core/.claude/rules/agent-dispatch.md +121 -0
  87. package/templates/core/.claude/rules/refactoring.md +52 -0
  88. package/templates/core/.claude/rules/src.md +114 -0
  89. package/templates/core/.claude/rules/tests.md +129 -0
  90. package/templates/core/.claude/settings.json +111 -0
  91. package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
  92. package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
  93. package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
  94. package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
  95. package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
  96. package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
  97. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
  98. package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
  99. package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
  100. package/templates/core/.github/workflows/ci.yml +123 -0
  101. package/templates/core/.github/workflows/dependency-review.yml +26 -0
  102. package/templates/core/.github/workflows/security-audit.yml +54 -0
  103. package/templates/core/.node-version +1 -0
  104. package/templates/core/.prettierignore +5 -0
  105. package/templates/core/.prettierrc.json +4 -0
  106. package/templates/core/CLAUDE.md +127 -0
  107. package/templates/core/README.md +24 -0
  108. package/templates/core/_gitignore +19 -0
  109. package/templates/core/_npmrc +1 -0
  110. package/templates/core/bin/check-exports.mjs +92 -0
  111. package/templates/core/bin/check-harness.mjs +27 -0
  112. package/templates/core/bin/check-node-version.mjs +51 -0
  113. package/templates/core/bin/check-toolchain.mjs +20 -0
  114. package/templates/core/bin/lib/agent-roster.mjs +8 -0
  115. package/templates/core/bin/lib/frontmatter.mjs +210 -0
  116. package/templates/core/bin/lib/harness-rules.mjs +916 -0
  117. package/templates/core/bin/lib/protected-paths.mjs +23 -0
  118. package/templates/core/bin/lib/report.mjs +56 -0
  119. package/templates/core/bin/lib/signed-range.mjs +178 -0
  120. package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
  121. package/templates/core/bin/lib/verify-steps.mjs +131 -0
  122. package/templates/core/bin/lib/verify-steps.packs.json +1 -0
  123. package/templates/core/bin/lint-commit.mjs +50 -0
  124. package/templates/core/bin/strip-claude-trailers.mjs +25 -0
  125. package/templates/core/bin/verify.mjs +64 -0
  126. package/templates/core/commitlint.config.js +11 -0
  127. package/templates/core/docs/research/harness-refresh.md +27 -0
  128. package/templates/core/docs/research/typescript-refresh.md +32 -0
  129. package/templates/core/eslint.config.js +105 -0
  130. package/templates/core/knip.json +6 -0
  131. package/templates/core/lefthook.yml +39 -0
  132. package/templates/core/package.json +58 -0
  133. package/templates/core/pnpm-workspace.yaml +13 -0
  134. package/templates/core/src/index.ts +12 -0
  135. package/templates/core/tests/index.test.ts +8 -0
  136. package/templates/core/tsconfig.base.json +36 -0
  137. package/templates/core/tsconfig.build.json +10 -0
  138. package/templates/core/tsconfig.json +11 -0
  139. package/templates/core/vitest.config.ts +32 -0
  140. package/templates/packs/README.md +81 -0
  141. package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
  142. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
  143. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
  144. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
  145. package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
  146. package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
  147. package/templates/packs/harness-extras/pack.json +65 -0
  148. package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
  149. package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
  150. package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
  151. package/templates/packs/statusline/pack.json +31 -0
@@ -0,0 +1,32 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ pool: "forks",
6
+ include: ["**/tests/**/*.test.ts", "**/*.test.ts"],
7
+ exclude: ["**/dist/**", "**/node_modules/**"],
8
+ coverage: {
9
+ provider: "v8",
10
+ include: ["src/**/*.ts"],
11
+ // No "**/index.ts" exclusion here (unlike a multi-module library
12
+ // barrel pattern): a fresh scaffold's index.ts IS its real content,
13
+ // so excluding it would make this gate measure nothing at all.
14
+ exclude: ["**/*.d.ts"],
15
+ // json emits coverage-final.json: the v8 text table hides files that
16
+ // are 100% on every metric, so the JSON is the authoritative
17
+ // per-file record when investigating a suspected gap.
18
+ reporter: ["text", "html", "json"],
19
+ thresholds: {
20
+ // Scaffold-appropriate starting floor. Raise these to the project's
21
+ // own measured floor once real coverage exists (perFile means every
22
+ // file must individually clear each threshold, not just the
23
+ // aggregate) -- never lower a threshold to make a red gate pass.
24
+ lines: 80,
25
+ functions: 80,
26
+ branches: 80,
27
+ statements: 80,
28
+ perFile: true,
29
+ },
30
+ },
31
+ },
32
+ });
@@ -0,0 +1,81 @@
1
+ # templates/packs/
2
+
3
+ Optional add-on bundles, installed **on top of** `templates/core` rather
4
+ than folded into it. A pack exists for one of two reasons: an artifact was
5
+ generically useful but cut from the baseline purely to hold its hard caps
6
+ (≤5 agents, ≤8 skills, ≤10 hooks, ≤3 CI workflows, ≤12 root scripts —
7
+ `templates/core`'s own `CLAUDE.md`), or it applies to only some project
8
+ kinds and shouldn't tax every bootstrap by default.
9
+
10
+ ## Layout
11
+
12
+ ```
13
+ templates/packs/<name>/
14
+ ├── pack.json # manifest: budget, requires, wiring
15
+ └── files/ # the pack's own file tree, mirroring the project root
16
+ # exactly as templates/core/ does -- emitted the same way
17
+ ```
18
+
19
+ ## The wiring contract
20
+
21
+ **A pack never edits YAML or JavaScript.** It may add files under `files/`,
22
+ and may extend three JSON files the baseline already reads at runtime:
23
+ `.claude/settings.json` (hook registrations, and top-level harness settings
24
+ such as `statusLine`), `package.json` (`scripts`), and
25
+ `bin/lib/verify-steps.packs.json` (gate steps, keyed to one of the five
26
+ fixed verify groups `templates/core/bin/lib/verify-steps.mjs` defines —
27
+ `format`/`lint`/`typecheck`/`build`/`test`). A gate registered this way runs
28
+ under `pnpm verify`, every `lefthook.yml` `pre-push` lane, and every
29
+ `.github/workflows/ci.yml` job automatically, because all three already
30
+ enumerate groups rather than individual steps.
31
+
32
+ `pack.json` fields:
33
+
34
+ - `schemaVersion` — currently `1`.
35
+ - `modes` — `["fresh"]` and/or `["fresh", "adopt"]`. Only artifacts with no
36
+ dependency on the baseline's exact file layout (an agent, most hooks) are
37
+ safely adopt-capable; a gate that assumes a specific source layout should
38
+ say so honestly in `adoptNotes` instead of claiming `adopt`.
39
+ - `budget` — the pack's cap deltas (`agents`/`skills`/`hooks`/`workflows`/
40
+ `scripts`), checked against `templates/core`'s own counts by a structural
41
+ test, never against `templates/core` + every other pack.
42
+ - `requires.paths` — baseline files a pack artifact imports (e.g.
43
+ `bin/lib/agent-roster.mjs`). Checked at install time in fresh mode; a
44
+ missing requirement is a hard install error, not a silent partial install.
45
+ - `wiring.settings` — a `.claude/settings.json` hook fragment, merged
46
+ append-only and idempotently. Omit it, or leave it `{}`, for a pack that
47
+ registers no hooks.
48
+ - `wiring.settingsTopLevel` — top-level `.claude/settings.json` keys that
49
+ aren't hook registrations (`statusLine`, `subagentStatusLine`, …), planted
50
+ whole. A key the project already defines with a different value is a hard
51
+ install error, never an overwrite; `hooks` is refused here, since it
52
+ belongs in `wiring.settings`. Optional.
53
+ - `wiring.packageScripts` — `package.json` script additions. Most packs need
54
+ none: a gate registers directly as `["node", "bin/check-x.mjs"]` in
55
+ `wiring.verifySteps`, not as a `pnpm` script.
56
+ - `wiring.verifySteps` — entries appended to `bin/lib/verify-steps.packs.json`.
57
+ - `adoptNotes` — free text surfaced verbatim in `/customize`'s Step 0
58
+ confirmation round when the pack applies to an adopted project.
59
+
60
+ ## Install path
61
+
62
+ - **Fresh mode**: the CLI installs a pack directly (`--pack <name>`,
63
+ repeatable) — it wrote the baseline moments ago, so there's no uncertainty
64
+ to defer.
65
+ - **Adopt mode**: the CLI never installs a pack. It surveys which packs
66
+ apply and stages their payload at `.groundwork/packs/<name>/`;
67
+ `/customize`'s Step 0 confirms and Round 1 installs, translating
68
+ `wiring.verifySteps`/`wiring.settings` against the project's _real_ gate
69
+ runner and hook config — read for real by that point, not guessed at
70
+ offline.
71
+
72
+ ## Available packs
73
+
74
+ | Pack | Contents |
75
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | `harness-extras` | A type-design-analyzer agent, the compaction-handoff hook pair, a read-only Bash guard, and a per-file size ratchet gate — the four artifacts the original baseline build cut purely to hold its caps. |
77
+ | `statusline` | A five-row Claude Code status line (session, model, context, quota, work) plus a per-subagent row renderer, both width-fit to the terminal. Registers top-level `statusLine`/`subagentStatusLine` settings, no hooks and no gate. Runs the same on macOS and Linux. |
78
+
79
+ `github-ops` (dependabot/scan-alert triage skills) and `publishing` (a
80
+ release workflow + npm-publish gates) are documented follow-ups, not yet
81
+ built — see root `CLAUDE.md`'s Known gaps.
@@ -0,0 +1,188 @@
1
+ ---
2
+ name: type-design-analyzer
3
+ description: Read-only type-design reviewer. Rates the type design quality of changed exports on four dimensions (encapsulation, invariant expression, invariant usefulness, invariant enforcement), each scored 1–10, and flags violations of strict-TS / branded-type / make-illegal-states-unrepresentable rules. Use after writing or changing any exported TypeScript types, interfaces, or function signatures. Complements code-reviewer (general structure/SOLID).
4
+ tools: Read, Grep, Glob, Bash
5
+ disallowedTools: Agent
6
+ model: claude-opus-5
7
+ effort: xhigh
8
+ maxTurns: 40
9
+ color: orange
10
+ ---
11
+
12
+ You are a type-design reviewer for this project. You are read-only: review
13
+ and report; **never edit**. In the hub-and-spoke pipeline you are a review
14
+ spoke — you analyse the type design of code a _different_ agent wrote
15
+ (`code-implementer`). That separation is the point: the author can't grade
16
+ their own types.
17
+
18
+ Start by reading the diff (`git diff`, or `git diff --staged`) and the
19
+ changed files. Focus exclusively on type design of exported symbols. Ground
20
+ every finding in the project's own coding rules and its `strict: true` /
21
+ ESM invariants.
22
+
23
+ ## Five-step method
24
+
25
+ For each changed export, work through these steps in order:
26
+
27
+ 1. **Identify invariants** — what must always be true about values of this
28
+ type? (e.g. "a `UserId` is always a non-empty string with a stable brand")
29
+ 2. **Check encapsulation** — are implementation details (internal fields,
30
+ sentinel values, index shapes) hidden from callers, or leaked into the
31
+ type?
32
+ 3. **Check invariant expression** — are the invariants expressed _in the
33
+ type system_ (branded types, discriminated unions, literal types) or
34
+ only in prose / runtime checks?
35
+ 4. **Check invariant usefulness** — does the type actually prevent wrong
36
+ usage at the call site, or does it collapse to `string` / `object` /
37
+ `unknown` at the boundary?
38
+ 5. **Check enforcement** — is correctness enforced at compile time (types)
39
+ or only at runtime (guards)? Prefer compile time; runtime is a fallback.
40
+
41
+ ## Four ratings
42
+
43
+ After the five-step walk, assign a **1–10 score** to each dimension, with a
44
+ one-sentence justification:
45
+
46
+ | Dimension | What a high score looks like |
47
+ | ------------------------- | ------------------------------------------------------------------------- |
48
+ | **Encapsulation** | Callers see exactly what they need; no accidental exposure of internals |
49
+ | **Invariant expression** | Invariants live in the type; impossible states are unrepresentable |
50
+ | **Invariant usefulness** | The type catches real misuse at the call site; not trivially widened away |
51
+ | **Invariant enforcement** | Violations are caught at compile time, not deferred to runtime |
52
+
53
+ Report each dimension as: `Dimension: N/10 — <one-sentence justification>`.
54
+
55
+ ## Project grounding
56
+
57
+ - **No `any`** (use `unknown` and narrow); no non-null `!` in `src/`.
58
+ - **Branded types** for semantic identifiers — every value that is "more
59
+ than a primitive" gets a brand so the type system catches mixups:
60
+
61
+ ```ts
62
+ // the UserId pattern — replicate it for every domain id/key
63
+ export type UserId = string & { readonly __brand: unique symbol };
64
+ ```
65
+
66
+ - **Make illegal states unrepresentable** — prefer discriminated unions over
67
+ boolean flags that can disagree:
68
+
69
+ ```ts
70
+ // flag — two booleans that can be mutually contradictory
71
+ type State = { loading: boolean; error: boolean; data: unknown };
72
+ // good — only valid combinations are representable
73
+ type State =
74
+ | { status: "loading" }
75
+ | { status: "error"; error: Error }
76
+ | { status: "ready"; data: unknown };
77
+ ```
78
+
79
+ - **`readonly`** on array/tuple fields; mutation should be opt-in, not the
80
+ default.
81
+ - **Compile-time enforcement preferred over runtime.** A parse-then-validate
82
+ pattern (e.g. a schema library narrowing to a branded type) is fine; bare
83
+ `as BrandedType` casts are not — they bypass the invariant.
84
+ - **The project's public entry points are the typed public contract**
85
+ (`package.json`'s `exports`/`main` field). Any type visible through those
86
+ entries is part of the semver surface — flag accidental re-exports of
87
+ internal shapes.
88
+
89
+ ## What findings look like
90
+
91
+ Anchor each finding to a concrete contrast so the fix is obvious.
92
+
93
+ **1 — Missing brand (invariant expression):**
94
+
95
+ ```ts
96
+ // flag — any string can be passed; mixups are invisible to the compiler
97
+ export type ConfigKey = string;
98
+ export function get(key: ConfigKey): string { … }
99
+
100
+ // good — brand prevents passing a raw string literal or a UserId
101
+ export type ConfigKey = string & { readonly __brand: unique symbol };
102
+ ```
103
+
104
+ **2 — Leaking internal sentinel (encapsulation):**
105
+
106
+ ```ts
107
+ // flag — callers must know -1 means "not found"; internal detail leaks out
108
+ export function indexOf(items: string[], target: string): number { … }
109
+ // good — the type encodes the optionality; no sentinel
110
+ export function indexOf(items: string[], target: string): number | undefined { … }
111
+ ```
112
+
113
+ **3 — Boolean flags for mutually exclusive states (invariant expression):**
114
+
115
+ ```ts
116
+ // flag — callers can observe { loading: true, error: true } which is nonsense
117
+ export type PollState = { loading: boolean; error: boolean; data: unknown };
118
+ // good — only valid combinations exist
119
+ export type PollState =
120
+ | { status: "polling" }
121
+ | { status: "failed"; error: Error }
122
+ | { status: "done"; data: unknown };
123
+ ```
124
+
125
+ **4 — `any` in a public signature (invariant usefulness):**
126
+
127
+ ```ts
128
+ // flag — any annotation erases the caller's guarantee; nothing is checked
129
+ export function transform(input: any): any { … }
130
+ // good — narrow at the boundary; everything downstream is typed
131
+ export function transform(input: unknown): TransformedResult {
132
+ if (!isValidInput(input)) throw new Error("invalid input");
133
+ …
134
+ }
135
+ ```
136
+
137
+ **5 — `as Brand` cast bypasses enforcement (invariant enforcement):**
138
+
139
+ ```ts
140
+ // flag — the cast is a lie; the string is never validated
141
+ function makeUserId(raw: string): UserId {
142
+ return raw as UserId;
143
+ }
144
+ // good — validation earns the brand
145
+ function makeUserId(raw: string): UserId {
146
+ if (!UUID_PATTERN.test(raw)) throw new Error("invalid user id");
147
+ return raw as UserId; // safe: UUID_PATTERN is the invariant guard
148
+ }
149
+ ```
150
+
151
+ ## Boundaries
152
+
153
+ - Rate **type design only** — correctness bugs, naming, SRP violations, and
154
+ SOLID checks belong to `code-reviewer`; don't duplicate them.
155
+ - Silent-failure / error-handling concerns belong to `silent-failure-
156
+ hunter`; stay on types.
157
+
158
+ ## Output
159
+
160
+ Rate each changed export with the four dimension scores. Group all type
161
+ findings as **Must-fix**, **Should-fix**, **Nits**. Cap each section at its
162
+ 10 most severe findings, most-severe first (collapse a recurring issue class
163
+ into one bullet rather than spilling past the cap). Cite `file:line` and the
164
+ violated rule. End with a one-line verdict.
165
+
166
+ **Scope discipline.** The dimension scores are the review; reserve
167
+ **Must-fix** for a type that actually admits an illegal state or erases a
168
+ caller guarantee (an `any`, a non-null `!`, an unearned `as Brand` cast).
169
+ Don't push every sub-10 score into Must-fix — a 7/10 dimension with a
170
+ defensible tradeoff is a **Nit**, not a defect — and don't demand invariants
171
+ stricter than the documented contract needs.
172
+
173
+ **Converge and report.** Once you've answered the checklist against the
174
+ files you were given, stop — don't keep re-reading or re-verifying "just in
175
+ case." Report what you found rather than chasing diminishing returns.
176
+
177
+ **Bounded output (survive a turn limit).** A long findings report across
178
+ many changed exports can itself run you out of turn budget mid-report.
179
+ Return your report **inline in your response** — you hold no write tool and
180
+ cannot write any file (this project's read-only Bash guard,
181
+ `guard-readonly-bash.mjs`, blocks every shell write route regardless), so a
182
+ scratchpad handoff is never an option here. If the diff touches many
183
+ exports, keep the whole report within roughly 8,000 characters (~2,000
184
+ tokens — the sub-agent output band Anthropic documents): the one-line
185
+ verdict, the four dimension scores per export (these stay compact already),
186
+ and the Must-fix list in full — these block the hub, never truncate them —
187
+ and for Should-fix/Nits a count plus a one-line summary per item rather than
188
+ every body.
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse guard (Bash): restrict read-only spokes to non-mutating shell
4
+ * commands.
5
+ *
6
+ * Every reviewer/research spoke in `.claude/agents/*.md` declares itself
7
+ * read-only in its system prompt and may hold the `Bash` tool for
8
+ * legitimate reads (`git diff`, `pnpm lint`, coverage files, `grep`), but
9
+ * nothing structurally stops one from running a mutating shell command
10
+ * instead. This hook closes that gap at the point a subagent's Bash call
11
+ * actually runs.
12
+ *
13
+ * The read-only roster is derived here, not hardcoded: every defined agent
14
+ * under `.claude/agents/*.md` whose frontmatter `name` is not in
15
+ * `WRITER_SPOKES` (`bin/lib/agent-roster.mjs` -- the same source
16
+ * `guard-hub-src-writes.mjs` uses) counts as read-only, so the two
17
+ * enforcement points can't drift apart on who's authorized to write what.
18
+ *
19
+ * Scope: only tool calls made from inside one of those read-only subagents
20
+ * are checked -- identified via the hook payload's `agent_type` field
21
+ * (present when `PreToolUse` fires inside a subagent context; absent for
22
+ * the hub's own Bash calls, which this hook does not restrict).
23
+ *
24
+ * Design tradeoff, matching every sibling guard hook's fail-open
25
+ * philosophy: this is a DENYLIST of known-mutating patterns, not a strict
26
+ * allowlist. A stricter allowlist would be more airtight but would also
27
+ * block legitimate read commands this hook's author didn't anticipate (a
28
+ * false positive wedges a reviewer's diagnostic work; a false negative
29
+ * merely defers to code review, which remains the authoritative backstop).
30
+ * Extend MUTATING_PATTERNS as new gaps are found rather than flipping to an
31
+ * allowlist.
32
+ */
33
+ import process from "node:process";
34
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
35
+ import { fileURLToPath } from "node:url";
36
+ import { dirname, join } from "node:path";
37
+ import { WRITER_SPOKES } from "../../bin/lib/agent-roster.mjs";
38
+
39
+ const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
40
+
41
+ async function readStdin() {
42
+ const chunks = [];
43
+ for await (const chunk of process.stdin) chunks.push(chunk);
44
+ return Buffer.concat(chunks).toString("utf8");
45
+ }
46
+
47
+ /** Extract the YAML frontmatter block's `name:` field, or `undefined`. */
48
+ function frontmatterName(filePath) {
49
+ const content = readFileSync(filePath, "utf8");
50
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
51
+ if (match === null) return undefined;
52
+ const nameLine = match[1].split("\n").find((line) => /^name:\s*/.test(line));
53
+ return nameLine?.replace(/^name:\s*/, "").trim();
54
+ }
55
+
56
+ /**
57
+ * Every defined agent under `agentsDir` (`.claude/agents/*.md`) whose
58
+ * frontmatter `name` is not in `WRITER_SPOKES`.
59
+ *
60
+ * @param {string} agentsDir absolute path to `.claude/agents/`
61
+ * @returns {Set<string>}
62
+ */
63
+ export function readOnlyAgentNames(agentsDir) {
64
+ const names = new Set();
65
+ if (!existsSync(agentsDir)) return names;
66
+ for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
67
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
68
+ const name = frontmatterName(join(agentsDir, entry.name));
69
+ if (name !== undefined && !WRITER_SPOKES.has(name)) names.add(name);
70
+ }
71
+ return names;
72
+ }
73
+
74
+ /**
75
+ * Segment a shell command on `&&`/`||`/single `|`/`;`/newline chain operators.
76
+ * A `|` immediately preceded by `>` is the clobber-redirect operator (`>|`),
77
+ * not a pipe, so it must not split -- otherwise `echo x >| file` gets
78
+ * chopped into "echo x >" and "file", hiding the redirect from the
79
+ * write-detection regex in classifyBashCommand.
80
+ */
81
+ function segments(command) {
82
+ return command.split(/&&|\|\||(?<!>)\||;|\n/).map((s) => s.trim());
83
+ }
84
+
85
+ /** Strip a leading path (e.g. `/usr/bin/rm` -> `rm`) for verb comparison. */
86
+ function baseName(token) {
87
+ const parts = token.split(/[/\\]/);
88
+ return parts[parts.length - 1];
89
+ }
90
+
91
+ // Global/wrapper flags -- per verb -- that consume the FOLLOWING token as
92
+ // their value, so the walk to find the subcommand must skip both. Without
93
+ // this, `git -C /tmp commit` or `pnpm --dir ./foo add lodash` resolve `sub`
94
+ // to the flag's *value* ("/tmp", "./foo") instead of the real subcommand,
95
+ // defeating MUTATING_SUBCOMMANDS entirely (a `--flag=value` inline form
96
+ // needs no entry here -- the value stays on the same token, which
97
+ // parseSegment already skips as "starts with -").
98
+ const FLAGS_WITH_VALUE = {
99
+ git: new Set([
100
+ "-c",
101
+ "-C",
102
+ "--git-dir",
103
+ "--work-tree",
104
+ "--namespace",
105
+ "--exec-path",
106
+ ]),
107
+ pnpm: new Set(["-C", "--dir", "--filter", "--filter-prod"]),
108
+ npm: new Set(["-C", "--prefix"]),
109
+ };
110
+
111
+ /**
112
+ * The command verb and (for multi-word CLIs) subcommand of one shell
113
+ * segment, skipping leading `VAR=value` environment assignments and any
114
+ * global flags (per FLAGS_WITH_VALUE) that appear before the subcommand.
115
+ *
116
+ * @param {string} segment
117
+ * @returns {{ verb: string, sub: string | undefined, tokens: string[] }}
118
+ */
119
+ function parseSegment(segment) {
120
+ const tokens = segment.split(/\s+/).filter(Boolean);
121
+ let i = 0;
122
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
123
+ if (tokens[i] === undefined) return { verb: "", sub: undefined, tokens: [] };
124
+
125
+ const verb = baseName(tokens[i]);
126
+ const valueFlags = FLAGS_WITH_VALUE[verb] ?? new Set();
127
+ let sub;
128
+ let j = i + 1;
129
+ while (j < tokens.length) {
130
+ const t = tokens[j];
131
+ if (valueFlags.has(t)) {
132
+ j += 2; // skip the flag AND its value token
133
+ continue;
134
+ }
135
+ if (t.startsWith("-")) {
136
+ j += 1; // a flag that doesn't consume a following value
137
+ continue;
138
+ }
139
+ sub = t;
140
+ break;
141
+ }
142
+ return { verb, sub, tokens: tokens.slice(i) };
143
+ }
144
+
145
+ // Mutating subcommands per top-level verb (e.g. "git" -> "commit"). A verb
146
+ // that's always mutating regardless of subcommand belongs in
147
+ // MUTATING_VERBS below instead.
148
+ const MUTATING_SUBCOMMANDS = {
149
+ git: new Set([
150
+ "add",
151
+ "commit",
152
+ "push",
153
+ "merge",
154
+ "rebase",
155
+ "cherry-pick",
156
+ "reset",
157
+ "checkout",
158
+ "switch",
159
+ "branch",
160
+ "tag",
161
+ "clean",
162
+ "apply",
163
+ "am",
164
+ "revert",
165
+ "restore",
166
+ "gc",
167
+ "worktree", // add/remove mutate the tree layout
168
+ "config",
169
+ "stash", // push/pop/drop/apply mutate the working tree; `stash list` is
170
+ // read-only but the false positive here is cheap -- use `git stash
171
+ // list` sparingly from a read-only spoke, or defer to the hub.
172
+ ]),
173
+ pnpm: new Set([
174
+ "add",
175
+ "remove",
176
+ "rm",
177
+ "publish",
178
+ "version",
179
+ "link",
180
+ "unlink",
181
+ ]),
182
+ npm: new Set([
183
+ "install",
184
+ "i",
185
+ "add",
186
+ "remove",
187
+ "rm",
188
+ "uninstall",
189
+ "publish",
190
+ "version",
191
+ "link",
192
+ "unlink",
193
+ ]),
194
+ };
195
+
196
+ // Verbs that mutate the filesystem regardless of subcommand.
197
+ const MUTATING_VERBS = new Set([
198
+ "rm",
199
+ "mv",
200
+ "cp",
201
+ "mkdir",
202
+ "rmdir",
203
+ "touch",
204
+ "chmod",
205
+ "chown",
206
+ "truncate",
207
+ "dd",
208
+ "tee",
209
+ ]);
210
+
211
+ /**
212
+ * Classify a shell command as blocked (mutating) or allowed for a read-only
213
+ * spoke. Denylist-based -- see the module header for the design tradeoff.
214
+ *
215
+ * @param {string} command
216
+ * @returns {{ blocked: boolean, reason?: string }}
217
+ */
218
+ export function classifyBashCommand(command) {
219
+ if (typeof command !== "string" || command.trim().length === 0) {
220
+ return { blocked: false };
221
+ }
222
+
223
+ for (const segment of segments(command)) {
224
+ if (segment.length === 0) continue;
225
+
226
+ // Write-redirection to a real file (not a discard target). No digit
227
+ // lookbehind: `1>file`/`2>file` are ordinary fd-prefixed writes, not fd
228
+ // duplication -- `2>&1` is excluded below because its target starts
229
+ // with `&`, which the target class already rejects. Global flag: a
230
+ // segment can carry more than one redirect (`cmd > /dev/null > real`),
231
+ // and a decoy discard target must not short-circuit the scan past a
232
+ // real one that follows it.
233
+ for (const redirect of segment.matchAll(/(>{1,2}\|?)\s*([^\s&|;]+)/g)) {
234
+ if (
235
+ !/^(\/dev\/null|nul)$/i.test(redirect[2].replace(/^["']|["']$/g, ""))
236
+ ) {
237
+ return {
238
+ blocked: true,
239
+ reason: `writes to "${redirect[2]}" via shell redirection ("${redirect[0]}")`,
240
+ };
241
+ }
242
+ }
243
+
244
+ const { verb, sub, tokens } = parseSegment(segment);
245
+ if (verb.length === 0) continue;
246
+
247
+ if (MUTATING_VERBS.has(verb)) {
248
+ return { blocked: true, reason: `runs "${verb}", a mutating command` };
249
+ }
250
+
251
+ if (
252
+ verb === "sed" &&
253
+ tokens.some((t) => t === "-i" || t.startsWith("-i"))
254
+ ) {
255
+ return { blocked: true, reason: `runs "sed -i" (in-place edit)` };
256
+ }
257
+
258
+ const mutatingSubs = MUTATING_SUBCOMMANDS[verb];
259
+ if (
260
+ mutatingSubs !== undefined &&
261
+ sub !== undefined &&
262
+ mutatingSubs.has(sub)
263
+ ) {
264
+ return {
265
+ blocked: true,
266
+ reason: `runs "${verb} ${sub}", a mutating subcommand`,
267
+ };
268
+ }
269
+ }
270
+
271
+ return { blocked: false };
272
+ }
273
+
274
+ // Deliberately inlined in every hook rather than shared: this pack's hook
275
+ // budget is exactly its three hooks, so a helper module would cost a slot.
276
+ // `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
277
+ // comparing them directly is false under any symlinked path and the body would
278
+ // never run -- exit 0.
279
+ function isEntryPoint() {
280
+ try {
281
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ // Only run when invoked directly, not when imported for testing.
288
+ if (isEntryPoint()) {
289
+ const raw = await readStdin();
290
+ let input;
291
+ try {
292
+ input = JSON.parse(raw);
293
+ } catch {
294
+ process.exit(0);
295
+ }
296
+
297
+ const agentType = input.agent_type;
298
+ if (typeof agentType !== "string" || agentType.length === 0) process.exit(0);
299
+
300
+ let readOnly;
301
+ try {
302
+ readOnly = readOnlyAgentNames(join(root, ".claude/agents"));
303
+ } catch {
304
+ process.exit(0); // can't determine the roster -> defer, don't wedge
305
+ }
306
+ if (!readOnly.has(agentType)) process.exit(0);
307
+
308
+ const command = input.tool_input?.command;
309
+ const verdict = classifyBashCommand(
310
+ typeof command === "string" ? command : "",
311
+ );
312
+ if (!verdict.blocked) process.exit(0);
313
+
314
+ process.stderr.write(`\
315
+ [guard-readonly-bash] Blocked: the "${agentType}" spoke is read-only, but this
316
+ command ${verdict.reason}.
317
+
318
+ Read-only spokes may inspect the repo but never mutate it -- that separation
319
+ is structural (CLAUDE.md § Agent Operating Model). If this command is
320
+ genuinely needed, hand the mutation back to the hub or to a writer spoke
321
+ (code-implementer / test-author) instead of running it here.
322
+ `);
323
+ process.exit(2);
324
+ }