@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
package/README.md CHANGED
@@ -1,15 +1,21 @@
1
1
  # @monte3l/groundwork
2
2
 
3
+ **What this is:** a command-line tool that sets up a TypeScript project's
4
+ toolchain and Claude Code configuration, or reports on how an existing
5
+ project compares to that setup.
6
+
3
7
  The offline bootstrapper behind [m3l-groundwork](https://github.com/monte3l/m3l-groundwork):
4
8
  one deterministic CLI that either writes a baseline TypeScript toolchain and
5
- Claude Code harness into an empty directory (**fresh** mode) or surveys an
6
- existing project and writes only a report (**adopt** mode, which never touches a
7
- project file).
9
+ Claude Code **harness** (its agents, skills, hooks, and settings) into an
10
+ empty directory (**fresh** mode) or surveys an existing project and writes
11
+ only a report (**adopt mode**, which never touches a project file --
12
+ it's for a project that already exists and shouldn't be rewritten
13
+ automatically).
8
14
 
9
15
  ```bash
10
16
  # currently a 1.0.0 release candidate, shipping on the `rc` dist-tag
11
17
  npx @monte3l/groundwork@rc my-new-project
12
- npx @monte3l/groundwork@rc my-new-project --pack statusline
18
+ npx @monte3l/groundwork@rc my-new-project --pack harness-extras
13
19
  npx @monte3l/groundwork@rc ../existing-project
14
20
  ```
15
21
 
@@ -18,6 +24,8 @@ fresh bootstrap ends with (`--skip-install` to skip it). Run with `--help` for
18
24
  every flag, or `--list-packs` for the optional packs.
19
25
 
20
26
  The `/customize` skill it installs, the packs, and the design are documented in
21
- the [repository README](https://github.com/monte3l/m3l-groundwork#readme).
27
+ the [repository README](https://github.com/monte3l/m3l-groundwork/blob/main/README.md).
28
+ Unfamiliar terms (harness, adopt mode, pack, and the rest) are defined in the
29
+ [glossary](https://github.com/monte3l/m3l-groundwork/blob/main/docs/glossary.md).
22
30
 
23
31
  MIT licensed.
@@ -1,10 +1,44 @@
1
1
  #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
3
+ // SPDX-License-Identifier: MIT
4
+
5
+ /**
6
+ * The published CLI's entry point: `npx @monte3l/groundwork <target-dir>`
7
+ * resolves here. Delegates straight to the built `main()` and turns a
8
+ * `CliUsageError` into exit code 2 (a bad invocation), any other thrown
9
+ * error into exit code 1 (a runtime failure) -- see `packages/cli/src/main.ts`.
10
+ * The error is printed with its full `cause` chain (`formatErrorChain`), so
11
+ * a wrapped failure never hides the underlying reason; `handleFatal` sets the
12
+ * exit code before printing and never throws, even when reporting fails --
13
+ * if the painted print throws, it falls back to a plain, unpainted
14
+ * `console.error` (`printRaw`). Not `process.stderr.write`: a write to a
15
+ * closed pipe reports EPIPE asynchronously as an `'error'` event on
16
+ * `process.stderr`, which nothing here listens for, so it would crash the
17
+ * process as an uncaught exception and turn exit code 2 into 1 after
18
+ * `handleFatal` returned; `console.error` swallows stream errors itself.
19
+ */
2
20
  import process from "node:process";
3
21
  import { main, CliUsageError } from "../dist/main.js";
22
+ import { handleFatal } from "../dist/fatal.js";
23
+ import { paint } from "../dist/term.js";
4
24
 
5
25
  try {
6
26
  main(process.argv.slice(2));
7
27
  } catch (error) {
8
- console.error(error instanceof Error ? error.message : String(error));
9
- process.exitCode = error instanceof CliUsageError ? 2 : 1;
28
+ handleFatal(
29
+ error,
30
+ {
31
+ setExitCode: (code) => {
32
+ process.exitCode = code;
33
+ },
34
+ print: (text) => {
35
+ console.error(paint(process.stderr, "danger", text));
36
+ },
37
+ printRaw: (text) => {
38
+ console.error(text);
39
+ },
40
+ },
41
+ (e) => e instanceof CliUsageError,
42
+ (process.env["M3L_DEBUG"] ?? "") !== "",
43
+ );
10
44
  }
package/dist/assets.d.ts CHANGED
@@ -11,6 +11,16 @@ export declare function escapeDotfileName(name: string): string;
11
11
  export declare function restoreDotfileName(name: string): string;
12
12
  /** `restoreDotfileName` applied to the final segment of a `/`- or `\`-separated relative path. */
13
13
  export declare function restoreDotfilePath(relPath: string): string;
14
+ /**
15
+ * Normalizes a native relative path to forward slashes, so a path recorded
16
+ * in `inventory.json` reads the same whichever OS ran the CLI.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * toPosixPath("src\\index.ts"); // "src/index.ts"
21
+ * ```
22
+ */
23
+ export declare function toPosixPath(p: string): string;
14
24
  /**
15
25
  * Resolves an asset for whichever layout is running. `fromDir` is the
16
26
  * directory of a module at `src/` or `dist/` depth; it defaults to this
package/dist/assets.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * The one place that decides where the CLI's data trees live: `templates/`
3
5
  * (the baseline and its packs) and the `/customize` plugin payload.
@@ -43,6 +45,18 @@ export function restoreDotfilePath(relPath) {
43
45
  const cut = Math.max(relPath.lastIndexOf("/"), relPath.lastIndexOf("\\"));
44
46
  return relPath.slice(0, cut + 1) + restoreDotfileName(relPath.slice(cut + 1));
45
47
  }
48
+ /**
49
+ * Normalizes a native relative path to forward slashes, so a path recorded
50
+ * in `inventory.json` reads the same whichever OS ran the CLI.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * toPosixPath("src\\index.ts"); // "src/index.ts"
55
+ * ```
56
+ */
57
+ export function toPosixPath(p) {
58
+ return p.replaceAll("\\", "/");
59
+ }
46
60
  /** True when `dir` is this project's own source checkout, by two independent markers. */
47
61
  function isSourceCheckout(dir) {
48
62
  if (!existsSync(join(dir, "pnpm-workspace.yaml"))) {
@@ -0,0 +1,173 @@
1
+ import { toPosixPath } from "./assets.js";
2
+ import type { FileConflict } from "./conflicts.js";
3
+ import { STAGED_SUFFIX, stagedNameFor } from "./staging.js";
4
+ import type { TokenTable } from "./tokens.js";
5
+ export { STAGED_SUFFIX, stagedNameFor, toPosixPath };
6
+ /**
7
+ * The directory name, under `.groundwork/`, that absent baseline files are
8
+ * staged into.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { join } from "node:path";
13
+ * const stagedDir = join(".groundwork", STAGED_BASELINE_DIR); // ".groundwork/baseline"
14
+ * ```
15
+ */
16
+ export declare const STAGED_BASELINE_DIR = "baseline";
17
+ /**
18
+ * One staged baseline file: its install path, its staged name, and the
19
+ * sha256 of the staged bytes, so `/customize` can verify the copy it installs.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * const file: StagedBaselineFile = {
24
+ * path: "eslint.config.js",
25
+ * staged: "eslint.config.js.staged",
26
+ * sha256: "e3b0c442...", // hex digest of the staged bytes
27
+ * };
28
+ * ```
29
+ */
30
+ export interface StagedBaselineFile {
31
+ /** The project-relative path the file installs to. */
32
+ path: string;
33
+ /** The staged file's name, relative to the staging directory: `path` + {@link STAGED_SUFFIX}. */
34
+ staged: string;
35
+ /** Lowercase hex sha256 of the staged bytes. */
36
+ sha256: string;
37
+ }
38
+ /**
39
+ * The validated plan for one {@link stageBaselineAdditions} run: every path
40
+ * it would write, plus each file's install path and template source, so
41
+ * staging never re-walks the template tree. Built by
42
+ * {@link planBaselineStaging}.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { planBaselineStaging, stageBaselineAdditions } from "./baseline-stage.js";
47
+ * const plan = planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens);
48
+ * stageBaselineAdditions(templateRoot, conflicts, groundworkDir, tokens, plan);
49
+ * ```
50
+ */
51
+ export interface BaselineStagingPlan {
52
+ /** The `groundworkDir` the plan was computed for; {@link stageBaselineAdditions} refuses the plan for any other. */
53
+ readonly groundworkDir: string;
54
+ /** Every path the run writes: one `<groundworkDir>/baseline/<path>.staged` per absent conflict, in `conflicts` order. */
55
+ readonly paths: readonly string[];
56
+ /** Each absent conflict's install path and template source, in the same order. */
57
+ readonly files: readonly {
58
+ readonly path: string;
59
+ readonly sourcePath: string;
60
+ }[];
61
+ }
62
+ /**
63
+ * Validates the absent conflicts in `conflicts` against `templateRoot` and
64
+ * computes the plan {@link stageBaselineAdditions} writes from: every path
65
+ * under `<groundworkDir>/baseline/` -- one `<path>.staged` per absent
66
+ * conflict -- and each file's template source, so adopt mode can
67
+ * scope-check `paths` before anything under `.groundwork/` is deleted or
68
+ * written and then hand the same plan to {@link stageBaselineAdditions}.
69
+ * Reads the template tree (not at all when nothing is absent); writes
70
+ * nothing. Both arrays are empty when nothing is absent.
71
+ *
72
+ * @throws The same plan `Error`s as {@link stageBaselineAdditions}: a
73
+ * template with two files installing to one path, an absent conflict with no
74
+ * template counterpart or whose staged name would escape the staging
75
+ * directory, or two colliding staged names.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import { assertAdoptWriteScope } from "./main.js";
80
+ * const plan = planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens);
81
+ * assertAdoptWriteScope(targetDir, plan.paths);
82
+ * stageBaselineAdditions(templateRoot, conflicts, groundworkDir, tokens, plan);
83
+ * ```
84
+ */
85
+ export declare function planBaselineStaging(templateRoot: string, conflicts: readonly FileConflict[], groundworkDir: string, tokens: TokenTable): BaselineStagingPlan;
86
+ /**
87
+ * Every path {@link stageBaselineAdditions} would write for `conflicts` -- a
88
+ * thin wrapper returning {@link planBaselineStaging}'s `paths`. Reads the
89
+ * template tree; writes nothing. Returns `[]` when nothing is absent.
90
+ *
91
+ * @throws The same plan `Error`s as {@link planBaselineStaging}.
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * import { assertAdoptWriteScope } from "./main.js";
96
+ * const paths = plannedBaselineStagingPaths(templateRoot, conflicts, groundworkDir, tokens);
97
+ * assertAdoptWriteScope(targetDir, paths);
98
+ * ```
99
+ */
100
+ export declare function plannedBaselineStagingPaths(templateRoot: string, conflicts: readonly FileConflict[], groundworkDir: string, tokens: TokenTable): string[];
101
+ /**
102
+ * Copies every template file `conflicts` marks "absent" from `templateRoot`
103
+ * into `<groundworkDir>/baseline/` as `<path>.staged`, byte-for-byte -- no
104
+ * token substitution into content, which is `/customize`'s job at install
105
+ * time. `tokens` is used only to match a tokenized template path
106
+ * (`__PROJECT_NAME__.txt`) to the substituted `relPath` the conflict plan
107
+ * reports. Recorded `path`/`staged` values use forward slashes
108
+ * ({@link toPosixPath}).
109
+ *
110
+ * What is guaranteed:
111
+ * - The plan is validated first, before anything is deleted or written (or,
112
+ * when `plan` is passed, was already validated by
113
+ * {@link planBaselineStaging} and the template tree is not walked again): a
114
+ * template with two files installing to the same path (a dotfile-escaped
115
+ * name beside its literal twin), an absent conflict with no template
116
+ * counterpart or whose staged name would escape the staging directory, or
117
+ * two staged names that would land on the same file (equal once
118
+ * NFC-normalized and case-folded, or one a directory prefix of the other)
119
+ * throws its own
120
+ * `Error`, leaving `.groundwork/` exactly as it was.
121
+ * - Then, still before anything is deleted or written, `groundworkDir` and
122
+ * `<groundworkDir>/baseline` are checked not to be symlinks, and every
123
+ * `.baseline-*` entry of the CLI-owned `groundworkDir` -- meant for work
124
+ * directories a crashed earlier run left, but removed whatever created
125
+ * it, so a concurrent run against the same directory can lose its
126
+ * in-progress work directory and fail with the incomplete/re-run error
127
+ * -- is removed (best effort; a failure to remove one only warns, a
128
+ * failure to list `groundworkDir` throws).
129
+ * - When nothing is absent, any previous staging is removed and nothing is
130
+ * created.
131
+ * - Files are then written into a temporary `.baseline-*` sibling directory
132
+ * and swapped in by rename only after every copy succeeded, so
133
+ * `baseline/` is never half-written and a copy failure leaves any previous
134
+ * `baseline/` intact. A previous staging is replaced wholesale (no stale
135
+ * file lingers).
136
+ * - With a previous `baseline/`, the swap is two renames: the previous one
137
+ * is parked inside the temporary directory, then the new one moved into
138
+ * place. A process killed between the two leaves `baseline/` absent and
139
+ * the previous copy at `.baseline-XXXXXX/previous`; the next run's sweep
140
+ * removes it and regenerates the staging.
141
+ * - If the final swap rename fails, the previous `baseline/` is renamed back
142
+ * into place. Only if that restore also fails is `baseline/` left absent:
143
+ * the previous staging then survives, parked inside the temporary
144
+ * directory, which is deliberately not removed (a later run's stale-dir
145
+ * sweep does remove it, regenerating the staging from scratch).
146
+ * - The temporary directory is otherwise always removed; a failure to remove
147
+ * it only warns, naming its path.
148
+ *
149
+ * @throws `Error` (no `cause`, no re-run advice) for an invalid plan, as
150
+ * above; `Error` before any delete or write when `groundworkDir` or
151
+ * `<groundworkDir>/baseline` is a symlink; `AggregateError` of the swap and
152
+ * restore failures, naming where the previous baseline is parked, when both
153
+ * renames fail; the `AssertionError` itself, unwrapped, if the staged-path
154
+ * containment invariant ever fails while writing; otherwise an `Error` with
155
+ * `cause`, including the cause's message, saying `.groundwork/` is
156
+ * incomplete and the CLI should be re-run.
157
+ *
158
+ * @param plan - The plan {@link planBaselineStaging} computed for these same
159
+ * `templateRoot`, `conflicts`, `groundworkDir` and `tokens`; computed here
160
+ * when omitted. A plan whose `groundworkDir` resolves to a different
161
+ * directory throws a plain `Error` naming both ("the plan was built for …")
162
+ * before anything is deleted or written.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * // conflicts: the plan `planConflicts` (conflicts.ts) computed for the target
167
+ * const conflicts = planConflicts(templateRoot, targetDir, tokens);
168
+ * const staged = stageBaselineAdditions(templateRoot, conflicts, ".groundwork", tokens);
169
+ * // staged: [{ path: "eslint.config.js", staged: "eslint.config.js.staged", sha256: "…" }, …]
170
+ * ```
171
+ */
172
+ export declare function stageBaselineAdditions(templateRoot: string, conflicts: readonly FileConflict[], groundworkDir: string, tokens: TokenTable, plan?: BaselineStagingPlan): StagedBaselineFile[];
173
+ //# sourceMappingURL=baseline-stage.d.ts.map
@@ -0,0 +1,215 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Adopt mode's staging of the baseline files a project lacks entirely: the
5
+ * template files a conflict plan marked "absent" are copied verbatim into
6
+ * `.groundwork/baseline/`, each under an inert `<path>.staged` name, so
7
+ * `/customize`'s Step 0 can install them from a self-contained copy rather
8
+ * than from `templateRoot` -- an absolute path that may not exist by the time
9
+ * it runs -- and no toolchain globbing the project ever picks one up.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { toPosixPath } from "./assets.js";
14
+ import { isPathContained } from "./emit.js";
15
+ import { STAGED_SUFFIX, assertPlanBuiltFor, clearStaging, collectTemplateFiles, findStagedPathCollision, prepareStaging, stageAtomically, stagedNameFor, writeStagedBytes, } from "./staging.js";
16
+ // Re-exported so existing consumers of this module keep one import site.
17
+ export { STAGED_SUFFIX, stagedNameFor, toPosixPath };
18
+ /**
19
+ * The directory name, under `.groundwork/`, that absent baseline files are
20
+ * staged into.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { join } from "node:path";
25
+ * const stagedDir = join(".groundwork", STAGED_BASELINE_DIR); // ".groundwork/baseline"
26
+ * ```
27
+ */
28
+ export const STAGED_BASELINE_DIR = "baseline";
29
+ /**
30
+ * Pairs every absent conflict with its template source. Four defects are
31
+ * refused, each with its own `Error` (no `cause`) and never skipped: a
32
+ * template with two files mapping to one install path, a `relPath` whose
33
+ * staged name would land outside `destDir` (CWE-22, docs/assurance-case.md),
34
+ * an absent path with no template counterpart, and two staged names that
35
+ * would land on the same file ({@link findStagedPathCollision}: equal once
36
+ * NFC-normalized and case-folded, or one a directory prefix of the other).
37
+ */
38
+ function planStaging(templateRoot, absent, tokens, destDir) {
39
+ const templateFiles = collectTemplateFiles(templateRoot, tokens);
40
+ const plan = absent.map(({ relPath }) => {
41
+ // No ":" refusal here: that check (a Windows drive letter / ADS) is packs-only, in pack-stage.ts.
42
+ const destPath = join(destDir, stagedNameFor(relPath));
43
+ if (!isPathContained(destPath, destDir)) {
44
+ throw new Error(`stageBaselineAdditions: staged path ${resolve(destPath)} for ${relPath} escapes ${resolve(destDir)}`);
45
+ }
46
+ const sourcePath = templateFiles.get(relPath);
47
+ if (sourcePath === undefined) {
48
+ throw new Error(`absent baseline file ${relPath} has no counterpart under ${templateRoot}`);
49
+ }
50
+ return { path: relPath, sourcePath };
51
+ });
52
+ const collision = findStagedPathCollision(plan.map(({ path }) => toPosixPath(stagedNameFor(path))));
53
+ if (collision !== undefined) {
54
+ throw new Error(`stageBaselineAdditions: two staged baseline files under ${destDir} collide: ${collision}; rename one of the template files under ${templateRoot}`);
55
+ }
56
+ return plan;
57
+ }
58
+ /** The absent conflicts in `conflicts` -- the files a baseline staging copies. */
59
+ function absentConflicts(conflicts) {
60
+ return conflicts.filter((c) => c.status === "absent");
61
+ }
62
+ /**
63
+ * Validates the absent conflicts in `conflicts` against `templateRoot` and
64
+ * computes the plan {@link stageBaselineAdditions} writes from: every path
65
+ * under `<groundworkDir>/baseline/` -- one `<path>.staged` per absent
66
+ * conflict -- and each file's template source, so adopt mode can
67
+ * scope-check `paths` before anything under `.groundwork/` is deleted or
68
+ * written and then hand the same plan to {@link stageBaselineAdditions}.
69
+ * Reads the template tree (not at all when nothing is absent); writes
70
+ * nothing. Both arrays are empty when nothing is absent.
71
+ *
72
+ * @throws The same plan `Error`s as {@link stageBaselineAdditions}: a
73
+ * template with two files installing to one path, an absent conflict with no
74
+ * template counterpart or whose staged name would escape the staging
75
+ * directory, or two colliding staged names.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import { assertAdoptWriteScope } from "./main.js";
80
+ * const plan = planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens);
81
+ * assertAdoptWriteScope(targetDir, plan.paths);
82
+ * stageBaselineAdditions(templateRoot, conflicts, groundworkDir, tokens, plan);
83
+ * ```
84
+ */
85
+ export function planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens) {
86
+ const absent = absentConflicts(conflicts);
87
+ if (absent.length === 0) {
88
+ return { groundworkDir, paths: [], files: [] };
89
+ }
90
+ const destDir = join(groundworkDir, STAGED_BASELINE_DIR);
91
+ const files = planStaging(templateRoot, absent, tokens, destDir);
92
+ const paths = files.map(({ path }) => join(destDir, stagedNameFor(path)));
93
+ return { groundworkDir, paths, files };
94
+ }
95
+ /**
96
+ * Every path {@link stageBaselineAdditions} would write for `conflicts` -- a
97
+ * thin wrapper returning {@link planBaselineStaging}'s `paths`. Reads the
98
+ * template tree; writes nothing. Returns `[]` when nothing is absent.
99
+ *
100
+ * @throws The same plan `Error`s as {@link planBaselineStaging}.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * import { assertAdoptWriteScope } from "./main.js";
105
+ * const paths = plannedBaselineStagingPaths(templateRoot, conflicts, groundworkDir, tokens);
106
+ * assertAdoptWriteScope(targetDir, paths);
107
+ * ```
108
+ */
109
+ export function plannedBaselineStagingPaths(templateRoot, conflicts, groundworkDir, tokens) {
110
+ return [
111
+ ...planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens)
112
+ .paths,
113
+ ];
114
+ }
115
+ /**
116
+ * Copies every template file `conflicts` marks "absent" from `templateRoot`
117
+ * into `<groundworkDir>/baseline/` as `<path>.staged`, byte-for-byte -- no
118
+ * token substitution into content, which is `/customize`'s job at install
119
+ * time. `tokens` is used only to match a tokenized template path
120
+ * (`__PROJECT_NAME__.txt`) to the substituted `relPath` the conflict plan
121
+ * reports. Recorded `path`/`staged` values use forward slashes
122
+ * ({@link toPosixPath}).
123
+ *
124
+ * What is guaranteed:
125
+ * - The plan is validated first, before anything is deleted or written (or,
126
+ * when `plan` is passed, was already validated by
127
+ * {@link planBaselineStaging} and the template tree is not walked again): a
128
+ * template with two files installing to the same path (a dotfile-escaped
129
+ * name beside its literal twin), an absent conflict with no template
130
+ * counterpart or whose staged name would escape the staging directory, or
131
+ * two staged names that would land on the same file (equal once
132
+ * NFC-normalized and case-folded, or one a directory prefix of the other)
133
+ * throws its own
134
+ * `Error`, leaving `.groundwork/` exactly as it was.
135
+ * - Then, still before anything is deleted or written, `groundworkDir` and
136
+ * `<groundworkDir>/baseline` are checked not to be symlinks, and every
137
+ * `.baseline-*` entry of the CLI-owned `groundworkDir` -- meant for work
138
+ * directories a crashed earlier run left, but removed whatever created
139
+ * it, so a concurrent run against the same directory can lose its
140
+ * in-progress work directory and fail with the incomplete/re-run error
141
+ * -- is removed (best effort; a failure to remove one only warns, a
142
+ * failure to list `groundworkDir` throws).
143
+ * - When nothing is absent, any previous staging is removed and nothing is
144
+ * created.
145
+ * - Files are then written into a temporary `.baseline-*` sibling directory
146
+ * and swapped in by rename only after every copy succeeded, so
147
+ * `baseline/` is never half-written and a copy failure leaves any previous
148
+ * `baseline/` intact. A previous staging is replaced wholesale (no stale
149
+ * file lingers).
150
+ * - With a previous `baseline/`, the swap is two renames: the previous one
151
+ * is parked inside the temporary directory, then the new one moved into
152
+ * place. A process killed between the two leaves `baseline/` absent and
153
+ * the previous copy at `.baseline-XXXXXX/previous`; the next run's sweep
154
+ * removes it and regenerates the staging.
155
+ * - If the final swap rename fails, the previous `baseline/` is renamed back
156
+ * into place. Only if that restore also fails is `baseline/` left absent:
157
+ * the previous staging then survives, parked inside the temporary
158
+ * directory, which is deliberately not removed (a later run's stale-dir
159
+ * sweep does remove it, regenerating the staging from scratch).
160
+ * - The temporary directory is otherwise always removed; a failure to remove
161
+ * it only warns, naming its path.
162
+ *
163
+ * @throws `Error` (no `cause`, no re-run advice) for an invalid plan, as
164
+ * above; `Error` before any delete or write when `groundworkDir` or
165
+ * `<groundworkDir>/baseline` is a symlink; `AggregateError` of the swap and
166
+ * restore failures, naming where the previous baseline is parked, when both
167
+ * renames fail; the `AssertionError` itself, unwrapped, if the staged-path
168
+ * containment invariant ever fails while writing; otherwise an `Error` with
169
+ * `cause`, including the cause's message, saying `.groundwork/` is
170
+ * incomplete and the CLI should be re-run.
171
+ *
172
+ * @param plan - The plan {@link planBaselineStaging} computed for these same
173
+ * `templateRoot`, `conflicts`, `groundworkDir` and `tokens`; computed here
174
+ * when omitted. A plan whose `groundworkDir` resolves to a different
175
+ * directory throws a plain `Error` naming both ("the plan was built for …")
176
+ * before anything is deleted or written.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * // conflicts: the plan `planConflicts` (conflicts.ts) computed for the target
181
+ * const conflicts = planConflicts(templateRoot, targetDir, tokens);
182
+ * const staged = stageBaselineAdditions(templateRoot, conflicts, ".groundwork", tokens);
183
+ * // staged: [{ path: "eslint.config.js", staged: "eslint.config.js.staged", sha256: "…" }, …]
184
+ * ```
185
+ */
186
+ export function stageBaselineAdditions(templateRoot, conflicts, groundworkDir, tokens,
187
+ // Defaulted before the symlink check and stale-dir sweep below, so an
188
+ // invalid plan -- a template defect, with its own message rather than the
189
+ // "incomplete, re-run" staging failure -- deletes and writes nothing.
190
+ plan = planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens)) {
191
+ assertPlanBuiltFor("stageBaselineAdditions", plan.groundworkDir, groundworkDir);
192
+ const target = {
193
+ groundworkDir,
194
+ dirName: STAGED_BASELINE_DIR,
195
+ noun: "baseline",
196
+ plural: false,
197
+ };
198
+ prepareStaging(target);
199
+ if (plan.files.length === 0) {
200
+ clearStaging(target);
201
+ return [];
202
+ }
203
+ return stageAtomically(target, (newDir) => plan.files.map(({ path, sourcePath }) => {
204
+ const stagedName = stagedNameFor(path);
205
+ // writeStagedBytes re-asserts the CWE-22 containment planStaging
206
+ // already checked, against the directory actually written to.
207
+ const sha256 = writeStagedBytes("stageBaselineAdditions", readFileSync(sourcePath), newDir, stagedName);
208
+ return {
209
+ path: toPosixPath(path),
210
+ staged: toPosixPath(stagedName),
211
+ sha256,
212
+ };
213
+ }));
214
+ }
215
+ //# sourceMappingURL=baseline-stage.js.map
package/dist/caps.d.ts CHANGED
@@ -20,6 +20,9 @@ export declare function countBaselineCaps(templateRoot: string): CapCounts;
20
20
  * the same shape as {@link countBaselineCaps} but without the `/customize`
21
21
  * adjustment, since a pack never ships that skill. Used to verify a pack's
22
22
  * declared `budget` matches what its own file tree actually contains.
23
+ * `packageScripts` is the pack's `wiring.packageScripts`, added to the
24
+ * `scripts` count -- those are merged into the target's real `package.json`
25
+ * at install time, never shipped as a file, so the tree walk can't see them.
23
26
  */
24
- export declare function countPackBudget(packFilesDir: string): CapCounts;
27
+ export declare function countPackBudget(packFilesDir: string, packageScripts?: Record<string, string>): CapCounts;
25
28
  //# sourceMappingURL=caps.d.ts.map
package/dist/caps.js CHANGED
@@ -1,8 +1,15 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Counts the baseline's own `.claude/` + workflow + script artifacts
3
5
  * against the five hard caps `templates/core`'s own `CLAUDE.md` states
4
6
  * (≤5 agents, ≤8 skills, ≤10 hooks, ≤3 CI workflows, ≤12 root scripts).
5
- * The counting logic and the cap numbers both live here, once, so
7
+ * A cap is a fixed upper bound on how many artifacts of one category
8
+ * (agents, skills, hooks, CI workflows, root `package.json` scripts) the
9
+ * emitted baseline may contain -- kept deliberately low so a bootstrapped
10
+ * project's Claude Code harness and toolchain stay small enough for a human
11
+ * to read and reason about in full, rather than growing without bound as
12
+ * more capability gets added. The counting logic and the cap numbers both live here, once, so
6
13
  * `report.ts` (the adoption-report table) and `main.ts` (the fresh-mode
7
14
  * post-install summary) can't state a different number for the same cap.
8
15
  */
@@ -62,8 +69,15 @@ export function countBaselineCaps(templateRoot) {
62
69
  * the same shape as {@link countBaselineCaps} but without the `/customize`
63
70
  * adjustment, since a pack never ships that skill. Used to verify a pack's
64
71
  * declared `budget` matches what its own file tree actually contains.
72
+ * `packageScripts` is the pack's `wiring.packageScripts`, added to the
73
+ * `scripts` count -- those are merged into the target's real `package.json`
74
+ * at install time, never shipped as a file, so the tree walk can't see them.
65
75
  */
66
- export function countPackBudget(packFilesDir) {
67
- return countArtifacts(packFilesDir);
76
+ export function countPackBudget(packFilesDir, packageScripts) {
77
+ const raw = countArtifacts(packFilesDir);
78
+ return {
79
+ ...raw,
80
+ scripts: raw.scripts + Object.keys(packageScripts ?? {}).length,
81
+ };
68
82
  }
69
83
  //# sourceMappingURL=caps.js.map
@@ -6,7 +6,28 @@ export interface FileConflict {
6
6
  /** Present only for a key-level comparison (package.json / tsconfig*.json): the top-level keys that differ. */
7
7
  keyDiffs: string[] | undefined;
8
8
  }
9
- /** Compares every file `templates/core` would emit against what `targetDir` already has. */
10
- export declare function planConflicts(templateRoot: string, targetDir: string, tokens: TokenTable): FileConflict[];
9
+ /**
10
+ * Compares every file `templates/core` (or a pack's `files/`) would emit
11
+ * against what `targetDir` already has. A target path this process cannot
12
+ * reach (`EACCES`/`EPERM`/`ELOOP`) is never reported `absent`: it is
13
+ * `divergent`, and a note naming the errno is appended to `undetermined`
14
+ * once (adopt mode passes the survey's own list, so the report shows it) --
15
+ * naming the enclosing directory for a permission failure on the `stat`, the
16
+ * path itself for a symlink loop, or for a permission failure or a
17
+ * directory-where-a-file-was-expected (`EISDIR`) on the read. A dangling
18
+ * symlink at the target path is `divergent` too, recorded naming the path;
19
+ * so is a dangling symlink at one of its ancestors below `targetDir`,
20
+ * recorded once naming that ancestor. A regular file where an enclosing
21
+ * directory should be (`ENOTDIR`) is `divergent`, recorded once naming that
22
+ * blocking ancestor. A genuinely missing path is `absent`; any other errno
23
+ * throws.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * const undetermined: string[] = [];
28
+ * const conflicts = planConflicts(templateRoot, targetDir, tokens, undetermined);
29
+ * ```
30
+ */
31
+ export declare function planConflicts(templateRoot: string, targetDir: string, tokens: TokenTable, undetermined?: string[]): FileConflict[];
11
32
  export {};
12
33
  //# sourceMappingURL=conflicts.d.ts.map