@monte3l/groundwork 0.1.0-next.1 → 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 +16 -8
  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/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,292 @@ 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 pre-flight over the `/customize` skill's own destination
247
+ * (`.claude/skills/customize/`): each directory component must be missing
248
+ * or a real directory, and no payload file name may be a directory. A
249
+ * symlink AT a payload name passes -- the install replaces it rather than
250
+ * writing through it. Nothing is written either way.
251
+ */
252
+ function assertSafeSkillDestination(targetDir) {
253
+ for (let depth = 1; depth <= CLAUDE_DEST_SEGMENTS.length; depth++) {
254
+ assertDirectoryComponent(join(targetDir, ...CLAUDE_DEST_SEGMENTS.slice(0, depth)), FRESH_SYMLINK_ADVICE);
255
+ }
256
+ const destDir = join(targetDir, ...CLAUDE_DEST_SEGMENTS);
257
+ for (const name of CUSTOMIZE_SKILL_FILE_NAMES) {
258
+ assertNotDirectory(join(destDir, name), FRESH_SYMLINK_ADVICE);
259
+ }
200
260
  }
201
- function runFresh(options) {
261
+ function runFresh(options, platform) {
262
+ if (platform === "win32") {
263
+ throw new Error("Windows is not supported yet (Linux and macOS only)");
264
+ }
202
265
  if (!isEmptyOrMissing(options.targetDir) && !options.force) {
203
266
  throw new Error(`${options.targetDir} already exists and is not empty (pass --force to overwrite, or --adopt to survey it instead)`);
204
267
  }
205
- mkdirSync(options.targetDir, { recursive: true });
268
+ // Resolve every --pack before the first write: a bad name must leave the
269
+ // target exactly as it was found.
270
+ const packs = options.packs.map((name) => resolveFreshPack(name));
206
271
  const tokens = buildTokens(options.projectName);
272
+ // Validate every destination the baseline AND each pack would write
273
+ // before the first write: a symlinked or non-directory component refuses
274
+ // the whole run with nothing written.
275
+ assertSafeEmitDestinations([templatesCoreDir(), ...packs.map((pack) => pack.filesDir)], options.targetDir, tokens);
276
+ // The /customize skill's destination too: its own install refuses a
277
+ // symlinked directory component or a directory at a payload name, but it
278
+ // runs only after the baseline is written -- too late to leave the target
279
+ // untouched.
280
+ assertSafeSkillDestination(options.targetDir);
281
+ mkdirSync(options.targetDir, { recursive: true });
207
282
  const result = emitTemplate(templatesCoreDir(), options.targetDir, tokens);
208
283
  console.log(`wrote ${result.filesWritten.length} files to ${options.targetDir}`);
209
284
  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
- }
285
+ for (const pack of packs) {
286
+ const name = pack.manifest.name;
215
287
  const packResult = installPack(pack, options.targetDir, tokens);
216
288
  console.log(`installed pack "${name}" (${packResult.filesWritten.length} files)`);
217
289
  installedPacks.push({ name, budget: packResult.budget });
218
290
  }
219
291
  if (installedPacks.length > 0) {
220
- console.log(formatCapsSummary(countBaselineCaps(templatesCoreDir()), installedPacks));
292
+ const summary = formatCapsSummary(countBaselineCaps(templatesCoreDir()), installedPacks);
293
+ console.log(summary.overCap
294
+ ? paint(process.stdout, "warning", summary.text)
295
+ : summary.text);
296
+ }
297
+ const { targetDir, skipInstall, projectName } = options;
298
+ let pluginResult;
299
+ try {
300
+ pluginResult = installCustomizeSkill(targetDir);
301
+ }
302
+ catch (error) {
303
+ // The target is no longer empty, so a plain re-run would auto-detect
304
+ // adopt mode; only --fresh --force repeats this run. With
305
+ // --skip-install, pnpm install was never going to run, so don't claim
306
+ // the failure stopped it (same split as the git-init branch below).
307
+ const notRun = skipInstall ? "git init" : "git init / pnpm install";
308
+ 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 });
309
+ }
310
+ console.log(pluginResult.filesWritten.length === 0
311
+ ? "the /customize skill was already up to date"
312
+ : `installed the /customize skill (${pluginResult.filesWritten.length} files)`);
313
+ try {
314
+ gitInit(targetDir);
315
+ }
316
+ catch (error) {
317
+ 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
318
  }
