@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.
- package/README.md +16 -8
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
package/dist/fs-guard.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The one symlink refusal adopt mode's staging writers share
|
|
5
|
+
* (`staging.ts`, used by `baseline-stage.ts` and `pack-stage.ts`; `main.ts`'s
|
|
6
|
+
* `.groundwork/` cleanup): a staging directory that is a symlink would
|
|
7
|
+
* redirect a recursive delete or a write outside the adopted project. Also
|
|
8
|
+
* the one recogniser for the "re-run the CLI" advice that refusal (and every
|
|
9
|
+
* other adopt-mode failure) ends with, so a wrapper never states it twice.
|
|
10
|
+
* Fresh mode's writers (`emit.ts`, `plugin.ts`) share the same refusals with
|
|
11
|
+
* fresh mode's own single retry instruction ({@link FRESH_RETRY}) instead.
|
|
12
|
+
*/
|
|
13
|
+
import { lstatSync } from "node:fs";
|
|
14
|
+
import { isAbsentError, permissionCode } from "./survey/internal/read-guard.js";
|
|
15
|
+
/** The tail every adopt-mode re-run instruction ends with, {@link assertNotSymlink}'s default advice included. */
|
|
16
|
+
const RERUN_TAIL = "re-run the CLI";
|
|
17
|
+
/**
|
|
18
|
+
* Adopt mode's generic re-run instruction, stated once so every adopt-mode
|
|
19
|
+
* failure that appends it (`main.ts`'s post-point-of-no-return error, the
|
|
20
|
+
* guarded `/customize` install) words it identically. Ends with the tail
|
|
21
|
+
* {@link endsWithRerunAdvice} recognises.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { FIX_AND_RERUN_ADVICE, endsWithRerunAdvice } from "./fs-guard.js";
|
|
26
|
+
*
|
|
27
|
+
* const message = `could not write x: EACCES; ${FIX_AND_RERUN_ADVICE}`;
|
|
28
|
+
* endsWithRerunAdvice(message); // true
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export const FIX_AND_RERUN_ADVICE = `fix the cause and ${RERUN_TAIL}`;
|
|
32
|
+
/**
|
|
33
|
+
* Whether `message` already ENDS with "re-run the CLI" advice, in any
|
|
34
|
+
* wording that ends that way (e.g. {@link assertNotSymlink}'s "remove it and
|
|
35
|
+
* re-run the CLI", or "fix the cause and re-run the CLI"), so a caller about
|
|
36
|
+
* to append its own re-run advice can skip it rather than state it twice.
|
|
37
|
+
* End-anchored: the phrase appearing earlier in the message -- e.g. inside
|
|
38
|
+
* an embedded path -- does not count.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { endsWithRerunAdvice } from "./fs-guard.js";
|
|
43
|
+
*
|
|
44
|
+
* endsWithRerunAdvice("x is a symlink -- remove it and re-run the CLI"); // true
|
|
45
|
+
* endsWithRerunAdvice("could not write /tmp/re-run the CLI/a: EACCES"); // false
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function endsWithRerunAdvice(message) {
|
|
49
|
+
return message.endsWith(RERUN_TAIL);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Fresh mode's retry instruction. Its target is no longer empty after a
|
|
53
|
+
* failed write, so a plain re-run would adopt it; only `--fresh --force`
|
|
54
|
+
* repeats that run. Never contains adopt mode's bare "re-run the CLI".
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { FRESH_RETRY } from "./fs-guard.js";
|
|
59
|
+
*
|
|
60
|
+
* const advice = `fix the cause, then ${FRESH_RETRY}`;
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export const FRESH_RETRY = "retry the same command with --fresh --force added";
|
|
64
|
+
/**
|
|
65
|
+
* The advice for a permission failure (`EACCES`/`EPERM`) inspecting a path:
|
|
66
|
+
* "remove it" (the symlink refusal's remedy) would be wrong there, so the
|
|
67
|
+
* caller's `advice` is replaced by a permissions fix that keeps the same
|
|
68
|
+
* mode-specific retry -- fresh mode's {@link FRESH_RETRY}, or adopt mode's
|
|
69
|
+
* "re-run the CLI" (so {@link endsWithRerunAdvice} still recognises it). A
|
|
70
|
+
* caller advice ending with neither is kept whole after the permissions fix,
|
|
71
|
+
* so its own retry is never dropped.
|
|
72
|
+
*/
|
|
73
|
+
function permissionAdvice(advice) {
|
|
74
|
+
if (advice.endsWith(FRESH_RETRY)) {
|
|
75
|
+
return `fix its permissions, then ${FRESH_RETRY}`;
|
|
76
|
+
}
|
|
77
|
+
if (endsWithRerunAdvice(advice)) {
|
|
78
|
+
return `fix its permissions and ${RERUN_TAIL}`;
|
|
79
|
+
}
|
|
80
|
+
return `fix its permissions; ${advice}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `lstat` without its one blind spot: `throwIfNoEntry: false` only answers a
|
|
84
|
+
* missing path with `undefined`, so any other failure (most realistically
|
|
85
|
+
* `EACCES` on a search-permission-denied ancestor) would escape raw -- no
|
|
86
|
+
* path in a readable message, no retry advice. An absent path (`ENOENT`, or
|
|
87
|
+
* `ENOTDIR` below a regular file) answers `undefined`, leaving the write
|
|
88
|
+
* that follows to raise its own error; anything else is wrapped here once,
|
|
89
|
+
* naming `path`, with the original as `cause`. A permission failure ends
|
|
90
|
+
* with {@link permissionAdvice}'s permissions fix; any other ends with
|
|
91
|
+
* `advice` unchanged.
|
|
92
|
+
*/
|
|
93
|
+
function inspect(path, advice) {
|
|
94
|
+
try {
|
|
95
|
+
return lstatSync(path, { throwIfNoEntry: false });
|
|
96
|
+
}
|
|
97
|
+
catch (cause) {
|
|
98
|
+
if (isAbsentError(cause))
|
|
99
|
+
return undefined;
|
|
100
|
+
const code = permissionCode(cause);
|
|
101
|
+
if (code !== undefined) {
|
|
102
|
+
throw new Error(`could not inspect ${path}: permission denied (${code}) -- ${permissionAdvice(advice)}`, { cause });
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`could not inspect ${path} -- ${advice}`, { cause });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Throws when `path` exists and is a symbolic link; a missing path passes.
|
|
109
|
+
* Uses `lstat`, so the link itself is inspected, never its target. Call it
|
|
110
|
+
* on every staging directory before the first `rm` or write under it.
|
|
111
|
+
*
|
|
112
|
+
* @param advice - What the message ends with after `--`; defaults to
|
|
113
|
+
* "remove it and re-run the CLI". A caller whose run needs a different
|
|
114
|
+
* retry (fresh mode's `--fresh --force`) passes its own, so the error
|
|
115
|
+
* never carries a second, contradicting instruction.
|
|
116
|
+
* @throws `Error` naming `path` when it is a symlink, or when it cannot be
|
|
117
|
+
* inspected at all (any `lstat` failure but `ENOENT`/`ENOTDIR`, chained
|
|
118
|
+
* as `cause`).
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* import { rmSync } from "node:fs";
|
|
123
|
+
* import { assertNotSymlink } from "./fs-guard.js";
|
|
124
|
+
*
|
|
125
|
+
* assertNotSymlink("/work/app/.groundwork"); // throws if it's a symlink
|
|
126
|
+
* rmSync("/work/app/.groundwork/inventory.json", { force: true });
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function assertNotSymlink(path, advice = `remove it and ${RERUN_TAIL}`) {
|
|
130
|
+
const stat = inspect(path, advice);
|
|
131
|
+
if (stat?.isSymbolicLink() === true) {
|
|
132
|
+
throw new Error(`refusing to write through a symlink: ${path} -- ${advice}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Throws when `path` exists and is not a real directory -- a symlink
|
|
137
|
+
* (dangling or not) or a file where a writer needs a directory. A missing
|
|
138
|
+
* path passes. Uses `lstat`, so a symlinked directory is refused rather
|
|
139
|
+
* than followed out of the tree being written.
|
|
140
|
+
*
|
|
141
|
+
* @param advice - What the message ends with after `--`, same convention as
|
|
142
|
+
* {@link assertNotSymlink}'s.
|
|
143
|
+
* @throws `Error` naming `path` when it is a symlink or a non-directory, or
|
|
144
|
+
* when it cannot be inspected (the original chained as `cause`).
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* import { assertDirectoryComponent, FRESH_SYMLINK_ADVICE } from "./fs-guard.js";
|
|
149
|
+
*
|
|
150
|
+
* assertDirectoryComponent("/work/app/.claude", FRESH_SYMLINK_ADVICE);
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export function assertDirectoryComponent(path, advice) {
|
|
154
|
+
assertNotSymlink(path, advice);
|
|
155
|
+
const stat = inspect(path, advice);
|
|
156
|
+
if (stat !== undefined && !stat.isDirectory()) {
|
|
157
|
+
throw new Error(`refusing to write under a non-directory: ${path} -- ${advice}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Throws when `path` exists and cannot be written as a plain file -- a
|
|
162
|
+
* symlink (dangling or not), which a write would follow, or a directory,
|
|
163
|
+
* which a write would fail on only after earlier writes had landed. A
|
|
164
|
+
* missing path or an existing regular file passes. Uses `lstat`, so the
|
|
165
|
+
* entry itself is inspected, never a link's target.
|
|
166
|
+
*
|
|
167
|
+
* @param advice - What the message ends with after `--`, same convention as
|
|
168
|
+
* {@link assertNotSymlink}'s.
|
|
169
|
+
* @throws `Error` naming `path` when it is a symlink or a directory, or
|
|
170
|
+
* when it cannot be inspected (the original chained as `cause`).
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```ts
|
|
174
|
+
* import { assertFileDestination, FRESH_SYMLINK_ADVICE } from "./fs-guard.js";
|
|
175
|
+
*
|
|
176
|
+
* assertFileDestination("/work/app/tsconfig.json", FRESH_SYMLINK_ADVICE);
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
export function assertFileDestination(path, advice) {
|
|
180
|
+
assertNotSymlink(path, advice);
|
|
181
|
+
assertNotDirectory(path, advice);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Throws when `path` exists and is a real directory, which a file write
|
|
185
|
+
* would fail on only after earlier writes had landed. A missing path, a
|
|
186
|
+
* file, or a symlink passes -- for a destination whose writer replaces a
|
|
187
|
+
* symlink rather than following it (fresh mode's `/customize` install),
|
|
188
|
+
* this is the one shape left to refuse up front. Uses `lstat`.
|
|
189
|
+
*
|
|
190
|
+
* @param advice - What the message ends with after `--`, same convention as
|
|
191
|
+
* {@link assertNotSymlink}'s.
|
|
192
|
+
* @throws `Error` naming `path` when it is a directory, or when it cannot be
|
|
193
|
+
* inspected (the original chained as `cause`).
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* import { assertNotDirectory, FRESH_SYMLINK_ADVICE } from "./fs-guard.js";
|
|
198
|
+
*
|
|
199
|
+
* assertNotDirectory("/work/app/.claude/skills/customize/SKILL.md", FRESH_SYMLINK_ADVICE);
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
export function assertNotDirectory(path, advice) {
|
|
203
|
+
const stat = inspect(path, advice);
|
|
204
|
+
if (stat?.isDirectory() === true) {
|
|
205
|
+
throw new Error(`refusing to write a file over a directory: ${path} -- ${advice}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Fresh mode's advice for a refused destination path (a symlink, or a
|
|
210
|
+
* non-directory where a directory is needed), replacing
|
|
211
|
+
* {@link assertNotSymlink}'s adopt-mode default. Carries {@link FRESH_RETRY}
|
|
212
|
+
* exactly once.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* import { assertNotSymlink, FRESH_SYMLINK_ADVICE } from "./fs-guard.js";
|
|
217
|
+
*
|
|
218
|
+
* assertNotSymlink("/work/app/package.json", FRESH_SYMLINK_ADVICE);
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
export const FRESH_SYMLINK_ADVICE = `remove it, then ${FRESH_RETRY}`;
|
|
222
|
+
//# sourceMappingURL=fs-guard.js.map
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The two mechanical steps fresh mode runs after emission -- writing the
|
|
5
|
+
* baseline template tree into the target directory (`emitTemplate` in
|
|
6
|
+
* `emit.ts`, called from `main.ts`'s `runFresh`). `gitInit` initializes a
|
|
7
|
+
* fresh git repository there (`git init -q`); `runInstall` then runs the
|
|
8
|
+
* first package install (`pnpm install`). Both run synchronously in the
|
|
9
|
+
* target directory with the child's output passed straight through.
|
|
10
|
+
*/
|
|
2
11
|
import { execFileSync } from "node:child_process";
|
|
3
12
|
export function gitInit(cwd) {
|
|
4
13
|
execFileSync("git", ["init", "-q"], { cwd, stdio: "inherit" });
|
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* `SKILL.md`, agent, and rule files. Hand-rolled because this package has no
|
|
4
|
-
* runtime dependencies (see `jsonc.ts` for the same trade-off). It handles
|
|
5
|
-
* the scalar forms real harness files contain: plain, single/double quoted,
|
|
6
|
-
* block scalars (`>-`, `>`, `|`, `|-`), block lists, and flow lists.
|
|
7
|
-
*
|
|
8
|
-
* Deliberately NOT supported: nested mappings (a key whose value is an
|
|
9
|
-
* indented map, e.g. `mcpServers:` with inline server definitions) -- such a
|
|
10
|
-
* key is recorded with an empty string value rather than misparsed -- plus
|
|
11
|
-
* anchors, tags, and multi-document streams. Every string result is
|
|
12
|
-
* trimmed; a block scalar's trailing newline is not preserved.
|
|
13
|
-
*/
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
14
3
|
const KEY_LINE = /^([A-Za-z_][\w-]*):(?:[ \t]+(.*))?$/;
|
|
15
4
|
const BLOCK_SCALAR = /^([>|])(?:[+-]\d?|\d[+-]?)?$/;
|
|
16
5
|
/** Removes the smallest common indent from every non-blank line. */
|
package/dist/harness/grade.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
1
3
|
/**
|
|
2
4
|
* Grades a project's Claude Code harness. `gradeHarness` reads the `.claude/`
|
|
3
5
|
* tree and `CLAUDE.md` once into a `HarnessSnapshot`, then runs every rule in
|
|
@@ -6,8 +8,8 @@
|
|
|
6
8
|
*/
|
|
7
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
10
|
import { join } from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
+
import { parseJsonc } from "../jsonc.js";
|
|
12
|
+
import { walkBoundedForGrading } from "../survey/fs-walk.js";
|
|
11
13
|
import { RULES } from "./rules.js";
|
|
12
14
|
import { HARNESS_CATEGORIES } from "./types.js";
|
|
13
15
|
const PROJECT_WALK_DEPTH = 8;
|
|
@@ -19,17 +21,40 @@ function readText(path) {
|
|
|
19
21
|
return undefined;
|
|
20
22
|
}
|
|
21
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads and parses a JSONC file the way the emitted twin
|
|
26
|
+
* (`templates/core/bin/lib/harness-rules.mjs`'s `readJsonc`) does, so both
|
|
27
|
+
* graders agree finding for finding: any read failure -- a directory at the
|
|
28
|
+
* path, a permission failure, even `EIO` -- becomes the failure's own
|
|
29
|
+
* message, never a throw. Not `jsonc.ts`'s `readJsoncFile`, which throws on
|
|
30
|
+
* a machine-level errno and words its errors differently: a grade reports
|
|
31
|
+
* what it could not read rather than aborting the whole report.
|
|
32
|
+
*/
|
|
33
|
+
function readJsoncLenient(path) {
|
|
34
|
+
let content;
|
|
35
|
+
try {
|
|
36
|
+
content = readFileSync(path, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
stage: "read",
|
|
42
|
+
error: error instanceof Error ? error.message : String(error),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return parseJsonc(content);
|
|
46
|
+
}
|
|
22
47
|
function readSettings(path) {
|
|
23
48
|
if (!existsSync(path)) {
|
|
24
49
|
return { present: false, error: undefined, parsed: undefined };
|
|
25
50
|
}
|
|
26
|
-
const result =
|
|
51
|
+
const result = readJsoncLenient(path);
|
|
27
52
|
return result.ok
|
|
28
53
|
? { present: true, error: undefined, parsed: result.value }
|
|
29
54
|
: { present: true, error: result.error, parsed: undefined };
|
|
30
55
|
}
|
|
31
56
|
function loadSnapshot(root) {
|
|
32
|
-
const entries =
|
|
57
|
+
const entries = walkBoundedForGrading(root, PROJECT_WALK_DEPTH);
|
|
33
58
|
const claudeEntries = entries.filter((entry) => entry.relPath.startsWith(".claude/"));
|
|
34
59
|
const readEach = (pattern, strip) => new Map(claudeEntries
|
|
35
60
|
.filter((entry) => !entry.isDirectory && pattern.test(entry.relPath))
|
|
@@ -52,12 +77,16 @@ function loadSnapshot(root) {
|
|
|
52
77
|
files,
|
|
53
78
|
};
|
|
54
79
|
});
|
|
55
|
-
const
|
|
80
|
+
const settingsLocal = readSettings(join(root, ".claude", "settings.local.json"));
|
|
81
|
+
const mcpJsonResult = readJsoncLenient(join(root, ".mcp.json"));
|
|
56
82
|
return {
|
|
57
83
|
settings: readSettings(join(root, ".claude", "settings.json")),
|
|
58
|
-
settingsLocal:
|
|
59
|
-
|
|
60
|
-
|
|
84
|
+
settingsLocal: settingsLocal.parsed,
|
|
85
|
+
settingsLocalError: settingsLocal.error,
|
|
86
|
+
// A malformed or absent .mcp.json is never a structural failure -- it
|
|
87
|
+
// just means agent-mcp-source (a rubric-only rule) can't see anything
|
|
88
|
+
// it supplies.
|
|
89
|
+
mcpJson: mcpJsonResult.ok ? mcpJsonResult.value : undefined,
|
|
61
90
|
hooks: readEach(/^\.claude\/hooks\/[^/]+$/, ".claude/hooks/"),
|
|
62
91
|
agents: readEach(/^\.claude\/agents\/[^/]+\.md$/, ".claude/agents/"),
|
|
63
92
|
skills,
|
package/dist/harness/rules.d.ts
CHANGED
|
@@ -15,6 +15,18 @@ export interface HarnessSnapshot {
|
|
|
15
15
|
};
|
|
16
16
|
/** `.claude/settings.local.json`, parsed, or `undefined` when absent/unparseable. */
|
|
17
17
|
settingsLocal: unknown;
|
|
18
|
+
/**
|
|
19
|
+
* Why `.claude/settings.local.json` failed to parse, or `undefined` when it
|
|
20
|
+
* parsed or is absent -- the distinction `settingsLocal` alone cannot make.
|
|
21
|
+
*/
|
|
22
|
+
settingsLocalError: string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Root `.mcp.json`, parsed, or `undefined` when absent/unparseable. A
|
|
25
|
+
* malformed or absent `.mcp.json` is never a structural failure -- it just
|
|
26
|
+
* means `agent-mcp-source` (a rubric-only rule) can't see anything it
|
|
27
|
+
* supplies.
|
|
28
|
+
*/
|
|
29
|
+
mcpJson: unknown;
|
|
18
30
|
/** Hook filename to source text. */
|
|
19
31
|
hooks: Map<string, string>;
|
|
20
32
|
/** Agent filename (with `.md`) to text. */
|
|
@@ -44,9 +56,14 @@ export interface HarnessRule {
|
|
|
44
56
|
check: (snapshot: HarnessSnapshot) => RuleResult;
|
|
45
57
|
}
|
|
46
58
|
/**
|
|
47
|
-
* Model ids and aliases
|
|
48
|
-
*
|
|
49
|
-
*
|
|
59
|
+
* Model ids and aliases the rubric accepts: the current ids and aliases, plus
|
|
60
|
+
* ids that were once listed here, kept until Anthropic deprecates them. The
|
|
61
|
+
* ids follow Anthropic's models overview and model-deprecations pages
|
|
62
|
+
* (retrieved 2026-10-01). A legacy id that was never listed here is
|
|
63
|
+
* deliberately not added, so the rule keeps nudging pins toward current
|
|
64
|
+
* models. Bump alongside the `harness-guidance` refresh sweep;
|
|
65
|
+
* `templates/core/bin/lib/harness-rules.mjs` carries the same list and the
|
|
66
|
+
* parity test keeps the two equal.
|
|
50
67
|
*/
|
|
51
68
|
export declare const CURRENT_MODELS: readonly string[];
|
|
52
69
|
/** Every rule, structural first. Order is the order findings are reported in. */
|
package/dist/harness/rules.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
1
3
|
/**
|
|
2
4
|
* The harness rule set: a declarative list of checks, each a pure function
|
|
3
5
|
* over a `HarnessSnapshot` (everything the grader read from disk, already in
|
|
@@ -15,9 +17,14 @@
|
|
|
15
17
|
import { isRecord } from "../merge-json.js";
|
|
16
18
|
import { fieldList, fieldText, parseFrontmatter } from "./frontmatter.js";
|
|
17
19
|
/**
|
|
18
|
-
* Model ids and aliases
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* Model ids and aliases the rubric accepts: the current ids and aliases, plus
|
|
21
|
+
* ids that were once listed here, kept until Anthropic deprecates them. The
|
|
22
|
+
* ids follow Anthropic's models overview and model-deprecations pages
|
|
23
|
+
* (retrieved 2026-10-01). A legacy id that was never listed here is
|
|
24
|
+
* deliberately not added, so the rule keeps nudging pins toward current
|
|
25
|
+
* models. Bump alongside the `harness-guidance` refresh sweep;
|
|
26
|
+
* `templates/core/bin/lib/harness-rules.mjs` carries the same list and the
|
|
27
|
+
* parity test keeps the two equal.
|
|
21
28
|
*/
|
|
22
29
|
export const CURRENT_MODELS = [
|
|
23
30
|
"inherit",
|
|
@@ -28,6 +35,7 @@ export const CURRENT_MODELS = [
|
|
|
28
35
|
"claude-opus-5",
|
|
29
36
|
"claude-opus-5-5",
|
|
30
37
|
"claude-sonnet-5",
|
|
38
|
+
"claude-sonnet-5-5",
|
|
31
39
|
"claude-fable-5-1",
|
|
32
40
|
"claude-haiku-4-5",
|
|
33
41
|
"claude-haiku-4-5-20251001",
|
|
@@ -36,6 +44,10 @@ const SKILL_BODY_LINE_LIMIT = 500;
|
|
|
36
44
|
const DESCRIPTION_MIN = 40;
|
|
37
45
|
const DESCRIPTION_MAX = 1024;
|
|
38
46
|
const BARE_ENTRY_POINT = /process\.argv\[1\]\s*===\s*fileURLToPath\(import\.meta\.url\)/;
|
|
47
|
+
// Contains `realpathSync(process.argv[1])`, but compares it to a URL-encoded
|
|
48
|
+
// pathname rather than an OS path -- the two never agree under a symlinked
|
|
49
|
+
// or percent-encoded path, so this form fails open too.
|
|
50
|
+
const URL_PATHNAME_ENTRY_POINT = /realpathSync\(process\.argv\[1\]\)\s*===\s*new URL\(import\.meta\.url\)\.pathname/;
|
|
39
51
|
const HOOK_PATH = /\.claude\/hooks\/([A-Za-z0-9_.-]+)/g;
|
|
40
52
|
const CLAUDE_PATH = /\.claude\/[A-Za-z0-9_.*/-]+/g;
|
|
41
53
|
const REFERENCE_PATH = /\breferences\/[A-Za-z0-9_./-]+\.md/g;
|
|
@@ -164,6 +176,7 @@ function globToRegExp(glob) {
|
|
|
164
176
|
function bodyLineCount(body) {
|
|
165
177
|
return body.replace(/\n+$/, "").split("\n").length;
|
|
166
178
|
}
|
|
179
|
+
/** Every rule that reads hook registrations depends on settings.json parsing cleanly -- isolating the parse failure here keeps a downstream rule from either failing confusingly or silently missing every registration. */
|
|
167
180
|
const settingsParses = {
|
|
168
181
|
id: "settings-parses",
|
|
169
182
|
level: "structural",
|
|
@@ -180,12 +193,32 @@ const settingsParses = {
|
|
|
180
193
|
],
|
|
181
194
|
}),
|
|
182
195
|
};
|
|
196
|
+
/** settings.local.json can register hooks too, and a downstream rule reading hook registrations needs to know when this file failed to parse rather than silently treating it as absent. */
|
|
197
|
+
const settingsLocalParses = {
|
|
198
|
+
id: "settings-local-parses",
|
|
199
|
+
level: "structural",
|
|
200
|
+
category: "settings",
|
|
201
|
+
check: (s) => ({
|
|
202
|
+
checked: s.settingsLocalError === undefined ? 0 : 1,
|
|
203
|
+
failures: s.settingsLocalError === undefined
|
|
204
|
+
? []
|
|
205
|
+
: [
|
|
206
|
+
{
|
|
207
|
+
subject: ".claude/settings.local.json",
|
|
208
|
+
message: `does not parse: ${s.settingsLocalError}`,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
/** A hook registration naming a file that doesn't exist on disk fails only at the moment Claude Code actually tries to run it -- this is the only check that catches it earlier. */
|
|
183
214
|
const hookDangling = {
|
|
184
215
|
id: "hook-dangling",
|
|
185
216
|
level: "structural",
|
|
186
217
|
category: "hooks",
|
|
187
218
|
check: (s) => {
|
|
188
|
-
|
|
219
|
+
// A broken settings.local.json hides its registrations; judging off
|
|
220
|
+
// settings.json alone would misreport them.
|
|
221
|
+
if (s.settings.error !== undefined || s.settingsLocalError !== undefined)
|
|
189
222
|
return { checked: 0, failures: [] };
|
|
190
223
|
const referenced = registeredHookFiles(s);
|
|
191
224
|
return {
|
|
@@ -199,12 +232,15 @@ const hookDangling = {
|
|
|
199
232
|
};
|
|
200
233
|
},
|
|
201
234
|
};
|
|
235
|
+
/** A hook file that nothing registers and no reachable hook imports is dead code that silently never runs -- easy to leave behind after refactoring settings.json. */
|
|
202
236
|
const hookOrphan = {
|
|
203
237
|
id: "hook-orphan",
|
|
204
238
|
level: "structural",
|
|
205
239
|
category: "hooks",
|
|
206
240
|
check: (s) => {
|
|
207
|
-
|
|
241
|
+
// A broken settings.local.json hides its registrations; judging off
|
|
242
|
+
// settings.json alone would misreport them.
|
|
243
|
+
if (s.settings.error !== undefined || s.settingsLocalError !== undefined)
|
|
208
244
|
return { checked: 0, failures: [] };
|
|
209
245
|
const referenced = reachableHookFiles(s);
|
|
210
246
|
const hookFiles = [...s.hooks.keys()].filter((name) => name.endsWith(".mjs") || name.endsWith(".js"));
|
|
@@ -219,6 +255,7 @@ const hookOrphan = {
|
|
|
219
255
|
};
|
|
220
256
|
},
|
|
221
257
|
};
|
|
258
|
+
/** The two weaker entry-point comparisons this rule flags both fail open under a symlinked or URL-encoded path -- the hook's own guard against running twice silently stops working exactly when it matters. */
|
|
222
259
|
const hookEntrypoint = {
|
|
223
260
|
id: "hook-entrypoint",
|
|
224
261
|
level: "structural",
|
|
@@ -229,14 +266,16 @@ const hookEntrypoint = {
|
|
|
229
266
|
checked: sources.length,
|
|
230
267
|
failures: sources
|
|
231
268
|
.filter(([, source]) => BARE_ENTRY_POINT.test(source) ||
|
|
269
|
+
URL_PATHNAME_ENTRY_POINT.test(source) ||
|
|
232
270
|
!source.includes("realpathSync(process.argv[1])"))
|
|
233
271
|
.map(([name]) => ({
|
|
234
272
|
subject: `.claude/hooks/${name}`,
|
|
235
|
-
message: "
|
|
273
|
+
message: "does not compare realpathSync(process.argv[1]) to fileURLToPath(import.meta.url) -- false under a symlinked or URL-encoded path, so the hook fails open",
|
|
236
274
|
})),
|
|
237
275
|
};
|
|
238
276
|
},
|
|
239
277
|
};
|
|
278
|
+
/** A skill with no SKILL.md, malformed frontmatter, or a `name` that doesn't match its directory won't load the way Claude Code expects -- these are wiring defects, not style choices. */
|
|
240
279
|
const skillShape = {
|
|
241
280
|
id: "skill-shape",
|
|
242
281
|
level: "structural",
|
|
@@ -270,6 +309,7 @@ const skillShape = {
|
|
|
270
309
|
return { checked: s.skills.length, failures };
|
|
271
310
|
},
|
|
272
311
|
};
|
|
312
|
+
/** An agent file needs valid frontmatter with a `name` matching its filename and a `description`, or Claude Code either can't dispatch to it or dispatches under the wrong identity. */
|
|
273
313
|
const agentShape = {
|
|
274
314
|
id: "agent-shape",
|
|
275
315
|
level: "structural",
|
|
@@ -305,6 +345,7 @@ const agentShape = {
|
|
|
305
345
|
return { checked: s.agents.size, failures };
|
|
306
346
|
},
|
|
307
347
|
};
|
|
348
|
+
/** A rule file whose frontmatter fails to parse, or whose `paths` list is empty, silently loads never or loads unconditionally when it was meant to be scoped to specific files. */
|
|
308
349
|
const ruleShape = {
|
|
309
350
|
id: "rule-shape",
|
|
310
351
|
level: "structural",
|
|
@@ -332,6 +373,7 @@ const ruleShape = {
|
|
|
332
373
|
return { checked: s.rules.size, failures };
|
|
333
374
|
},
|
|
334
375
|
};
|
|
376
|
+
/** CLAUDE.md naming a `.claude/` path that doesn't exist misleads whoever reads it next; a rule file CLAUDE.md never mentions is just as easy to forget was ever wired in. */
|
|
335
377
|
const claudeMdRefs = {
|
|
336
378
|
id: "claudemd-refs",
|
|
337
379
|
level: "structural",
|
|
@@ -368,6 +410,7 @@ const claudeMdRefs = {
|
|
|
368
410
|
return { checked: refs.size + s.rules.size, failures };
|
|
369
411
|
},
|
|
370
412
|
};
|
|
413
|
+
/** Anthropic's guidance caps a skill body so loading SKILL.md into context stays cheap -- detail past the limit belongs in references/, not inline. */
|
|
371
414
|
const skillBodySize = {
|
|
372
415
|
id: "skill-body-size",
|
|
373
416
|
level: "rubric",
|
|
@@ -393,6 +436,7 @@ const skillBodySize = {
|
|
|
393
436
|
return { checked, failures };
|
|
394
437
|
},
|
|
395
438
|
};
|
|
439
|
+
/** A thin or missing description gives Claude nothing reliable to match the skill or agent against -- it either never triggers, or triggers on the wrong request. */
|
|
396
440
|
const descriptionSubstance = {
|
|
397
441
|
id: "description-substance",
|
|
398
442
|
level: "rubric",
|
|
@@ -442,6 +486,7 @@ const descriptionSubstance = {
|
|
|
442
486
|
return { checked, failures };
|
|
443
487
|
},
|
|
444
488
|
};
|
|
489
|
+
/** An agent that pins no model inherits whatever the calling session happens to run, and a stale model id may reference an alias that's since been retired. */
|
|
445
490
|
const modelPinCurrency = {
|
|
446
491
|
id: "model-pin-currency",
|
|
447
492
|
level: "rubric",
|
|
@@ -472,6 +517,7 @@ const modelPinCurrency = {
|
|
|
472
517
|
return { checked, failures };
|
|
473
518
|
},
|
|
474
519
|
};
|
|
520
|
+
/** An agent that declares no `tools` inherits every tool available, wider access than the agent's actual job usually needs. */
|
|
475
521
|
const agentToolScope = {
|
|
476
522
|
id: "agent-tool-scope",
|
|
477
523
|
level: "rubric",
|
|
@@ -494,6 +540,58 @@ const agentToolScope = {
|
|
|
494
540
|
return { checked, failures };
|
|
495
541
|
},
|
|
496
542
|
};
|
|
543
|
+
/** Assumes a plugin id's name segment (`context7` in `context7@claude-plugins-official`) is the MCP server name it supplies -- true for context7, not guaranteed in general. Reads only `.claude/settings.json`'s `enabledPlugins`, never user-scope settings or `.claude/settings.local.json`, so a plugin enabled only there yields a false positive. */
|
|
544
|
+
function enabledPluginNames(settings) {
|
|
545
|
+
const names = new Set();
|
|
546
|
+
if (!isRecord(settings) || !isRecord(settings["enabledPlugins"])) {
|
|
547
|
+
return names;
|
|
548
|
+
}
|
|
549
|
+
for (const [key, value] of Object.entries(settings["enabledPlugins"])) {
|
|
550
|
+
if (value !== true)
|
|
551
|
+
continue;
|
|
552
|
+
const name = key.split("@")[0];
|
|
553
|
+
if (name !== undefined && name !== "")
|
|
554
|
+
names.add(name);
|
|
555
|
+
}
|
|
556
|
+
return names;
|
|
557
|
+
}
|
|
558
|
+
/** Reads only a root `.mcp.json`, never user-scope or `.claude/settings.local.json` MCP config, so a server supplied only there yields a false positive. */
|
|
559
|
+
function mcpJsonServerNames(mcpJson) {
|
|
560
|
+
if (!isRecord(mcpJson) || !isRecord(mcpJson["mcpServers"])) {
|
|
561
|
+
return new Set();
|
|
562
|
+
}
|
|
563
|
+
return new Set(Object.keys(mcpJson["mcpServers"]));
|
|
564
|
+
}
|
|
565
|
+
/** An agent whose `mcpServers` names a server no `enabledPlugins` entry or `.mcp.json` actually supplies is a grant that silently does nothing -- exactly the gap the baseline's own code-implementer.md has (`mcpServers: [context7]`) until a project enables the context7 plugin. Rubric, not structural: this is expected mid-customize, only a nudge to finish wiring it. */
|
|
566
|
+
const agentMcpSource = {
|
|
567
|
+
id: "agent-mcp-source",
|
|
568
|
+
level: "rubric",
|
|
569
|
+
category: "agents",
|
|
570
|
+
check: (s) => {
|
|
571
|
+
const failures = [];
|
|
572
|
+
let checked = 0;
|
|
573
|
+
const supplied = new Set([
|
|
574
|
+
...enabledPluginNames(s.settings.parsed),
|
|
575
|
+
...mcpJsonServerNames(s.mcpJson),
|
|
576
|
+
]);
|
|
577
|
+
for (const [file, text] of s.agents) {
|
|
578
|
+
const parsed = parseFrontmatter(text);
|
|
579
|
+
if (!parsed.ok)
|
|
580
|
+
continue;
|
|
581
|
+
for (const server of fieldList(parsed.fields, "mcpServers") ?? []) {
|
|
582
|
+
checked++;
|
|
583
|
+
if (!supplied.has(server)) {
|
|
584
|
+
failures.push({
|
|
585
|
+
subject: `.claude/agents/${file}`,
|
|
586
|
+
message: `mcpServers names "${server}", which is not supplied by any enabledPlugins entry or .mcp.json`,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return { checked, failures };
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
/** A hook registration with no `timeout` can hang the whole session indefinitely if the hook itself ever gets stuck. */
|
|
497
595
|
const hookTimeout = {
|
|
498
596
|
id: "hook-timeout",
|
|
499
597
|
level: "rubric",
|
|
@@ -511,6 +609,7 @@ const hookTimeout = {
|
|
|
511
609
|
};
|
|
512
610
|
},
|
|
513
611
|
};
|
|
612
|
+
/** A rule scoped to a `paths` glob that matches no file in the project silently never loads -- its checklist becomes advice nobody ever sees. */
|
|
514
613
|
const ruleGlobsLive = {
|
|
515
614
|
id: "rule-globs-live",
|
|
516
615
|
level: "rubric",
|
|
@@ -536,6 +635,7 @@ const ruleGlobsLive = {
|
|
|
536
635
|
return { checked, failures };
|
|
537
636
|
},
|
|
538
637
|
};
|
|
638
|
+
/** SKILL.md pointing at a references/ file that doesn't exist promises detail that simply isn't there when someone follows the link. */
|
|
539
639
|
const skillReferencesResolve = {
|
|
540
640
|
id: "skill-references-resolve",
|
|
541
641
|
level: "rubric",
|
|
@@ -563,6 +663,7 @@ const skillReferencesResolve = {
|
|
|
563
663
|
/** Every rule, structural first. Order is the order findings are reported in. */
|
|
564
664
|
export const RULES = [
|
|
565
665
|
settingsParses,
|
|
666
|
+
settingsLocalParses,
|
|
566
667
|
hookDangling,
|
|
567
668
|
hookOrphan,
|
|
568
669
|
hookEntrypoint,
|
|
@@ -574,6 +675,7 @@ export const RULES = [
|
|
|
574
675
|
descriptionSubstance,
|
|
575
676
|
modelPinCurrency,
|
|
576
677
|
agentToolScope,
|
|
678
|
+
agentMcpSource,
|
|
577
679
|
hookTimeout,
|
|
578
680
|
ruleGlobsLive,
|
|
579
681
|
skillReferencesResolve,
|