@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/packs.js CHANGED
@@ -1,17 +1,25 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Loads pack manifests from `templates/packs/<name>/pack.json` and installs
3
5
  * a pack's files + JSON wiring into a freshly-bootstrapped project. Adopt
4
6
  * mode never calls `installPack` -- it surveys packs into the report
5
- * (`observeWiring`, `stagePackFiles`) and defers installation to
6
- * `/customize`, which reads a project's real gate runner before translating
7
- * a pack's wiring (see inventory.ts).
7
+ * (`observeWiring`; `pack-stage.ts`'s `stagePacks` stages them, inert) and
8
+ * defers installation to `/customize`, which reads a project's real gate
9
+ * runner before translating a pack's wiring (see inventory.ts). Both modes call `loadPack` for every
10
+ * pack they handle (fresh mode: each `--pack`; adopt mode: every pack)
11
+ * before writing anything, and `loadPack` refuses a prototype-sensitive
12
+ * key in `wiring.settings`, `wiring.settingsTopLevel` or
13
+ * `wiring.packageScripts`, so such a pack is neither installed nor staged.
8
14
  */
9
15
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
10
16
  import { dirname, join } from "node:path";
11
17
  import { resolveAsset } from "./assets.js";
12
18
  import { emitTemplate } from "./emit.js";
13
19
  import { parseJsonc } from "./jsonc.js";
14
- import { isRecord, mergePackageScripts, mergeSettingsHooks, mergeSettingsTopLevel, mergeVerifySteps, } from "./merge-json.js";
20
+ import { blockedAbsentNote } from "./survey/internal/blocked-path.js";
21
+ import { probePath, readFailure, recordedReadCode, unreadableNote, } from "./survey/internal/read-guard.js";
22
+ import { isPrototypeSensitiveKey, isRecord, mergePackageScripts, mergeSettingsHooks, mergeSettingsTopLevel, mergeVerifySteps, } from "./merge-json.js";
15
23
  /** `templates/packs`, resolved the same way `templatesCoreDir()` resolves `templates/core`. */
16
24
  export function packsRootDir() {
17
25
  return resolveAsset({ repo: "templates/packs", local: "templates/packs" });
@@ -26,7 +34,111 @@ export function listPackNames(root = packsRootDir()) {
26
34
  .map((entry) => entry.name)
27
35
  .sort();
28
36
  }
29
- /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown or malformed. */
37
+ /** A pack's own `name` becomes a path segment under `.groundwork/packs/`, so it must be a bare lowercase identifier: no separators, no `..`. */
38
+ const PACK_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
39
+ const CAP_KEYS = [
40
+ "agents",
41
+ "skills",
42
+ "hooks",
43
+ "workflows",
44
+ "scripts",
45
+ ];
46
+ /** True when `budget` has an own non-negative integer for every {@link CapCounts} key (one read per key). */
47
+ function isValidBudget(budget) {
48
+ if (!isRecord(budget))
49
+ return false;
50
+ return CAP_KEYS.every((key) => {
51
+ if (!Object.hasOwn(budget, key))
52
+ return false;
53
+ const value = budget[key];
54
+ return Number.isInteger(value) && value >= 0;
55
+ });
56
+ }
57
+ /** The two CLI modes a pack's `modes` may name (see mode.ts). */
58
+ const PACK_MODES = ["fresh", "adopt"];
59
+ /** The `bin/lib/verify-steps.mjs` groups a pack's verify step may join. */
60
+ const VERIFY_GROUPS = [
61
+ "format",
62
+ "lint",
63
+ "typecheck",
64
+ "build",
65
+ "test",
66
+ ];
67
+ /** Throws, naming the entry's 0-based `index` and the offending field, when one `wiring.verifySteps[]` entry isn't an object, lacks a string `id`/`name`, lacks a non-empty string-array `cmd`, or names an unknown `group`. Each field is read once into a local before it is checked. */
68
+ function assertValidVerifyStep(name, step, index) {
69
+ const where = `pack "${name}": pack.json's wiring.verifySteps[${String(index)}]`;
70
+ if (!isRecord(step)) {
71
+ throw new Error(`${where} must be an object, got ${String(JSON.stringify(step))}`);
72
+ }
73
+ for (const key of ["id", "name"]) {
74
+ const value = Object.hasOwn(step, key) ? step[key] : undefined;
75
+ if (typeof value !== "string") {
76
+ throw new Error(`${where}.${key} must be a string, got ${String(JSON.stringify(value))}`);
77
+ }
78
+ }
79
+ const cmd = Object.hasOwn(step, "cmd") ? step["cmd"] : undefined;
80
+ if (!Array.isArray(cmd) ||
81
+ cmd.length === 0 ||
82
+ !cmd.every((part) => typeof part === "string")) {
83
+ throw new Error(`${where}.cmd must be a non-empty array of strings, got ${String(JSON.stringify(cmd))}`);
84
+ }
85
+ const group = Object.hasOwn(step, "group")
86
+ ? step["group"]
87
+ : undefined;
88
+ if (typeof group !== "string" || !VERIFY_GROUPS.includes(group)) {
89
+ throw new Error(`${where} group ${String(JSON.stringify(group))} must be one of ${VERIFY_GROUPS.join(", ")}`);
90
+ }
91
+ }
92
+ /**
93
+ * Throws, naming the pack, the field and the key, when one of `fragment`'s own
94
+ * enumerable keys is prototype-sensitive ({@link isPrototypeSensitiveKey}).
95
+ * `Object.keys` sees a `__proto__` key here because `parseJsonc`, like
96
+ * `JSON.parse`, creates it as an ordinary own data property. Only the
97
+ * fragment's own top-level keys are checked: they are the names a merge
98
+ * writes, while everything nested below them is an opaque value.
99
+ */
100
+ function assertNoPrototypeSensitiveKeys(name, field, fragment) {
101
+ for (const key of Object.keys(fragment)) {
102
+ if (isPrototypeSensitiveKey(key)) {
103
+ throw new Error(`pack "${name}": pack.json's wiring.${field} must not use the prototype-sensitive key ${JSON.stringify(key)}`);
104
+ }
105
+ }
106
+ }
107
+ /**
108
+ * Throws, naming the offending key or value, when `wiring.settings`/`packageScripts` is missing or isn't an object, `verifySteps` is missing or isn't an array, any `verifySteps[]` entry is malformed (see {@link assertValidVerifyStep}), or `settings`, `packageScripts` or (when it is an object) `settingsTopLevel` has a prototype-sensitive own key (see {@link assertNoPrototypeSensitiveKeys}).
109
+ * Those three are the only wiring fields whose keys a merge writes; `verifySteps` ids are values. An absent or non-object `settingsTopLevel` is not rejected here.
110
+ */
111
+ function assertValidWiringShape(name, wiring) {
112
+ for (const key of ["settings", "packageScripts"]) {
113
+ const value = Object.hasOwn(wiring, key) ? wiring[key] : undefined;
114
+ if (!isRecord(value)) {
115
+ throw new Error(`pack "${name}": pack.json's wiring.${key} must be an object`);
116
+ }
117
+ assertNoPrototypeSensitiveKeys(name, key, value);
118
+ }
119
+ const topLevel = Object.hasOwn(wiring, "settingsTopLevel")
120
+ ? wiring["settingsTopLevel"]
121
+ : undefined;
122
+ if (isRecord(topLevel)) {
123
+ assertNoPrototypeSensitiveKeys(name, "settingsTopLevel", topLevel);
124
+ }
125
+ const steps = Object.hasOwn(wiring, "verifySteps")
126
+ ? wiring["verifySteps"]
127
+ : undefined;
128
+ if (!Array.isArray(steps)) {
129
+ throw new Error(`pack "${name}": pack.json's wiring.verifySteps must be an array`);
130
+ }
131
+ steps.forEach((step, index) => {
132
+ assertValidVerifyStep(name, step, index);
133
+ });
134
+ }
135
+ /** True when `steps` is a non-empty array of non-empty strings, none containing a line break -- each is printed as one indented line. */
136
+ function isValidSetupSteps(steps) {
137
+ return (Array.isArray(steps) &&
138
+ steps.length > 0 &&
139
+ steps.every((step) => typeof step === "string" && step !== "" && !/[\r\n]/.test(step)));
140
+ }
141
+ /** Loads and validates one pack's manifest from under `root` (default `templates/packs`, overridable for tests). Throws, naming the available packs, if unknown; throws naming the pack and the problem if malformed, including a prototype-sensitive key in its wiring (see {@link assertValidWiringShape}) or a `setupSteps` that is present but not a non-empty array of single-line, non-empty strings. */
30
142
  export function loadPack(name, root = packsRootDir()) {
31
143
  const packDir = join(root, name);
32
144
  const manifestPath = join(packDir, "pack.json");
@@ -38,10 +150,47 @@ export function loadPack(name, root = packsRootDir()) {
38
150
  if (!parsed.ok) {
39
151
  throw new Error(`pack "${name}": pack.json failed to parse -- ${parsed.error}`);
40
152
  }
41
- const manifest = parsed.value;
153
+ const raw = parsed.value;
154
+ if (!isRecord(raw)) {
155
+ throw new Error(`pack "${name}": pack.json must be an object, got ${String(JSON.stringify(raw))}`);
156
+ }
157
+ const manifest = raw;
42
158
  if (manifest.schemaVersion !== 1) {
43
159
  throw new Error(`pack "${name}": unsupported pack.json schemaVersion ${JSON.stringify(manifest.schemaVersion)}`);
44
160
  }
161
+ const manifestName = manifest.name;
162
+ if (typeof manifestName !== "string" ||
163
+ !PACK_NAME_PATTERN.test(manifestName)) {
164
+ throw new Error(`pack "${name}": pack.json's pack name ${JSON.stringify(manifestName)} must match ${String(PACK_NAME_PATTERN)} (a bare lowercase identifier, no path separators)`);
165
+ }
166
+ const modes = manifest.modes;
167
+ if (!Array.isArray(modes) ||
168
+ modes.length === 0 ||
169
+ !modes.every((mode) => typeof mode === "string")) {
170
+ throw new Error(`pack "${name}": pack.json's modes must be a non-empty array of strings`);
171
+ }
172
+ const badMode = modes.find((mode) => !PACK_MODES.includes(mode));
173
+ if (badMode !== undefined) {
174
+ throw new Error(`pack "${name}": pack.json's mode ${JSON.stringify(badMode)} must be one of ${PACK_MODES.join(", ")}`);
175
+ }
176
+ const wiring = manifest.wiring;
177
+ if (!isRecord(wiring)) {
178
+ throw new Error(`pack "${name}": pack.json's wiring must be an object`);
179
+ }
180
+ assertValidWiringShape(name, wiring);
181
+ const budget = manifest.budget;
182
+ if (!isValidBudget(budget)) {
183
+ throw new Error(`pack "${name}": pack.json's budget must set ${CAP_KEYS.join(", ")} to non-negative integers`);
184
+ }
185
+ const setupSteps = manifest.setupSteps;
186
+ if (setupSteps !== undefined && !isValidSetupSteps(setupSteps)) {
187
+ throw new Error(`pack "${name}": pack.json's setupSteps must be a non-empty array of single-line, non-empty strings`);
188
+ }
189
+ // pack-stage.ts's stagePacks keys each pack's staging directory on
190
+ // manifest.name, so a mismatch would let two pack directories collide.
191
+ if (manifestName !== name) {
192
+ throw new Error(`pack "${name}": pack.json's name "${manifestName}" does not match its directory name "${name}"`);
193
+ }
45
194
  return { manifest, filesDir: join(packDir, "files") };
46
195
  }
47
196
  function readJsonOrThrow(path) {
@@ -126,35 +275,96 @@ export function installPack(pack, targetDir, tokens) {
126
275
  return { filesWritten, budget: manifest.budget };
127
276
  }
128
277
  /**
129
- * Copies a pack's `pack.json` + `files/` tree, unmodified, into
130
- * `<groundworkDir>/packs/<name>/` -- adopt mode's staging area. `/customize`
131
- * installs from this self-contained copy rather than from `templateRoot`
132
- * (an absolute path that may not exist by the time it runs). Token
133
- * substitution is a no-op here (`{}`): staging a project's real name into
134
- * pack content is `/customize`'s job, not this offline copy's.
278
+ * Reads `.claude/settings.json` for {@link observeWiring}. A failure that is
279
+ * a fact about the project's own tree -- a permission failure
280
+ * (`EACCES`/`EPERM`), a directory at the path (`EISDIR`), the file vanishing
281
+ * after the exists probe or a dangling symlink (`ENOENT`), a symlink loop
282
+ * (`ELOOP`) -- is recorded as an observation naming the path and errno, and
283
+ * once in `undetermined` (so the adoption report shows it), and `undefined`
284
+ * is returned; any other errno is about the machine and throws, naming the
285
+ * path with the original failure as `cause`.
135
286
  */
136
- export function stagePackFiles(pack, groundworkDir) {
137
- const destDir = join(groundworkDir, "packs", pack.manifest.name);
138
- const { filesWritten } = emitTemplate(pack.filesDir, join(destDir, "files"), {});
139
- writeJson(join(destDir, "pack.json"), pack.manifest);
140
- return [...filesWritten.map((f) => join("files", f)), "pack.json"];
287
+ function readSettingsOrObserve(settingsPath, observations, undetermined) {
288
+ try {
289
+ return readFileSync(settingsPath, "utf8");
290
+ }
291
+ catch (error) {
292
+ const code = recordedReadCode(error);
293
+ if (code === undefined)
294
+ throw readFailure(settingsPath, error);
295
+ observations.push(`.claude/settings.json (${settingsPath}) exists but could not be read (${code})`);
296
+ // Called once per pack against the same file: record the note once.
297
+ const note = unreadableNote(settingsPath, code);
298
+ if (!undetermined.includes(note))
299
+ undetermined.push(note);
300
+ return undefined;
301
+ }
302
+ }
303
+ /**
304
+ * Whether `path` exists, for {@link observeWiring}. A real `stat`, never
305
+ * `existsSync`, so a file under a directory this process cannot search is
306
+ * not observed as missing: an `EACCES`/`EPERM`/`ELOOP` is recorded as an
307
+ * observation naming the path and errno, and `undefined` is returned so the
308
+ * caller states neither "found" nor "not found". `ENOENT`/`ENOTDIR` answers
309
+ * `false` only when nothing is really there: a dangling symlink at the path
310
+ * or an ancestor, or a regular file blocking an ancestor, is observed and
311
+ * recorded once in `undetermined` (the same note `conflicts.ts` records via
312
+ * `blockedAbsentNote`) and answers `undefined`. Any other errno throws (see
313
+ * `probePath`).
314
+ */
315
+ function existsOrObserve(path, targetDir, observations, undetermined) {
316
+ const probe = probePath(path);
317
+ if (probe.kind === "unresolvable") {
318
+ observations.push(`could not check whether ${path} exists (${probe.code}) -- left undetermined, not reported missing`);
319
+ return undefined;
320
+ }
321
+ if (probe.kind === "present")
322
+ return true;
323
+ const note = blockedAbsentNote(path, targetDir);
324
+ if (note === undefined)
325
+ return false;
326
+ observations.push(`${note} -- left undetermined, not reported missing`);
327
+ // Called once per pack against the same path: record the note once.
328
+ if (!undetermined.includes(note))
329
+ undetermined.push(note);
330
+ return undefined;
141
331
  }
142
332
  /**
143
333
  * Index-level, adopt-mode-only facts about how a pack's wiring would land
144
334
  * against a real project's current `.claude/settings.json` and
145
335
  * `bin/lib/verify-steps.packs.json` -- never a verdict on whether it will
146
- * work, which is `/customize`'s Step 0 judgment call to make after reading
147
- * the project's real gate runner and hook config.
336
+ * work. That verdict is a judgment call for `/customize`'s Step 0 (the
337
+ * adopt-mode reconcile step in `/customize`) to make after reading the
338
+ * project's real gate runner and hook config. A path this process cannot
339
+ * reach (`EACCES`/`EPERM`/`ELOOP`) is observed with its errno, never as
340
+ * "not found"; nor is a dangling symlink or a file blocking an ancestor
341
+ * directory, which is also recorded once in `undetermined`. A
342
+ * `.claude/settings.json` that exists but cannot be read for a reason that
343
+ * is a property of the project's tree (`EACCES`/`EPERM`, `EISDIR`, `ENOENT`,
344
+ * `ELOOP`) is observed with its errno and also recorded once in
345
+ * `undetermined` -- adopt mode passes the survey's own list, so the report
346
+ * shows it; any other errno throws.
347
+ *
348
+ * @example
349
+ * ```ts
350
+ * const undetermined: string[] = [];
351
+ * const observations = observeWiring(targetDir, pack.manifest, undetermined);
352
+ * ```
148
353
  */
149
- export function observeWiring(targetDir, manifest) {
354
+ export function observeWiring(targetDir, manifest, undetermined = []) {
150
355
  const observations = [];
151
356
  const settingsPath = join(targetDir, ".claude", "settings.json");
152
- if (!existsSync(settingsPath)) {
357
+ const settingsExists = existsOrObserve(settingsPath, targetDir, observations, undetermined);
358
+ if (settingsExists === false) {
153
359
  observations.push("no .claude/settings.json found");
154
360
  }
155
- else {
156
- const parsed = parseJsonc(readFileSync(settingsPath, "utf8"));
157
- if (!parsed.ok || !isRecord(parsed.value)) {
361
+ else if (settingsExists) {
362
+ const content = readSettingsOrObserve(settingsPath, observations, undetermined);
363
+ const parsed = content === undefined ? undefined : parseJsonc(content);
364
+ if (parsed === undefined) {
365
+ // Unreadable -- already recorded as an observation.
366
+ }
367
+ else if (!parsed.ok || !isRecord(parsed.value)) {
158
368
  observations.push(".claude/settings.json exists but could not be parsed");
159
369
  }
160
370
  else {
@@ -172,14 +382,17 @@ export function observeWiring(targetDir, manifest) {
172
382
  }
173
383
  }
174
384
  }
175
- if (existsSync(join(targetDir, ".claude", "settings.local.json"))) {
385
+ if (existsOrObserve(join(targetDir, ".claude", "settings.local.json"), targetDir, observations, undetermined) === true) {
176
386
  observations.push(".claude/settings.local.json is present and may shadow a merged hook entry");
177
387
  }
178
388
  if (manifest.wiring.verifySteps.length > 0) {
179
389
  const stepsPath = join(targetDir, "bin", "lib", "verify-steps.packs.json");
180
- observations.push(existsSync(stepsPath)
181
- ? "bin/lib/verify-steps.packs.json exists"
182
- : "no bin/lib/verify-steps.packs.json found -- no bin/verify.mjs-shaped gate runner detected");
390
+ const stepsExist = existsOrObserve(stepsPath, targetDir, observations, undetermined);
391
+ if (stepsExist !== undefined) {
392
+ observations.push(stepsExist
393
+ ? "bin/lib/verify-steps.packs.json exists"
394
+ : "no bin/lib/verify-steps.packs.json found -- no bin/verify.mjs-shaped gate runner detected");
395
+ }
183
396
  }
184
397
  return observations;
185
398
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * GENERATED FILE -- do not hand-edit. Run `node bin/build-design-tokens.mjs`
3
+ * to regenerate from design/source/dtcg (the vendored m3l-design tokens).
4
+ * `--check` (used by `pnpm verify`) fails instead of writing on drift.
5
+ * Source of truth: design/source/dtcg (DTCG 2025.10). See design/README.md.
6
+ */
7
+ /** One role's resolved terminal color, per theme -- see term.ts's `paint()`. */
8
+ export interface PaletteRoleColors {
9
+ readonly success: string;
10
+ readonly info: string;
11
+ readonly warning: string;
12
+ readonly danger: string;
13
+ readonly accent: string;
14
+ readonly secondary: string;
15
+ }
16
+ /** {@link PaletteRoleColors} for each theme -- see term.ts's `resolveThemeId()`. */
17
+ export interface Palette {
18
+ readonly light: PaletteRoleColors;
19
+ readonly dark: PaletteRoleColors;
20
+ }
21
+ /** The resolved terminal palette, light and dark -- see term.ts's `paint()`. */
22
+ export declare const PALETTE: Palette;
23
+ //# sourceMappingURL=palette.d.ts.map
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /** The resolved terminal palette, light and dark -- see term.ts's `paint()`. */
4
+ export const PALETTE = {
5
+ light: {
6
+ success: "#086d31",
7
+ info: "#065da0",
8
+ warning: "#7d4f0a",
9
+ danger: "#963730",
10
+ accent: "#692746",
11
+ secondary: "#554d50",
12
+ },
13
+ dark: {
14
+ success: "#89d298",
15
+ info: "#8ac3fe",
16
+ warning: "#ebb16c",
17
+ danger: "#fda297",
18
+ accent: "#d1789e",
19
+ secondary: "#cdc5c8",
20
+ },
21
+ };
22
+ //# sourceMappingURL=palette.js.map
package/dist/plugin.d.ts CHANGED
@@ -1,22 +1,138 @@
1
+ /**
2
+ * What an install wrote.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { installCustomizeSkill } from "./plugin.js";
7
+ *
8
+ * const { filesWritten } = installCustomizeSkill("/work/app");
9
+ * filesWritten.at(-1); // ".claude/skills/customize/SKILL.md"
10
+ * ```
11
+ */
1
12
  export interface InstallPluginResult {
13
+ /** Paths relative to the project root, in write order (`SKILL.md` last); empty when the destination already held this exact payload. */
2
14
  filesWritten: string[];
3
15
  }
4
16
  /**
5
17
  * Installs the skill into `<targetDir>/.claude/skills/customize/`. Used by
6
- * fresh-bootstrap mode, where the directory is always new.
18
+ * fresh-bootstrap mode, where the directory is normally new (`--force` may
19
+ * point it at a non-empty one or an earlier install). An earlier install
20
+ * that is already byte-identical to this payload is left untouched
21
+ * (`filesWritten` is empty); otherwise any existing `SKILL.md` is removed
22
+ * first, then each payload file is removed and recreated. A symlinked
23
+ * `.claude`, `.claude/skills` or `.claude/skills/customize` is refused, not
24
+ * routed around.
25
+ *
26
+ * @throws `Error` ("could not install the /customize skill ...", raw error
27
+ * as `cause`) on a missing or unreadable source file, a directory at any
28
+ * payload name, a payload name or existing file that cannot be `lstat`ed,
29
+ * or an existing regular file whose read fails with anything other than
30
+ * `EACCES`/`EPERM` (all before anything is removed or written), a symlinked
31
+ * or non-directory directory component, any fs failure, or a failed write
32
+ * -- after removing every file this call wrote. An existing regular file
33
+ * whose read fails with `EACCES` or `EPERM` is not a failure: it is replaced
34
+ * like any stale copy. Entries it replaced before the failure are not
35
+ * restored; the error names them. A failure to locate the default
36
+ * `sourceDir` is thrown the same way. The message ends, once, by saying to
37
+ * fix the cause (for a symlink: remove it), then retry
38
+ * the same command with `--fresh --force` added -- a plain re-run would
39
+ * adopt the now-non-empty target -- and nowhere in the error chain gives
40
+ * adopt mode's bare "re-run the CLI" advice.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * import { installCustomizeSkill } from "./plugin.js";
45
+ *
46
+ * installCustomizeSkill("/work/app").filesWritten.length; // 5
47
+ * ```
7
48
  */
8
49
  export declare function installCustomizeSkill(targetDir: string, sourceDir?: string): InstallPluginResult;
9
50
  type InstallLocation = "claude" | "groundwork" | "already-present";
51
+ /**
52
+ * What the adopt-mode install did, and where.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { installCustomizeSkillGuarded } from "./plugin.js";
57
+ *
58
+ * const result = installCustomizeSkillGuarded("/work/app");
59
+ * if (result.fallbackReason !== undefined) console.log(result.fallbackReason);
60
+ * ```
61
+ */
10
62
  export interface GuardedInstallResult {
63
+ /** Paths relative to the project root, in write order (`SKILL.md` last); empty for `"already-present"`. */
11
64
  filesWritten: string[];
12
65
  location: InstallLocation;
66
+ /**
67
+ * Set on every `"groundwork"` result, and only then: why
68
+ * `.claude/skills/customize/` could not be used, naming the path
69
+ * responsible -- a symlinked or non-directory component, or the existing
70
+ * project entry under one of the skill's payload names.
71
+ */
72
+ fallbackReason?: string;
73
+ /**
74
+ * Set exactly when `fallbackReason` is: `"component"` when a
75
+ * `.claude`/`.claude/skills`/`.claude/skills/customize` component is a
76
+ * symlink or not a directory (no project-local copy of the skill exists),
77
+ * `"entry"` when a project-owned entry sits under one of the skill's
78
+ * payload names (a project copy exists, and is what a user would replace).
79
+ */
80
+ fallbackCause?: "entry" | "component";
13
81
  }
14
82
  /**
15
- * Adopt-mode install: purely additive, never overwrites. If the project
16
- * already has its own `.claude/skills/customize/SKILL.md` and its content
17
- * differs from what this CLI ships, the skill is written to
18
- * `.groundwork/customize/` instead -- reported in the adoption report
19
- * rather than silently overwriting whatever the project already had there.
83
+ * Adopt-mode install: purely additive, never overwrites or removes a project
84
+ * entry under `.claude/skills/customize/`.
85
+ *
86
+ * - If `.claude`, `.claude/skills` or `.claude/skills/customize` is a
87
+ * symlink or not a directory, the skill is written to
88
+ * `.groundwork/customize/` instead, `fallbackReason` names that path and
89
+ * `fallbackCause` is `"component"` -- the entry (and any link target) is
90
+ * left untouched.
91
+ * - Otherwise, if every payload file already there is a regular file
92
+ * matching what this CLI ships byte-for-byte: with all five present the
93
+ * result is `"already-present"` (nothing written); with no `SKILL.md` file
94
+ * (none at all, or an install interrupted before writing it) the missing
95
+ * files are written into `.claude/skills/customize/`, `SKILL.md` last,
96
+ * never rewriting or removing the correct ones.
97
+ * - Anything else under a payload name (a differing file, a regular file
98
+ * that cannot be read, a symlink -- dangling or not -- a directory, a
99
+ * `SKILL.md` without its data; detected by `lstat`) is kept as the
100
+ * project's own: the skill is written to `.groundwork/customize/` instead,
101
+ * `fallbackReason` names that entry (saying so when it could not be read)
102
+ * and `fallbackCause` is `"entry"`. Adopt mode never guesses at a project
103
+ * entry it cannot compare, and the fallback does not guess either: it
104
+ * leaves that entry exactly as it was. Claude Code does not load a skill
105
+ * from there; the caller must say so.
106
+ *
107
+ * Writes into `.claude/skills/customize/` use `"wx"` only, so an entry that
108
+ * appears there mid-install fails the run (rolled back) rather than being
109
+ * replaced. Writes into the CLI-owned `.groundwork/customize/` replace that
110
+ * directory's payload files (any `SKILL.md` removed first, then each file
111
+ * removed and recreated with `"wx"`); a symlinked `.groundwork` or
112
+ * `.groundwork/customize`, or a directory at any payload name there, is
113
+ * refused, never routed around.
114
+ *
115
+ * @throws `Error` ("could not install the /customize skill ...", raw error
116
+ * as `cause`) on a missing or unreadable source file (before anything is
117
+ * written), a payload name under `.claude/skills/customize/` that cannot be
118
+ * `lstat`ed or read (a regular file there whose read is refused with
119
+ * `EACCES`/`EPERM` falls back instead; any other read failure throws before
120
+ * anything is written), any other fs failure while probing or writing, a
121
+ * symlinked `.groundwork`/`.groundwork/customize`, or a directory at a
122
+ * payload name there -- after removing every file this call wrote. A failed
123
+ * `.groundwork/customize/` install also names why the fallback was taken.
124
+ * A failure to locate the default `sourceDir` is thrown the same way. The
125
+ * message ends by
126
+ * saying to fix the cause (for a symlink: remove it) and re-run the CLI,
127
+ * once.
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * import { installCustomizeSkillGuarded } from "./plugin.js";
132
+ *
133
+ * const { location } = installCustomizeSkillGuarded("/work/app");
134
+ * // "claude" | "groundwork" | "already-present"
135
+ * ```
20
136
  */
21
137
  export declare function installCustomizeSkillGuarded(targetDir: string, sourceDir?: string): GuardedInstallResult;
22
138
  export {};