222
- const pluginResult = installCustomizeSkill(options.targetDir);
223
- console.log(`installed the /customize skill (${pluginResult.filesWritten.length} files)`);
224
- gitInit(options.targetDir);
225
319
  console.log("initialized git repository");
226
- if (!options.skipInstall) {
227
- runInstall(options.targetDir);
320
+ if (!skipInstall) {
321
+ try {
322
+ runInstall(targetDir);
323
+ }
324
+ catch (error) {
325
+ console.log(paint(process.stdout, "warning", `\n${projectName} written to ${targetDir}, but dependencies are not installed`));
326
+ throw new Error(`${describeInstallFailure(error)}; the project was written to ${targetDir} -- run \`pnpm install\` there yourself to finish`, { cause: error });
327
+ }
228
328
  console.log("installed dependencies");
229
329
  }
230
- console.log(`\n✓ ${options.projectName} is ready at ${options.targetDir}`);
330
+ console.log(paint(process.stdout, "success", `\n✓ ${projectName} is ready at ${targetDir}`));
331
+ }
332
+ /**
333
+ * Explains why the post-emission `pnpm install` failed: a missing binary
334
+ * specifically, otherwise the exit status, killing signal, or error code
335
+ * when the thrown value carries one. Each property is read exactly once.
336
+ */
337
+ function describeInstallFailure(error) {
338
+ if (typeof error !== "object" || error === null) {
339
+ return "`pnpm install` failed";
340
+ }
341
+ const code = "code" in error ? error.code : undefined;
342
+ const status = "status" in error ? error.status : undefined;
343
+ const signal = "signal" in error ? error.signal : undefined;
344
+ if (code === "ENOENT")
345
+ return "pnpm was not found on PATH";
346
+ if (typeof status === "number") {
347
+ return `\`pnpm install\` failed (exit status ${String(status)})`;
348
+ }
349
+ if (typeof signal === "string") {
350
+ return `\`pnpm install\` failed (killed by signal ${signal})`;
351
+ }
352
+ if (typeof code === "string")
353
+ return `\`pnpm install\` failed (${code})`;
354
+ return "`pnpm install` failed";
355
+ }
356
+ // A pack that was renamed or folded into another, mapped to its successor so
357
+ // an old `--pack` name gets a pointed hint rather than a bare "unknown pack".
358
+ const RENAMED_PACKS = new Map([
359
+ ["statusline", "harness-extras"],
360
+ ]);
361
+ /**
362
+ * Loads one `--pack` for fresh mode -- called for every pack before any file
363
+ * is written. An unknown name (with a rename hint when {@link RENAMED_PACKS}
364
+ * knows its successor) or a fresh-incompatible pack is a usage error (exit
365
+ * 2); a pack that exists but whose manifest fails to load propagates
366
+ * `loadPack`'s own error unchanged (exit 1), since that is a broken install,
367
+ * not a bad invocation.
368
+ */
369
+ function resolveFreshPack(name) {
370
+ const available = listPackNames();
371
+ if (!available.includes(name)) {
372
+ const successor = RENAMED_PACKS.get(name);
373
+ const hint = successor === undefined
374
+ ? ""
375
+ : ` -- it was renamed to "${successor}"; use --pack ${successor}`;
376
+ const list = available.length > 0 ? available.join(", ") : "none";
377
+ throw new CliUsageError(`unknown pack "${name}"${hint} (available: ${list})\n\n${USAGE}`);
378
+ }
379
+ const pack = loadPack(name);
380
+ if (!pack.manifest.modes.includes("fresh")) {
381
+ throw new CliUsageError(`pack "${name}" does not support fresh mode (modes: ${pack.manifest.modes.join(", ")})\n\n${USAGE}`);
382
+ }
383
+ return pack;
384
+ }
385
+ /**
386
+ * Rejects flags and targets adopt mode cannot honor, as usage errors (exit
387
+ * 2) rather than silently ignoring them: a missing target directory (there
388
+ * is nothing to survey), `--force` (adopt mode never writes project files),
389
+ * and `--pack` (every pack is surveyed automatically).
390
+ */
391
+ function assertAdoptUsage(options) {
392
+ if (!existsSync(options.targetDir)) {
393
+ 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}`);
394
+ }
395
+ if (options.force) {
396
+ throw new CliUsageError(`--force has no effect in adopt mode -- adopt mode never writes project files; run /customize to reconcile instead.\n\n${USAGE}`);
397
+ }
398
+ if (options.packs.length > 0) {
399
+ throw new CliUsageError(`--pack has no effect in adopt mode -- every pack is surveyed automatically; run /customize to install one.\n\n${USAGE}`);
400
+ }
401
+ }
402
+ /**
403
+ * Adopt mode's write-scope invariant (docs/assurance-case.md's trust
404
+ * boundary around the adopted project): every path it writes resolves under
405
+ * `<targetDir>/.groundwork/` or the guarded `/customize` install at
406
+ * `<targetDir>/.claude/skills/customize/` -- never an existing project file.
407
+ * `paths` may be absolute or relative to `targetDir`. Containment is
408
+ * {@link isPathContained}'s, so a sibling that merely shares a root's name
409
+ * as a prefix (`.groundwork-evil/`) is rejected. Both roots are derived from
410
+ * `customize-paths.ts`'s segment constants, so they cannot drift from where
411
+ * the install actually writes. The check is lexical -- it never touches the
412
+ * filesystem, so it does not detect a symlinked directory component; that
413
+ * refusal lives in the writers themselves (`fs-guard.ts`).
414
+ *
415
+ * @throws `AssertionError` (from `node:assert/strict`) naming the first path
416
+ * that escapes both allowed roots.
417
+ *
418
+ * @example
419
+ * ```ts
420
+ * import { assertAdoptWriteScope } from "./main.js";
421
+ *
422
+ * assertAdoptWriteScope("/work/app", [".groundwork/inventory.json"]); // ok
423
+ * assertAdoptWriteScope("/work/app", ["/work/app/package.json"]); // throws
424
+ * ```
425
+ */
426
+ export function assertAdoptWriteScope(targetDir, paths) {
427
+ const allowedRoots = [
428
+ resolve(targetDir, GROUNDWORK_DEST_SEGMENTS[0]),
429
+ resolve(targetDir, ...CLAUDE_DEST_SEGMENTS),
430
+ ];
431
+ for (const path of paths) {
432
+ const resolved = resolve(targetDir, path);
433
+ assert.ok(allowedRoots.some((root) => isPathContained(resolved, root)), `adopt mode wrote outside its scope: ${resolved}`);
434
+ }
435
+ }
436
+ /** The previous run's files `runAdopt` deletes at its point of no return, in deletion order. */
437
+ const STALE_FILE_NAMES = [
438
+ "inventory.json",
439
+ "adoption-report.md",
440
+ "adoption-decisions.json",
441
+ ];
442
+ /**
443
+ * Which previous `.groundwork/` files a failed run actually removed, worded
444
+ * for a message: only those in `removed` are named, and the decisions file
445
+ * carries its "a re-run does not recreate it" caveat only when it is one of
446
+ * them.
447
+ */
448
+ function describeRemoved(removed) {
449
+ if (removed.length === 0) {
450
+ return "no previous .groundwork/ inventory, report or decisions file was removed";
451
+ }
452
+ const names = removed.length === 1
453
+ ? removed.join("")
454
+ : `${removed.slice(0, -1).join(", ")} and ${removed.slice(-1).join("")}`;
455
+ const verb = removed.length === 1 ? "was" : "were";
456
+ const decisionsCaveat = removed.includes("adoption-decisions.json")
457
+ ? " -- adoption-decisions.json held the decisions /customize recorded, which a re-run does not recreate"
458
+ : "";
459
+ return `the previous .groundwork/${names} ${verb} removed${decisionsCaveat}`;
460
+ }
461
+ /**
462
+ * A failure after `runAdopt`'s point of no return. The message embeds the
463
+ * cause's own message so it stands alone (`formatErrorChain` then skips the
464
+ * redundant `caused by:` line), names only the previous files actually
465
+ * removed, and appends the re-run advice unless the cause already ends
466
+ * with equivalent advice in any wording ({@link endsWithRerunAdvice}).
467
+ */
468
+ function removedStaleFilesError(cause, removed) {
469
+ const reason = cause instanceof Error ? cause.message : String(cause);
470
+ const advice = endsWithRerunAdvice(reason) ? "" : `; ${FIX_AND_RERUN_ADVICE}`;
471
+ return new Error(`adopt mode failed (${reason}); ${describeRemoved(removed)}${advice}`, { cause });
472
+ }
473
+ /**
474
+ * Rethrows a failure after `runAdopt`'s point of no return. An
475
+ * `AssertionError` (a broken invariant: a bug, not something a re-run
476
+ * fixes) keeps its identity, after one warning naming the previous files it
477
+ * removed, so that loss is not silent; anything else becomes
478
+ * {@link removedStaleFilesError}.
479
+ */
480
+ function rethrowAfterPointOfNoReturn(cause, removed) {
481
+ if (cause instanceof assert.AssertionError) {
482
+ if (removed.length > 0) {
483
+ console.warn(`warning: adopt mode stopped on a broken invariant after ${describeRemoved(removed)}`);
484
+ }
485
+ throw cause;
486
+ }
487
+ throw removedStaleFilesError(cause, removed);
488
+ }
489
+ /**
490
+ * The adopt-mode next step after a `.groundwork/customize/` fallback, chosen
491
+ * by why it was taken: only an `"entry"` fallback has a project-local copy
492
+ * of the skill to replace. Returned without a `Next: ` prefix and starting
493
+ * lower-case; each caller adds its own framing. `undefined` -- which the installer never returns
494
+ * alongside a `"groundwork"` location -- is a contract violation and throws
495
+ * rather than guessing.
496
+ */
497
+ function groundworkNextStep(cause) {
498
+ const staged = "the current /customize skill is staged at .groundwork/customize/, but Claude Code does not load skills from there";
499
+ switch (cause) {
500
+ case "component":
501
+ 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.`;
502
+ case "entry":
503
+ 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.`;
504
+ case undefined:
505
+ throw new Error('unhandled fallback cause: undefined (a "groundwork" install result must carry fallbackCause)');
506
+ default: {
507
+ const exhaustive = cause;
508
+ throw new Error(`unhandled fallback cause: ${String(exhaustive)}`);
509
+ }
510
+ }
511
+ }
512
+ /**
513
+ * The report's `## Next step` text for a `"groundwork"` install result: the
514
+ * fallback reason first, then {@link groundworkNextStep}'s sentence -- the
515
+ * same order the console prints them in, which the `"component"` text's
516
+ * "the .claude path named above" depends on. `renderReport` strips one
517
+ * leading `Next: ` and upper-cases only the text's first character, so the
518
+ * reason carries that prefix and the sentence after it is capitalized here.
519
+ * A missing reason keeps the sentence alone rather than printing `undefined`.
520
+ */
521
+ function groundworkReportNextStep(result) {
522
+ const sentence = groundworkNextStep(result.fallbackCause);
523
+ const reason = result.fallbackReason;
524
+ if (reason === undefined) {
525
+ return `Next: ${sentence}`;
526
+ }
527
+ const separator = /[.!?]$/.test(reason) ? " " : ". ";
528
+ return `Next: ${reason}${separator}${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}`;
231
529
  }
232
530
  /**
233
531
  * Surveys an already-established project and writes `.groundwork/` --
@@ -235,53 +533,216 @@ function runFresh(options) {
235
533
  * the one addition is a purely-additive, collision-guarded copy of the
236
534
  * `/customize` skill (see `installCustomizeSkillGuarded`), so the report
237
535
  * 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>/`.
536
+ * `templates/packs/` is surveyed (`main` rejects `--pack` in this mode, see
537
+ * `assertAdoptUsage`) and staged, unapplied and inert, at
538
+ * `.groundwork/packs/<name>/` -- its manifest as `pack.json.staged`, its
539
+ * files as `files/<path>.staged` -- all packs written to a temporary
540
+ * sibling directory and swapped in by rename, so `packs/` is never
541
+ * half-written (`stagePacks`); absent baseline files are staged the same
542
+ * way as inert `<path>.staged` copies at `.groundwork/baseline/`. With a
543
+ * previous staging in place each swap is two renames (park the old
544
+ * directory, then move the new one in); a kill between them leaves that
545
+ * directory absent with the old copy under its `.packs-*`/`.baseline-*`
546
+ * work directory's `previous/` -- safe, because `inventory.json` was
547
+ * already deleted, so nothing reads the gap as a completed run.
548
+ *
549
+ * Checked before anything under `.groundwork/` is deleted or written, in
550
+ * this order: the three stale-file paths are scope-checked against
551
+ * {@link assertAdoptWriteScope}; every pack is loaded and validated by
552
+ * `loadPack` and surveyed (`planConflicts`, `observeWiring`); the pack
553
+ * staging plan (`planPackStaging`) and the baseline staging plan
554
+ * (`planBaselineStaging`) are each computed once, validated, and every path
555
+ * they would write scope-checked; the `/customize` skill's planned install
556
+ * paths (`plannedCustomizeSkillPaths`, a lexical check of its path
557
+ * constants) are scope-checked; then `.groundwork/`, `.groundwork/packs`
558
+ * and `.groundwork/baseline` are each refused if they are a symlink. So an
559
+ * invalid pack (a malformed manifest, a prototype-sensitive key in its
560
+ * wiring, an unstageable file tree), an invalid baseline plan, an
561
+ * out-of-scope staging or skill-install path, or a symlinked staging
562
+ * directory throws with the previous `.groundwork/` untouched.
563
+ *
564
+ * **The point of no return** is the first deletion of a stale
565
+ * `inventory.json`/`adoption-report.md`/`adoption-decisions.json` (in that
566
+ * order), which follows those checks; which of the three existed is
567
+ * recorded just before. After the deletions, packs and the baseline are
568
+ * staged from the plans already computed (their trees are not walked
569
+ * again), the harness and toolchain are graded, the `/customize` skill is
570
+ * installed and its writes scope-checked, `adoption-report.md` is written
571
+ * (its `## Next step` leading with the same sentence the console prints
572
+ * when the skill fell back to `.groundwork/customize/`),
573
+ * and `inventory.json` is written last (atomically, via a temp file and
574
+ * rename). A failure in any of those steps -- a failed deletion included --
575
+ * is rethrown as an `Error` with the failure as `cause` and its message
576
+ * embedded, naming only the previous files that existed and were actually
577
+ * removed (and, when `adoption-decisions.json` is among them, that it held
578
+ * the decisions `/customize` recorded, which a re-run does not recreate),
579
+ * and saying to fix the cause and re-run the CLI -- once, even when the
580
+ * cause's own message already says so. An `AssertionError` (a broken
581
+ * write-scope or containment invariant, a bug a re-run cannot fix) keeps
582
+ * its identity instead, after one `console.warn` naming the removed files
583
+ * (none when nothing was removed).
584
+ *
585
+ * If `inventory.json` fails to write, the just-written report is first
586
+ * removed (best effort: a failed removal only warns, never masking the
587
+ * write failure). Only console output follows `inventory.json`, so its
588
+ * presence means every step of the run completed.
240
589
  */
241
590
  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
591
  console.log(`adopt mode: ${detection.signal}`);
246
592
  const survey = surveyProject(options.targetDir);
247
593
  const templateRoot = templatesCoreDir();
248
594
  const tokens = buildTokens(options.projectName);
249
- const conflicts = planConflicts(templateRoot, options.targetDir, tokens);
595
+ // The survey's own `undetermined` list: a baseline or pack target this
596
+ // process cannot reach is recorded there (and so in the report), never
597
+ // reported as a clean add.
598
+ const conflicts = planConflicts(templateRoot, options.targetDir, tokens, survey.undetermined);
250
599
  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);
