@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,373 @@
1
+ // New-project scaffolder (architecture §14 "Unified stack standard").
2
+ // `factory/scaffold/base/` is the always-copied template tree;
3
+ // `factory/scaffold/ui/` layers Vue+Vite on top for `--ui` projects, and
4
+ // `factory/scaffold/ui-tailwind/` layers the utility CSS over that when the
5
+ // operator answered `styling: tailwind`. What gets layered is not this
6
+ // module's opinion: factory/policies/stack.yml holds the answers, stack.ts
7
+ // parses them, and an answer these templates cannot build stops the scaffold
8
+ // (requireScaffoldable) instead of quietly producing a different project —
9
+ // which is what the old hardcoded "Vue + Tailwind + a private design system"
10
+ // path did to anyone who wanted something else. Deterministic file copy +
11
+ // string substitution only — no LLM calls, no network, no git push (guardrails.md:
12
+ // only the operator pushes/creates the remote; this module prints the
13
+ // commands for them to run, never executes them).
14
+ import { spawnSync } from 'node:child_process';
15
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { SmithError } from './errors.js';
18
+ import { runGit as git } from './git.js';
19
+ import { PROJECTS_DIR, REPO_ROOT, ROADMAP_PATH, SCAFFOLD_DIR } from './paths.js';
20
+ import { readRoadmapText, roadmapDeclaresId } from './roadmap.js';
21
+ import { loadStackAnswers, requireScaffoldable } from './stack.js';
22
+ export class ScaffoldError extends SmithError {
23
+ }
24
+ const PROJECT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
25
+ const PLACEHOLDER = '__PROJECT_NAME__';
26
+ /** Extensions worth scanning for the placeholder token — binary/lockfile-shaped files are skipped. */
27
+ const TEXT_EXTENSIONS = new Set([
28
+ '.json',
29
+ '.ts',
30
+ '.vue',
31
+ '.md',
32
+ '.yml',
33
+ '.yaml',
34
+ '.html',
35
+ '.css',
36
+ '.gitignore',
37
+ ]);
38
+ /** Template-only files that get merged, not copied verbatim (see mergePackageJson). */
39
+ export const PACKAGE_FRAGMENT_NAME = 'package.fragment.json';
40
+ /**
41
+ * Suffix stripped at copy time. `biome.json` specifically CANNOT live under
42
+ * that literal name inside factory/scaffold/base/: Biome's own config
43
+ * discovery treats every `biome.json` it finds as a project root, and a
44
+ * nested one under black-smith's OWN root config trips "Found a nested root
45
+ * configuration" and fails `pnpm exec biome check .` on THIS repo — a
46
+ * problem that only exists while the template lives inside black-smith's
47
+ * tree, not in the scaffolded project itself (which rightly wants
48
+ * `biome.json` as ITS OWN root config). Renaming the template file sidesteps
49
+ * discovery here without weakening the shipped config.
50
+ */
51
+ const TEMPLATE_SUFFIX = '.tmpl';
52
+ export function validateProjectName(name) {
53
+ if (!PROJECT_NAME_PATTERN.test(name)) {
54
+ throw new ScaffoldError('scaffold.invalid-project-name', `Project name "${name}" must be lowercase kebab-case (^[a-z][a-z0-9-]*$).`, { name });
55
+ }
56
+ }
57
+ function destFileName(entry) {
58
+ return entry.endsWith(TEMPLATE_SUFFIX) ? entry.slice(0, -TEMPLATE_SUFFIX.length) : entry;
59
+ }
60
+ function isTextFile(destFilePath) {
61
+ const ext = path.extname(destFilePath) || path.basename(destFilePath); // .gitignore has no extname
62
+ return TEXT_EXTENSIONS.has(ext);
63
+ }
64
+ /**
65
+ * Recursively copy `srcDir` into `destDir`, substituting PLACEHOLDER in text
66
+ * file contents. Exported for mcp.ts, which layers a template onto an ALREADY
67
+ * scaffolded project rather than creating one — same copy semantics, different
68
+ * precondition, so it reuses this instead of growing a second copier that
69
+ * could drift on placeholder or `.tmpl` handling.
70
+ */
71
+ export function copyTemplateDir(srcDir, destDir, projectName, filesWritten, skip = new Set()) {
72
+ // Stripping TEMPLATE_SUFFIX means `x.yaml` and `x.yaml.tmpl` in ONE template
73
+ // directory are two sources for one destination, and readdirSync does not
74
+ // sort: which one survived was the filesystem's answer, not the repo's, and
75
+ // the loser's edits were discarded in silence (D-179). Refuse instead —
76
+ // there is no legitimate reason for two entries in the same directory to
77
+ // target the same file. The map is per-directory on purpose: `ui/` copying
78
+ // over a file `base/` already wrote is layering, which is by design.
79
+ const claimedBy = new Map();
80
+ for (const entry of readdirSync(srcDir)) {
81
+ if (skip.has(entry))
82
+ continue;
83
+ const destName = destFileName(entry);
84
+ const priorEntry = claimedBy.get(destName);
85
+ if (priorEntry !== undefined) {
86
+ throw new ScaffoldError('scaffold.duplicate-destination', `Template entries "${priorEntry}" and "${entry}" both copy to "${destName}" in ${srcDir}. Delete one — the surviving copy is whichever the filesystem lists last.`, { srcDir, destName, entries: [priorEntry, entry] });
87
+ }
88
+ claimedBy.set(destName, entry);
89
+ const srcPath = path.join(srcDir, entry);
90
+ const destPath = path.join(destDir, destName);
91
+ const stat = statSync(srcPath);
92
+ if (stat.isDirectory()) {
93
+ mkdirSync(destPath, { recursive: true });
94
+ copyTemplateDir(srcPath, destPath, projectName, filesWritten, skip);
95
+ continue;
96
+ }
97
+ mkdirSync(path.dirname(destPath), { recursive: true });
98
+ if (isTextFile(destPath)) {
99
+ const text = readFileSync(srcPath, 'utf8').split(PLACEHOLDER).join(projectName);
100
+ writeFileSync(destPath, text, 'utf8');
101
+ }
102
+ else {
103
+ copyFileSync(srcPath, destPath);
104
+ }
105
+ filesWritten.push(destPath);
106
+ }
107
+ }
108
+ /** Deep-merges a `package.fragment.json` (ui variant's extra scripts/deps) onto the base package.json. */
109
+ export function mergePackageJson(targetDir, fragmentPath) {
110
+ const packageJsonPath = path.join(targetDir, 'package.json');
111
+ const base = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
112
+ const fragment = JSON.parse(readFileSync(fragmentPath, 'utf8'));
113
+ for (const section of ['scripts', 'dependencies', 'devDependencies']) {
114
+ base[section] = { ...(base[section] ?? {}), ...(fragment[section] ?? {}) };
115
+ }
116
+ writeFileSync(packageJsonPath, `${JSON.stringify(base, null, 2)}\n`, 'utf8');
117
+ }
118
+ /** Verbatim recursive copy — no placeholder substitution, no `.tmpl` stripping. */
119
+ function copyTree(srcDir, destDir, written) {
120
+ mkdirSync(destDir, { recursive: true });
121
+ for (const entry of readdirSync(srcDir)) {
122
+ const src = path.join(srcDir, entry);
123
+ const dest = path.join(destDir, entry);
124
+ if (statSync(src).isDirectory()) {
125
+ copyTree(src, dest, written);
126
+ continue;
127
+ }
128
+ copyFileSync(src, dest);
129
+ written.push(dest);
130
+ }
131
+ }
132
+ /**
133
+ * Vendor the operator's design system into `<project>/design/`.
134
+ *
135
+ * Vendored, never referenced from an external repo: a design system a project
136
+ * cannot build offline is a dependency on somebody else's uptime. What is
137
+ * copied comes from stack.yml, not from here — this repo used to hardcode its
138
+ * own dashboard's private kit as `hds/` in every scaffolded project, which
139
+ * made every project downstream of a design system its operator could not
140
+ * obtain. `design_system: none` vendors nothing, which is the right answer
141
+ * for a project that owns its own components.
142
+ *
143
+ * A named source that does not exist is a refusal, not a skip. The previous
144
+ * version's `if (!existsSync(src)) continue` meant a moved kit produced a
145
+ * project whose stylesheet imported a directory that was never written, and
146
+ * the scaffold reported success. Resolution is split out from the copy so the
147
+ * refusal lands before the target directory exists, rather than leaving half
148
+ * a project for the operator to clean up.
149
+ */
150
+ function resolveDesignSystemSource(stack, repoRoot) {
151
+ if (stack.designSystem === 'none' || stack.designSystemSource === '')
152
+ return null;
153
+ const source = stack.designSystemSource;
154
+ const sourceDir = path.isAbsolute(source) ? source : path.join(repoRoot, source);
155
+ if (!existsSync(sourceDir)) {
156
+ throw new ScaffoldError('scaffold.design-system-missing', `factory/policies/stack.yml names design_system_source ${source}, which does not exist (looked in ${sourceDir}). Fix the path or set design_system: none.`, { designSystem: stack.designSystem, source, sourceDir });
157
+ }
158
+ return sourceDir;
159
+ }
160
+ /** Copy the resolved kit in. Resolution already happened, and already refused. */
161
+ function vendorDesignSystem(targetDir, sourceDir) {
162
+ if (sourceDir === null)
163
+ return [];
164
+ const written = [];
165
+ copyTree(sourceDir, path.join(targetDir, DESIGN_DIR), written);
166
+ return written;
167
+ }
168
+ /** Where a vendored kit lands, and the token file the style entry imports if it finds one. */
169
+ const DESIGN_DIR = 'design';
170
+ const DESIGN_TOKENS_FILE = 'tokens.css';
171
+ /**
172
+ * Write `src/styles/main.css`, which is generated rather than copied.
173
+ *
174
+ * Its whole content is a consequence of two answers — whether Tailwind is the
175
+ * utility layer, and whether a design system was vendored — so a static
176
+ * template could only be right for one combination. It was: `@import
177
+ * 'tailwindcss'` over `@import '../../hds/hds-tokens.css'`, both unconditional.
178
+ */
179
+ function writeStyleEntry(targetDir, stack) {
180
+ const lines = [
181
+ '/* Style entry, generated once from the stack answers',
182
+ ` * (styling: ${stack.styling}, design_system: ${stack.designSystem}).`,
183
+ ' * Yours from here — nothing regenerates this file.',
184
+ ' */',
185
+ ];
186
+ if (stack.styling === 'tailwind')
187
+ lines.push("@import 'tailwindcss';");
188
+ const tokens = path.join(targetDir, DESIGN_DIR, DESIGN_TOKENS_FILE);
189
+ if (existsSync(tokens))
190
+ lines.push(`@import '../../${DESIGN_DIR}/${DESIGN_TOKENS_FILE}';`);
191
+ else if (stack.designSystem !== 'none') {
192
+ lines.push(`/* ${stack.designSystem} is vendored under ${DESIGN_DIR}/ but ships no`, ` * ${DESIGN_TOKENS_FILE} — import its entry point here. */`);
193
+ }
194
+ lines.push('', '.app {', ' margin: 0 auto;', ' max-width: 60rem;', ' padding: 2rem 1.5rem;', '}', '');
195
+ const dest = path.join(targetDir, 'src', 'styles', 'main.css');
196
+ mkdirSync(path.dirname(dest), { recursive: true });
197
+ writeFileSync(dest, lines.join('\n'), 'utf8');
198
+ return dest;
199
+ }
200
+ const SETUP_BRANCH = 'setup';
201
+ /**
202
+ * Who the first commit is attributed to when the machine can name nobody.
203
+ * Deliberately not a person and not a deliverable address: nobody authored
204
+ * this commit, so a later `git log` should name the step it belongs to rather
205
+ * than implicate whoever's laptop happened to run it.
206
+ */
207
+ const FALLBACK_IDENTITY = { name: 'setup', email: 'setup@localhost' };
208
+ /**
209
+ * git needs an identity to commit, and on a machine with none it guesses one
210
+ * from the username and hostname — unless the username is empty (`fatal: empty
211
+ * ident name`) or `user.useConfigOnly` turned guessing off. A GitHub runner is
212
+ * the first case, which is how black-smith's own CI found this: every
213
+ * `smith new` inside a fresh container died on the scaffold's first commit.
214
+ *
215
+ * The scaffolder creates this repo, so no one outside it gets a chance to
216
+ * configure an identity in between — the fallback has to live here. It is
217
+ * written repo-locally, so it can never leak into the operator's global
218
+ * config, and only when git itself says it has nothing.
219
+ */
220
+ function ensureCommitIdentity(targetDir) {
221
+ try {
222
+ git(targetDir, ['var', 'GIT_COMMITTER_IDENT']);
223
+ return 'configured';
224
+ }
225
+ catch {
226
+ git(targetDir, ['config', 'user.name', FALLBACK_IDENTITY.name]);
227
+ git(targetDir, ['config', 'user.email', FALLBACK_IDENTITY.email]);
228
+ return 'fallback';
229
+ }
230
+ }
231
+ /** git init + first commit on a `setup` branch — never touches/pushes main (guardrails.md). */
232
+ function initGitRepo(targetDir) {
233
+ git(targetDir, ['init']);
234
+ git(targetDir, ['checkout', '-b', SETUP_BRANCH]);
235
+ const identity = ensureCommitIdentity(targetDir);
236
+ git(targetDir, ['add', '-A']);
237
+ git(targetDir, ['commit', '-m', 'Initial project scaffold']);
238
+ return identity;
239
+ }
240
+ /**
241
+ * Install, then the project's own gates in the order ci.yml runs them.
242
+ *
243
+ * The order is the point (P9-19): a scaffold that proves a different sequence
244
+ * than CI proves nothing about CI. `pnpm install` carries no
245
+ * `--frozen-lockfile` because this run is what CREATES the lockfile that CI's
246
+ * frozen install then checks against — the reason the first PR out of any epic
247
+ * used to red-CI on step one.
248
+ */
249
+ const TOOLCHAIN_STEPS = [
250
+ { name: 'install', command: 'pnpm', args: ['install'] },
251
+ { name: 'lint', command: 'pnpm', args: ['run', 'lint'] },
252
+ { name: 'typecheck', command: 'pnpm', args: ['run', 'typecheck'] },
253
+ { name: 'typecheck:test', command: 'pnpm', args: ['run', 'typecheck:test'] },
254
+ { name: 'test:coverage', command: 'pnpm', args: ['run', 'test:coverage'] },
255
+ { name: 'build', command: 'pnpm', args: ['run', 'build'] },
256
+ ];
257
+ /** Enough of a failing step's tail to diagnose from, bounded so a runaway log can't become the CLI's entire output. */
258
+ const OUTPUT_TAIL_BYTES = 4096;
259
+ const defaultRunCommand = (command, args, cwd) => {
260
+ const proc = spawnSync(command, [...args], { cwd, encoding: 'utf8' });
261
+ if (proc.error)
262
+ return { exitCode: null, output: proc.error.message };
263
+ return { exitCode: proc.status, output: `${proc.stdout ?? ''}${proc.stderr ?? ''}` };
264
+ };
265
+ /**
266
+ * Install the toolchain and run the project's own gates against it, so the
267
+ * first epic planned in this project inherits a repo whose checks are known to
268
+ * pass rather than a serial `task-0-toolchain` that claims the hottest files in
269
+ * the tree (D-3, D-4). The cost belongs to scaffolding, not to that epic's
270
+ * budget.
271
+ *
272
+ * Stops at the first red step, exactly as ci.yml's step list does: the answer
273
+ * the operator needs is which gate went red first, and running four more
274
+ * commands against a tree that failed `install` produces noise, not evidence.
275
+ * Never throws — a scaffold whose gates fail is still a tree worth keeping and
276
+ * fixing; the caller decides what a failed report is worth (`smith new` exits
277
+ * non-zero on one).
278
+ */
279
+ function runToolchain(targetDir, run) {
280
+ const steps = [];
281
+ for (const step of TOOLCHAIN_STEPS) {
282
+ const line = [step.command, ...step.args].join(' ');
283
+ const { exitCode, output } = run(step.command, step.args, targetDir);
284
+ const ok = exitCode === 0;
285
+ steps.push({
286
+ name: step.name,
287
+ command: line,
288
+ ok,
289
+ exitCode,
290
+ ...(ok ? {} : { output: output.slice(-OUTPUT_TAIL_BYTES) }),
291
+ });
292
+ if (!ok)
293
+ return { status: 'failed', steps, failedStep: step.name };
294
+ }
295
+ return { status: 'verified', steps };
296
+ }
297
+ export function scaffoldProject(opts) {
298
+ validateProjectName(opts.projectName);
299
+ const stack = opts.stack ?? loadStackAnswers();
300
+ // Before the directory is created, not after: a refusal that leaves half a
301
+ // project behind is a refusal the operator has to clean up.
302
+ requireScaffoldable(stack, { ui: opts.ui });
303
+ const templateDir = opts.templateDir ?? SCAFFOLD_DIR;
304
+ const repoRoot = opts.repoRoot ?? REPO_ROOT;
305
+ const designSystemDir = opts.ui ? resolveDesignSystemSource(stack, repoRoot) : null;
306
+ const targetDir = opts.targetDir ?? path.join(opts.projectsDir ?? PROJECTS_DIR, opts.projectName);
307
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
308
+ throw new ScaffoldError('scaffold.target-exists', `Refusing to scaffold into a non-empty directory: ${targetDir}.`, { targetDir });
309
+ }
310
+ mkdirSync(targetDir, { recursive: true });
311
+ const filesWritten = [];
312
+ const baseDir = path.join(templateDir, 'base');
313
+ copyTemplateDir(baseDir, targetDir, opts.projectName, filesWritten);
314
+ if (opts.ui) {
315
+ const uiDir = path.join(templateDir, 'ui');
316
+ copyTemplateDir(uiDir, targetDir, opts.projectName, filesWritten, new Set([PACKAGE_FRAGMENT_NAME]));
317
+ mergePackageJson(targetDir, path.join(uiDir, PACKAGE_FRAGMENT_NAME));
318
+ if (stack.styling === 'tailwind') {
319
+ // A layer, not a variant: it overwrites vite.config.ts with the same
320
+ // file plus the plugin, and merges the dependency in. `plain-css`
321
+ // projects never see Tailwind in their lockfile, which is the point.
322
+ const tailwindDir = path.join(templateDir, 'ui-tailwind');
323
+ copyTemplateDir(tailwindDir, targetDir, opts.projectName, filesWritten, new Set([PACKAGE_FRAGMENT_NAME]));
324
+ mergePackageJson(targetDir, path.join(tailwindDir, PACKAGE_FRAGMENT_NAME));
325
+ }
326
+ filesWritten.push(...vendorDesignSystem(targetDir, designSystemDir));
327
+ filesWritten.push(writeStyleEntry(targetDir, stack));
328
+ }
329
+ // Before the commit, not after: `pnpm install` is what writes
330
+ // pnpm-lock.yaml, and a lockfile outside the first commit is a lockfile
331
+ // ci.yml's `--frozen-lockfile` cannot see.
332
+ const toolchain = opts.skipToolchain
333
+ ? {
334
+ status: 'skipped',
335
+ steps: [],
336
+ reason: 'skipToolchain was set — no install ran, and no gate was proven.',
337
+ }
338
+ : runToolchain(targetDir, opts.runCommand ?? defaultRunCommand);
339
+ const commitIdentity = opts.skipGit ? 'skipped' : initGitRepo(targetDir);
340
+ return {
341
+ targetDir,
342
+ filesWritten,
343
+ branch: SETUP_BRANCH,
344
+ toolchain,
345
+ commitIdentity,
346
+ commands: {
347
+ ghRepoCreate: `gh repo create ${opts.projectName} --private --source=${targetDir} --remote=origin --push=false`,
348
+ push: `git -C ${targetDir} push -u origin ${SETUP_BRANCH}`,
349
+ },
350
+ };
351
+ }
352
+ /**
353
+ * Appends a bootstrap milestone for the new project to factory/specs/
354
+ * roadmap.md (architecture §12: "the planner maintains it"; a fresh project
355
+ * gets a placeholder milestone so the Projects/Roadmap pages have something
356
+ * to show the moment it exists, edited further as real epics get scoped).
357
+ */
358
+ export function registerProjectInRoadmap(projectName, roadmapPath = ROADMAP_PATH) {
359
+ const text = readRoadmapText(roadmapPath);
360
+ const id = `${projectName}-bootstrap`;
361
+ // See registerMcpMilestone: the substring `- id: <id>` is not what
362
+ // parseRoadmap reads, and a duplicate id takes the whole roadmap away from
363
+ // every reader of it rather than merely adding a stray section.
364
+ if (roadmapDeclaresId(text, id))
365
+ return; // idempotent — already registered
366
+ const block = `\n## ${projectName} — bootstrap\n` +
367
+ `- id: ${id}\n` +
368
+ '- status: planned\n' +
369
+ `- project: ${projectName}\n` +
370
+ '- epics: []\n' +
371
+ '- goal: Scaffold complete; awaiting the first planned epic.\n';
372
+ writeFileSync(roadmapPath, `${text.trimEnd()}\n${block}`, 'utf8');
373
+ }