@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
@@ -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
- /** The two mechanical steps after emission: `git init`, then the first install. */
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,3 +1,5 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  function isHarnessPath(relPath) {
2
4
  return relPath.startsWith(".claude/") || relPath === "CLAUDE.md";
3
5
  }
@@ -1,16 +1,5 @@
1
- /**
2
- * A reader for the YAML subset Claude Code frontmatter actually uses --
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. */
@@ -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 { readJsoncFile } from "../jsonc.js";
10
- import { walkBounded } from "../survey/fs-walk.js";
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 = readJsoncFile(path);
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 = walkBounded(root, PROJECT_WALK_DEPTH);
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 settingsLocalResult = readJsoncFile(join(root, ".claude", "settings.local.json"));
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: settingsLocalResult.ok
59
- ? settingsLocalResult.value
60
- : undefined,
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,
@@ -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 considered current. Bump alongside the
48
- * `harness-guidance` refresh sweep; `templates/core/bin/lib/harness-rules.mjs`
49
- * carries the same list and the parity test keeps the two equal.
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. */
@@ -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 considered current. Bump alongside the
19
- * `harness-guidance` refresh sweep; `templates/core/bin/lib/harness-rules.mjs`
20
- * carries the same list and the parity test keeps the two equal.
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
- if (s.settings.error !== undefined)
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
- if (s.settings.error !== undefined)
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: "compares process.argv[1] to import.meta.url without realpathSync -- false under any symlinked path, so the hook fails open",
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,