600
+ const stagedBaselineDir = `.groundwork/${STAGED_BASELINE_DIR}`;
601
+ const stalePaths = STALE_FILE_NAMES.map((name) => ({
602
+ name,
603
+ path: join(groundworkDir, name),
604
+ }));
605
+ const inventoryPath = join(groundworkDir, "inventory.json");
277
606
  const reportPath = join(groundworkDir, "adoption-report.md");
278
- writeFileSync(reportPath, renderReport(inventory));
607
+ assertAdoptWriteScope(options.targetDir, stalePaths.map(({ path }) => path));
608
+ // Every pack must pass loadPack's validation (including its
609
+ // prototype-sensitive wiring-key check), and both staging plans (packs and
610
+ // baseline) must be computed and scope-checked, before anything under
611
+ // .groundwork/ is touched, so an invalid pack or plan fails the run with
612
+ // the previous inventory/report still intact rather than half-cleared.
613
+ const loadedPacks = listPackNames().map((name) => loadPack(name));
614
+ const packs = loadedPacks.map((pack) => ({
615
+ name: pack.manifest.name,
616
+ modes: pack.manifest.modes,
617
+ budget: pack.manifest.budget,
618
+ fileConflicts: planConflicts(pack.filesDir, options.targetDir, tokens, survey.undetermined),
619
+ wiring: pack.manifest.wiring,
620
+ wiringObservations: observeWiring(options.targetDir, pack.manifest, survey.undetermined),
621
+ adoptNotes: pack.manifest.adoptNotes,
622
+ }));
623
+ // Each plan is computed once, here, and handed to its stager below, so
624
+ // what was scope-checked is exactly what gets written.
625
+ const packPlan = planPackStaging(loadedPacks, groundworkDir, tokens);
626
+ assertAdoptWriteScope(options.targetDir, packPlan.paths);
627
+ const baselinePlan = planBaselineStaging(templateRoot, conflicts, groundworkDir, tokens);
628
+ assertAdoptWriteScope(options.targetDir, baselinePlan.paths);
629
+ // A constant drift guard only: it lexically checks paths built from
630
+ // customize-paths.ts's constants, so it can catch one of those constants
631
+ // being edited to escape the project, and nothing else -- it reads no
632
+ // filesystem state, so it cannot detect a symlink or any other runtime
633
+ // condition. The installer lstat-checks every directory component itself
634
+ // (and falls back to .groundwork/customize/ when one under .claude/ is a
635
+ // symlink or not a directory -- see fallbackReason below).
636
+ assertAdoptWriteScope(options.targetDir, plannedCustomizeSkillPaths());
637
+ // A symlinked .groundwork/ or staging directory would redirect a delete
638
+ // or a write outside the project; refuse it before anything is deleted.
639
+ // (Each stager repeats its own check; these make the refusal precede the
640
+ // deletions below.)
641
+ assertNotSymlink(groundworkDir);
642
+ assertNotSymlink(join(options.targetDir, STAGED_PACKS_DIR));
643
+ assertNotSymlink(join(groundworkDir, STAGED_BASELINE_DIR));
644
+ // Recorded before any deletion, so a failure message names only files a
645
+ // previous run actually left (and this run actually removed).
646
+ const preexisting = new Set(stalePaths.filter(({ path }) => existsSync(path)).map(({ name }) => name));
647
+ const removed = [];
648
+ let inventory;
649
+ let pluginResult;
650
+ let nextStep;
651
+ try {
652
+ // The point of no return: the first deletion. A previous run's
653
+ // inventory/report -- and the decisions /customize recorded against
654
+ // them -- must not survive a run that fails part-way: /customize would
655
+ // read them as describing the new staging. All three go before anything
656
+ // is staged; inventory/report are rewritten only at the end, and the
657
+ // decisions file only by /customize. Inside the wrapping, so a failed
658
+ // deletion reports which files are already gone. Every path is removed,
659
+ // not just the pre-existing ones: existsSync follows symlinks, so a
660
+ // dangling one must still go before the report's "wx" write below.
661
+ for (const { name, path } of stalePaths) {
662
+ rmSync(path, { force: true });
663
+ if (preexisting.has(name)) {
664
+ removed.push(name);
665
+ }
666
+ }
667
+ const stagedPacks = stagePacks(loadedPacks, groundworkDir, tokens, packPlan);
668
+ const stagedBaselineFiles = stageBaselineAdditions(templateRoot, conflicts, groundworkDir, tokens, baselinePlan);
669
+ inventory = buildInventory({
670
+ detection,
671
+ templateRoot,
672
+ targetDir: options.targetDir,
673
+ survey,
674
+ conflicts,
675
+ packs,
676
+ harnessGrade: gradeHarness(options.targetDir),
677
+ toolchainGrade: gradeToolchain(options.targetDir),
678
+ stagedBaseline: {
679
+ dir: stagedBaselineDir,
680
+ suffix: STAGED_SUFFIX,
681
+ files: stagedBaselineFiles,
682
+ },
683
+ stagedPacks,
684
+ });
685
+ // The /customize skill install runs before the two .groundwork/ files
686
+ // (the report's "wx" write below can still fail after it), so
687
+ // inventory.json's presence still means the install completed. Its
688
+ // planned paths were scope-checked before the deletions above.
689
+ pluginResult = installCustomizeSkillGuarded(options.targetDir);
690
+ // Deliberately no rollback if this throws: what the installer reports it
691
+ // wrote must sit inside the planned scope, so a failure here means the
692
+ // installer and customize-paths.ts drifted apart -- a programming error
693
+ // to surface loudly, not a runtime condition to recover from.
694
+ assertAdoptWriteScope(options.targetDir, pluginResult.filesWritten);
695
+ // Resolved before the report and inventory are written, so a contract
696
+ // violation in the result fails the run before inventory.json claims it
697
+ // completed.
698
+ // The fresh copy is staged where Claude Code never loads a skill from,
699
+ // so the generic next step would be false there; the report then names
700
+ // the fallback reason and the same sentence the console prints, in the
701
+ // console's order. Otherwise the report keeps its own generic sentence.
702
+ const isGroundwork = pluginResult.location === "groundwork";
703
+ nextStep = isGroundwork
704
+ ? `Next: ${groundworkNextStep(pluginResult.fallbackCause)}`
705
+ : "Next: open this project in Claude Code and run /customize.";
706
+ const reportNextStep = isGroundwork
707
+ ? groundworkReportNextStep(pluginResult)
708
+ : undefined;
709
+ // The report next, inventory.json last (written atomically): nothing
710
+ // that can fail follows it, so its presence means the run completed.
711
+ mkdirSync(groundworkDir, { recursive: true });
712
+ // "wx": the path was removed above, so anything there now (a symlink
713
+ // raced in mid-run) makes the write fail instead of being followed.
714
+ writeFileSync(reportPath, renderReport(inventory, reportNextStep), {
715
+ flag: "wx",
716
+ });
717
+ }
718
+ catch (cause) {
719
+ rethrowAfterPointOfNoReturn(cause, removed);
720
+ }
721
+ const { stagedPacks } = inventory;
722
+ const stagedBaselineFiles = inventory.stagedBaseline.files;
723
+ try {
724
+ writeInventory(inventory, groundworkDir);
725
+ }
726
+ catch (error) {
727
+ // Without inventory.json the run did not complete; a report left behind
728
+ // would read as if it had. Best effort: a failed removal only warns, so
729
+ // it can never mask the write failure being rethrown.
730
+ try {
731
+ rmSync(reportPath, { force: true });
732
+ }
733
+ catch (cleanupError) {
734
+ console.warn(`warning: could not remove ${reportPath} after inventory.json failed to write -- delete it by hand (${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)})`);
735
+ }
736
+ rethrowAfterPointOfNoReturn(error, removed);
737
+ }
279
738
  console.log(`wrote ${relative(options.targetDir, inventoryPath)}`);
