@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +574 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +21 -13
- package/dist/packs.js +241 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +44 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +295 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +41 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* points at nothing, a skill with no frontmatter -- and fail the gate.
|
|
6
6
|
* Rubric rules encode Anthropic's published guidance and only ever warn.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Also used by m3l-groundwork's own adopt mode (the tool that generated this
|
|
9
|
+
* project's harness); if you're contributing a change back upstream, keep
|
|
10
|
+
* this file's behavior in sync with its source at
|
|
11
|
+
* `packages/cli/src/harness/{rules,grade}.ts` there.
|
|
11
12
|
*/
|
|
12
13
|
import { spawnSync } from "node:child_process";
|
|
13
14
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
@@ -15,10 +16,14 @@ import { join, relative } from "node:path";
|
|
|
15
16
|
import { fieldList, fieldText, parseFrontmatter } from "./frontmatter.mjs";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* Model ids and aliases
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Model ids and aliases the rubric accepts: the current ids and aliases, plus
|
|
20
|
+
* ids that were once listed here, kept until Anthropic deprecates them. The
|
|
21
|
+
* ids follow Anthropic's models overview and model-deprecations pages
|
|
22
|
+
* (retrieved 2026-10-01). A legacy id that was never listed here is
|
|
23
|
+
* deliberately not added, so the rule keeps nudging pins toward current
|
|
24
|
+
* models. Bump alongside a harness-guidance refresh sweep.
|
|
25
|
+
* @public Not imported anywhere else in this project -- exported only for
|
|
26
|
+
* m3l-groundwork's own upstream parity check (see the file header above).
|
|
22
27
|
*/
|
|
23
28
|
export const CURRENT_MODELS = [
|
|
24
29
|
"inherit",
|
|
@@ -29,6 +34,7 @@ export const CURRENT_MODELS = [
|
|
|
29
34
|
"claude-opus-5",
|
|
30
35
|
"claude-opus-5-5",
|
|
31
36
|
"claude-sonnet-5",
|
|
37
|
+
"claude-sonnet-5-5",
|
|
32
38
|
"claude-fable-5-1",
|
|
33
39
|
"claude-haiku-4-5",
|
|
34
40
|
"claude-haiku-4-5-20251001",
|
|
@@ -40,6 +46,11 @@ const DESCRIPTION_MAX = 1024;
|
|
|
40
46
|
const PROJECT_WALK_DEPTH = 8;
|
|
41
47
|
const BARE_ENTRY_POINT =
|
|
42
48
|
/process\.argv\[1\]\s*===\s*fileURLToPath\(import\.meta\.url\)/;
|
|
49
|
+
// Contains `realpathSync(process.argv[1])`, but compares it to a URL-encoded
|
|
50
|
+
// pathname rather than an OS path -- the two never agree under a symlinked
|
|
51
|
+
// or percent-encoded path, so this form fails open too.
|
|
52
|
+
const URL_PATHNAME_ENTRY_POINT =
|
|
53
|
+
/realpathSync\(process\.argv\[1\]\)\s*===\s*new URL\(import\.meta\.url\)\.pathname/;
|
|
43
54
|
const HOOK_PATH = /\.claude\/hooks\/([A-Za-z0-9_.-]+)/g;
|
|
44
55
|
const CLAUDE_PATH = /\.claude\/[A-Za-z0-9_.*/-]+/g;
|
|
45
56
|
const REFERENCE_PATH = /\breferences\/[A-Za-z0-9_./-]+\.md/g;
|
|
@@ -57,6 +68,13 @@ const SKIP_DIR_NAMES = new Set([
|
|
|
57
68
|
".nx",
|
|
58
69
|
]);
|
|
59
70
|
|
|
71
|
+
// Claude Code creates git worktrees at `.claude/worktrees/<name>/` -- each a
|
|
72
|
+
// full second checkout that must not be walked twice. Matched as an exact
|
|
73
|
+
// path relative to the walk root, never by bare name: a directory literally
|
|
74
|
+
// named `worktrees` elsewhere (`src/worktrees/`, `.claude/skills/worktrees/`)
|
|
75
|
+
// is real project content and must stay visible to every grade.
|
|
76
|
+
const SKIP_REL_DIR_PATHS = new Set([".claude/worktrees"]);
|
|
77
|
+
|
|
60
78
|
const isRecord = (value) =>
|
|
61
79
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
80
|
|
|
@@ -76,9 +94,11 @@ function walkBounded(root, maxDepth) {
|
|
|
76
94
|
for (const entry of entries) {
|
|
77
95
|
if (entry.isDirectory() && SKIP_DIR_NAMES.has(entry.name)) continue;
|
|
78
96
|
const path = join(dir, entry.name);
|
|
97
|
+
const relPath = relative(root, path).split("\\").join("/");
|
|
98
|
+
if (entry.isDirectory() && SKIP_REL_DIR_PATHS.has(relPath)) continue;
|
|
79
99
|
results.push({
|
|
80
100
|
path,
|
|
81
|
-
relPath
|
|
101
|
+
relPath,
|
|
82
102
|
isDirectory: entry.isDirectory(),
|
|
83
103
|
});
|
|
84
104
|
if (entry.isDirectory()) visit(path, depth + 1);
|
|
@@ -203,9 +223,9 @@ function loadSnapshot(root) {
|
|
|
203
223
|
|
|
204
224
|
const settingsPath = join(root, ".claude", "settings.json");
|
|
205
225
|
const settingsResult = readJsonc(settingsPath);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
);
|
|
226
|
+
const settingsLocalPath = join(root, ".claude", "settings.local.json");
|
|
227
|
+
const settingsLocalResult = readJsonc(settingsLocalPath);
|
|
228
|
+
const mcpJsonResult = readJsonc(join(root, ".mcp.json"));
|
|
209
229
|
|
|
210
230
|
return {
|
|
211
231
|
settings: !existsSync(settingsPath)
|
|
@@ -216,6 +236,15 @@ function loadSnapshot(root) {
|
|
|
216
236
|
settingsLocal: settingsLocalResult.ok
|
|
217
237
|
? settingsLocalResult.value
|
|
218
238
|
: undefined,
|
|
239
|
+
// Only a file that exists can fail to parse -- an absent one is not an error.
|
|
240
|
+
settingsLocalError:
|
|
241
|
+
!settingsLocalResult.ok && existsSync(settingsLocalPath)
|
|
242
|
+
? settingsLocalResult.error
|
|
243
|
+
: undefined,
|
|
244
|
+
// A malformed or absent .mcp.json is never a structural failure -- it
|
|
245
|
+
// just means agent-mcp-source (a rubric-only rule) can't see anything
|
|
246
|
+
// it supplies.
|
|
247
|
+
mcpJson: mcpJsonResult.ok ? mcpJsonResult.value : undefined,
|
|
219
248
|
hooks: readEach(/^\.claude\/hooks\/[^/]+$/, ".claude/hooks/"),
|
|
220
249
|
agents: readEach(/^\.claude\/agents\/[^/]+\.md$/, ".claude/agents/"),
|
|
221
250
|
skills,
|
|
@@ -351,6 +380,7 @@ function bodyLineCount(body) {
|
|
|
351
380
|
|
|
352
381
|
// --- rules -----------------------------------------------------------------
|
|
353
382
|
|
|
383
|
+
/** 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. */
|
|
354
384
|
const settingsParses = {
|
|
355
385
|
id: "settings-parses",
|
|
356
386
|
level: "structural",
|
|
@@ -369,12 +399,35 @@ const settingsParses = {
|
|
|
369
399
|
}),
|
|
370
400
|
};
|
|
371
401
|
|
|
402
|
+
/** 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. */
|
|
403
|
+
const settingsLocalParses = {
|
|
404
|
+
id: "settings-local-parses",
|
|
405
|
+
level: "structural",
|
|
406
|
+
category: "settings",
|
|
407
|
+
check: (s) => ({
|
|
408
|
+
checked: s.settingsLocalError === undefined ? 0 : 1,
|
|
409
|
+
failures:
|
|
410
|
+
s.settingsLocalError === undefined
|
|
411
|
+
? []
|
|
412
|
+
: [
|
|
413
|
+
{
|
|
414
|
+
subject: ".claude/settings.local.json",
|
|
415
|
+
message: `does not parse: ${s.settingsLocalError}`,
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
}),
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/** 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. */
|
|
372
422
|
const hookDangling = {
|
|
373
423
|
id: "hook-dangling",
|
|
374
424
|
level: "structural",
|
|
375
425
|
category: "hooks",
|
|
376
426
|
check: (s) => {
|
|
377
|
-
|
|
427
|
+
// A broken settings.local.json hides its registrations; judging off
|
|
428
|
+
// settings.json alone would misreport them.
|
|
429
|
+
if (s.settings.error !== undefined || s.settingsLocalError !== undefined)
|
|
430
|
+
return { checked: 0, failures: [] };
|
|
378
431
|
const referenced = registeredHookFiles(s);
|
|
379
432
|
return {
|
|
380
433
|
checked: referenced.size,
|
|
@@ -388,12 +441,16 @@ const hookDangling = {
|
|
|
388
441
|
},
|
|
389
442
|
};
|
|
390
443
|
|
|
444
|
+
/** 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. */
|
|
391
445
|
const hookOrphan = {
|
|
392
446
|
id: "hook-orphan",
|
|
393
447
|
level: "structural",
|
|
394
448
|
category: "hooks",
|
|
395
449
|
check: (s) => {
|
|
396
|
-
|
|
450
|
+
// A broken settings.local.json hides its registrations; judging off
|
|
451
|
+
// settings.json alone would misreport them.
|
|
452
|
+
if (s.settings.error !== undefined || s.settingsLocalError !== undefined)
|
|
453
|
+
return { checked: 0, failures: [] };
|
|
397
454
|
const referenced = reachableHookFiles(s);
|
|
398
455
|
const hookFiles = [...s.hooks.keys()].filter(
|
|
399
456
|
(name) => name.endsWith(".mjs") || name.endsWith(".js"),
|
|
@@ -411,6 +468,7 @@ const hookOrphan = {
|
|
|
411
468
|
},
|
|
412
469
|
};
|
|
413
470
|
|
|
471
|
+
/** 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. */
|
|
414
472
|
const hookEntrypoint = {
|
|
415
473
|
id: "hook-entrypoint",
|
|
416
474
|
level: "structural",
|
|
@@ -425,17 +483,19 @@ const hookEntrypoint = {
|
|
|
425
483
|
.filter(
|
|
426
484
|
([, source]) =>
|
|
427
485
|
BARE_ENTRY_POINT.test(source) ||
|
|
486
|
+
URL_PATHNAME_ENTRY_POINT.test(source) ||
|
|
428
487
|
!source.includes("realpathSync(process.argv[1])"),
|
|
429
488
|
)
|
|
430
489
|
.map(([name]) => ({
|
|
431
490
|
subject: `.claude/hooks/${name}`,
|
|
432
491
|
message:
|
|
433
|
-
"
|
|
492
|
+
"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",
|
|
434
493
|
})),
|
|
435
494
|
};
|
|
436
495
|
},
|
|
437
496
|
};
|
|
438
497
|
|
|
498
|
+
/** 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. */
|
|
439
499
|
const skillShape = {
|
|
440
500
|
id: "skill-shape",
|
|
441
501
|
level: "structural",
|
|
@@ -470,6 +530,7 @@ const skillShape = {
|
|
|
470
530
|
},
|
|
471
531
|
};
|
|
472
532
|
|
|
533
|
+
/** 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. */
|
|
473
534
|
const agentShape = {
|
|
474
535
|
id: "agent-shape",
|
|
475
536
|
level: "structural",
|
|
@@ -505,6 +566,7 @@ const agentShape = {
|
|
|
505
566
|
},
|
|
506
567
|
};
|
|
507
568
|
|
|
569
|
+
/** 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. */
|
|
508
570
|
const ruleShape = {
|
|
509
571
|
id: "rule-shape",
|
|
510
572
|
level: "structural",
|
|
@@ -532,6 +594,7 @@ const ruleShape = {
|
|
|
532
594
|
},
|
|
533
595
|
};
|
|
534
596
|
|
|
597
|
+
/** 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. */
|
|
535
598
|
const claudeMdRefs = {
|
|
536
599
|
id: "claudemd-refs",
|
|
537
600
|
level: "structural",
|
|
@@ -568,6 +631,7 @@ const claudeMdRefs = {
|
|
|
568
631
|
},
|
|
569
632
|
};
|
|
570
633
|
|
|
634
|
+
/** Anthropic's guidance caps a skill body so loading SKILL.md into context stays cheap -- detail past the limit belongs in references/, not inline. */
|
|
571
635
|
const skillBodySize = {
|
|
572
636
|
id: "skill-body-size",
|
|
573
637
|
level: "rubric",
|
|
@@ -592,6 +656,7 @@ const skillBodySize = {
|
|
|
592
656
|
},
|
|
593
657
|
};
|
|
594
658
|
|
|
659
|
+
/** 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. */
|
|
595
660
|
const descriptionSubstance = {
|
|
596
661
|
id: "description-substance",
|
|
597
662
|
level: "rubric",
|
|
@@ -640,6 +705,7 @@ const descriptionSubstance = {
|
|
|
640
705
|
},
|
|
641
706
|
};
|
|
642
707
|
|
|
708
|
+
/** 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. */
|
|
643
709
|
const modelPinCurrency = {
|
|
644
710
|
id: "model-pin-currency",
|
|
645
711
|
level: "rubric",
|
|
@@ -669,6 +735,7 @@ const modelPinCurrency = {
|
|
|
669
735
|
},
|
|
670
736
|
};
|
|
671
737
|
|
|
738
|
+
/** An agent that declares no `tools` inherits every tool available, wider access than the agent's actual job usually needs. */
|
|
672
739
|
const agentToolScope = {
|
|
673
740
|
id: "agent-tool-scope",
|
|
674
741
|
level: "rubric",
|
|
@@ -691,6 +758,56 @@ const agentToolScope = {
|
|
|
691
758
|
},
|
|
692
759
|
};
|
|
693
760
|
|
|
761
|
+
/** 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. */
|
|
762
|
+
function enabledPluginNames(settings) {
|
|
763
|
+
const names = new Set();
|
|
764
|
+
if (!isRecord(settings) || !isRecord(settings["enabledPlugins"])) {
|
|
765
|
+
return names;
|
|
766
|
+
}
|
|
767
|
+
for (const [key, value] of Object.entries(settings["enabledPlugins"])) {
|
|
768
|
+
if (value !== true) continue;
|
|
769
|
+
const name = key.split("@")[0];
|
|
770
|
+
if (name !== undefined && name !== "") names.add(name);
|
|
771
|
+
}
|
|
772
|
+
return names;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** 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. */
|
|
776
|
+
function mcpJsonServerNames(mcpJson) {
|
|
777
|
+
if (!isRecord(mcpJson) || !isRecord(mcpJson["mcpServers"])) return new Set();
|
|
778
|
+
return new Set(Object.keys(mcpJson["mcpServers"]));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** 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. */
|
|
782
|
+
const agentMcpSource = {
|
|
783
|
+
id: "agent-mcp-source",
|
|
784
|
+
level: "rubric",
|
|
785
|
+
category: "agents",
|
|
786
|
+
check: (s) => {
|
|
787
|
+
const failures = [];
|
|
788
|
+
let checked = 0;
|
|
789
|
+
const supplied = new Set([
|
|
790
|
+
...enabledPluginNames(s.settings.parsed),
|
|
791
|
+
...mcpJsonServerNames(s.mcpJson),
|
|
792
|
+
]);
|
|
793
|
+
for (const [file, text] of s.agents) {
|
|
794
|
+
const parsed = parseFrontmatter(text);
|
|
795
|
+
if (!parsed.ok) continue;
|
|
796
|
+
for (const server of fieldList(parsed.fields, "mcpServers") ?? []) {
|
|
797
|
+
checked++;
|
|
798
|
+
if (!supplied.has(server)) {
|
|
799
|
+
failures.push({
|
|
800
|
+
subject: `.claude/agents/${file}`,
|
|
801
|
+
message: `mcpServers names "${server}", which is not supplied by any enabledPlugins entry or .mcp.json`,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { checked, failures };
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
/** A hook registration with no `timeout` can hang the whole session indefinitely if the hook itself ever gets stuck. */
|
|
694
811
|
const hookTimeout = {
|
|
695
812
|
id: "hook-timeout",
|
|
696
813
|
level: "rubric",
|
|
@@ -709,6 +826,7 @@ const hookTimeout = {
|
|
|
709
826
|
},
|
|
710
827
|
};
|
|
711
828
|
|
|
829
|
+
/** A rule scoped to a `paths` glob that matches no file in the project silently never loads -- its checklist becomes advice nobody ever sees. */
|
|
712
830
|
const ruleGlobsLive = {
|
|
713
831
|
id: "rule-globs-live",
|
|
714
832
|
level: "rubric",
|
|
@@ -734,6 +852,7 @@ const ruleGlobsLive = {
|
|
|
734
852
|
},
|
|
735
853
|
};
|
|
736
854
|
|
|
855
|
+
/** SKILL.md pointing at a references/ file that doesn't exist promises detail that simply isn't there when someone follows the link. */
|
|
737
856
|
const skillReferencesResolve = {
|
|
738
857
|
id: "skill-references-resolve",
|
|
739
858
|
level: "rubric",
|
|
@@ -762,11 +881,12 @@ const skillReferencesResolve = {
|
|
|
762
881
|
|
|
763
882
|
/**
|
|
764
883
|
* Every rule, structural first. Order is the order findings are reported in.
|
|
765
|
-
* @public
|
|
766
|
-
* m3l-groundwork
|
|
884
|
+
* @public Not imported anywhere else in this project -- exported only for
|
|
885
|
+
* m3l-groundwork's own upstream parity check (see the file header above).
|
|
767
886
|
*/
|
|
768
887
|
export const RULES = [
|
|
769
888
|
settingsParses,
|
|
889
|
+
settingsLocalParses,
|
|
770
890
|
hookDangling,
|
|
771
891
|
hookOrphan,
|
|
772
892
|
hookEntrypoint,
|
|
@@ -778,6 +898,7 @@ export const RULES = [
|
|
|
778
898
|
descriptionSubstance,
|
|
779
899
|
modelPinCurrency,
|
|
780
900
|
agentToolScope,
|
|
901
|
+
agentMcpSource,
|
|
781
902
|
hookTimeout,
|
|
782
903
|
ruleGlobsLive,
|
|
783
904
|
skillReferencesResolve,
|
|
@@ -835,6 +956,7 @@ export function reportGrade(grade, reporter) {
|
|
|
835
956
|
export function reportOfficialValidation(rootDir, reporter) {
|
|
836
957
|
let ran = false;
|
|
837
958
|
let findings = 0;
|
|
959
|
+
let unreadable = 0;
|
|
838
960
|
for (const dir of [".claude/skills", ".claude/agents"]) {
|
|
839
961
|
const target = join(rootDir, dir);
|
|
840
962
|
if (!existsSync(target)) continue;
|
|
@@ -851,22 +973,49 @@ export function reportOfficialValidation(rootDir, reporter) {
|
|
|
851
973
|
try {
|
|
852
974
|
report = JSON.parse(result.stdout);
|
|
853
975
|
} catch {
|
|
976
|
+
report = undefined;
|
|
977
|
+
}
|
|
978
|
+
// Anything other than a real report -- a spawn failure other than
|
|
979
|
+
// ENOENT (result.stdout is then null, and JSON.parse(null) parses as
|
|
980
|
+
// the value `null` rather than throwing), or a valid-JSON error payload
|
|
981
|
+
// with no `contents` array -- is treated the same as unreadable, never
|
|
982
|
+
// silently read as "zero findings" or allowed to crash on `.contents`.
|
|
983
|
+
if (!Array.isArray(report?.contents)) {
|
|
854
984
|
reporter.warn(
|
|
855
985
|
`claude plugin validate gave no readable report for ${dir}`,
|
|
856
986
|
);
|
|
987
|
+
unreadable++;
|
|
857
988
|
continue;
|
|
858
989
|
}
|
|
859
990
|
ran = true;
|
|
860
|
-
|
|
861
|
-
|
|
991
|
+
// Each entry and finding is external data too: a malformed one is
|
|
992
|
+
// reported and skipped, never allowed to crash the gate or vanish.
|
|
993
|
+
for (const entry of report.contents) {
|
|
994
|
+
if (!isRecord(entry) || typeof entry.file !== "string") {
|
|
995
|
+
reporter.warn(
|
|
996
|
+
`claude plugin validate reported a malformed entry for ${dir} -- skipped`,
|
|
997
|
+
);
|
|
998
|
+
unreadable++;
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const errors = Array.isArray(entry.errors) ? entry.errors : [];
|
|
1002
|
+
const warnings = Array.isArray(entry.warnings) ? entry.warnings : [];
|
|
1003
|
+
const file = relative(rootDir, entry.file);
|
|
1004
|
+
for (const item of [...errors, ...warnings]) {
|
|
862
1005
|
findings++;
|
|
1006
|
+
if (!isRecord(item)) {
|
|
1007
|
+
reporter.warn(
|
|
1008
|
+
`[claude-validate] ${file} -- malformed finding (not an object)`,
|
|
1009
|
+
);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
863
1012
|
reporter.warn(
|
|
864
|
-
`[claude-validate] ${
|
|
1013
|
+
`[claude-validate] ${file} -- ${String(item.path)}: ${String(item.message)}`,
|
|
865
1014
|
);
|
|
866
1015
|
}
|
|
867
1016
|
}
|
|
868
1017
|
}
|
|
869
|
-
if (ran && findings === 0) {
|
|
1018
|
+
if (ran && findings === 0 && unreadable === 0) {
|
|
870
1019
|
reporter.ok("claude plugin validate: no findings");
|
|
871
1020
|
}
|
|
872
1021
|
}
|
|
@@ -3,21 +3,109 @@
|
|
|
3
3
|
// source and test trees. Shared by:
|
|
4
4
|
// - .claude/hooks/guard-branch-isolation.mjs (blocks writes while HEAD is main)
|
|
5
5
|
// - .claude/hooks/guard-hub-src-writes.mjs (blocks hub writes on any branch)
|
|
6
|
+
// - .claude/hooks/post-edit-verify.mjs (decides whether to run the gate)
|
|
6
7
|
//
|
|
7
8
|
// Keeping the regex in one place means neither guard can silently diverge
|
|
8
9
|
// from the other when the protected glob set evolves.
|
|
9
10
|
|
|
11
|
+
import { realpathSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:/;
|
|
15
|
+
|
|
16
|
+
/** `\` and `/` both normalized to `/`, so a Windows-style path is matched the same as a POSIX one. */
|
|
17
|
+
function normalizeSlashes(path) {
|
|
18
|
+
return path.replace(/\\/g, "/");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Exported so a caller can decide whether canonicalizing a path even makes sense (a relative path has no filesystem anchor of its own to resolve against). */
|
|
22
|
+
export function isAbsoluteLike(path) {
|
|
23
|
+
return path.startsWith("/") || WINDOWS_DRIVE.test(path);
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
|
-
* Returns true if `filePath` has
|
|
12
|
-
*
|
|
27
|
+
* Returns true if `filePath` has a `src/` or `tests/` path segment -- this
|
|
28
|
+
* single check covers a flat `src/`/`tests/` layout AND a nested one
|
|
13
29
|
* (`packages/<pkg>/src/`), since both contain the literal substring
|
|
14
30
|
* `/src/` preceded by a path boundary.
|
|
15
31
|
*
|
|
16
|
-
*
|
|
32
|
+
* `projectDir`, when given, scopes an ABSOLUTE `filePath` to the project
|
|
33
|
+
* before applying that check: the path is made relative to `projectDir` by
|
|
34
|
+
* literal string prefix, not `node:path` (whose `relative`/`isAbsolute` are
|
|
35
|
+
* host-OS-dependent), so the same logic works identically on POSIX and
|
|
36
|
+
* Windows-style paths regardless of which OS is actually running the hook.
|
|
37
|
+
* An absolute path that does not start with `projectDir` is outside the
|
|
38
|
+
* project entirely and is never protected -- this is what stops a checkout
|
|
39
|
+
* living under a path that happens to contain the literal substring `/src/`
|
|
40
|
+
* (e.g. `~/src/other-project`) from being treated as protected merely
|
|
41
|
+
* because that substring appears somewhere above the real project root.
|
|
42
|
+
*
|
|
43
|
+
* A relative `filePath` (or a call with no `projectDir`) is matched as-is,
|
|
44
|
+
* after slash normalization -- the same behavior this function has always had.
|
|
17
45
|
*
|
|
18
46
|
* @param {string} filePath
|
|
47
|
+
* @param {string} [projectDir]
|
|
19
48
|
* @returns {boolean}
|
|
20
49
|
*/
|
|
21
|
-
export function isProtectedPath(filePath) {
|
|
22
|
-
|
|
50
|
+
export function isProtectedPath(filePath, projectDir) {
|
|
51
|
+
const path = normalizeSlashes(filePath);
|
|
52
|
+
let candidate = path;
|
|
53
|
+
|
|
54
|
+
if (isAbsoluteLike(path) && typeof projectDir === "string") {
|
|
55
|
+
const root = normalizeSlashes(projectDir).replace(/\/+$/, "");
|
|
56
|
+
// Compared case-INSENSITIVELY (macOS's default APFS, and Windows, are
|
|
57
|
+
// both case-insensitive-but-case-preserving -- a `filePath` spelled with
|
|
58
|
+
// different case than `projectDir` can still denote the identical real
|
|
59
|
+
// file). This holds even when the caller couldn't fully canonicalize a
|
|
60
|
+
// not-yet-existing path against the real filesystem (see
|
|
61
|
+
// `canonicalize()` below) and matters more than it costs: on a
|
|
62
|
+
// genuinely case-SENSITIVE filesystem this can only make the check
|
|
63
|
+
// MORE conservative (occasionally treating two truly-different,
|
|
64
|
+
// same-spelled-but-cased directories as the same project), never less --
|
|
65
|
+
// and erring toward "still protected" is the safe side for a guard.
|
|
66
|
+
const pathLower = path.toLowerCase();
|
|
67
|
+
const rootLower = root.toLowerCase();
|
|
68
|
+
if (pathLower === rootLower || pathLower.startsWith(`${rootLower}/`)) {
|
|
69
|
+
candidate = path.slice(root.length).replace(/^\/+/, "");
|
|
70
|
+
} else {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return /(^|\/)src\//.test(candidate) || /(^|\/)tests\//.test(candidate);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves `path` to its canonical, case-correct, symlink-resolved form,
|
|
80
|
+
* via the deepest existing ancestor -- never throws, even for a path (or a
|
|
81
|
+
* tail of one) that doesn't exist yet, e.g. a file a Write is about to
|
|
82
|
+
* create in a directory that doesn't exist yet either.
|
|
83
|
+
*
|
|
84
|
+
* Uses `realpathSync.native`, not plain `realpathSync`: on a
|
|
85
|
+
* case-insensitive-but-case-preserving filesystem (macOS's default APFS),
|
|
86
|
+
* only the native variant corrects a wrongly-cased spelling to the real
|
|
87
|
+
* on-disk case -- the same canonical case `git rev-parse --show-toplevel`
|
|
88
|
+
* already returns. Comparing an un-canonicalized `filePath` against a
|
|
89
|
+
* canonicalized `projectDir`/worktree root (or vice versa) would otherwise
|
|
90
|
+
* make two spellings of the identical file compare as different paths,
|
|
91
|
+
* which is exactly the shape of bug this function exists to close.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} path
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function canonicalize(path) {
|
|
97
|
+
const absolute = resolve(path);
|
|
98
|
+
const tail = [];
|
|
99
|
+
let candidate = absolute;
|
|
100
|
+
while (true) {
|
|
101
|
+
try {
|
|
102
|
+
const real = realpathSync.native(candidate);
|
|
103
|
+
return tail.length === 0 ? real : join(real, ...tail.reverse());
|
|
104
|
+
} catch {
|
|
105
|
+
const parent = dirname(candidate);
|
|
106
|
+
if (parent === candidate) return absolute; // filesystem root; give up
|
|
107
|
+
tail.push(basename(candidate));
|
|
108
|
+
candidate = parent;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
23
111
|
}
|