@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,555 @@
1
+ // MCP surface: the agent-callable face every project leaving the factory ships
2
+ // (docs/standards/mcp.md). Four jobs live here and nothing else:
3
+ //
4
+ // addMcpSurface() — layer factory/scaffold/mcp/ onto an ALREADY scaffolded
5
+ // project, and register the milestone that makes it due.
6
+ // checkManifest() — turn the numbered baseline in docs/standards/mcp.md into
7
+ // pass/fail over a declared manifest.
8
+ // runMcpCheck() — what `smith mcp check` prints: the manifest verdict plus
9
+ // the roadmap's own (MCP-M1/M2, "the surface is due").
10
+ // resolveMcpSurface()/mcpBlockers()
11
+ // — what the epic-close gate refuses on.
12
+ //
13
+ // checkManifest is deliberately pure and filesystem-free: `smith mcp check` and
14
+ // the epic-close gate both call it, and a gate that needs a working directory
15
+ // to render a verdict is a gate that behaves differently in CI than on a
16
+ // laptop. Rule ids (MCP-P1, MCP-T4, ...) are the contract with the doc — when
17
+ // one changes here, the doc's numbered list changes in the same commit.
18
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { SmithError } from './errors.js';
21
+ import { PROJECTS_DIR, ROADMAP_PATH, SCAFFOLD_DIR, WORKSPACES_DIR } from './paths.js';
22
+ import { loadRoadmap, ownsEpic, readRoadmapText, roadmapDeclaresId, } from './roadmap.js';
23
+ import { copyTemplateDir, mergePackageJson, PACKAGE_FRAGMENT_NAME, validateProjectName, } from './scaffold.js';
24
+ export class McpError extends SmithError {
25
+ }
26
+ /**
27
+ * The wire revision the factory pins — chosen to be what the Tier-1 TypeScript
28
+ * SDK actually negotiates, not the newest revision on paper. Verified against
29
+ * @modelcontextprotocol/sdk@1.30.0 (the only published dist-tag as of
30
+ * 2026-08-07), whose LATEST_PROTOCOL_VERSION is '2025-11-25'. Pinning the
31
+ * newer MCP_TARGET_REVISION today would produce a standard nothing can build.
32
+ */
33
+ export const MCP_PROTOCOL_REVISION = '2025-11-25';
34
+ /**
35
+ * Where the pin moves once the SDK ships it. 2026-07-28 removes the
36
+ * initialize/initialized handshake and the Mcp-Session-Id header outright, so
37
+ * the scaffold is written to depend on neither — see docs/standards/mcp.md
38
+ * "Forward compatibility" for the migration trigger.
39
+ */
40
+ export const MCP_TARGET_REVISION = '2026-07-28';
41
+ /** Suffix of the milestone every project must close before its final one. */
42
+ export const MCP_MILESTONE_SUFFIX = 'mcp-surface';
43
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
44
+ const TOOL_NAME = /^[a-z][a-z0-9_]{2,63}$/;
45
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
46
+ const MUTATION_CLASSES = ['read-only', 'write', 'destructive', 'outbound'];
47
+ function isRecord(value) {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
49
+ }
50
+ function isHttps(value) {
51
+ return typeof value === 'string' && value.startsWith('https://');
52
+ }
53
+ /** MCP-T1/T2/T3/T4/T5 — one tool entry against the tool-surface policy. */
54
+ function checkTool(tool, index, seen, violations) {
55
+ const at = `/tools/${index}`;
56
+ if (!isRecord(tool)) {
57
+ violations.push({ rule: 'MCP-T5', path: at, message: 'Tool entry must be an object.' });
58
+ return;
59
+ }
60
+ const name = tool.name;
61
+ if (typeof name !== 'string' || !TOOL_NAME.test(name)) {
62
+ violations.push({
63
+ rule: 'MCP-T5',
64
+ path: at,
65
+ message: `Tool name must match ${TOOL_NAME.source}; got ${JSON.stringify(name)}.`,
66
+ });
67
+ }
68
+ else if (seen.has(name)) {
69
+ violations.push({ rule: 'MCP-T5', path: at, message: `Duplicate tool name "${name}".` });
70
+ }
71
+ else {
72
+ seen.add(name);
73
+ }
74
+ const mutation = tool.mutation;
75
+ if (typeof mutation !== 'string' || !MUTATION_CLASSES.includes(mutation)) {
76
+ violations.push({
77
+ rule: 'MCP-T2',
78
+ path: at,
79
+ message: `Tool must declare a mutation class (${MUTATION_CLASSES.join(' | ')}); got ${JSON.stringify(mutation)}.`,
80
+ });
81
+ return; // every remaining rule is relative to the class — nothing left to say
82
+ }
83
+ // Hints are read out of an unvalidated object on purpose: a missing hint is
84
+ // a policy violation reported under the rule its mutation class belongs to,
85
+ // never a silently-defaulted `false`.
86
+ const hints = isRecord(tool.annotations) ? tool.annotations : {};
87
+ const { readOnlyHint, destructiveHint, openWorldHint } = hints;
88
+ if (mutation === 'read-only') {
89
+ if (readOnlyHint !== true || destructiveHint !== false || openWorldHint !== false) {
90
+ violations.push({
91
+ rule: 'MCP-T1',
92
+ path: at,
93
+ message: 'A read-only tool must annotate readOnlyHint: true, destructiveHint: false, openWorldHint: false.',
94
+ });
95
+ }
96
+ }
97
+ if (mutation === 'write' || mutation === 'destructive') {
98
+ if (readOnlyHint !== false) {
99
+ violations.push({
100
+ rule: 'MCP-T2',
101
+ path: at,
102
+ message: `A ${mutation} tool must annotate readOnlyHint: false.`,
103
+ });
104
+ }
105
+ if (mutation === 'destructive' && destructiveHint !== true) {
106
+ violations.push({
107
+ rule: 'MCP-T2',
108
+ path: at,
109
+ message: 'A destructive tool must annotate destructiveHint: true.',
110
+ });
111
+ }
112
+ }
113
+ if (mutation === 'outbound' && openWorldHint !== true) {
114
+ violations.push({
115
+ rule: 'MCP-T3',
116
+ path: at,
117
+ message: 'An outbound tool must annotate openWorldHint: true.',
118
+ });
119
+ }
120
+ if (mutation !== 'read-only') {
121
+ const approval = isRecord(tool.approval) ? tool.approval : undefined;
122
+ const signed = approval !== undefined &&
123
+ typeof approval.operator === 'string' &&
124
+ approval.operator.length > 0 &&
125
+ typeof approval.date === 'string' &&
126
+ ISO_DATE.test(approval.date) &&
127
+ typeof approval.milestone === 'string' &&
128
+ approval.milestone.length > 0;
129
+ if (!signed) {
130
+ violations.push({
131
+ rule: 'MCP-T4',
132
+ path: at,
133
+ message: `A ${mutation} tool needs approval: { operator, date (YYYY-MM-DD), milestone } recorded at the mcp surface milestone.`,
134
+ });
135
+ }
136
+ }
137
+ }
138
+ /** MCP-X1..X4 — transport declaration and, for the HTTP opt-in, its preconditions. */
139
+ function checkTransport(transport, violations) {
140
+ const at = '/transport';
141
+ if (!isRecord(transport) || (transport.kind !== 'stdio' && transport.kind !== 'http')) {
142
+ violations.push({
143
+ rule: 'MCP-X1',
144
+ path: at,
145
+ message: 'Transport must be { kind: "stdio" } or the declared HTTP opt-in { kind: "http" }.',
146
+ });
147
+ return;
148
+ }
149
+ if (transport.kind === 'stdio')
150
+ return; // no port, no listener, nothing further to prove
151
+ const access = isRecord(transport.access) ? transport.access : undefined;
152
+ if (access?.cloudflareAccess !== true) {
153
+ violations.push({
154
+ rule: 'MCP-X2',
155
+ path: `${at}/access`,
156
+ message: 'An HTTP surface must sit behind Cloudflare Access (mcp.md MCP-X2: nothing is exposed beyond localhost without something authenticating in front of it).',
157
+ });
158
+ }
159
+ const rs = isRecord(transport.resourceServer) ? transport.resourceServer : undefined;
160
+ if (!rs) {
161
+ violations.push({
162
+ rule: 'MCP-X3',
163
+ path: `${at}/resourceServer`,
164
+ message: 'An HTTP surface must declare itself an OAuth 2.0 protected resource.',
165
+ });
166
+ return;
167
+ }
168
+ if (!isHttps(rs.canonicalUri)) {
169
+ violations.push({
170
+ rule: 'MCP-X3',
171
+ path: `${at}/resourceServer/canonicalUri`,
172
+ message: 'The canonical resource URI must be an https:// URL — it is the token audience.',
173
+ });
174
+ }
175
+ if (!Array.isArray(rs.authorizationServers) || rs.authorizationServers.length === 0) {
176
+ violations.push({
177
+ rule: 'MCP-X3',
178
+ path: `${at}/resourceServer/authorizationServers`,
179
+ message: 'Declare at least one authorization server issuer.',
180
+ });
181
+ }
182
+ else if (!rs.authorizationServers.every(isHttps)) {
183
+ violations.push({
184
+ rule: 'MCP-X3',
185
+ path: `${at}/resourceServer/authorizationServers`,
186
+ message: 'Every authorization server issuer must be an https:// URL.',
187
+ });
188
+ }
189
+ if (rs.resourceIndicators !== true) {
190
+ violations.push({
191
+ rule: 'MCP-X3',
192
+ path: `${at}/resourceServer/resourceIndicators`,
193
+ message: 'RFC 8707 resource indicators are mandatory — they are what binds a token to us.',
194
+ });
195
+ }
196
+ if (rs.tokenPassthrough !== false) {
197
+ violations.push({
198
+ rule: 'MCP-X4',
199
+ path: `${at}/resourceServer/tokenPassthrough`,
200
+ message: 'Token passthrough is never allowed: the server MUST NOT accept a token that was not issued for its own canonical URI.',
201
+ });
202
+ }
203
+ }
204
+ /**
205
+ * The manifest-checkable subset of docs/standards/mcp.md. Source-level rules
206
+ * (MCP-P3 session-independence, MCP-S1 output redaction) are not decidable from
207
+ * a declaration and are enforced where the code is, not here.
208
+ */
209
+ export function checkManifest(manifest) {
210
+ const violations = [];
211
+ if (!isRecord(manifest)) {
212
+ return {
213
+ ok: false,
214
+ violations: [{ rule: 'MCP-P2', path: '/', message: 'Manifest must be a JSON object.' }],
215
+ };
216
+ }
217
+ if (manifest.protocolRevision !== MCP_PROTOCOL_REVISION) {
218
+ violations.push({
219
+ rule: 'MCP-P1',
220
+ path: '/protocolRevision',
221
+ message: `Pin the wire revision to ${MCP_PROTOCOL_REVISION} (what the Tier-1 SDK negotiates); got ${JSON.stringify(manifest.protocolRevision)}.`,
222
+ });
223
+ }
224
+ if (typeof manifest.name !== 'string' || manifest.name.length === 0) {
225
+ violations.push({
226
+ rule: 'MCP-P2',
227
+ path: '/name',
228
+ message: 'Manifest must name the server.',
229
+ });
230
+ }
231
+ if (typeof manifest.version !== 'string' || !SEMVER.test(manifest.version)) {
232
+ violations.push({
233
+ rule: 'MCP-P2',
234
+ path: '/version',
235
+ message: `Server version must be semver; got ${JSON.stringify(manifest.version)}.`,
236
+ });
237
+ }
238
+ checkTransport(manifest.transport, violations);
239
+ if (!Array.isArray(manifest.tools)) {
240
+ violations.push({ rule: 'MCP-T5', path: '/tools', message: 'Manifest must declare tools[].' });
241
+ }
242
+ else {
243
+ const seen = new Set();
244
+ manifest.tools.forEach((tool, index) => {
245
+ checkTool(tool, index, seen, violations);
246
+ });
247
+ }
248
+ return { ok: violations.length === 0, violations };
249
+ }
250
+ /**
251
+ * Layers the MCP surface onto an existing project. Unlike scaffoldProject this
252
+ * REQUIRES the directory to already be a project (a package.json is the tell) —
253
+ * an MCP server with no project behind it has nothing to expose, and silently
254
+ * creating one would hide a mistyped project name.
255
+ */
256
+ export function addMcpSurface(opts) {
257
+ validateProjectName(opts.projectName);
258
+ const templateDir = path.join(opts.templateDir ?? SCAFFOLD_DIR, 'mcp');
259
+ const targetDir = opts.targetDir ?? path.join(opts.projectsDir ?? PROJECTS_DIR, opts.projectName);
260
+ const manifestPath = path.join(targetDir, 'mcp.manifest.json');
261
+ if (!existsSync(path.join(targetDir, 'package.json'))) {
262
+ throw new McpError('mcp.unknown-project', `${targetDir} is not a scaffolded project (no package.json). Run \`smith new ${opts.projectName}\` first.`, { targetDir, projectName: opts.projectName });
263
+ }
264
+ if (existsSync(manifestPath)) {
265
+ throw new McpError('mcp.surface-exists', `${manifestPath} already exists. Edit the manifest in place; re-scaffolding would discard declared tools and their operator sign-off.`, { manifestPath, projectName: opts.projectName });
266
+ }
267
+ // Read the roadmap before writing anything, and throw here if it cannot be
268
+ // read. The registration is the LAST step below, so a roadmap this command
269
+ // cannot open used to fail after mcp.manifest.json existed — and the retry
270
+ // then hit `mcp.surface-exists` above, which refuses on purpose. A mistyped
271
+ // --roadmap-path left a half-initialised surface that the only command able
272
+ // to finish it would permanently decline to touch.
273
+ if (!opts.skipRoadmap)
274
+ readRoadmapText(opts.roadmapPath);
275
+ const filesWritten = [];
276
+ copyTemplateDir(templateDir, targetDir, opts.projectName, filesWritten, new Set([PACKAGE_FRAGMENT_NAME]));
277
+ mergePackageJson(targetDir, path.join(templateDir, PACKAGE_FRAGMENT_NAME));
278
+ const milestoneId = opts.skipRoadmap
279
+ ? `${opts.projectName}-${MCP_MILESTONE_SUFFIX}`
280
+ : registerMcpMilestone(opts.projectName, opts.roadmapPath);
281
+ return {
282
+ targetDir,
283
+ manifestPath,
284
+ filesWritten,
285
+ milestoneId,
286
+ protocolRevision: MCP_PROTOCOL_REVISION,
287
+ transport: 'stdio',
288
+ };
289
+ }
290
+ /**
291
+ * Appends the `<project> — mcp surface` milestone to the roadmap. This is what
292
+ * makes the surface *due*: docs/standards/mcp.md requires it to be closed
293
+ * before the project's final milestone, and `smith epic close` refuses while
294
+ * checkManifest is red.
295
+ */
296
+ export function registerMcpMilestone(projectName, roadmapPath = ROADMAP_PATH) {
297
+ const id = `${projectName}-${MCP_MILESTONE_SUFFIX}`;
298
+ const text = readRoadmapText(roadmapPath);
299
+ // roadmapDeclaresId, not `text.includes('- id: ' + id)`: the substring is
300
+ // not what parseRoadmap means by a declaration, and appending a second copy
301
+ // of a milestone it already holds costs the whole file — milestoneId is the
302
+ // milestones table's primary key, so parseRoadmap refuses the roadmap from
303
+ // the duplicate onward, and `smith mcp init` cannot be run twice to undo it.
304
+ if (roadmapDeclaresId(text, id))
305
+ return id; // idempotent — already registered
306
+ const block = `\n## ${projectName} — mcp surface\n` +
307
+ `- id: ${id}\n` +
308
+ '- status: planned\n' +
309
+ `- project: ${projectName}\n` +
310
+ // Seeded, not empty: this list is what the epic-close gate reads to decide
311
+ // the surface is due (resolveMcpSurface/ownsEpic), and `[]` matched no epic
312
+ // at all — the gate shipped unreachable. Naming the epic here also states
313
+ // in the roadmap itself which epic closes the milestone, the way
314
+ // envkit-config-loader already does, instead of leaving it to convention.
315
+ `- epics: [${id}]\n` +
316
+ '- goal: Declare and harden the MCP surface — `smith mcp check` green against ' +
317
+ 'docs/standards/mcp.md. Required before the final milestone can close.\n';
318
+ writeFileSync(roadmapPath, `${text.trimEnd()}\n${block}`, 'utf8');
319
+ return id;
320
+ }
321
+ const MANIFEST_NAME = 'mcp.manifest.json';
322
+ /** The project's own `<project>-mcp-surface` milestone, if the roadmap declares one. */
323
+ function findSurfaceMilestone(milestones, project) {
324
+ return (milestones.find((m) => m.project === project && m.milestoneId.endsWith(MCP_MILESTONE_SUFFIX)) ??
325
+ null);
326
+ }
327
+ /**
328
+ * The roadmap half of the standard — docs/standards/mcp.md Enforcement step 2,
329
+ * "the milestone is mandatory and closes before the project's final milestone".
330
+ * checkManifest can only judge a manifest that exists; a project that never
331
+ * registered the milestone has nothing to judge, which is the failure mode
332
+ * these two rules cover.
333
+ */
334
+ export function checkRoadmapMcpMilestone(project, milestones) {
335
+ const surface = findSurfaceMilestone(milestones, project);
336
+ if (!surface) {
337
+ return [
338
+ {
339
+ rule: 'MCP-M1',
340
+ path: `/milestones/${project}`,
341
+ message: `Project "${project}" has no \`${project}-${MCP_MILESTONE_SUFFIX}\` milestone. Run \`smith mcp init ${project}\` to register it.`,
342
+ },
343
+ ];
344
+ }
345
+ if (surface.status === 'completed')
346
+ return [];
347
+ // Only a violation once the rest of the project is done: while other work is
348
+ // still open the surface is legitimately pending, and firing here would make
349
+ // MCP-M2 a permanent red for every project from its first day.
350
+ const others = milestones.filter((m) => m.project === project && m.milestoneId !== surface.milestoneId);
351
+ if (others.length === 0 || !others.every((m) => m.status === 'completed'))
352
+ return [];
353
+ return [
354
+ {
355
+ rule: 'MCP-M2',
356
+ path: `/milestones/${surface.milestoneId}`,
357
+ message: `Every other "${project}" milestone is completed while ${surface.milestoneId} is "${surface.status}". A project reaching its last milestone without a closed MCP surface is not finished.`,
358
+ },
359
+ ];
360
+ }
361
+ /**
362
+ * Every checkout of `projectName` across every root: `<root>/<p>` plus each
363
+ * task worktree beside it. Roots are searched in order and the result is
364
+ * deduplicated, so overlapping roots enlarge the search without doubling a
365
+ * candidate in the list an ambiguity refusal prints.
366
+ */
367
+ function projectCheckouts(projectName, roots) {
368
+ const found = [];
369
+ const seen = new Set();
370
+ const take = (dir) => {
371
+ if (seen.has(dir))
372
+ return;
373
+ seen.add(dir);
374
+ found.push(dir);
375
+ };
376
+ for (const root of roots) {
377
+ const main = path.join(root, projectName);
378
+ if (existsSync(main))
379
+ take(main);
380
+ const worktreeRoot = path.join(root, '.wt', projectName);
381
+ if (!existsSync(worktreeRoot))
382
+ continue;
383
+ for (const entry of readdirSync(worktreeRoot, { withFileTypes: true })
384
+ .filter((e) => e.isDirectory())
385
+ .map((e) => path.join(worktreeRoot, e.name))
386
+ .sort()) {
387
+ take(entry);
388
+ }
389
+ }
390
+ return found;
391
+ }
392
+ /** True when `cwd` is `dir` or lives under it — path-segment-wise, not by prefix. */
393
+ function isWithin(dir, cwd) {
394
+ const rel = path.relative(dir, cwd);
395
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
396
+ }
397
+ /**
398
+ * D-133. Which checkout `smith mcp check <project>` is about.
399
+ *
400
+ * The old answer was `<root>/<project>`, unconditionally. During an epic that
401
+ * is a *different valid checkout* from the one the caller is standing in —
402
+ * task work happens in `<root>/.wt/<project>/<task-id>` — so the command
403
+ * parsed a manifest the task had never touched and returned `ok: true`,
404
+ * `violations: []`, exit 0, both before and after the very edit the check
405
+ * existed to grade. A well-formed wrong answer is worse than a refusal, because
406
+ * nothing about it invites a second look.
407
+ *
408
+ * So: an explicit `--target-dir` wins; otherwise the caller's own checkout
409
+ * answers it; otherwise, if more than one checkout exists, refuse and name them
410
+ * all. The default root is only assumed when nothing else is a candidate — and
411
+ * "more than one" now spans every root, so a project that exists both beside
412
+ * this clone and under the legacy `workspaces/` is a refusal, not a coin flip.
413
+ */
414
+ export function resolveMcpTarget(opts) {
415
+ if (opts.targetDir)
416
+ return { targetDir: path.resolve(opts.targetDir), source: 'flag' };
417
+ const roots = opts.projectRoots ?? [PROJECTS_DIR, WORKSPACES_DIR];
418
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
419
+ const checkouts = projectCheckouts(opts.projectName, roots);
420
+ // Deepest match wins: nothing nests today, but a checkout inside a checkout
421
+ // should resolve to the inner one rather than to whichever came first.
422
+ const containing = checkouts
423
+ .filter((dir) => isWithin(dir, cwd))
424
+ .sort((a, b) => b.length - a.length);
425
+ if (containing[0])
426
+ return { targetDir: containing[0], source: 'cwd' };
427
+ if (checkouts.length > 1) {
428
+ throw new McpError('mcp.ambiguous-target', `"${opts.projectName}" has ${checkouts.length} checkouts and ${cwd} is not inside any of them, so there is nothing to infer the target from. Pass --target-dir with one of:\n${checkouts
429
+ .map((dir) => ` ${dir}`)
430
+ .join('\n')}`, { projectName: opts.projectName, cwd, candidates: checkouts });
431
+ }
432
+ // Exactly one checkout: that one, wherever it lives. Constructing
433
+ // `<first root>/<project>` instead would have named a directory that does not
434
+ // exist while a real one sat under the second root — the single-root code
435
+ // this replaced got away with it only because the two were always the same
436
+ // path. Nothing at all: the root a new project would land in, so the caller
437
+ // is told where the surface is expected rather than where it is missing from.
438
+ return {
439
+ targetDir: checkouts[0] ?? path.join(roots[0] ?? PROJECTS_DIR, opts.projectName),
440
+ source: 'default',
441
+ };
442
+ }
443
+ /**
444
+ * `smith mcp check` — the manifest verdict and the roadmap verdict in one
445
+ * report. Refuses (rather than reporting a red) when there is no manifest at
446
+ * all: "your surface is broken" and "you have no surface" are different
447
+ * answers, and collapsing them would let `smith mcp init` be skipped silently.
448
+ */
449
+ export function runMcpCheck(opts) {
450
+ validateProjectName(opts.projectName);
451
+ const { targetDir, source } = resolveMcpTarget(opts);
452
+ const manifestPath = path.join(targetDir, MANIFEST_NAME);
453
+ const roadmapPath = opts.roadmapPath ?? ROADMAP_PATH;
454
+ if (!existsSync(manifestPath)) {
455
+ throw new McpError('mcp.no-surface', `${manifestPath} does not exist. Run \`smith mcp init ${opts.projectName}\` before checking the surface.`, { manifestPath, projectName: opts.projectName });
456
+ }
457
+ let manifest;
458
+ try {
459
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
460
+ }
461
+ catch (cause) {
462
+ throw new McpError('mcp.unreadable-manifest', `${manifestPath} is not valid JSON: ${cause.message}`, { manifestPath, projectName: opts.projectName });
463
+ }
464
+ const violations = [
465
+ ...checkManifest(manifest).violations,
466
+ ...checkRoadmapMcpMilestone(opts.projectName, loadRoadmap(roadmapPath)),
467
+ ];
468
+ return {
469
+ ok: violations.length === 0,
470
+ violations,
471
+ projectName: opts.projectName,
472
+ targetDir,
473
+ targetSource: source,
474
+ manifestPath,
475
+ roadmapPath,
476
+ };
477
+ }
478
+ /** The explicit "this epic owes no surface" value. Named so a skip is legible in a diff. */
479
+ export const MCP_SURFACE_NOT_REQUIRED = Object.freeze({
480
+ required: false,
481
+ milestoneId: null,
482
+ manifestPath: null,
483
+ check: null,
484
+ problem: null,
485
+ });
486
+ /**
487
+ * Reads the roadmap to decide whether this epic is under the mcp surface
488
+ * milestone, and if so renders checkManifest over the project's real manifest.
489
+ * A missing or unparseable manifest resolves to `check: null` rather than
490
+ * throwing — mcpBlockers turns that into a refusal, so the gate reports the
491
+ * problem instead of crashing the verdict that was meant to report it.
492
+ *
493
+ * `check` is checkManifest alone — deliberately not the roadmap rules that
494
+ * `smith mcp check` also runs. Both M rules are meaningless or harmful here:
495
+ *
496
+ * - MCP-M1 ("no `<project>-mcp-surface` milestone") cannot fire. Reaching this
497
+ * line at all means a surface milestone was found and claims this epic.
498
+ * - MCP-M2 fires when the surface milestone is not yet `completed` while every
499
+ * other milestone is. Nothing in this codebase ever writes a milestone
500
+ * status — registerMcpMilestone seeds `planned`, epic.ts never touches the
501
+ * roadmap, and only a human edit moves it to `completed`. So blocking the
502
+ * close on MCP-M2 would require the operator to mark the surface done before
503
+ * the gate would let them close the epic that makes it done, leaving the gate
504
+ * to rubber-stamp an assertion the operator had already been forced to write.
505
+ * That is the circularity D-108 described; it is unreal today only because
506
+ * the manifest rules are the ones wired here.
507
+ *
508
+ * MCP-M2's honest host is the *final* milestone's close — the standard's
509
+ * "closes before the project's final milestone" — not this epic's. Wiring it
510
+ * here is the tempting fix and it is the wrong one.
511
+ */
512
+ export function resolveMcpSurface(opts) {
513
+ const milestone = loadRoadmap(opts.roadmapPath).find((m) => m.milestoneId.endsWith(MCP_MILESTONE_SUFFIX) && ownsEpic(m, opts.epicId));
514
+ if (!milestone)
515
+ return MCP_SURFACE_NOT_REQUIRED;
516
+ const manifestPath = path.join(opts.projectDir, MANIFEST_NAME);
517
+ let check = null;
518
+ let problem = 'missing';
519
+ if (existsSync(manifestPath)) {
520
+ problem = 'unreadable';
521
+ try {
522
+ check = checkManifest(JSON.parse(readFileSync(manifestPath, 'utf8')));
523
+ problem = null;
524
+ }
525
+ catch {
526
+ // The reason stays here on purpose (see McpManifestProblem). runMcpCheck
527
+ // is the caller that renders it, and it re-reads the file to do so.
528
+ }
529
+ }
530
+ return { required: true, milestoneId: milestone.milestoneId, manifestPath, check, problem };
531
+ }
532
+ /**
533
+ * The hard gate, expressed as blockers rather than its own refusal path
534
+ * (operator decision of 2026-08-07). summarizeEpic folds these into
535
+ * EpicSummary.blockers, which runEpicVerdict already turns into
536
+ * hold/mechanical-blockers and closeEpic already refuses without
537
+ * --override-rationale — so the override records the overridden MCP rules by
538
+ * name, and there is exactly one override policy in the codebase.
539
+ */
540
+ export function mcpBlockers(epicId, status) {
541
+ if (!status.required)
542
+ return [];
543
+ const milestone = status.milestoneId ?? MCP_MILESTONE_SUFFIX;
544
+ const manifest = status.manifestPath ?? MANIFEST_NAME;
545
+ // Fail closed on `check === null` exactly as before; `problem` only chooses
546
+ // the wording, so a status that somehow carries neither still blocks.
547
+ if (!status.check) {
548
+ return [
549
+ status.problem === 'unreadable'
550
+ ? `epic ${epicId} is under milestone ${milestone} but its MCP surface at ${manifest} exists and could not be read, so no verdict could be rendered — fix the manifest in place and run \`smith mcp check\` for the parser's own message. Do not re-run \`smith mcp init\`: it refuses over an existing manifest, because re-scaffolding would discard the declared tools and their operator sign-off.`
551
+ : `epic ${epicId} is under milestone ${milestone} but has no MCP surface at ${manifest} — run \`smith mcp init\` and declare it.`,
552
+ ];
553
+ }
554
+ return status.check.violations.map((v) => `mcp surface ${milestone}: ${v.rule} at ${v.path} — ${v.message}`);
555
+ }
@@ -0,0 +1,82 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ // factory/orchestrator/src/paths.ts -> repo root is three levels up.
4
+ const here = path.dirname(fileURLToPath(import.meta.url));
5
+ export const REPO_ROOT = path.resolve(here, '..', '..', '..');
6
+ /**
7
+ * The repo's own `.env`, read at CLI start by `loadDotEnv`. Gitignored, and
8
+ * the one place `docs/runbooks/providers.md` tells an operator to put a
9
+ * provider key. Anchored on this module, not on cwd: an agent runs `smith`
10
+ * from a worktree, and the key belongs to the clone.
11
+ */
12
+ export const DOTENV_PATH = path.join(REPO_ROOT, '.env');
13
+ export const TAXONOMY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'taxonomy.yml');
14
+ export const SCHEMA_DIR = path.join(REPO_ROOT, 'factory', 'specs', 'schema');
15
+ export const SPECS_ACTIVE_DIR = path.join(REPO_ROOT, 'factory', 'specs', 'active');
16
+ export const STATE_EVENTS_DIR = path.join(REPO_ROOT, 'state', 'events');
17
+ /** Where a task's declared artifacts must live: `state/artifacts/<task-id>/` (P9-22). */
18
+ export const STATE_ARTIFACTS_DIR = path.join(REPO_ROOT, 'state', 'artifacts');
19
+ export const WORKTREE_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'worktree.yml');
20
+ export const STATE_DB_PATH = path.join(REPO_ROOT, 'state', 'smith.db');
21
+ /** The background watcher's lock and last tick: `state/daemon/{daemon.pid,status.json}`. */
22
+ export const STATE_DAEMON_DIR = path.join(REPO_ROOT, 'state', 'daemon');
23
+ export const DB_MIGRATIONS_DIR = path.join(REPO_ROOT, 'factory', 'orchestrator', 'drizzle');
24
+ export const ROADMAP_PATH = path.join(REPO_ROOT, 'factory', 'specs', 'roadmap.md');
25
+ export const SCAFFOLD_DIR = path.join(REPO_ROOT, 'factory', 'scaffold');
26
+ /**
27
+ * `workspaces/` inside this clone. Still a legal place to keep a project, and
28
+ * still searched for ones already there -- no longer where a new one goes.
29
+ */
30
+ export const WORKSPACES_DIR = path.join(REPO_ROOT, 'workspaces');
31
+ /**
32
+ * Where a project lands when nothing says otherwise: beside this clone.
33
+ *
34
+ * A project this factory builds is not a part of it -- it takes no dependency
35
+ * on it and carries no mark of it -- and a project scaffolded under REPO_ROOT
36
+ * contradicted that from the first commit: inside this clone's git tree, its
37
+ * ignore rules and its lint roots, and read as factory code by every tool that
38
+ * walks up from a file inside it. Beside, not within. A task's worktrees follow
39
+ * it there on their own, since worktree.ts places them next to the project it
40
+ * is handed rather than at a path of its own choosing.
41
+ */
42
+ export const PROJECTS_DIR = path.dirname(REPO_ROOT);
43
+ export const SCHEDULER_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'scheduler.yml');
44
+ export const LESSONS_MD_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'lessons.md');
45
+ /** The shipped role templates — read at dispatch for their `<!-- LESSONS:<scope> -->` markers (P9-2). */
46
+ export const AGENTS_DIR = path.join(REPO_ROOT, '.claude', 'agents');
47
+ export const CROSSCHECK_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'crosscheck.yml');
48
+ /** Who may hold `Agent` — read by `smith delegation check` (delegation.ts, D13 step 3). */
49
+ export const DELEGATION_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'delegation.yml');
50
+ export const BUDGETS_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'budgets.yml');
51
+ /** How much judgment an epic buys — read by `smith effort show` (effort.ts). */
52
+ export const EFFORT_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'effort.yml');
53
+ /** The security-reviewer's dispatch triggers — read by `smith security triggers` (P9-4). */
54
+ export const SENSITIVE_PATHS_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'sensitive-paths.yml');
55
+ /** The guard hook's rule data — read by `smith policy check`/`smith policy hook` (policy.ts). */
56
+ export const GUARDRAILS_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'guardrails.yml');
57
+ /**
58
+ * The S1-S4 ladder and which levels block a merge (severity.ts).
59
+ *
60
+ * Declared here like every other policy file rather than inside severity.ts,
61
+ * which held its own `${REPO_ROOT}/factory/policies/severity.yml` — a second
62
+ * spelling of a path this module exists to spell once, and the only one built
63
+ * by interpolation instead of path.join.
64
+ */
65
+ export const SEVERITY_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'severity.yml');
66
+ /**
67
+ * The operator's stack answers, recorded at install time (stack.ts).
68
+ *
69
+ * A declaration, not state: it is what this operator said they build with,
70
+ * and the scaffolder reads it instead of the prose in docs/standards/stack.md
71
+ * — which described one operator's stack as if it were everyone's.
72
+ */
73
+ export const STACK_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'stack.yml');
74
+ /**
75
+ * Open judge sandbox leases, one file per worktree (sandbox.ts).
76
+ *
77
+ * Under `state/` because a lease is state, not a declaration — and in the
78
+ * main clone rather than the judge's own worktree, which is the point: a
79
+ * judge cannot revoke its own guard by deleting a file it is not allowed to
80
+ * reach.
81
+ */
82
+ export const SANDBOX_LEASE_DIR = path.join(REPO_ROOT, 'state', 'sandboxes');