280
739
  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`);
740
+ if (stagedBaselineFiles.length > 0) {
741
+ console.log(`staged ${stagedBaselineFiles.length} baseline file(s) at ${stagedBaselineDir}/ for /customize`);
742
+ }
743
+ if (stagedPacks.length > 0) {
744
+ console.log(`staged ${stagedPacks.length} pack(s) at ${STAGED_PACKS_DIR}/ for /customize`);
283
745
  }
284
- const pluginResult = installCustomizeSkillGuarded(options.targetDir);
285
746
  if (pluginResult.location === "already-present") {
286
747
  console.log("the /customize skill was already up to date");
287
748
  }
@@ -290,11 +751,20 @@ function runAdopt(options, detection) {
290
751
  ? ".claude/skills/customize/"
291
752
  : ".groundwork/customize/";
292
753
  console.log(`installed the /customize skill into ${where} (${pluginResult.filesWritten.length} files)`);
754
+ if (pluginResult.fallbackReason !== undefined) {
755
+ console.log(` ${pluginResult.fallbackReason}`);
756
+ }
293
757
  }
294
- console.log(`\n✓ adoption report ready at ${reportPath}`);
295
- console.log("Next: open this project in Claude Code and run /customize.");
758
+ console.log(paint(process.stdout, "success", `\n✓ adoption report ready at ${reportPath}`));
759
+ console.log(nextStep);
296
760
  }
297
- export function main(argv) {
761
+ /**
762
+ * Runs the CLI. `platform` is injectable so fresh mode's Windows refusal
763
+ * (a runtime error, exit 1, raised before anything is written) is
764
+ * unit-testable on any OS; it defaults to `process.platform`. Adopt mode and
765
+ * `--help`/`--version`/`--list-packs` run on every platform.
766
+ */
767
+ export function main(argv, platform = process.platform) {
298
768
  const options = parseArgs(argv);
299
769
  if (options.help) {
300
770
  console.log(USAGE);
@@ -323,10 +793,11 @@ export function main(argv) {
323
793
  fresh: options.fresh,
324
794
  });
325
795
  if (resolved.mode === "adopt") {
796
+ assertAdoptUsage(options);
326
797
  runAdopt(options, resolved);
327
798
  }
328
799
  else {
329
- runFresh(options);
800
+ runFresh(options, platform);
330
801
  }
331
802
  }
332
803
  //# sourceMappingURL=main.js.map