@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
@@ -17,19 +17,20 @@
17
17
  * not a JavaScript tokenizer; it can mis-handle a regex literal or a template
18
18
  * string, which none of the graded baseline files contain.
19
19
  *
20
- * This file is the emitted twin of m3l-groundwork's own
21
- * `packages/cli/src/toolchain/{rules,grade}.ts`; a parity test runs both over
22
- * the real baseline and asserts identical findings.
20
+ * Also used by m3l-groundwork's own adopt mode (the tool that generated this
21
+ * project's toolchain); if you're contributing a change back upstream, keep
22
+ * this file's behavior in sync with its source at
23
+ * `packages/cli/src/toolchain/{rules,grade}.ts` there.
23
24
  */
24
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
25
+ import { readFileSync, readdirSync, statSync } from "node:fs";
25
26
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
26
27
 
27
28
  /**
28
29
  * Options TypeScript 6.0 deprecated. `removedIn` is the major that drops them
29
30
  * (`outFile` and `moduleResolution: classic` already went in 6.0).
30
31
  * https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html
31
- * @public Exported for the parity test that compares this file with its TypeScript twin in
32
- * m3l-groundwork; nothing else in a bootstrapped project imports it.
32
+ * @public Not imported anywhere else in this project -- exported only for
33
+ * m3l-groundwork's own upstream parity check (see the file header above).
33
34
  */
