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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +574 -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 +21 -13
  41. package/dist/packs.js +241 -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 +44 -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 +295 -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 +41 -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/dist/main.js CHANGED
@@ -1,32 +1,43 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Entry point: `m3l-groundwork <target-dir> [options]`. No prompts, no
3
5
  * interactivity, no network call beyond the package install -- this must
4
6
  * work on a plane against a warm pnpm store.
5
7
  *
6
8
  * Two modes, auto-detected from the target directory (`--adopt`/`--fresh`
7
- * force either): **fresh** writes the baseline into an empty directory, as
8
- * before, and installs any `--pack` requested. **adopt** surveys an
9
+ * force either): **fresh** writes the baseline template tree into an empty
10
+ * or missing target directory, then installs any `--pack` requested.
11
+ * **adopt** surveys an
9
12
  * already-established project and writes only a report -- see `mode.ts`,
10
13
  * `survey/survey.ts`, `conflicts.ts`, and `report.ts`. Adopt mode never
11
14
  * touches a project file, including a pack's: it surveys every pack under
12
- * `templates/packs/` into the report and defers installation to
13
- * `/customize`; see `runAdopt`.
15
+ * `templates/packs/` into the report, stages each as inert `.staged` copies
16
+ * under `.groundwork/packs/`, and defers installation to `/customize`; see
17
+ * `runAdopt`.
14
18
  */
15
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync, } from "node:fs";
20
+ import assert from "node:assert/strict";
16
21
  import { join, relative, resolve, basename } from "node:path";
22
+ import process from "node:process";
17
23
  import { resolveAsset } from "./assets.js";
18
24
  import { CAP_LIMITS, countBaselineCaps } from "./caps.js";
19
- import { emitTemplate } from "./emit.js";
25
+ import { assertSafeEmitDestinations, emitTemplate, isPathContained, } from "./emit.js";
20
26
  import { installCustomizeSkill, installCustomizeSkillGuarded, } from "./plugin.js";
27
+ import { CLAUDE_DEST_SEGMENTS, CUSTOMIZE_SKILL_FILE_NAMES, GROUNDWORK_DEST_SEGMENTS, plannedCustomizeSkillPaths, } from "./customize-paths.js";
21
28
  import { gitInit, runInstall } from "./git.js";
22
29
  import { gradeHarness } from "./harness/grade.js";
23
30
  import { detectMode, resolveMode } from "./mode.js";
24
31
  import { surveyProject } from "./survey/survey.js";
25
32
  import { planConflicts } from "./conflicts.js";
33
+ import { STAGED_BASELINE_DIR, STAGED_SUFFIX, planBaselineStaging, stageBaselineAdditions, } from "./baseline-stage.js";
34
+ import { assertDirectoryComponent, assertNotDirectory, assertNotSymlink, endsWithRerunAdvice, FIX_AND_RERUN_ADVICE, FRESH_SYMLINK_ADVICE, } from "./fs-guard.js";
26
35
  import { buildInventory, resolveCliVersion, writeInventory, } from "./inventory.js";
27
- import { listPackNames, loadPack, installPack, observeWiring, stagePackFiles, } from "./packs.js";
36
+ import { listPackNames, loadPack, installPack, observeWiring, } from "./packs.js";
37
+ import { STAGED_PACKS_DIR, planPackStaging, stagePacks } from "./pack-stage.js";
28
38
  import { renderReport } from "./report.js";
29
39
  import { gradeToolchain } from "./toolchain/grade.js";
