@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +13 -5
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -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
- * This file is the emitted twin of m3l-groundwork's own
9
- * `packages/cli/src/harness/{rules,grade}.ts`; a parity test runs both over
10
- * the real baseline and asserts identical findings.
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 considered current. Bump alongside a harness-guidance
19
- * refresh sweep.
20
- * @public Exported for the parity test that compares this file with its TypeScript twin in
21
- * m3l-groundwork; nothing else in a bootstrapped project imports it.
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: relative(root, path).split("\\").join("/"),
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 settingsLocalResult = readJsonc(
207
- join(root, ".claude", "settings.local.json"),
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
- if (s.settings.error !== undefined) return { checked: 0, failures: [] };
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
- if (s.settings.error !== undefined) return { checked: 0, failures: [] };
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
- "compares process.argv[1] to import.meta.url without realpathSync -- false under any symlinked path, so the hook fails open",
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 Exported for the parity test that compares this file with its TypeScript twin in
766
- * m3l-groundwork; nothing else in a bootstrapped project imports it.
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
- for (const entry of report.contents ?? []) {
861
- for (const item of [...(entry.errors ?? []), ...(entry.warnings ?? [])]) {
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] ${relative(rootDir, entry.file)} -- ${item.path}: ${item.message}`,
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 any `src/` or `tests/` path segment --
12
- * this single check covers a flat `src/`/`tests/` layout AND a nested one
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
- * Matches both relative and absolute paths (the `(^|\/)` anchor).
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
- return /(^|\/)src\//.test(filePath) || /(^|\/)tests\//.test(filePath);
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
  }