34
35
  export const LEGACY_OPTIONS = [
35
36
  {
@@ -113,8 +114,8 @@ export const LEGACY_OPTIONS = [
113
114
  * (`@typescript-eslint/no-unused-vars` covers both with an `^_` escape hatch
114
115
  * tsc's flags lack), and `forceConsistentCasingInFileNames` (defaults on).
115
116
  * `allowUnreachableCode` is graded separately -- it must be `false`.
116
- * @public Exported for the parity test that compares this file with its TypeScript twin in
117
- * m3l-groundwork; nothing else in a bootstrapped project imports it.
117
+ * @public Not imported anywhere else in this project -- exported only for
118
+ * m3l-groundwork's own upstream parity check (see the file header above).
118
119
  */
119
120
  export const STRICT_FLAGS = [
120
121
  "strict",
@@ -132,8 +133,8 @@ export const STRICT_FLAGS = [
132
133
 
133
134
  /**
134
135
  * The rubric categories, in report order.
135
- * @public Exported for the parity test that compares this file with its TypeScript twin in
136
- * m3l-groundwork; nothing else in a bootstrapped project imports it.
136
+ * @public Not imported anywhere else in this project -- exported only for
137
+ * m3l-groundwork's own upstream parity check (see the file header above).
137
138
  */
138
139
  export const CATEGORIES = [
139
140
  "tsconfig",
@@ -207,6 +208,13 @@ const SKIP_DIR_NAMES = new Set([
207
208
  ".nx",
208
209
  ]);
209
210
 
211
+ // Claude Code creates git worktrees at `.claude/worktrees/<name>/` -- each a
212
+ // full second checkout that must not be walked twice. Matched as an exact
213
+ // path relative to the walk root, never by bare name: a directory literally
214
+ // named `worktrees` elsewhere (`src/worktrees/`, `.claude/skills/worktrees/`)
215
+ // is real project content and must stay visible to every grade.
216
+ const SKIP_REL_DIR_PATHS = new Set([".claude/worktrees"]);
217
+
210
218
  const isRecord = (value) =>
211
219
  typeof value === "object" && value !== null && !Array.isArray(value);
212
220
  const lower = (value) =>
@@ -228,9 +236,11 @@ function walkBounded(root, maxDepth) {
228
236
  for (const entry of entries) {
229
237
  if (entry.isDirectory() && SKIP_DIR_NAMES.has(entry.name)) continue;
230
238
  const path = join(dir, entry.name);
239
+ const relPath = relative(root, path).split("\\").join("/");
240
+ if (entry.isDirectory() && SKIP_REL_DIR_PATHS.has(relPath)) continue;
231
241
  results.push({
232
242
  path,
233
- relPath: relative(root, path).split("\\").join("/"),
243
+ relPath,
234
244
  isDirectory: entry.isDirectory(),
235
245
  });
236
246
  if (entry.isDirectory()) visit(path, depth + 1);
@@ -293,13 +303,123 @@ function stripJsoncNoise(content) {
293
303
  return result.replace(/,(\s*[}\]])/g, "$1");
294
304
  }
295
305
 
306
+ /**
307
+ * Strips `//` and block comments from JS/TS source, leaving `'`, `"` and
308
+ * `` ` `` string literals alone. Keeps trailing commas -- valid JS, and
309
+ * removing them is a JSON-only cleanup.
310
+ *
311
+ * A character scanner, not a parser: a regex literal containing a quote
312
+ * (`/["']/`) can be misread as opening a string, and a `${...}` expression
313
+ * inside a template literal containing its own backtick or quote can close
314
+ * the outer template early. Neither shape appears in this project's actual
315
+ * `eslint.config.js`/`vitest.config.ts`/`verify-steps.mjs` files.
316
+ */
317
+ function stripJsComments(content) {
318
+ let result = "";
319
+ let quote;
320
+ let inLineComment = false;
321
+ let inBlockComment = false;
322
+ for (let i = 0; i < content.length; i++) {
323
+ const ch = content[i];
324
+ const next = content[i + 1];
325
+ if (inLineComment) {
326
+ if (ch === "\n") {
327
+ inLineComment = false;
328
+ result += ch;
329
+ }
330
+ continue;
331
+ }
332
+ if (inBlockComment) {
333
+ if (ch === "*" && next === "/") {
334
+ inBlockComment = false;
335
+ i++;
336
+ }
337
+ continue;
338
+ }
339
+ if (quote !== undefined) {
340
+ result += ch;
341
+ if (ch === "\\") {
342
+ result += next ?? "";
343
+ i++;
344
+ continue;
345
+ }
346
+ if (ch === quote) quote = undefined;
347
+ continue;
348
+ }
349
+ if (ch === '"' || ch === "'" || ch === "`") {
350
+ quote = ch;
351
+ result += ch;
352
+ continue;
353
+ }
354
+ if (ch === "/" && next === "/") {
355
+ inLineComment = true;
356
+ i++;
357
+ continue;
358
+ }
359
+ if (ch === "/" && next === "*") {
360
+ inBlockComment = true;
361
+ i++;
362
+ continue;
363
+ }
364
+ result += ch;
365
+ }
366
+ return result;
367
+ }
368
+
369
+ // --- errno classification (twin of packages/cli/src/survey/internal/read-guard.ts) ---
370
+
371
+ /** The `code` of a Node system error, or `undefined` for anything without a string `code`. */
372
+ function errnoCode(error) {
373
+ if (typeof error !== "object" || error === null) return undefined;
374
+ const code = Reflect.get(error, "code");
375
+ return typeof code === "string" ? code : undefined;
376
+ }
377
+
378
+ /** "Nothing is at this path": nothing there, or an ancestor is a file. */
379
+ const ABSENT_CODES = new Set(["ENOENT", "ENOTDIR"]);
380
+
381
+ /**
382
+ * "Something is at this path this process cannot read or resolve": a
383
+ * permission failure, or a symlink loop. Never folded into "absent".
384
+ */
385
+ const UNRESOLVABLE_CODES = new Set(["EACCES", "EPERM", "ELOOP"]);
386
+
387
+ const isAbsentError = (error) => ABSENT_CODES.has(errnoCode(error) ?? "");
388
+
389
+ function unresolvableCode(error) {
390
+ const code = errnoCode(error);
391
+ return code !== undefined && UNRESOLVABLE_CODES.has(code) ? code : undefined;
392
+ }
393
+
394
+ /**
395
+ * Reads and parses a JSONC file. A missing (`ENOENT`/`ENOTDIR`), unreadable
396
+ * (`EACCES`/`EPERM`), unresolvable (`ELOOP`), not-a-regular-file (`EISDIR`)
397
+ * or unparseable file is reported, not thrown. The existence check is a real
398
+ * `stat`, never `existsSync`, so a file under a directory this process cannot
399
+ * search is reported with its errno rather than as absent. Any other failure
400
+ * (`EIO`, `EMFILE`, ...) throws an `Error` naming the path, with the original
401
+ * as `cause`.
402
+ */
296
403
  function readJsonc(path) {
297
- if (!existsSync(path)) return { ok: false, error: `${path} does not exist` };
404
+ let content;
298
405
  try {
299
- return {
300
- ok: true,
301
- value: JSON.parse(stripJsoncNoise(readFileSync(path, "utf8"))),
302
- };
406
+ statSync(path);
407
+ content = readFileSync(path, "utf8");
408
+ } catch (error) {
409
+ if (isAbsentError(error)) {
410
+ return { ok: false, error: `${path} does not exist` };
411
+ }
412
+ if (errnoCode(error) === "EISDIR") {
413
+ return { ok: false, error: `${path} is not a regular file` };
414
+ }
415
+ const code = unresolvableCode(error);
416
+ if (code === undefined) {
417
+ throw new Error(`could not read ${path}`, { cause: error });
418
+ }
419
+ return { ok: false, error: `${path} is unreadable (${code})` };
420
+ }
421
+ try {
422
+ return { ok: true, value: JSON.parse(stripJsoncNoise(content)) };
303
423
  } catch (error) {
304
424
  return {
305
425
  ok: false,
@@ -311,7 +431,7 @@ function readJsonc(path) {
311
431
  /** File text with comments stripped, or `undefined` when unreadable. */
312
432
  function readSource(path) {
313
433
  try {
314
- return stripJsoncNoise(readFileSync(path, "utf8"));
434
+ return stripJsComments(readFileSync(path, "utf8"));
315
435
  } catch {
316
436
  return undefined;
317
437
  }
@@ -326,12 +446,25 @@ function readRaw(path) {
326
446
  }
327
447
  }
328
448
 
449
+ /**
450
+ * Whether `path` is a candidate `extends` target: a regular file, or
451
+ * something this process cannot reach (`EACCES`/`EPERM`/`ELOOP`). The latter
452
+ * is taken as the target so `readJsonc` records it as unreadable with its
453
+ * errno -- never reported as resolving to no file when it may well be there.
454
+ * `ENOENT`/`ENOTDIR` is not a candidate; any other errno throws.
455
+ */
329
456
  function isFile(path) {
457
+ let stats;
330
458
  try {
331
- return statSync(path).isFile();
332
- } catch {
333
- return false;
459
+ stats = statSync(path);
460
+ } catch (error) {
461
+ if (isAbsentError(error)) return false;
462
+ if (unresolvableCode(error) !== undefined) return true;
463
+ throw new Error(`could not check whether ${path} exists`, {
464
+ cause: error,
465
+ });
334
466
  }
467
+ return stats.isFile();
335
468
  }
336
469
 
337
470
  /** The `extends` value as a list: a string, or TypeScript 5.0+'s array form. */
@@ -469,20 +602,34 @@ function scrapeGateSteps(text) {
469
602
  * Reads every `verify.mjs` invocation out of one YAML surface as data: the
470
603
  * `--group`/`--step` each names. YAML is scraped, never parsed. An invocation
471
604
  * naming neither (a matrix, or a bare full run) marks the surface `dynamic`.
605
+ * Comments are stripped per line before any `\` line continuation is joined,
606
+ * so a comment ending in `\` cannot swallow the next line. A quoted value is
607
+ * read unquoted. A mention inside an `echo` (in the same shell command, not an
608
+ * earlier `&&`/`;`/`|` segment) or inside a step's `name:` with no `run:`
609
+ * before it on the line is not an invocation.
472
610
  */
473
611
  function scrapeLaneInvocations(text) {
474
612
  const groups = [];
475
613
  const steps = [];
476
614
  let dynamic = false;
477
615
  let seen = false;
478
- for (const raw of text.split("\n")) {
479
- const line = raw.replace(/(^|\s)#.*$/, "");
616
+ const logicalLines = text
617
+ .split("\n")
618
+ .map((raw) => raw.replace(/(^|\s)#.*$/, ""))
619
+ .join("\n")
620
+ .replace(/\\\r?\n[ \t]*/g, " ")
621
+ .split("\n");
622
+ for (const line of logicalLines) {
480
623
  const at = line.indexOf("verify.mjs");
481
624
  if (at === -1) continue;
625
+ const prefix = line.slice(0, at);
626
+ const lastSegment = prefix.split(/&&|\|\||[;|]/).pop() ?? "";
627
+ if (/\becho\b/.test(lastSegment)) continue;
628
+ if (/\bname\s*:/.test(prefix) && !/\brun\s*:/.test(prefix)) continue;
482
629
  seen = true;
483
630
  const rest = line.slice(at);
484
- const group = /--group[ =]+([A-Za-z][\w-]*)/.exec(rest);
485
- const step = /--step[ =]+([A-Za-z][\w-]*)/.exec(rest);
631
+ const group = /--group[ =]+["']?([A-Za-z][\w-]*)["']?/.exec(rest);
632
+ const step = /--step[ =]+["']?([A-Za-z][\w-]*)["']?/.exec(rest);
486
633
  if (group !== null) groups.push(group[1]);
487
634
  else if (step !== null) steps.push(step[1]);
488
635
  else dynamic = true;
@@ -657,6 +804,7 @@ const NONE = { checked: 0, failures: [] };
657
804
 
658
805
  // --- structural rules ------------------------------------------------------
659
806
 
807
+ /** A tsconfig that fails to parse breaks every rule that reads its chain -- reporting the parse error itself, first, keeps a later rule's silence from being mistaken for a clean file. */
660
808
  const tsconfigParses = {
661
809
  id: "tsconfig-parses",
662
810
  level: "structural",
@@ -672,6 +820,7 @@ const tsconfigParses = {
672
820
  }),
673
821
  };
674
822
 
823
+ /** An `extends` specifier that resolves to no file silently drops every option the base file would have set -- tsc itself gives no error until something downstream trips on the missing option. */
675
824
  const tsconfigExtendsResolves = {
676
825
  id: "tsconfig-extends-resolves",
677
826
  level: "structural",
@@ -694,6 +843,7 @@ const tsconfigExtendsResolves = {
694
843
  },
695
844
  };
696
845
 
846
+ /** The tsconfig `build` compiles must actually set an outDir (or `build` emits .js next to sources) and must not set noEmit -- and if a separate tooling tsconfig sits beside it, that one must set noEmit, or typecheck starts emitting too. */
697
847
  const tsconfigEmitCoherence = {
698
848
  id: "tsconfig-emit-coherence",
699
849
  level: "structural",
@@ -732,6 +882,7 @@ const tsconfigEmitCoherence = {
732
882
  },
733
883
  };
734
884
 
885
+ /** A verify step naming a package.json script, a file, or a group that doesn't exist is a wiring defect no offline check other than this one would catch before someone actually runs it. */
735
886
  const gateWiring = {
736
887
  id: "gate-wiring",
737
888
  level: "structural",
@@ -800,6 +951,7 @@ const gateWiring = {
800
951
  },
801
952
  };
802
953
 
954
+ /** Confirms every CI/lefthook lane invokes verify.mjs by static group name, not by step id or a dynamic matrix -- otherwise a new gate can silently go unenforced in one surface while it passes in another. */
803
955
  const gateLaneParity = {
804
956
  id: "gate-lane-parity",
805
957
  level: "structural",
@@ -853,6 +1005,7 @@ function enginesBounds(range) {
853
1005
  };
854
1006
  }
855
1007
 
1008
+ /** `.node-version` outside the range package.json's `engines.node` declares means the pinned dev/CI Node isn't even a Node version the project claims to support. */
856
1009
  const nodePinCoherence = {
857
1010
  id: "node-pin-coherence",
858
1011
  level: "structural",
@@ -885,6 +1038,7 @@ const nodePinCoherence = {
885
1038
 
886
1039
  // --- rubric rules ----------------------------------------------------------
887
1040
 
1041
+ /** The strict-family flags are the floor current TypeScript guidance recommends; tsc raises no warning for a flag simply left off, so this is the only check that notices. */
888
1042
  const strictFlags = {
889
1043
  id: "strict-flags",
890
1044
  level: "rubric",
@@ -910,6 +1064,7 @@ const strictFlags = {
910
1064
  },
911
1065
  };
912
1066
 
1067
+ /** Warns ahead of a TypeScript major that removes an option outright -- by the time tsc itself rejects it, the fix is no longer a choice, it's an emergency. */
913
1068
  const tsconfigOptionLifecycle = {
914
1069
  id: "tsconfig-option-lifecycle",
915
1070
  level: "rubric",
@@ -947,6 +1102,7 @@ const tsconfigOptionLifecycle = {
947
1102
  },
948
1103
  };
949
1104
 
1105
+ /** An outdated `module`/`moduleResolution`/`target` still compiles fine today, but forgoes current Node/bundler resolution semantics and syntax the project doesn't need to lower. */
950
1106
  const moduleTargetModern = {
951
1107
  id: "module-target-modern",
952
1108
  level: "rubric",
@@ -989,6 +1145,7 @@ const moduleTargetModern = {
989
1145
  },
990
1146
  };
991
1147
 
1148
+ /** ESLint 10 reads only eslint.config.* -- a lingering legacy eslintrc file means either an old ESLint is still in play, or a current one is silently linting nothing. */
992
1149
  const eslintFlatConfig = {
993
1150
  id: "eslint-flat-config",
994
1151
  level: "rubric",
@@ -1008,6 +1165,7 @@ const eslintFlatConfig = {
1008
1165
  },
1009
1166
  };
1010
1167
 
1168
+ /** Without a type-checked preset and `projectService`, typescript-eslint runs syntax-only rules and silently skips every rule that needs real type information. */
1011
1169
  const eslintTypedLinting = {
1012
1170
  id: "eslint-typed-linting",
1013
1171
  level: "rubric",
@@ -1054,6 +1212,7 @@ const eslintTypedLinting = {
1054
1212
  },
1055
1213
  };
1056
1214
 
1215
+ /** `bin/**` and `.claude/hooks/**` are code this project actually runs, not incidental scripts -- if ESLint has no config block for them, or actively ignores src, they ship unlinted. */
1057
1216
  const eslintCoversEmittedCode = {
1058
1217
  id: "eslint-covers-emitted-code",
1059
1218
  level: "rubric",
@@ -1093,6 +1252,7 @@ const eslintCoversEmittedCode = {
1093
1252
  },
1094
1253
  };
1095
1254
 
1255
+ /** A coverage threshold without `perFile: true` lets one well-tested file's coverage average out another file with none -- the gate passes while a whole file goes untested. */
1096
1256
  const coverageGate = {
1097
1257
  id: "coverage-gate",
1098
1258
  level: "rubric",
@@ -1119,6 +1279,7 @@ const coverageGate = {
1119
1279
  },
1120
1280
  };
1121
1281
 
1282
+ /** An unpinned toolchain package, a missing `packageManager`, or an `@types/node` major that doesn't match the pinned Node version each make installs non-reproducible across machines and CI runs. */
1122
1283
  const toolchainPinShape = {
1123
1284
  id: "toolchain-pin-shape",
1124
1285
  level: "rubric",
@@ -1172,8 +1333,8 @@ const toolchainPinShape = {
1172
1333
 
1173
1334
  /**
1174
1335
  * Every rule, structural first. Order is the order findings are reported in.
1175
- * @public Exported for the parity test that compares this file with its TypeScript twin in
1176
- * m3l-groundwork; nothing else in a bootstrapped project imports it.
1336
+ * @public Not imported anywhere else in this project -- exported only for
1337
+ * m3l-groundwork's own upstream parity check (see the file header above).
1177
1338
  */
1178
1339
  export const RULES = [
1179
1340
  tsconfigParses,
@@ -2,11 +2,17 @@
2
2
  /**
3
3
  * The single source of truth for what `pnpm verify` runs locally, what the
4
4
  * lefthook `pre-push` lanes run, and what each `.github/workflows/ci.yml`
5
- * job runs. Both YAML files name a *group*, never a step id: groups are a
6
- * closed, five-member set (see GROUPS below); steps are not. That's what
7
- * lets a new step -- core or pack-contributed -- join `pnpm verify` without
8
- * ever touching either YAML file, and what keeps this the one list to keep
9
- * in sync instead of three that agree by hand.
5
+ * job runs.
6
+ *
7
+ * Two words, two meanings: a *step* is one gate (e.g. "Lint", cmd
8
+ * `pnpm lint`); a *group* is one of five fixed buckets a step belongs to
9
+ * (`format`/`lint`/`typecheck`/`build`/`test`, see GROUPS below). A *lane* is
10
+ * the outside caller that runs a whole group at once -- a lefthook
11
+ * `pre-push` lane, or a CI job in `.github/workflows/ci.yml`. Both YAML files
12
+ * name a *group*, never a step id: that's what lets a new step -- core or
13
+ * pack-contributed -- join `pnpm verify` without ever touching either YAML
14
+ * file, and what keeps this the one list to keep in sync instead of three
15
+ * that agree by hand.
10
16
  *
11
17
  * Pack-installed steps live in verify-steps.packs.json, a plain JSON array
12
18
  * the bootstrapper CLI appends to when installing a pack -- it has no JS
@@ -25,9 +31,11 @@ import { fileURLToPath } from "node:url";
25
31
  export const GROUPS = ["format", "lint", "typecheck", "build", "test"];
26
32
 
27
33
  /**
28
- * The gate steps this baseline ships, before any pack's are appended.
29
- * @public Exported for the parity test that compares this file with its TypeScript twin in
30
- * m3l-groundwork; nothing else in a bootstrapped project imports it.
34
+ * The gate steps this project ships out of the box, before any pack's own
35
+ * steps are appended.
36
+ * @public Not imported anywhere else in this project -- exported only so
37
+ * m3l-groundwork can compare it against its own upstream copy when it
38
+ * updates this tooling.
31
39
  */
32
40
  export const CORE_STEPS = [
33
41
  {
@@ -90,11 +98,21 @@ function readPackSteps() {
90
98
  let raw;
91
99
  try {
92
100
  raw = readFileSync(path, "utf8");
93
- } catch {
94
- return [];
101
+ } catch (error) {
102
+ if (error?.code === "ENOENT") return [];
103
+ throw new Error(`verify-steps.packs.json: cannot read ${path}`, {
104
+ cause: error,
105
+ });
95
106
  }
96
107
 
97
- const parsed = JSON.parse(raw);
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ } catch (error) {
112
+ throw new Error(`verify-steps.packs.json: failed to parse ${path}`, {
113
+ cause: error,
114
+ });
115
+ }
98
116
  if (!Array.isArray(parsed)) {
99
117
  throw new Error("verify-steps.packs.json must be a JSON array of steps");
100
118
  }
@@ -7,6 +7,10 @@
7
7
  * `lefthook.yml` each invoke `--group <name>`, never individual step ids, so
8
8
  * a new step -- core or pack-contributed -- is picked up by both without
9
9
  * either file changing.
10
+ *
11
+ * "Group" and "lane" are two different things: a group is one of the five
12
+ * fixed buckets above; a lane is the outside caller that runs a whole group
13
+ * at once -- one lefthook `pre-push` lane, or one CI job in `ci.yml`.
10
14
  */
11
15
  import process from "node:process";
12
16
  import { spawnSync } from "node:child_process";
@@ -19,8 +23,16 @@ import {
19
23
 
20
24
  const args = process.argv.slice(2);
21
25
  const stepIndex = args.indexOf("--step");
26
+ if (stepIndex !== -1 && !args[stepIndex + 1]) {
27
+ console.error("verify: --step requires a value");
28
+ process.exit(1);
29
+ }
22
30
  const requestedId = stepIndex === -1 ? null : args[stepIndex + 1];
23
31
  const groupIndex = args.indexOf("--group");
32
+ if (groupIndex !== -1 && !args[groupIndex + 1]) {
33
+ console.error("verify: --group requires a value");
34
+ process.exit(1);
35
+ }
24
36
  const requestedGroup = groupIndex === -1 ? null : args[groupIndex + 1];
25
37
 
26
38
  if (requestedGroup && !GROUPS.includes(requestedGroup)) {
@@ -22,6 +22,11 @@ export default defineConfig(
22
22
  ".claude/agents/**",
23
23
  ".claude/skills/**",
24
24
  ".claude/rules/**",
25
+ // A worktree the harness creates (see .prettierignore) is a full,
26
+ // independent checkout under here, with its own eslint-relevant files
27
+ // -- without this, `pnpm lint` from the main checkout also walks (and
28
+ // can fail on) that copy's own in-progress state.
29
+ ".claude/worktrees/**",
25
30
  ],
26
31
  },
27
32
  js.configs.recommended,
@@ -3,8 +3,8 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
- "description": "Bootstrapped by m3l-groundwork.",
7
- "packageManager": "pnpm@12.4.0",
6
+ "description": "TODO: describe __PROJECT_NAME__ (bootstrapped by m3l-groundwork).",
7
+ "packageManager": "pnpm@12.8.1",
8
8
  "engines": {
9
9
  "node": ">=24"
10
10
  },
@@ -41,18 +41,18 @@
41
41
  "@commitlint/types": "^21.2.0",
42
42
  "@eslint/js": "^10.0.1",
43
43
  "@types/node": "^24.13.3",
44
- "@vitest/coverage-v8": "^4.1.11",
45
- "eslint": "^10.9.1",
44
+ "@vitest/coverage-v8": "^5.0.2",
45
+ "eslint": "^10.11.0",
46
46
  "eslint-import-resolver-typescript": "^4.4.5",
47
47
  "eslint-plugin-import-x": "^4.17.1",
48
48
  "eslint-plugin-tsdoc": "^0.5.2",
49
49
  "globals": "^17.12.0",
50
- "knip": "^6.34.0",
50
+ "knip": "^6.38.0",
51
51
  "lefthook": "^2.1.12",
52
52
  "prettier": "^3.9.6",
53
53
  "publint": "^0.3.24",
54
54
  "typescript": "^6.0.3",
55
- "typescript-eslint": "^8.69.0",
56
- "vitest": "^4.1.11"
55
+ "typescript-eslint": "^8.71.0",
56
+ "vitest": "^5.0.2"
57
57
  }
58
58
  }
@@ -4,7 +4,14 @@ export default defineConfig({
4
4
  test: {
5
5
  pool: "forks",
6
6
  include: ["**/tests/**/*.test.ts", "**/*.test.ts"],
7
- exclude: ["**/dist/**", "**/node_modules/**"],
7
+ exclude: [
8
+ "**/dist/**",
9
+ "**/node_modules/**",
10
+ // A worktree the harness creates (see .prettierignore) is a full,
11
+ // independent checkout under here, with its own tests/ tree --
12
+ // without this every test in the project runs twice, once per copy.
13
+ "**/.claude/worktrees/**",
14
+ ],
8
15
  coverage: {
9
16
  provider: "v8",
10
17
  include: ["src/**/*.ts"],
@@ -18,24 +18,33 @@ templates/packs/<name>/
18
18
 
19
19
  ## The wiring contract
20
20
 
21
- **A pack never edits YAML or JavaScript.** It may add files under `files/`,
22
- and may extend three JSON files the baseline already reads at runtime:
21
+ **A pack never edits YAML or JavaScript.** It may add files under `files/`.
22
+ **A pack also can't ship a `.claude/rules/*.md` file**: the harness grader's
23
+ structural `claudemd-refs` rule fails on any rule `CLAUDE.md` doesn't name
24
+ by path, and a pack has no way to edit `CLAUDE.md` (that's not one of the
25
+ three JSON files below either). If a pack needs to state a project-wide
26
+ policy, put it in the body of a skill it ships instead — see the `worktrees`
27
+ pack's `working-in-worktrees` skill for the pattern.
28
+ It may also extend three JSON files the baseline already reads at runtime:
23
29
  `.claude/settings.json` (hook registrations, and top-level harness settings
24
30
  such as `statusLine`), `package.json` (`scripts`), and
25
- `bin/lib/verify-steps.packs.json` (gate steps, keyed to one of the five
26
- fixed verify groups `templates/core/bin/lib/verify-steps.mjs` defines —
27
- `format`/`lint`/`typecheck`/`build`/`test`). A gate registered this way runs
31
+ `bin/lib/verify-steps.packs.json`. That last file holds gate steps, each
32
+ keyed to one of the five fixed verify groups
33
+ `templates/core/bin/lib/verify-steps.mjs` defines —
34
+ `format`/`lint`/`typecheck`/`build`/`test`. A gate registered this way runs
28
35
  under `pnpm verify`, every `lefthook.yml` `pre-push` lane, and every
29
- `.github/workflows/ci.yml` job automatically, because all three already
30
- enumerate groups rather than individual steps.
36
+ `.github/workflows/ci.yml` job automatically. That works because all three
37
+ already enumerate groups rather than individual steps.
31
38
 
32
39
  `pack.json` fields:
33
40
 
34
41
  - `schemaVersion` — currently `1`.
35
- - `modes` — `["fresh"]` and/or `["fresh", "adopt"]`. Only artifacts with no
36
- dependency on the baseline's exact file layout (an agent, most hooks) are
37
- safely adopt-capable; a gate that assumes a specific source layout should
38
- say so honestly in `adoptNotes` instead of claiming `adopt`.
42
+ - `modes` — `["fresh"]` and/or `["fresh", "adopt"]`. "Adopt-capable" means
43
+ an artifact has no dependency on the baseline's exact file layout (an
44
+ agent, most hooks), so it's safe to install into an already-existing
45
+ project's own layout. A gate that assumes a specific source layout isn't
46
+ adopt-capable -- it should say so honestly in `adoptNotes` instead of
47
+ claiming `adopt`.
39
48
  - `budget` — the pack's cap deltas (`agents`/`skills`/`hooks`/`workflows`/
40
49
  `scripts`), checked against `templates/core`'s own counts by a structural
41
50
  test, never against `templates/core` + every other pack.
@@ -63,19 +72,21 @@ enumerate groups rather than individual steps.
63
72
  repeatable) — it wrote the baseline moments ago, so there's no uncertainty
64
73
  to defer.
65
74
  - **Adopt mode**: the CLI never installs a pack. It surveys which packs
66
- apply and stages their payload at `.groundwork/packs/<name>/`;
67
- `/customize`'s Step 0 confirms and Round 1 installs, translating
68
- `wiring.verifySteps`/`wiring.settings` against the project's _real_ gate
69
- runner and hook config — read for real by that point, not guessed at
70
- offline.
75
+ apply and stages their payload at `.groundwork/packs/<name>/`. Later,
76
+ `/customize` confirms the install in **Step 0** (its first, up-front
77
+ confirmation round, before any file is written) and performs it in
78
+ **Round 1** (the first pass of deterministic edits that follows). That
79
+ installation translates `wiring.verifySteps`/`wiring.settings` against
80
+ the project's _real_ gate runner and hook config — read for real by that
81
+ point, not guessed at offline.
71
82
 
72
83
  ## Available packs
73
84
 
74
- | Pack | Contents |
75
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
76
- | `harness-extras` | A type-design-analyzer agent, the compaction-handoff hook pair, a read-only Bash guard, and a per-file size ratchet gate — the four artifacts the original baseline build cut purely to hold its caps. |
77
- | `statusline` | A five-row Claude Code status line (session, model, context, quota, work) plus a per-subagent row renderer, both width-fit to the terminal. Registers top-level `statusLine`/`subagentStatusLine` settings, no hooks and no gate. Runs the same on macOS and Linux. |
78
-
79
- `github-ops` (dependabot/scan-alert triage skills) and `publishing` (a
80
- release workflow + npm-publish gates) are documented follow-ups, not yet
81
- built — see root `CLAUDE.md`'s Known gaps.
85
+ | Pack | Contents |
86
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
87
+ | `harness-extras` | Claude Code session ergonomics: the compaction-handoff hook pair, a read-only Bash guard, and a five-row status line (session, model, context, quota, work) plus a per-subagent row renderer. The only pack that sets top-level settings keys (`statusLine`, `subagentStatusLine`). |
88
+ | `quality` | Two language-level review aids: a per-file size ratchet gate (`check-file-budget.mjs`, a `build`-group verify step) and a read-only `type-design-analyzer` agent. No hooks, no settings. Adopt-capable and recommended for every project kind. |
89
+ | `github` | GitHub-hosted collaboration: Anthropic's official Claude Code GitHub Action wired for `@claude` mention-mode, a second Action that posts an automated Claude review comment on every PR, and three `gh`-CLI skills — `reviewing-dependabot-prs`, `triaging-scan-alerts`, `watching-pr-checks`. No hooks, no gate. The two workflows need an auth secret this pack cannot create; see its `adoptNotes`. |
90
+ | `publishing` | A release pipeline: `release.yml` (changesets version-PR / staged, provenance-attested npm publish via trusted publishing), `check-publish-version.mjs`, `check-dts-deps.mjs`, `check-license-headers.mjs` and a `REUSE.toml` template. Fresh mode only — see "Install path" below and its `adoptNotes`. |
91
+ | `supply-chain` | Secret scanning (`gitleaks.yml` with a `.gitleaks.toml`) and an OpenSSF Scorecard run (`scorecard.yml`) for any project on GitHub, published or not. A pure file drop of two read-only workflows: no hooks, no settings, no scripts, no gate. Adopt-capable and recommended for every project kind. |
92
+ | `worktrees` | Enforces that all src/tests development happens inside an isolated git worktree, on any branch, for any caller: a `working-in-worktrees` skill (start/status/sync/finish/fan-out), a `SessionStart` hook that installs dependencies into a freshly created worktree, a `PreToolUse` guard stricter than the baseline's own `guard-branch-isolation.mjs`/`guard-hub-src-writes.mjs`, a `repair-core-bare.mjs` hook (`SessionStart`, and `PostToolUse` after `EnterWorktree`/`ExitWorktree`/`Agent`) that resets the `core.bare = true` Claude Code's worktree tools are reported to leave in a normal repo's shared config, and a `.worktreeinclude` copying `.env`/`.env.local`/`.env.*.local` into every worktree Claude Code creates. Changes the day-to-day workflow, not just a nicety — see its `adoptNotes`. |