@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.
- package/README.md +23 -0
- package/bin/m3l-groundwork.mjs +10 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +79 -0
- package/dist/caps.d.ts +25 -0
- package/dist/caps.js +69 -0
- package/dist/conflicts.d.ts +12 -0
- package/dist/conflicts.js +77 -0
- package/dist/emit.d.ts +7 -0
- package/dist/emit.js +42 -0
- package/dist/git.d.ts +3 -0
- package/dist/git.js +9 -0
- package/dist/harness/conformance.d.ts +20 -0
- package/dist/harness/conformance.js +18 -0
- package/dist/harness/frontmatter.d.ts +38 -0
- package/dist/harness/frontmatter.js +204 -0
- package/dist/harness/grade.d.ts +4 -0
- package/dist/harness/grade.js +105 -0
- package/dist/harness/rules.d.ts +55 -0
- package/dist/harness/rules.js +580 -0
- package/dist/harness/types.d.ts +32 -0
- package/dist/harness/types.js +9 -0
- package/dist/inventory.d.ts +63 -0
- package/dist/inventory.js +66 -0
- package/dist/jsonc.d.ts +14 -0
- package/dist/jsonc.js +83 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.js +297 -0
- package/dist/merge-json.d.ts +74 -0
- package/dist/merge-json.js +135 -0
- package/dist/mode.d.ts +19 -0
- package/dist/mode.js +53 -0
- package/dist/packs.d.ts +61 -0
- package/dist/packs.js +186 -0
- package/dist/plugin.d.ts +23 -0
- package/dist/plugin.js +79 -0
- package/dist/report.d.ts +4 -0
- package/dist/report.js +323 -0
- package/dist/survey/fs-walk.d.ts +14 -0
- package/dist/survey/fs-walk.js +60 -0
- package/dist/survey/survey-docs.d.ts +4 -0
- package/dist/survey/survey-docs.js +69 -0
- package/dist/survey/survey-harness.d.ts +4 -0
- package/dist/survey/survey-harness.js +121 -0
- package/dist/survey/survey-shape.d.ts +4 -0
- package/dist/survey/survey-shape.js +182 -0
- package/dist/survey/survey-toolchain.d.ts +4 -0
- package/dist/survey/survey-toolchain.js +217 -0
- package/dist/survey/survey.d.ts +5 -0
- package/dist/survey/survey.js +21 -0
- package/dist/survey/types.d.ts +117 -0
- package/dist/survey/types.js +8 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +13 -0
- package/dist/toolchain/conformance.d.ts +20 -0
- package/dist/toolchain/conformance.js +30 -0
- package/dist/toolchain/grade.d.ts +4 -0
- package/dist/toolchain/grade.js +244 -0
- package/dist/toolchain/rules.d.ts +118 -0
- package/dist/toolchain/rules.js +706 -0
- package/dist/toolchain/tsconfig-chain.d.ts +36 -0
- package/dist/toolchain/tsconfig-chain.js +116 -0
- package/dist/toolchain/types.d.ts +27 -0
- package/dist/toolchain/types.js +9 -0
- package/package.json +59 -0
- package/plugin/skills/customize/SKILL.md +305 -0
- package/plugin/src/domain-map.ts +134 -0
- package/plugin/src/index.ts +4 -0
- package/plugin/src/kind-facet-map.ts +174 -0
- package/plugin/src/pack-map.ts +65 -0
- package/templates/core/.claude/agents/Explore.md +43 -0
- package/templates/core/.claude/agents/code-implementer.md +258 -0
- package/templates/core/.claude/agents/code-reviewer.md +163 -0
- package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
- package/templates/core/.claude/agents/test-author.md +211 -0
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
- package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
- package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
- package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
- package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
- package/templates/core/.claude/rules/agent-dispatch.md +121 -0
- package/templates/core/.claude/rules/refactoring.md +52 -0
- package/templates/core/.claude/rules/src.md +114 -0
- package/templates/core/.claude/rules/tests.md +129 -0
- package/templates/core/.claude/settings.json +111 -0
- package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
- package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
- package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
- package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
- package/templates/core/.github/workflows/ci.yml +123 -0
- package/templates/core/.github/workflows/dependency-review.yml +26 -0
- package/templates/core/.github/workflows/security-audit.yml +54 -0
- package/templates/core/.node-version +1 -0
- package/templates/core/.prettierignore +5 -0
- package/templates/core/.prettierrc.json +4 -0
- package/templates/core/CLAUDE.md +127 -0
- package/templates/core/README.md +24 -0
- package/templates/core/_gitignore +19 -0
- package/templates/core/_npmrc +1 -0
- package/templates/core/bin/check-exports.mjs +92 -0
- package/templates/core/bin/check-harness.mjs +27 -0
- package/templates/core/bin/check-node-version.mjs +51 -0
- package/templates/core/bin/check-toolchain.mjs +20 -0
- package/templates/core/bin/lib/agent-roster.mjs +8 -0
- package/templates/core/bin/lib/frontmatter.mjs +210 -0
- package/templates/core/bin/lib/harness-rules.mjs +916 -0
- package/templates/core/bin/lib/protected-paths.mjs +23 -0
- package/templates/core/bin/lib/report.mjs +56 -0
- package/templates/core/bin/lib/signed-range.mjs +178 -0
- package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
- package/templates/core/bin/lib/verify-steps.mjs +131 -0
- package/templates/core/bin/lib/verify-steps.packs.json +1 -0
- package/templates/core/bin/lint-commit.mjs +50 -0
- package/templates/core/bin/strip-claude-trailers.mjs +25 -0
- package/templates/core/bin/verify.mjs +64 -0
- package/templates/core/commitlint.config.js +11 -0
- package/templates/core/docs/research/harness-refresh.md +27 -0
- package/templates/core/docs/research/typescript-refresh.md +32 -0
- package/templates/core/eslint.config.js +105 -0
- package/templates/core/knip.json +6 -0
- package/templates/core/lefthook.yml +39 -0
- package/templates/core/package.json +58 -0
- package/templates/core/pnpm-workspace.yaml +13 -0
- package/templates/core/src/index.ts +12 -0
- package/templates/core/tests/index.test.ts +8 -0
- package/templates/core/tsconfig.base.json +36 -0
- package/templates/core/tsconfig.build.json +10 -0
- package/templates/core/tsconfig.json +11 -0
- package/templates/core/vitest.config.ts +32 -0
- package/templates/packs/README.md +81 -0
- package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
- package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
- package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
- package/templates/packs/harness-extras/pack.json +65 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
- package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
- 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
|
+
}
|