40
+ import { paint } from "./term.js";
30
41
  const USAGE = [
31
42
  "usage: m3l-groundwork <target-dir> [options]",
32
43
  "",
@@ -45,9 +56,9 @@ const USAGE = [
45
56
  "looks like a project (package.json, .git, or loose source files) is",
46
57
  "surveyed and adopted instead -- see .groundwork/adoption-report.md.",
47
58
  "",
48
- "A pack requested in adopt mode is not installed -- adopt mode never",
49
- "writes project files. Every available pack is surveyed into the report",
50
- "regardless; run /customize to install one.",
59
+ "--pack and --force are rejected in adopt mode -- adopt mode never writes",
60
+ "project files. Every available pack is surveyed into the report",
61
+ "automatically; run /customize to install one.",
51
62
  ].join("\n");
52
63
  // --name takes a single value; --pack is repeatable. Every other
53
64
  // recognized flag is a bare boolean.
@@ -56,6 +67,15 @@ const REPEATABLE_VALUE_FLAGS = new Set(["--pack"]);
56
67
  const HELP_FLAGS = new Set(["--help", "-h"]);
57
68
  const VERSION_FLAGS = new Set(["--version", "-v"]);
58
69
  const LIST_PACKS_FLAGS = new Set(["--list-packs"]);
70
+ // A --pack value is a bare directory name under templates/packs/ -- this
71
+ // allowlist keeps it from ever reaching packs.ts's join() as a path.
72
+ const PACK_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
73
+ // A --name value is substituted verbatim into the emitted package.json, so it
74
+ // must fit a conservative, ASCII-lowercase subset of npm's naming rules
75
+ // (optional @scope/). Reserved names like `node_modules` are out of scope.
76
+ const NPM_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
77
+ // npm's own ceiling on a package name's total length, scope included.
78
+ const NPM_NAME_MAX_LENGTH = 214;
59
79
  const BOOLEAN_FLAGS = new Set([
60
80
  "--skip-install",
61
81
  "--force",
@@ -110,6 +130,9 @@ function tokenizeArgv(argv) {
110
130
  if (value === undefined || value.startsWith("-")) {
111
131
  throw new CliUsageError(`${arg} requires a value\n\n${USAGE}`);
112
132
  }
133
+ if (values.has(arg)) {
134
+ throw new CliUsageError(`${arg} given more than once\n\n${USAGE}`);
135
+ }
113
136
  values.set(arg, value);
114
137
  i++;
115
138
  continue;
@@ -153,7 +176,17 @@ export function parseArgs(argv) {
153
176
  }
154
177
  const targetDir = resolve(targetArg);
155
178
  const explicitName = values.get("--name");
156
- const packs = [...new Set(repeatableValues.get("--pack") ?? [])].sort();
179
+ if (explicitName !== undefined &&
180
+ (explicitName.length > NPM_NAME_MAX_LENGTH ||
181
+ !NPM_NAME_PATTERN.test(explicitName))) {
182
+ throw new CliUsageError(`--name must be a valid npm package name (lowercase, optional @scope/, at most ${String(NPM_NAME_MAX_LENGTH)} characters): ${JSON.stringify(explicitName)}\n\n${USAGE}`);
183
+ }
184
+ const packValues = repeatableValues.get("--pack") ?? [];
185
+ const badPack = packValues.find((name) => !PACK_NAME_PATTERN.test(name));
186
+ if (badPack !== undefined) {
187
+ throw new CliUsageError(`--pack must be a pack name matching ${PACK_NAME_PATTERN.source}: ${JSON.stringify(badPack)}\n\n${USAGE}`);
188
+ }
189
+ const packs = [...new Set(packValues)].sort();
157
190
  return {
158
191
  targetDir,
159
192
  projectName: explicitName ?? basename(targetDir),
@@ -180,7 +213,18 @@ function buildTokens(projectName) {
180
213
  function formatCounts(counts) {
181
214
  return `${counts.agents} agents, ${counts.skills} skills, ${counts.hooks} hooks, ${counts.workflows} workflows, ${counts.scripts} scripts`;
182
215
  }
183
- /** The post-`--pack`-install caps summary line(s) printed to fresh-mode's console output. */
216
+ /**
217
+ * The post-`--pack`-install caps summary line(s) printed to fresh-mode's
218
+ * console output, plus whether any cap is exceeded.
219
+ *
220
+ * @example
221
+ * ```ts
222
+ * const summary = formatCapsSummary(baselineCounts, [
223
+ * { name: "harness-extras", budget: packBudget },
224
+ * ]);
225
+ * console.log(summary.text);
226
+ * ```
227
+ */
184
228
  export function formatCapsSummary(baseline, installed) {
185
229
  const total = { ...baseline };
186
230
  const lines = [
@@ -196,38 +240,328 @@ export function formatCapsSummary(baseline, installed) {
196
240
  }
197
241
  const overCap = ["agents", "skills", "hooks", "workflows", "scripts"].filter((key) => total[key] > CAP_LIMITS[key]);
198
242
  lines.push(`= ${formatCounts(total)}${overCap.length > 0 ? ` ⚠ over cap: ${overCap.join(", ")}` : ""}`);
199
- return lines.join("\n");
243
+ return { text: lines.join("\n"), overCap: overCap.length > 0 };
244
+ }
245
+ /**
246
+ * Fresh mode's closing "setup required" block: one heading, then each pack
247
+ * that declares `setupSteps` (in `packs` order) followed by its commands,
248
+ * each indented two spaces. `undefined` when no pack declares any, so the
249
+ * caller prints nothing extra.
250
+ */
251
+ function formatSetupBlock(targetDir, packs) {
252
+ const lines = [];
253
+ for (const { manifest } of packs) {
254
+ const steps = manifest.setupSteps ?? [];
255
+ if (steps.length === 0)
256
+ continue;
257
+ lines.push(`pack "${manifest.name}":`, ...steps.map((step) => ` ${step}`));
258
+ }
259
+ if (lines.length === 0)
260
+ return undefined;
261
+ return [
262
+ `\nthe installed pack(s) need setup before the first \`pnpm verify\` -- run these in ${targetDir}:`,
263
+ ...lines,
264
+ ].join("\n");
200
265
  }
201
- function runFresh(options) {
266
+ /**
267
+ * Fresh mode's pre-flight over the `/customize` skill's own destination
268
+ * (`.claude/skills/customize/`): each directory component must be missing
269
+ * or a real directory, and no payload file name may be a directory. A
270
+ * symlink AT a payload name passes -- the install replaces it rather than
271
+ * writing through it. Nothing is written either way.
272
+ */
273
+ function assertSafeSkillDestination(targetDir) {
274
+ for (let depth = 1; depth <= CLAUDE_DEST_SEGMENTS.length; depth++) {
275
+ assertDirectoryComponent(join(targetDir, ...CLAUDE_DEST_SEGMENTS.slice(0, depth)), FRESH_SYMLINK_ADVICE);
276
+ }
277
+ const destDir = join(targetDir, ...CLAUDE_DEST_SEGMENTS);
278
+ for (const name of CUSTOMIZE_SKILL_FILE_NAMES) {
279
+ assertNotDirectory(join(destDir, name), FRESH_SYMLINK_ADVICE);
280
+ }
281
+ }
282
+ function runFresh(options, platform) {
283
+ if (platform === "win32") {
284
+ throw new Error("Windows is not supported yet (Linux and macOS only)");
285
+ }
202
286
  if (!isEmptyOrMissing(options.targetDir) && !options.force) {
203
287
  throw new Error(`${options.targetDir} already exists and is not empty (pass --force to overwrite, or --adopt to survey it instead)`);
204
288
  }
205
- mkdirSync(options.targetDir, { recursive: true });
289
+ // Resolve every --pack before the first write: a bad name must leave the
290
+ // target exactly as it was found.
291
+ const packs = options.packs.map((name) => resolveFreshPack(name));
206
292
  const tokens = buildTokens(options.projectName);
293
+ // Validate every destination the baseline AND each pack would write
294
+ // before the first write: a symlinked or non-directory component refuses
295
+ // the whole run with nothing written.
296
+ assertSafeEmitDestinations([templatesCoreDir(), ...packs.map((pack) => pack.filesDir)], options.targetDir, tokens);
297
+ // The /customize skill's destination too: its own install refuses a
298
+ // symlinked directory component or a directory at a payload name, but it
299
+ // runs only after the baseline is written -- too late to leave the target
300
+ // untouched.
301
+ assertSafeSkillDestination(options.targetDir);
302
+ mkdirSync(options.targetDir, { recursive: true });
207
303
  const result = emitTemplate(templatesCoreDir(), options.targetDir, tokens);
208
304
  console.log(`wrote ${result.filesWritten.length} files to ${options.targetDir}`);
209
305
  const installedPacks = [];
210
- for (const name of options.packs) {
211
- const pack = loadPack(name);
212
- if (!pack.manifest.modes.includes("fresh")) {
213
- throw new Error(`pack "${name}" does not support fresh mode (modes: ${pack.manifest.modes.join(", ")})`);
214
- }
306
+ for (const pack of packs) {
307
+ const name = pack.manifest.name;
215
308
  const packResult = installPack(pack, options.targetDir, tokens);
216
309
  console.log(`installed pack "${name}" (${packResult.filesWritten.length} files)`);
217
310
  installedPacks.push({ name, budget: packResult.budget });
218
311
  }
219
312
  if (installedPacks.length > 0) {
220
- console.log(formatCapsSummary(countBaselineCaps(templatesCoreDir()), installedPacks));
313
+ const summary = formatCapsSummary(countBaselineCaps(templatesCoreDir()), installedPacks);
314
+ console.log(summary.overCap
315
+ ? paint(process.stdout, "warning", summary.text)
316
+ : summary.text);
317
+ }
318
+ const { targetDir, skipInstall, projectName } = options;
319
+ // Built once, now that the project and its packs are on disk, and printed
320
+ // exactly once per run: at the very end on success, or just before the
321
+ // throw when git init / pnpm install fails -- a plain re-run of a
322
+ // non-empty target adopts it, and adopt mode never prints setupSteps. The
323
+ // /customize-skill failure below deliberately does not print it: its
324
+ // message says to re-run with --fresh --force, which prints it on success.
325
+ const setupBlock = formatSetupBlock(targetDir, packs);
326
+ const printSetupBlock = () => {
327
+ if (setupBlock !== undefined) {
328
+ console.log(paint(process.stdout, "warning", setupBlock));
329
+ }
330
+ };
331
+ let pluginResult;
332
+ try {
333
+ pluginResult = installCustomizeSkill(targetDir);
334
+ }
335
+ catch (error) {
336
+ // The target is no longer empty, so a plain re-run would auto-detect
337
+ // adopt mode; only --fresh --force repeats this run. With
338
+ // --skip-install, pnpm install was never going to run, so don't claim
339
+ // the failure stopped it (same split as the git-init branch below).
340
+ const notRun = skipInstall ? "git init" : "git init / pnpm install";
341
+ throw new Error(`the project was written to ${targetDir}, but the /customize skill install failed and ${notRun} did not run -- fix the cause, then re-run with --fresh --force (plus your original --name/--pack/--skip-install); a plain re-run adopts it`, { cause: error });
342
+ }
343
+ console.log(pluginResult.filesWritten.length === 0
344
+ ? "the /customize skill was already up to date"
345
+ : `installed the /customize skill (${pluginResult.filesWritten.length} files)`);
346
+ try {
347
+ gitInit(targetDir);
348
+ }
349
+ catch (error) {
350
+ printSetupBlock();
351
+ throw new Error(`git init failed, but the project was written to ${targetDir}; run \`git init\`${skipInstall ? "" : " and `pnpm install`"} there yourself`, { cause: error });
221
352
  }
222
- const pluginResult = installCustomizeSkill(options.targetDir);
223
- console.log(`installed the /customize skill (${pluginResult.filesWritten.length} files)`);
224
- gitInit(options.targetDir);
225
353
  console.log("initialized git repository");
226
- if (!options.skipInstall) {
227
- runInstall(options.targetDir);
354
+ if (!skipInstall) {
355
+ try {
356
+ runInstall(targetDir);
357
+ }
358
+ catch (error) {
359
+ console.log(paint(process.stdout, "warning", `\n${projectName} written to ${targetDir}, but dependencies are not installed`));
360
+ printSetupBlock();
361
+ throw new Error(`${describeInstallFailure(error)}; the project was written to ${targetDir} -- run \`pnpm install\` there yourself to finish`, { cause: error });
362
+ }
228
363
  console.log("installed dependencies");
229
364
  }
230
- console.log(`\n✓ ${options.projectName} is ready at ${options.targetDir}`);
365
+ console.log(paint(process.stdout, "success", `\n✓ ${projectName} is ready at ${targetDir}`));
366
+ printSetupBlock();
367
+ }
368
+ /**
369
+ * Explains why the post-emission `pnpm install` failed: a missing binary
370
+ * specifically, otherwise the exit status, killing signal, or error code
371
+ * when the thrown value carries one. Each property is read exactly once.
372
+ */
373
+ function describeInstallFailure(error) {
374
+ if (typeof error !== "object" || error === null) {
375
+ return "`pnpm install` failed";
376
+ }
377
+ const code = "code" in error ? error.code : undefined;
378
+ const status = "status" in error ? error.status : undefined;
379
+ const signal = "signal" in error ? error.signal : undefined;
380
+ if (code === "ENOENT")
381
+ return "pnpm was not found on PATH";
382
+ if (typeof status === "number") {
383
+ return `\`pnpm install\` failed (exit status ${String(status)})`;
384
+ }
385
+ if (typeof signal === "string") {
386
+ return `\`pnpm install\` failed (killed by signal ${signal})`;
387
+ }
388
+ if (typeof code === "string")
389
+ return `\`pnpm install\` failed (${code})`;
390
+ return "`pnpm install` failed";
391
+ }
392
+ // A pack that was renamed or folded into another, mapped to its successor so
393
+ // an old `--pack` name gets a pointed hint rather than a bare "unknown pack".
394
+ const RENAMED_PACKS = new Map([
395
+ ["statusline", "harness-extras"],
396
+ ]);
397
+ /**
398
+ * Loads one `--pack` for fresh mode -- called for every pack before any file
399
+ * is written. An unknown name (with a rename hint when {@link RENAMED_PACKS}
400
+ * knows its successor) or a fresh-incompatible pack is a usage error (exit
401
+ * 2); a pack that exists but whose manifest fails to load propagates
402
+ * `loadPack`'s own error unchanged (exit 1), since that is a broken install,
403
+ * not a bad invocation.
404
+ */
405
+ function resolveFreshPack(name) {
406
+ const available = listPackNames();
407
+ if (!available.includes(name)) {
408
+ const successor = RENAMED_PACKS.get(name);
409
+ const hint = successor === undefined
410
+ ? ""
411
+ : ` -- it was renamed to "${successor}"; use --pack ${successor}`;
412
+ const list = available.length > 0 ? available.join(", ") : "none";
413
+ throw new CliUsageError(`unknown pack "${name}"${hint} (available: ${list})\n\n${USAGE}`);
414
+ }
415
+ const pack = loadPack(name);
416
+ if (!pack.manifest.modes.includes("fresh")) {
417
+ throw new CliUsageError(`pack "${name}" does not support fresh mode (modes: ${pack.manifest.modes.join(", ")})\n\n${USAGE}`);
418
+ }
419
+ return pack;
420
+ }
421
+ /**
422
+ * Rejects flags and targets adopt mode cannot honor, as usage errors (exit
423
+ * 2) rather than silently ignoring them: a missing target directory (there
424
+ * is nothing to survey), `--force` (adopt mode never writes project files),
425
+ * and `--pack` (every pack is surveyed automatically).
426
+ */
427
+ function assertAdoptUsage(options) {
428
+ if (!existsSync(options.targetDir)) {
429
+ throw new CliUsageError(`${options.targetDir} does not exist -- --adopt needs an existing project to survey (use fresh mode, or omit --adopt, to bootstrap a new one)\n\n${USAGE}`);
430
+ }
431
+ if (options.force) {
432
+ throw new CliUsageError(`--force has no effect in adopt mode -- adopt mode never writes project files; run /customize to reconcile instead.\n\n${USAGE}`);
433
+ }
434
+ if (options.packs.length > 0) {
435
+ throw new CliUsageError(`--pack has no effect in adopt mode -- every pack is surveyed automatically; run /customize to install one.\n\n${USAGE}`);
436
+ }
437
+ }
438
+ /**
439
+ * Adopt mode's write-scope invariant (docs/assurance-case.md's trust
440
+ * boundary around the adopted project): every path it writes resolves under
441
+ * `<targetDir>/.groundwork/` or the guarded `/customize` install at
442
+ * `<targetDir>/.claude/skills/customize/` -- never an existing project file.
443
+ * `paths` may be absolute or relative to `targetDir`. Containment is
444
+ * {@link isPathContained}'s, so a sibling that merely shares a root's name
445
+ * as a prefix (`.groundwork-evil/`) is rejected. Both roots are derived from
446
+ * `customize-paths.ts`'s segment constants, so they cannot drift from where
447
+ * the install actually writes. The check is lexical -- it never touches the
448
+ * filesystem, so it does not detect a symlinked directory component; that
449
+ * refusal lives in the writers themselves (`fs-guard.ts`).
450
+ *
451
+ * @throws `AssertionError` (from `node:assert/strict`) naming the first path
452
+ * that escapes both allowed roots.
453
+ *
454
+ * @example
455
+ * ```ts
456
+ * import { assertAdoptWriteScope } from "./main.js";
457
+ *
458
+ * assertAdoptWriteScope("/work/app", [".groundwork/inventory.json"]); // ok
459
+ * assertAdoptWriteScope("/work/app", ["/work/app/package.json"]); // throws
460
+ * ```
461
+ */
462
+ export function assertAdoptWriteScope(targetDir, paths) {
463
+ const allowedRoots = [
464
+ resolve(targetDir, GROUNDWORK_DEST_SEGMENTS[0]),
465
+ resolve(targetDir, ...CLAUDE_DEST_SEGMENTS),
466
+ ];
467
+ for (const path of paths) {
468
+ const resolved = resolve(targetDir, path);
469
+ assert.ok(allowedRoots.some((root) => isPathContained(resolved, root)), `adopt mode wrote outside its scope: ${resolved}`);
470
+ }
471
+ }
472
+ /** The previous run's files `runAdopt` deletes at its point of no return, in deletion order. */
473
+ const STALE_FILE_NAMES = [
474
+ "inventory.json",
475
+ "adoption-report.md",
476
+ "adoption-decisions.json",
477
+ ];
478
+ /**
479
+ * Which previous `.groundwork/` files a failed run actually removed, worded
480
+ * for a message: only those in `removed` are named, and the decisions file
481
+ * carries its "a re-run does not recreate it" caveat only when it is one of
482
+ * them.
483
+ */
484
+ function describeRemoved(removed) {
485
+ if (removed.length === 0) {
486
+ return "no previous .groundwork/ inventory, report or decisions file was removed";
487
+ }
488
+ const names = removed.length === 1
489
+ ? removed.join("")
490
+ : `${removed.slice(0, -1).join(", ")} and ${removed.slice(-1).join("")}`;
491
+ const verb = removed.length === 1 ? "was" : "were";
492
+ const decisionsCaveat = removed.includes("adoption-decisions.json")
493
+ ? " -- adoption-decisions.json held the decisions /customize recorded, which a re-run does not recreate"
494
+ : "";
495
+ return `the previous .groundwork/${names} ${verb} removed${decisionsCaveat}`;
496
+ }
497
+ /**
498
+ * A failure after `runAdopt`'s point of no return. The message embeds the
499
+ * cause's own message so it stands alone (`formatErrorChain` then skips the
500
+ * redundant `caused by:` line), names only the previous files actually
501
+ * removed, and appends the re-run advice unless the cause already ends
502
+ * with equivalent advice in any wording ({@link endsWithRerunAdvice}).
503
+ */
504
+ function removedStaleFilesError(cause, removed) {
505
+ const reason = cause instanceof Error ? cause.message : String(cause);
506
+ const advice = endsWithRerunAdvice(reason) ? "" : `; ${FIX_AND_RERUN_ADVICE}`;
507
+ return new Error(`adopt mode failed (${reason}); ${describeRemoved(removed)}${advice}`, { cause });
508
+ }
509
+ /**
510
+ * Rethrows a failure after `runAdopt`'s point of no return. An
511
+ * `AssertionError` (a broken invariant: a bug, not something a re-run
512
+ * fixes) keeps its identity, after one warning naming the previous files it
513
+ * removed, so that loss is not silent; anything else becomes
514
+ * {@link removedStaleFilesError}.
515
+ */
516
+ function rethrowAfterPointOfNoReturn(cause, removed) {
517
+ if (cause instanceof assert.AssertionError) {
518
+ if (removed.length > 0) {
519
+ console.warn(`warning: adopt mode stopped on a broken invariant after ${describeRemoved(removed)}`);
520
+ }
521
+ throw cause;
522
+ }
523
+ throw removedStaleFilesError(cause, removed);
524
+ }
525
+ /**
526
+ * The adopt-mode next step after a `.groundwork/customize/` fallback, chosen
527
+ * by why it was taken: only an `"entry"` fallback has a project-local copy
528
+ * of the skill to replace. Returned without a `Next: ` prefix and starting
529
+ * lower-case; each caller adds its own framing. `undefined` -- which the installer never returns
530
+ * alongside a `"groundwork"` location -- is a contract violation and throws
531
+ * rather than guessing.
532
+ */
533
+ function groundworkNextStep(cause) {
534
+ const staged = "the current /customize skill is staged at .groundwork/customize/, but Claude Code does not load skills from there";
535
+ switch (cause) {
536
+ case "component":
537
+ return `${staged}, and no project-local .claude/skills/customize/ copy exists -- fix or replace the .claude path named above so .claude/skills/customize/ is a real directory, copy the staged skill there and then run /customize, or run the m3l-groundwork plugin's own /customize.`;
538
+ case "entry":
539
+ return `${staged} -- run the m3l-groundwork plugin's own /customize, or replace the project-local .claude/skills/customize/ copy with the staged one and then run /customize.`;
540
+ case undefined:
541
+ throw new Error('unhandled fallback cause: undefined (a "groundwork" install result must carry fallbackCause)');
542
+ default: {
543
+ const exhaustive = cause;
544
+ throw new Error(`unhandled fallback cause: ${String(exhaustive)}`);
545
+ }
546
+ }
547
+ }
548
+ /**
549
+ * The report's `## Next step` text for a `"groundwork"` install result: the
550
+ * fallback reason first, then {@link groundworkNextStep}'s sentence -- the
551
+ * same order the console prints them in, which the `"component"` text's
552
+ * "the .claude path named above" depends on. `renderReport` strips one
553
+ * leading `Next: ` and upper-cases only the text's first character, so the
554
+ * reason carries that prefix and the sentence after it is capitalized here.
555
+ * A missing reason keeps the sentence alone rather than printing `undefined`.
556
+ */
557
+ function groundworkReportNextStep(result) {
558
+ const sentence = groundworkNextStep(result.fallbackCause);
559
+ const reason = result.fallbackReason;
560
+ if (reason === undefined) {
561
+ return `Next: ${sentence}`;
562
+ }
563
+ const separator = /[.!?]$/.test(reason) ? " " : ". ";
564
+ return `Next: ${reason}${separator}${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}`;
231
565
  }
232
566
  /**
233
567
  * Surveys an already-established project and writes `.groundwork/` --
@@ -235,53 +569,216 @@ function runFresh(options) {
235
569
  * the one addition is a purely-additive, collision-guarded copy of the
236
570
  * `/customize` skill (see `installCustomizeSkillGuarded`), so the report
237
571
  * can point straight at a working next step. Every pack under
238
- * `templates/packs/` is surveyed (not just those named by `--pack`, which
239
- * this mode ignores) and staged, unapplied, at `.groundwork/packs/<name>/`.
572
+ * `templates/packs/` is surveyed (`main` rejects `--pack` in this mode, see
573
+ * `assertAdoptUsage`) and staged, unapplied and inert, at
574
+ * `.groundwork/packs/<name>/` -- its manifest as `pack.json.staged`, its
575
+ * files as `files/<path>.staged` -- all packs written to a temporary
576
+ * sibling directory and swapped in by rename, so `packs/` is never
577
+ * half-written (`stagePacks`); absent baseline files are staged the same
578
+ * way as inert `<path>.staged` copies at `.groundwork/baseline/`. With a
579
+ * previous staging in place each swap is two renames (park the old
580
+ * directory, then move the new one in); a kill between them leaves that
581
+ * directory absent with the old copy under its `.packs-*`/`.baseline-*`
582
+ * work directory's `previous/` -- safe, because `inventory.json` was
583
+ * already deleted, so nothing reads the gap as a completed run.
584
+ *
585
+ * Checked before anything under `.groundwork/` is deleted or written, in
586
+ * this order: the three stale-file paths are scope-checked against
587
+ * {@link assertAdoptWriteScope}; every pack is loaded and validated by
588
+ * `loadPack` and surveyed (`planConflicts`, `observeWiring`); the pack
589
+ * staging plan (`planPackStaging`) and the baseline staging plan
590
+ * (`planBaselineStaging`) are each computed once, validated, and every path
591
+ * they would write scope-checked; the `/customize` skill's planned install
592
+ * paths (`plannedCustomizeSkillPaths`, a lexical check of its path
593
+ * constants) are scope-checked; then `.groundwork/`, `.groundwork/packs`
594
+ * and `.groundwork/baseline` are each refused if they are a symlink. So an
595
+ * invalid pack (a malformed manifest, a prototype-sensitive key in its
596
+ * wiring, an unstageable file tree), an invalid baseline plan, an
597
+ * out-of-scope staging or skill-install path, or a symlinked staging
598
+ * directory throws with the previous `.groundwork/` untouched.
599
+ *
600
+ * **The point of no return** is the first deletion of a stale
601
+ * `inventory.json`/`adoption-report.md`/`adoption-decisions.json` (in that
602
+ * order), which follows those checks; which of the three existed is
603
+ * recorded just before. After the deletions, packs and the baseline are
604
+ * staged from the plans already computed (their trees are not walked
605
+ * again), the harness and toolchain are graded, the `/customize` skill is
606
+ * installed and its writes scope-checked, `adoption-report.md` is written
607
+ * (its `## Next step` leading with the same sentence the console prints
608
+ * when the skill fell back to `.groundwork/customize/`),
609
+ * and `inventory.json` is written last (atomically, via a temp file and
610
+ * rename). A failure in any of those steps -- a failed deletion included --
611
+ * is rethrown as an `Error` with the failure as `cause` and its message
612
+ * embedded, naming only the previous files that existed and were actually
613
+ * removed (and, when `adoption-decisions.json` is among them, that it held
614
+ * the decisions `/customize` recorded, which a re-run does not recreate),
615
+ * and saying to fix the cause and re-run the CLI -- once, even when the
616
+ * cause's own message already says so. An `AssertionError` (a broken
617
+ * write-scope or containment invariant, a bug a re-run cannot fix) keeps
618
+ * its identity instead, after one `console.warn` naming the removed files
619
+ * (none when nothing was removed).
620
+ *
621
+ * If `inventory.json` fails to write, the just-written report is first
622
+ * removed (best effort: a failed removal only warns, never masking the
623
+ * write failure). Only console output follows `inventory.json`, so its
624
+ * presence means every step of the run completed.
240
625
  */
241
626
  function runAdopt(options, detection) {
242
- if (options.force) {
243
- throw new Error("--force has no effect in adopt mode -- adopt mode never writes project files; run /customize to reconcile instead.");
244
- }
245
627
  console.log(`adopt mode: ${detection.signal}`);
246
628
  const survey = surveyProject(options.targetDir);
247
629
  const templateRoot = templatesCoreDir();
248
630
  const tokens = buildTokens(options.projectName);
249
- const conflicts = planConflicts(templateRoot, options.targetDir, tokens);
631
+ // The survey's own `undetermined` list: a baseline or pack target this
632
+ // process cannot reach is recorded there (and so in the report), never
633
+ // reported as a clean add.
634
+ const conflicts = planConflicts(templateRoot, options.targetDir, tokens, survey.undetermined);
250
635
  const groundworkDir = join(options.targetDir, ".groundwork");
251
- const packs = listPackNames().map((name) => {
252
- const pack = loadPack(name);
253
- const fileConflicts = planConflicts(pack.filesDir, options.targetDir, tokens);
254
- const wiringObservations = observeWiring(options.targetDir, pack.manifest);
255
- stagePackFiles(pack, groundworkDir);
256
- return {
257
- name: pack.manifest.name,
258
- modes: pack.manifest.modes,
259
- budget: pack.manifest.budget,
260
- fileConflicts,
261
- wiring: pack.manifest.wiring,
262
- wiringObservations,
263
- adoptNotes: pack.manifest.adoptNotes,
264
- };
265
- });
266
- const inventory = buildInventory({
267
- detection,
268
- templateRoot,
269
- targetDir: options.targetDir,
270
- survey,
271
- conflicts,
272
- packs,
273
- harnessGrade: gradeHarness(options.targetDir),
274
- toolchainGrade: gradeToolchain(options.targetDir),
275
- });
276
- const inventoryPath = writeInventory(inventory, groundworkDir);
636
+ const stagedBaselineDir = `.groundwork/${STAGED_BASELINE_DIR}`;
637
+ const stalePaths = STALE_FILE_NAMES.map((name) => ({
638
+ name,
639
+ path: join(groundworkDir, name),
640
+ }));
641
+ const inventoryPath = join(groundworkDir, "inventory.json");
277
642
  const reportPath = join(groundworkDir, "adoption-report.md");
278
- writeFileSync(reportPath, renderReport(inventory));
643
+ assertAdoptWriteScope(options.targetDir, stalePaths.map(({ path }) => path));
644
+ // Every pack must pass loadPack's validation (including its
645
+ // prototype-sensitive wiring-key check), and both staging plans (packs and
646
+ // baseline) must be computed and scope-checked, before anything under
647
+ // .groundwork/ is touched, so an invalid pack or plan fails the run with
648
+ // the previous inventory/report still intact rather than half-cleared.
649
+ const loadedPacks = listPackNames().map((name) => loadPack(name));
650
+ const packs = loadedPacks.map((pack) => ({
651
+ name: pack.manifest.name,
652
+ modes: pack.manifest.modes,
653
+ budget: pack.manifest.budget,
654
+ fileConflicts: planConflicts(pack.filesDir, options.targetDir, tokens, survey.undetermined),
655
+ wiring: pack.manifest.wiring,
656
+ wiringObservations: observeWiring(options.targetDir, pack.manifest, survey.undetermined),
657
+ adoptNotes: pack.manifest.adoptNotes,
658
+ }));
659
+ // Each plan is computed once, here, and handed to its stager below, so
660
+ // what was scope-checked is exactly what gets written.
661
+ const packPlan = planPackStaging(loadedPacks, groundworkDir, tokens);
662
+ assertAdoptWriteScope(options.targetDir, packPlan.paths);
663
+ const baselinePlan = planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens);
664
+ assertAdoptWriteScope(options.targetDir, baselinePlan.paths);
665
+ // A constant drift guard only: it lexically checks paths built from
666
+ // customize-paths.ts's constants, so it can catch one of those constants
667
+ // being edited to escape the project, and nothing else -- it reads no
668
+ // filesystem state, so it cannot detect a symlink or any other runtime
669
+ // condition. The installer lstat-checks every directory component itself
670
+ // (and falls back to .groundwork/customize/ when one under .claude/ is a
671
+ // symlink or not a directory -- see fallbackReason below).
672
+ assertAdoptWriteScope(options.targetDir, plannedCustomizeSkillPaths());
673
+ // A symlinked .groundwork/ or staging directory would redirect a delete
674
+ // or a write outside the project; refuse it before anything is deleted.
675
+ // (Each stager repeats its own check; these make the refusal precede the
676
+ // deletions below.)
677
+ assertNotSymlink(groundworkDir);
678
+ assertNotSymlink(join(options.targetDir, STAGED_PACKS_DIR));
679
+ assertNotSymlink(join(groundworkDir, STAGED_BASELINE_DIR));
680
+ // Recorded before any deletion, so a failure message names only files a
681
+ // previous run actually left (and this run actually removed).
682
+ const preexisting = new Set(stalePaths.filter(({ path }) => existsSync(path)).map(({ name }) => name));
683
+ const removed = [];
684
+ let inventory;
685
+ let pluginResult;
686
+ let nextStep;
687
+ try {
688
+ // The point of no return: the first deletion. A previous run's
689
+ // inventory/report -- and the decisions /customize recorded against
690
+ // them -- must not survive a run that fails part-way: /customize would
691
+ // read them as describing the new staging. All three go before anything
692
+ // is staged; inventory/report are rewritten only at the end, and the
693
+ // decisions file only by /customize. Inside the wrapping, so a failed
694
+ // deletion reports which files are already gone. Every path is removed,
695
+ // not just the pre-existing ones: existsSync follows symlinks, so a
696
+ // dangling one must still go before the report's "wx" write below.
697
+ for (const { name, path } of stalePaths) {
698
+ rmSync(path, { force: true });
699
+ if (preexisting.has(name)) {
700
+ removed.push(name);
701
+ }
702
+ }
703
+ const stagedPacks = stagePacks(loadedPacks, groundworkDir, tokens, packPlan);
704
+ const stagedBaselineFiles = stageBaselineAdditions(templateRoot, conflicts, groundworkDir, tokens, baselinePlan);
705
+ inventory = buildInventory({
706
+ detection,
707
+ templateRoot,
708
+ targetDir: options.targetDir,
709
+ survey,
710
+ conflicts,
711
+ packs,
712
+ harnessGrade: gradeHarness(options.targetDir),
713
+ toolchainGrade: gradeToolchain(options.targetDir),
714
+ stagedBaseline: {
715
+ dir: stagedBaselineDir,
716
+ suffix: STAGED_SUFFIX,
717
+ files: stagedBaselineFiles,
718
+ },
719
+ stagedPacks,
720
+ });
721
+ // The /customize skill install runs before the two .groundwork/ files
722
+ // (the report's "wx" write below can still fail after it), so
723
+ // inventory.json's presence still means the install completed. Its
724
+ // planned paths were scope-checked before the deletions above.
725
+ pluginResult = installCustomizeSkillGuarded(options.targetDir);
726
+ // Deliberately no rollback if this throws: what the installer reports it
727
+ // wrote must sit inside the planned scope, so a failure here means the
728
+ // installer and customize-paths.ts drifted apart -- a programming error
729
+ // to surface loudly, not a runtime condition to recover from.
730
+ assertAdoptWriteScope(options.targetDir, pluginResult.filesWritten);
731
+ // Resolved before the report and inventory are written, so a contract
732
+ // violation in the result fails the run before inventory.json claims it
733
+ // completed.
734
+ // The fresh copy is staged where Claude Code never loads a skill from,
735
+ // so the generic next step would be false there; the report then names
736
+ // the fallback reason and the same sentence the console prints, in the
737
+ // console's order. Otherwise the report keeps its own generic sentence.
738
+ const isGroundwork = pluginResult.location === "groundwork";
739
+ nextStep = isGroundwork
740
+ ? `Next: ${groundworkNextStep(pluginResult.fallbackCause)}`
741
+ : "Next: open this project in Claude Code and run /customize.";
742
+ const reportNextStep = isGroundwork
743
+ ? groundworkReportNextStep(pluginResult)
744
+ : undefined;
745
+ // The report next, inventory.json last (written atomically): nothing
746
+ // that can fail follows it, so its presence means the run completed.
747
+ mkdirSync(groundworkDir, { recursive: true });
748
+ // "wx": the path was removed above, so anything there now (a symlink
749
+ // raced in mid-run) makes the write fail instead of being followed.
750
+ writeFileSync(reportPath, renderReport(inventory, reportNextStep), {
751
+ flag: "wx",
752
+ });
753
+ }
754
+ catch (cause) {
755
+ rethrowAfterPointOfNoReturn(cause, removed);
756
+ }
757
+ const { stagedPacks } = inventory;
758
+ const stagedBaselineFiles = inventory.stagedBaseline.files;
759
+ try {
760
+ writeInventory(inventory, groundworkDir);
761
+ }
762
+ catch (error) {
763
+ // Without inventory.json the run did not complete; a report left behind
764
+ // would read as if it had. Best effort: a failed removal only warns, so
765
+ // it can never mask the write failure being rethrown.
766
+ try {
767
+ rmSync(reportPath, { force: true });
768
+ }
769
+ catch (cleanupError) {
770
+ console.warn(`warning: could not remove ${reportPath} after inventory.json failed to write -- delete it by hand (${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)})`);
771
+ }
772
+ rethrowAfterPointOfNoReturn(error, removed);
773
+ }
279
774
  console.log(`wrote ${relative(options.targetDir, inventoryPath)}`);
280
775
  console.log(`wrote ${relative(options.targetDir, reportPath)}`);
281
- if (packs.length > 0) {
282
- console.log(`staged ${packs.length} pack(s) at .groundwork/packs/ for /customize`);
776
+ if (stagedBaselineFiles.length > 0) {
777
+ console.log(`staged ${stagedBaselineFiles.length} baseline file(s) at ${stagedBaselineDir}/ for /customize`);
778
+ }
779
+ if (stagedPacks.length > 0) {
780
+ console.log(`staged ${stagedPacks.length} pack(s) at ${STAGED_PACKS_DIR}/ for /customize`);
283
781
  }
284
- const pluginResult = installCustomizeSkillGuarded(options.targetDir);
285
782
  if (pluginResult.location === "already-present") {
286
783
  console.log("the /customize skill was already up to date");
287
784
  }
@@ -290,11 +787,20 @@ function runAdopt(options, detection) {
290
787
  ? ".claude/skills/customize/"
291
788
  : ".groundwork/customize/";
292
789
  console.log(`installed the /customize skill into ${where} (${pluginResult.filesWritten.length} files)`);
790
+ if (pluginResult.fallbackReason !== undefined) {
791
+ console.log(` ${pluginResult.fallbackReason}`);
792
+ }
293
793
  }
294
- console.log(`\n✓ adoption report ready at ${reportPath}`);
295
- console.log("Next: open this project in Claude Code and run /customize.");
794
+ console.log(paint(process.stdout, "success", `\n✓ adoption report ready at ${reportPath}`));
795
+ console.log(nextStep);
296
796
  }
297
- export function main(argv) {
797
+ /**
798
+ * Runs the CLI. `platform` is injectable so fresh mode's Windows refusal
799
+ * (a runtime error, exit 1, raised before anything is written) is
800
+ * unit-testable on any OS; it defaults to `process.platform`. Adopt mode and
801
+ * `--help`/`--version`/`--list-packs` run on every platform.
802
+ */
803
+ export function main(argv, platform = process.platform) {
298
804
  const options = parseArgs(argv);
299
805
  if (options.help) {
300
806
  console.log(USAGE);
@@ -323,10 +829,11 @@ export function main(argv) {
323
829
  fresh: options.fresh,
324
830
  });
325
831
  if (resolved.mode === "adopt") {
832
+ assertAdoptUsage(options);
326
833
  runAdopt(options, resolved);
327
834
  }
328
835
  else {
329
- runFresh(options);
836
+ runFresh(options, platform);
330
837
  }
331
838
  }
332
839
  //# sourceMappingURL=main.js.map