@monte3l/groundwork 0.0.0

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 (151) hide show
  1. package/README.md +23 -0
  2. package/bin/m3l-groundwork.mjs +10 -0
  3. package/dist/assets.d.ts +20 -0
  4. package/dist/assets.js +79 -0
  5. package/dist/caps.d.ts +25 -0
  6. package/dist/caps.js +69 -0
  7. package/dist/conflicts.d.ts +12 -0
  8. package/dist/conflicts.js +77 -0
  9. package/dist/emit.d.ts +7 -0
  10. package/dist/emit.js +42 -0
  11. package/dist/git.d.ts +3 -0
  12. package/dist/git.js +9 -0
  13. package/dist/harness/conformance.d.ts +20 -0
  14. package/dist/harness/conformance.js +18 -0
  15. package/dist/harness/frontmatter.d.ts +38 -0
  16. package/dist/harness/frontmatter.js +204 -0
  17. package/dist/harness/grade.d.ts +4 -0
  18. package/dist/harness/grade.js +105 -0
  19. package/dist/harness/rules.d.ts +55 -0
  20. package/dist/harness/rules.js +580 -0
  21. package/dist/harness/types.d.ts +32 -0
  22. package/dist/harness/types.js +9 -0
  23. package/dist/inventory.d.ts +63 -0
  24. package/dist/inventory.js +66 -0
  25. package/dist/jsonc.d.ts +14 -0
  26. package/dist/jsonc.js +83 -0
  27. package/dist/main.d.ts +24 -0
  28. package/dist/main.js +297 -0
  29. package/dist/merge-json.d.ts +74 -0
  30. package/dist/merge-json.js +135 -0
  31. package/dist/mode.d.ts +19 -0
  32. package/dist/mode.js +53 -0
  33. package/dist/packs.d.ts +61 -0
  34. package/dist/packs.js +186 -0
  35. package/dist/plugin.d.ts +23 -0
  36. package/dist/plugin.js +79 -0
  37. package/dist/report.d.ts +4 -0
  38. package/dist/report.js +323 -0
  39. package/dist/survey/fs-walk.d.ts +14 -0
  40. package/dist/survey/fs-walk.js +60 -0
  41. package/dist/survey/survey-docs.d.ts +4 -0
  42. package/dist/survey/survey-docs.js +69 -0
  43. package/dist/survey/survey-harness.d.ts +4 -0
  44. package/dist/survey/survey-harness.js +121 -0
  45. package/dist/survey/survey-shape.d.ts +4 -0
  46. package/dist/survey/survey-shape.js +182 -0
  47. package/dist/survey/survey-toolchain.d.ts +4 -0
  48. package/dist/survey/survey-toolchain.js +217 -0
  49. package/dist/survey/survey.d.ts +5 -0
  50. package/dist/survey/survey.js +21 -0
  51. package/dist/survey/types.d.ts +117 -0
  52. package/dist/survey/types.js +8 -0
  53. package/dist/tokens.d.ts +13 -0
  54. package/dist/tokens.js +13 -0
  55. package/dist/toolchain/conformance.d.ts +20 -0
  56. package/dist/toolchain/conformance.js +30 -0
  57. package/dist/toolchain/grade.d.ts +4 -0
  58. package/dist/toolchain/grade.js +244 -0
  59. package/dist/toolchain/rules.d.ts +118 -0
  60. package/dist/toolchain/rules.js +706 -0
  61. package/dist/toolchain/tsconfig-chain.d.ts +36 -0
  62. package/dist/toolchain/tsconfig-chain.js +116 -0
  63. package/dist/toolchain/types.d.ts +27 -0
  64. package/dist/toolchain/types.js +9 -0
  65. package/package.json +59 -0
  66. package/plugin/skills/customize/SKILL.md +305 -0
  67. package/plugin/src/domain-map.ts +134 -0
  68. package/plugin/src/index.ts +4 -0
  69. package/plugin/src/kind-facet-map.ts +174 -0
  70. package/plugin/src/pack-map.ts +65 -0
  71. package/templates/core/.claude/agents/Explore.md +43 -0
  72. package/templates/core/.claude/agents/code-implementer.md +258 -0
  73. package/templates/core/.claude/agents/code-reviewer.md +163 -0
  74. package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
  75. package/templates/core/.claude/agents/test-author.md +211 -0
  76. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
  77. package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
  78. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
  79. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
  80. package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
  81. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
  82. package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
  83. package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
  84. package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
  85. package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
  86. package/templates/core/.claude/rules/agent-dispatch.md +121 -0
  87. package/templates/core/.claude/rules/refactoring.md +52 -0
  88. package/templates/core/.claude/rules/src.md +114 -0
  89. package/templates/core/.claude/rules/tests.md +129 -0
  90. package/templates/core/.claude/settings.json +111 -0
  91. package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
  92. package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
  93. package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
  94. package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
  95. package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
  96. package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
  97. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
  98. package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
  99. package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
  100. package/templates/core/.github/workflows/ci.yml +123 -0
  101. package/templates/core/.github/workflows/dependency-review.yml +26 -0
  102. package/templates/core/.github/workflows/security-audit.yml +54 -0
  103. package/templates/core/.node-version +1 -0
  104. package/templates/core/.prettierignore +5 -0
  105. package/templates/core/.prettierrc.json +4 -0
  106. package/templates/core/CLAUDE.md +127 -0
  107. package/templates/core/README.md +24 -0
  108. package/templates/core/_gitignore +19 -0
  109. package/templates/core/_npmrc +1 -0
  110. package/templates/core/bin/check-exports.mjs +92 -0
  111. package/templates/core/bin/check-harness.mjs +27 -0
  112. package/templates/core/bin/check-node-version.mjs +51 -0
  113. package/templates/core/bin/check-toolchain.mjs +20 -0
  114. package/templates/core/bin/lib/agent-roster.mjs +8 -0
  115. package/templates/core/bin/lib/frontmatter.mjs +210 -0
  116. package/templates/core/bin/lib/harness-rules.mjs +916 -0
  117. package/templates/core/bin/lib/protected-paths.mjs +23 -0
  118. package/templates/core/bin/lib/report.mjs +56 -0
  119. package/templates/core/bin/lib/signed-range.mjs +178 -0
  120. package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
  121. package/templates/core/bin/lib/verify-steps.mjs +131 -0
  122. package/templates/core/bin/lib/verify-steps.packs.json +1 -0
  123. package/templates/core/bin/lint-commit.mjs +50 -0
  124. package/templates/core/bin/strip-claude-trailers.mjs +25 -0
  125. package/templates/core/bin/verify.mjs +64 -0
  126. package/templates/core/commitlint.config.js +11 -0
  127. package/templates/core/docs/research/harness-refresh.md +27 -0
  128. package/templates/core/docs/research/typescript-refresh.md +32 -0
  129. package/templates/core/eslint.config.js +105 -0
  130. package/templates/core/knip.json +6 -0
  131. package/templates/core/lefthook.yml +39 -0
  132. package/templates/core/package.json +58 -0
  133. package/templates/core/pnpm-workspace.yaml +13 -0
  134. package/templates/core/src/index.ts +12 -0
  135. package/templates/core/tests/index.test.ts +8 -0
  136. package/templates/core/tsconfig.base.json +36 -0
  137. package/templates/core/tsconfig.build.json +10 -0
  138. package/templates/core/tsconfig.json +11 -0
  139. package/templates/core/vitest.config.ts +32 -0
  140. package/templates/packs/README.md +81 -0
  141. package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
  142. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
  143. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
  144. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
  145. package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
  146. package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
  147. package/templates/packs/harness-extras/pack.json +65 -0
  148. package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
  149. package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
  150. package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
  151. package/templates/packs/statusline/pack.json +31 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Pure, deterministic merges over parsed JSON -- the entire mechanism that
3
+ * lets a pack extend `.claude/settings.json` (its `hooks` block and a few
4
+ * top-level keys), `package.json`'s `scripts`, and
5
+ * `bin/lib/verify-steps.packs.json` without `packages/cli` ever parsing
6
+ * YAML or JavaScript. Every merge is append-only (it never rebuilds an
7
+ * object wholesale, which would risk reordering keys Prettier would
8
+ * otherwise preserve) and idempotent (merging the same fragment twice
9
+ * produces the same result as merging it once).
10
+ */
11
+ export function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ /**
15
+ * Merges a pack's `.claude/settings.json` hook fragment into an existing
16
+ * settings object. Per event, an entry is matched by `matcher` (both absent
17
+ * counts as a match -- e.g. `PreCompact`, which has none); within a matched
18
+ * entry, only `hooks` commands not already present (compared by exact
19
+ * `command` string) are appended. A command that matches on `command` but
20
+ * differs in its other fields (`if`, `timeout`) is a hard collision, never
21
+ * a silent overwrite.
22
+ */
23
+ export function mergeSettingsHooks(existing, fragment) {
24
+ const settings = isRecord(existing)
25
+ ? { ...existing }
26
+ : {};
27
+ const existingHooks = settings["hooks"];
28
+ const hooks = isRecord(existingHooks)
29
+ ? { ...existingHooks }
30
+ : {};
31
+ for (const [event, entries] of Object.entries(fragment)) {
32
+ const existingEntries = Array.isArray(hooks[event])
33
+ ? [...hooks[event]]
34
+ : [];
35
+ for (const entry of entries) {
36
+ const matchIndex = existingEntries.findIndex((candidate) => (candidate.matcher ?? undefined) === (entry.matcher ?? undefined));
37
+ if (matchIndex === -1) {
38
+ existingEntries.push(entry);
39
+ continue;
40
+ }
41
+ const matched = existingEntries[matchIndex];
42
+ if (matched === undefined) {
43
+ continue;
44
+ }
45
+ const mergedHooks = [...matched.hooks];
46
+ for (const hookCmd of entry.hooks) {
47
+ const duplicate = mergedHooks.find((candidate) => candidate.command === hookCmd.command);
48
+ if (duplicate) {
49
+ if (JSON.stringify(duplicate) !== JSON.stringify(hookCmd)) {
50
+ throw new Error(`settings.json merge collision: "${event}" (matcher ${JSON.stringify(entry.matcher)}) already has a hook for "${hookCmd.command}" with different config`);
51
+ }
52
+ continue; // Identical entry already present -- idempotent no-op.
53
+ }
54
+ mergedHooks.push(hookCmd);
55
+ }
56
+ existingEntries[matchIndex] = { ...matched, hooks: mergedHooks };
57
+ }
58
+ hooks[event] = existingEntries;
59
+ }
60
+ return { ...settings, hooks };
61
+ }
62
+ /**
63
+ * Merges a pack's top-level `.claude/settings.json` keys (`statusLine`,
64
+ * `subagentStatusLine` -- settings that are not hook registrations) into an
65
+ * existing settings object. Disjoint from `mergeSettingsHooks`, which owns the
66
+ * `hooks` block: a fragment key of `hooks` is rejected outright rather than
67
+ * silently clobbering it. A key not yet present is appended; one already
68
+ * present with an identical value is a no-op; one present with a different
69
+ * value is a hard collision, never a silent overwrite -- an adopted project's
70
+ * own `statusLine` is the user's to replace deliberately.
71
+ */
72
+ export function mergeSettingsTopLevel(existing, fragment) {
73
+ const settings = isRecord(existing)
74
+ ? { ...existing }
75
+ : {};
76
+ for (const [key, value] of Object.entries(fragment)) {
77
+ if (key === "hooks") {
78
+ throw new Error('settings.json merge: "hooks" is owned by mergeSettingsHooks and cannot be set as a top-level key');
79
+ }
80
+ if (!Object.hasOwn(settings, key)) {
81
+ settings[key] = value;
82
+ continue;
83
+ }
84
+ if (JSON.stringify(settings[key]) !== JSON.stringify(value)) {
85
+ throw new Error(`settings.json merge collision: "${key}" is already set with a different value`);
86
+ }
87
+ // Identical value already present -- idempotent no-op.
88
+ }
89
+ return settings;
90
+ }
91
+ /**
92
+ * Merges a pack's `package.json` script additions into the existing
93
+ * `scripts` block. Never overwrites a differing existing script -- the
94
+ * collision is returned for the caller to report, consistent with adopt
95
+ * mode's "report, then the user decides per conflict" policy.
96
+ */
97
+ export function mergePackageScripts(existing, additions) {
98
+ const scripts = { ...(existing ?? {}) };
99
+ const collisions = [];
100
+ for (const [name, cmd] of Object.entries(additions)) {
101
+ const currentValue = scripts[name];
102
+ if (currentValue !== undefined) {
103
+ if (currentValue !== cmd) {
104
+ collisions.push({ name, existing: currentValue, incoming: cmd });
105
+ }
106
+ continue; // Either identical (no-op) or a collision already recorded.
107
+ }
108
+ scripts[name] = cmd;
109
+ }
110
+ return { scripts, collisions };
111
+ }
112
+ /**
113
+ * Merges a pack's verify-step additions into an existing step array
114
+ * (`bin/lib/verify-steps.packs.json`'s parsed contents). Append-by-`id`,
115
+ * idempotent; a same-`id`-different-content collision is a hard error.
116
+ */
117
+ export function mergeVerifySteps(existing, additions) {
118
+ const current = Array.isArray(existing)
119
+ ? [...existing]
120
+ : [];
121
+ for (const step of additions) {
122
+ const existingIndex = current.findIndex((candidate) => candidate.id === step.id);
123
+ if (existingIndex === -1) {
124
+ current.push(step);
125
+ continue;
126
+ }
127
+ const existingStep = current[existingIndex];
128
+ if (JSON.stringify(existingStep) !== JSON.stringify(step)) {
129
+ throw new Error(`verify-steps.packs.json merge collision: step "${step.id}" is already registered with different config`);
130
+ }
131
+ // Identical entry already present -- idempotent no-op.
132
+ }
133
+ return current;
134
+ }
135
+ //# sourceMappingURL=merge-json.js.map
package/dist/mode.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ type Mode = "fresh" | "adopt";
2
+ export interface ModeDetection {
3
+ mode: Mode;
4
+ signal: string;
5
+ }
6
+ /** Inspects `dir` and reports which mode it implies, and why. */
7
+ export declare function detectMode(dir: string): ModeDetection;
8
+ export interface ModeFlags {
9
+ adopt: boolean;
10
+ fresh: boolean;
11
+ }
12
+ /**
13
+ * Applies `--adopt`/`--fresh` overrides onto an auto-detected mode. Throws
14
+ * if both are passed -- an explicit contradiction should never resolve
15
+ * silently to one of the two.
16
+ */
17
+ export declare function resolveMode(detected: ModeDetection, flags: ModeFlags): ModeDetection;
18
+ export {};
19
+ //# sourceMappingURL=mode.d.ts.map
package/dist/mode.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Decides whether the CLI is writing a fresh project into an empty directory
3
+ * or adopting an already-established one. Adopt mode never overwrites
4
+ * project files -- see conflicts.ts and inventory.ts for what it does
5
+ * instead. Detection is a fact-check, never a guess it hides: the signal
6
+ * that chose the mode is always carried alongside it so an auto-detection
7
+ * is never a silent surprise.
8
+ */
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { extname } from "node:path";
11
+ const LOOSE_SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js"]);
12
+ /** Inspects `dir` and reports which mode it implies, and why. */
13
+ export function detectMode(dir) {
14
+ if (!existsSync(dir)) {
15
+ return { mode: "fresh", signal: `${dir} does not exist yet` };
16
+ }
17
+ const entries = readdirSync(dir, { withFileTypes: true });
18
+ if (entries.length === 0) {
19
+ return { mode: "fresh", signal: `${dir} is empty` };
20
+ }
21
+ if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) {
22
+ return { mode: "adopt", signal: "found package.json" };
23
+ }
24
+ if (entries.some((entry) => entry.isDirectory() && entry.name === ".git")) {
25
+ return { mode: "adopt", signal: "found a .git directory" };
26
+ }
27
+ const looseSource = entries.find((entry) => entry.isFile() && LOOSE_SOURCE_EXTENSIONS.has(extname(entry.name)));
28
+ if (looseSource !== undefined) {
29
+ return { mode: "adopt", signal: `found ${looseSource.name}` };
30
+ }
31
+ return {
32
+ mode: "fresh",
33
+ signal: `${dir} exists but has no recognizable project markers`,
34
+ };
35
+ }
36
+ /**
37
+ * Applies `--adopt`/`--fresh` overrides onto an auto-detected mode. Throws
38
+ * if both are passed -- an explicit contradiction should never resolve
39
+ * silently to one of the two.
40
+ */
41
+ export function resolveMode(detected, flags) {
42
+ if (flags.adopt && flags.fresh) {
43
+ throw new Error("--adopt and --fresh are mutually exclusive");
44
+ }
45
+ if (flags.adopt) {
46
+ return { mode: "adopt", signal: "--adopt forced" };
47
+ }
48
+ if (flags.fresh) {
49
+ return { mode: "fresh", signal: "--fresh forced" };
50
+ }
51
+ return detected;
52
+ }
53
+ //# sourceMappingURL=mode.js.map
@@ -0,0 +1,61 @@
1
+ import type { CapCounts } from "./caps.js";
2
+ import type { SettingsHooksFragment, SettingsTopLevelFragment, VerifyStepAddition } from "./merge-json.js";
3
+ import type { TokenTable } from "./tokens.js";
4
+ export interface PackWiring {
5
+ settings: SettingsHooksFragment;
6
+ /** Top-level `.claude/settings.json` keys that aren't hook registrations (`statusLine`). Optional: packs that only register hooks omit it. */
7
+ settingsTopLevel?: SettingsTopLevelFragment;
8
+ packageScripts: Record<string, string>;
9
+ verifySteps: VerifyStepAddition[];
10
+ }
11
+ export interface PackManifest {
12
+ schemaVersion: number;
13
+ name: string;
14
+ description: string;
15
+ modes: string[];
16
+ budget: CapCounts;
17
+ requires: {
18
+ paths: string[];
19
+ } | undefined;
20
+ wiring: PackWiring;
21
+ adoptNotes: string | undefined;
22
+ }
23
+ export interface Pack {
24
+ manifest: PackManifest;
25
+ filesDir: string;
26
+ }
27
+ /** `templates/packs`, resolved the same way `templatesCoreDir()` resolves `templates/core`. */
28
+ export declare function packsRootDir(): string;
29
+ /** Names of every pack directory that has a `pack.json` under `root`, sorted for deterministic install order. `root` defaults to `templates/packs`, overridable for tests. */
30
+ export declare function listPackNames(root?: string): string[];
31
+ /** 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. */
32
+ export declare function loadPack(name: string, root?: string): Pack;
33
+ export interface PackInstallResult {
34
+ filesWritten: string[];
35
+ budget: CapCounts;
36
+ }
37
+ /**
38
+ * Installs one pack into a target directory that already has the baseline
39
+ * emitted (fresh mode only). Copies `files/` via the existing `emitTemplate`
40
+ * unchanged, checks `requires.paths` against the just-emitted tree, then
41
+ * applies the pack's three JSON wiring merges.
42
+ */
43
+ export declare function installPack(pack: Pack, targetDir: string, tokens: TokenTable): PackInstallResult;
44
+ /**
45
+ * Copies a pack's `pack.json` + `files/` tree, unmodified, into
46
+ * `<groundworkDir>/packs/<name>/` -- adopt mode's staging area. `/customize`
47
+ * installs from this self-contained copy rather than from `templateRoot`
48
+ * (an absolute path that may not exist by the time it runs). Token
49
+ * substitution is a no-op here (`{}`): staging a project's real name into
50
+ * pack content is `/customize`'s job, not this offline copy's.
51
+ */
52
+ export declare function stagePackFiles(pack: Pack, groundworkDir: string): string[];
53
+ /**
54
+ * Index-level, adopt-mode-only facts about how a pack's wiring would land
55
+ * against a real project's current `.claude/settings.json` and
56
+ * `bin/lib/verify-steps.packs.json` -- never a verdict on whether it will
57
+ * work, which is `/customize`'s Step 0 judgment call to make after reading
58
+ * the project's real gate runner and hook config.
59
+ */
60
+ export declare function observeWiring(targetDir: string, manifest: PackManifest): string[];
61
+ //# sourceMappingURL=packs.d.ts.map
package/dist/packs.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Loads pack manifests from `templates/packs/<name>/pack.json` and installs
3
+ * a pack's files + JSON wiring into a freshly-bootstrapped project. Adopt
4
+ * 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).
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { resolveAsset } from "./assets.js";
12
+ import { emitTemplate } from "./emit.js";
13
+ import { parseJsonc } from "./jsonc.js";
14
+ import { isRecord, mergePackageScripts, mergeSettingsHooks, mergeSettingsTopLevel, mergeVerifySteps, } from "./merge-json.js";
15
+ /** `templates/packs`, resolved the same way `templatesCoreDir()` resolves `templates/core`. */
16
+ export function packsRootDir() {
17
+ return resolveAsset({ repo: "templates/packs", local: "templates/packs" });
18
+ }
19
+ /** Names of every pack directory that has a `pack.json` under `root`, sorted for deterministic install order. `root` defaults to `templates/packs`, overridable for tests. */
20
+ export function listPackNames(root = packsRootDir()) {
21
+ if (!existsSync(root)) {
22
+ return [];
23
+ }
24
+ return readdirSync(root, { withFileTypes: true })
25
+ .filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, "pack.json")))
26
+ .map((entry) => entry.name)
27
+ .sort();
28
+ }
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. */
30
+ export function loadPack(name, root = packsRootDir()) {
31
+ const packDir = join(root, name);
32
+ const manifestPath = join(packDir, "pack.json");
33
+ if (!existsSync(manifestPath)) {
34
+ const available = listPackNames(root);
35
+ throw new Error(`unknown pack "${name}" -- available: ${available.length > 0 ? available.join(", ") : "(none)"}`);
36
+ }
37
+ const parsed = parseJsonc(readFileSync(manifestPath, "utf8"));
38
+ if (!parsed.ok) {
39
+ throw new Error(`pack "${name}": pack.json failed to parse -- ${parsed.error}`);
40
+ }
41
+ const manifest = parsed.value;
42
+ if (manifest.schemaVersion !== 1) {
43
+ throw new Error(`pack "${name}": unsupported pack.json schemaVersion ${JSON.stringify(manifest.schemaVersion)}`);
44
+ }
45
+ return { manifest, filesDir: join(packDir, "files") };
46
+ }
47
+ function readJsonOrThrow(path) {
48
+ const parsed = parseJsonc(readFileSync(path, "utf8"));
49
+ if (!parsed.ok) {
50
+ throw new Error(`${path} failed to parse -- ${parsed.error}`);
51
+ }
52
+ return parsed.value;
53
+ }
54
+ function writeJson(path, value) {
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
57
+ }
58
+ /**
59
+ * Serializes `bin/lib/verify-steps.packs.json` to match exactly what
60
+ * Prettier would produce for this shape: every field one per line except
61
+ * `cmd`, whose short array of strings Prettier collapses onto a single
62
+ * line when it fits within printWidth. `JSON.stringify(value, null, 2)`
63
+ * never collapses an array, so writing this file through the generic
64
+ * `writeJson` would fail `prettier --check` on every install. This printer
65
+ * is scoped to exactly the one shape `VerifyStepAddition[]` has -- it is
66
+ * not a general JSON formatter.
67
+ */
68
+ function writeVerifyStepsPacksJson(path, steps) {
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ if (steps.length === 0) {
71
+ writeFileSync(path, "[]\n");
72
+ return;
73
+ }
74
+ const lines = ["["];
75
+ steps.forEach((step, index) => {
76
+ const comma = index < steps.length - 1 ? "," : "";
77
+ lines.push(" {", ` "id": ${JSON.stringify(step.id)},`, ` "group": ${JSON.stringify(step.group)},`, ` "name": ${JSON.stringify(step.name)},`, ` "cmd": [${step.cmd.map((arg) => JSON.stringify(arg)).join(", ")}]`, ` }${comma}`);
78
+ });
79
+ lines.push("]");
80
+ writeFileSync(path, lines.join("\n") + "\n");
81
+ }
82
+ /**
83
+ * Installs one pack into a target directory that already has the baseline
84
+ * emitted (fresh mode only). Copies `files/` via the existing `emitTemplate`
85
+ * unchanged, checks `requires.paths` against the just-emitted tree, then
86
+ * applies the pack's three JSON wiring merges.
87
+ */
88
+ export function installPack(pack, targetDir, tokens) {
89
+ const { manifest, filesDir } = pack;
90
+ for (const relPath of manifest.requires?.paths ?? []) {
91
+ if (!existsSync(join(targetDir, relPath))) {
92
+ throw new Error(`pack "${manifest.name}" requires "${relPath}", which is missing from the target -- install the baseline first`);
93
+ }
94
+ }
95
+ const { filesWritten } = emitTemplate(filesDir, targetDir, tokens);
96
+ const settingsPath = join(targetDir, ".claude", "settings.json");
97
+ const existingSettings = existsSync(settingsPath)
98
+ ? readJsonOrThrow(settingsPath)
99
+ : {};
100
+ let settings = existingSettings;
101
+ // Only touch the hooks block when the pack registers hooks: merging an empty
102
+ // fragment would still write an empty `hooks: {}` into a settings file that
103
+ // had none.
104
+ if (Object.keys(manifest.wiring.settings).length > 0) {
105
+ settings = mergeSettingsHooks(settings, manifest.wiring.settings);
106
+ }
107
+ settings = mergeSettingsTopLevel(settings, manifest.wiring.settingsTopLevel ?? {});
108
+ writeJson(settingsPath, settings);
109
+ if (Object.keys(manifest.wiring.packageScripts).length > 0) {
110
+ const pkgPath = join(targetDir, "package.json");
111
+ const pkg = readJsonOrThrow(pkgPath);
112
+ const { scripts, collisions } = mergePackageScripts(pkg["scripts"], manifest.wiring.packageScripts);
113
+ if (collisions.length > 0) {
114
+ throw new Error(`pack "${manifest.name}": package.json script collision(s): ${collisions.map((c) => c.name).join(", ")}`);
115
+ }
116
+ pkg["scripts"] = scripts;
117
+ writeJson(pkgPath, pkg);
118
+ }
119
+ if (manifest.wiring.verifySteps.length > 0) {
120
+ const stepsPath = join(targetDir, "bin", "lib", "verify-steps.packs.json");
121
+ const existingSteps = existsSync(stepsPath)
122
+ ? readJsonOrThrow(stepsPath)
123
+ : [];
124
+ writeVerifyStepsPacksJson(stepsPath, mergeVerifySteps(existingSteps, manifest.wiring.verifySteps));
125
+ }
126
+ return { filesWritten, budget: manifest.budget };
127
+ }
128
+ /**
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.
135
+ */
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"];
141
+ }
142
+ /**
143
+ * Index-level, adopt-mode-only facts about how a pack's wiring would land
144
+ * against a real project's current `.claude/settings.json` and
145
+ * `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.
148
+ */
149
+ export function observeWiring(targetDir, manifest) {
150
+ const observations = [];
151
+ const settingsPath = join(targetDir, ".claude", "settings.json");
152
+ if (!existsSync(settingsPath)) {
153
+ observations.push("no .claude/settings.json found");
154
+ }
155
+ else {
156
+ const parsed = parseJsonc(readFileSync(settingsPath, "utf8"));
157
+ if (!parsed.ok || !isRecord(parsed.value)) {
158
+ observations.push(".claude/settings.json exists but could not be parsed");
159
+ }
160
+ else {
161
+ const hooks = parsed.value["hooks"];
162
+ for (const event of Object.keys(manifest.wiring.settings)) {
163
+ const existingEntries = isRecord(hooks) ? hooks[event] : undefined;
164
+ observations.push(Array.isArray(existingEntries) && existingEntries.length > 0
165
+ ? `.claude/settings.json already has a "${event}" entry (${existingEntries.length} registration(s))`
166
+ : `.claude/settings.json has no "${event}" entry yet`);
167
+ }
168
+ for (const key of Object.keys(manifest.wiring.settingsTopLevel ?? {})) {
169
+ observations.push(key in parsed.value
170
+ ? `.claude/settings.json already sets a top-level "${key}" -- installing this pack would collide with it, so replacing it is a decision for the user`
171
+ : `.claude/settings.json has no top-level "${key}" yet`);
172
+ }
173
+ }
174
+ }
175
+ if (existsSync(join(targetDir, ".claude", "settings.local.json"))) {
176
+ observations.push(".claude/settings.local.json is present and may shadow a merged hook entry");
177
+ }
178
+ if (manifest.wiring.verifySteps.length > 0) {
179
+ 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");
183
+ }
184
+ return observations;
185
+ }
186
+ //# sourceMappingURL=packs.js.map
@@ -0,0 +1,23 @@
1
+ export interface InstallPluginResult {
2
+ filesWritten: string[];
3
+ }
4
+ /**
5
+ * Installs the skill into `<targetDir>/.claude/skills/customize/`. Used by
6
+ * fresh-bootstrap mode, where the directory is always new.
7
+ */
8
+ export declare function installCustomizeSkill(targetDir: string, sourceDir?: string): InstallPluginResult;
9
+ type InstallLocation = "claude" | "groundwork" | "already-present";
10
+ export interface GuardedInstallResult {
11
+ filesWritten: string[];
12
+ location: InstallLocation;
13
+ }
14
+ /**
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.
20
+ */
21
+ export declare function installCustomizeSkillGuarded(targetDir: string, sourceDir?: string): GuardedInstallResult;
22
+ export {};
23
+ //# sourceMappingURL=plugin.d.ts.map
package/dist/plugin.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Installs the `/customize` skill into a bootstrapped or adopted project.
3
+ * Claude Code's marketplace-based plugin installation is an interactive,
4
+ * network-involving flow this offline CLI can't drive; instead this copies
5
+ * the skill's SKILL.md plus its deterministic backing data directly into a
6
+ * destination directory, so `/customize` works immediately with no further
7
+ * setup step.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { resolveAsset } from "./assets.js";
12
+ /** Resolves the plugin payload for a source checkout (`packages/plugin`) or a published tarball (`plugin/`). */
13
+ function pluginDir() {
14
+ return resolveAsset({ repo: "packages/plugin", local: "plugin" });
15
+ }
16
+ /**
17
+ * Copies `skills/customize/SKILL.md` and its backing data
18
+ * (`src/kind-facet-map.ts`, `src/domain-map.ts`, `src/pack-map.ts`) from
19
+ * `sourceDir` into `destDir`. A missing source file is a broken install,
20
+ * not something to degrade past silently -- it throws.
21
+ */
22
+ function copyCustomizeSkillFiles(destDir, sourceDir) {
23
+ const skillSourceDir = join(sourceDir, "skills", "customize");
24
+ const dataSourceDir = join(sourceDir, "src");
25
+ mkdirSync(destDir, { recursive: true });
26
+ const filesWritten = [];
27
+ const copyInto = (from, toName) => {
28
+ if (!existsSync(from)) {
29
+ throw new Error(`the /customize skill's source file is missing: ${from}`);
30
+ }
31
+ writeFileSync(join(destDir, toName), readFileSync(from, "utf8"));
32
+ filesWritten.push(toName);
33
+ };
34
+ copyInto(join(skillSourceDir, "SKILL.md"), "SKILL.md");
35
+ copyInto(join(dataSourceDir, "kind-facet-map.ts"), "kind-facet-map.ts");
36
+ copyInto(join(dataSourceDir, "domain-map.ts"), "domain-map.ts");
37
+ copyInto(join(dataSourceDir, "pack-map.ts"), "pack-map.ts");
38
+ return { filesWritten };
39
+ }
40
+ /**
41
+ * Installs the skill into `<targetDir>/.claude/skills/customize/`. Used by
42
+ * fresh-bootstrap mode, where the directory is always new.
43
+ */
44
+ export function installCustomizeSkill(targetDir, sourceDir = pluginDir()) {
45
+ const destDir = join(targetDir, ".claude", "skills", "customize");
46
+ const result = copyCustomizeSkillFiles(destDir, sourceDir);
47
+ return {
48
+ filesWritten: result.filesWritten.map((name) => join(".claude", "skills", "customize", name)),
49
+ };
50
+ }
51
+ /**
52
+ * Adopt-mode install: purely additive, never overwrites. If the project
53
+ * already has its own `.claude/skills/customize/SKILL.md` and its content
54
+ * differs from what this CLI ships, the skill is written to
55
+ * `.groundwork/customize/` instead -- reported in the adoption report
56
+ * rather than silently overwriting whatever the project already had there.
57
+ */
58
+ export function installCustomizeSkillGuarded(targetDir, sourceDir = pluginDir()) {
59
+ const existingSkillMdPath = join(targetDir, ".claude", "skills", "customize", "SKILL.md");
60
+ const sourceSkillMdPath = join(sourceDir, "skills", "customize", "SKILL.md");
61
+ if (existsSync(existingSkillMdPath)) {
62
+ const existingContent = readFileSync(existingSkillMdPath, "utf8");
63
+ const sourceContent = existsSync(sourceSkillMdPath)
64
+ ? readFileSync(sourceSkillMdPath, "utf8")
65
+ : undefined;
66
+ if (sourceContent !== undefined && existingContent === sourceContent) {
67
+ return { filesWritten: [], location: "already-present" };
68
+ }
69
+ const destDir = join(targetDir, ".groundwork", "customize");
70
+ const result = copyCustomizeSkillFiles(destDir, sourceDir);
71
+ return {
72
+ filesWritten: result.filesWritten.map((name) => join(".groundwork", "customize", name)),
73
+ location: "groundwork",
74
+ };
75
+ }
76
+ const result = installCustomizeSkill(targetDir, sourceDir);
77
+ return { filesWritten: result.filesWritten, location: "claude" };
78
+ }
79
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1,4 @@
1
+ import type { Inventory } from "./inventory.js";
2
+ /** Renders the full adoption report as Markdown. */
3
+ export declare function renderReport(inventory: Inventory): string;
4
+ //# sourceMappingURL=report.d.ts.map