@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,132 @@
1
+ function str(v) {
2
+ return typeof v === 'string' && v.length > 0 ? v : null;
3
+ }
4
+ function bool(v) {
5
+ return typeof v === 'boolean' ? v : null;
6
+ }
7
+ function subjectOf(payload) {
8
+ // The boolean each emitter alone writes is the primary discriminator: it is
9
+ // always present, where `fingerprint` and `plan_version` are only usually so.
10
+ if ('blocks' in payload)
11
+ return 'finding';
12
+ if ('sound' in payload)
13
+ return 'plan';
14
+ if ('ready' in payload)
15
+ return 'epic';
16
+ if (str(payload.fingerprint))
17
+ return 'finding';
18
+ if (typeof payload.plan_version === 'number')
19
+ return 'plan';
20
+ return 'epic';
21
+ }
22
+ function heldOf(subject, payload) {
23
+ if (subject === 'finding')
24
+ return bool(payload.blocks);
25
+ const ready = subject === 'epic' ? bool(payload.ready) : bool(payload.sound);
26
+ return ready === null ? null : !ready;
27
+ }
28
+ function reasonOf(payload, taskId) {
29
+ const raw = payload.escalation_reason;
30
+ if (raw === 'disagreement' || raw === 'insufficient-providers')
31
+ return raw;
32
+ if (raw === null || raw === undefined) {
33
+ throw new Error(`quorum-escalations.missing-reason: an escalate outcome on "${taskId}" carries no escalation_reason`);
34
+ }
35
+ throw new Error(`quorum-escalations.unknown-reason: "${String(raw)}" on "${taskId}" is not one of disagreement, insufficient-providers`);
36
+ }
37
+ function participantsOf(payload) {
38
+ const raw = payload.participants;
39
+ if (!Array.isArray(raw))
40
+ return [];
41
+ return raw.map((p) => {
42
+ const r = (p ?? {});
43
+ return {
44
+ provider: str(r.provider) ?? '(unnamed)',
45
+ mode: str(r.mode) ?? '(unknown)',
46
+ ok: r.ok === true,
47
+ verdict: str(r.verdict),
48
+ excludedAsFinder: r.excluded_as_finder === true,
49
+ };
50
+ });
51
+ }
52
+ function rationalesOf(payload) {
53
+ const raw = payload.rationales;
54
+ if (!Array.isArray(raw))
55
+ return [];
56
+ return raw.map((p) => {
57
+ const r = (p ?? {});
58
+ return {
59
+ provider: str(r.provider) ?? '(unnamed)',
60
+ verdict: str(r.verdict) ?? '(none)',
61
+ rationale: str(r.rationale) ?? '',
62
+ };
63
+ });
64
+ }
65
+ /**
66
+ * The cases whose LATEST word was `escalate`.
67
+ *
68
+ * Latest in log order, not first: a case settled on a later run is closed, and
69
+ * a case that escalated again after being settled is open again. `not-run` --
70
+ * planQuorum's "no quorum was called at all" -- closes a case exactly like
71
+ * `decided` does; there is nothing outstanding about a quorum nobody needed.
72
+ *
73
+ * Oldest first, because the list is a debt and the oldest debt is the one that
74
+ * has been ignored longest.
75
+ */
76
+ export function openQuorumEscalations(events) {
77
+ const latest = new Map();
78
+ for (const { record } of events) {
79
+ if (record.event_type !== 'quorum-decision')
80
+ continue;
81
+ const payload = record.payload;
82
+ const subject = subjectOf(payload);
83
+ const taskId = str(payload.task_id) ?? str(record.task_id) ?? '(no task)';
84
+ const fingerprint = str(payload.fingerprint);
85
+ const key = `${subject}:${subject === 'finding' ? (fingerprint ?? taskId) : taskId}`;
86
+ if (payload.outcome !== 'escalate') {
87
+ latest.set(key, null);
88
+ continue;
89
+ }
90
+ latest.set(key, {
91
+ key,
92
+ subject,
93
+ reason: reasonOf(payload, taskId),
94
+ taskId,
95
+ epicId: str(payload.epic_id),
96
+ findingId: str(payload.finding_id),
97
+ fingerprint,
98
+ planVersion: typeof payload.plan_version === 'number' ? payload.plan_version : null,
99
+ triggerReason: str(payload.trigger_reason),
100
+ finderProvider: str(payload.finder_provider),
101
+ held: heldOf(subject, payload),
102
+ participants: participantsOf(payload),
103
+ rationales: rationalesOf(payload),
104
+ ts: record.ts,
105
+ sessionId: record.session_id,
106
+ });
107
+ }
108
+ const open = [];
109
+ for (const entry of latest.values())
110
+ if (entry)
111
+ open.push(entry);
112
+ return open.sort((a, b) => a.ts === b.ts ? a.key.localeCompare(b.key) : a.ts.localeCompare(b.ts));
113
+ }
114
+ const UNGATED_HINT = "No quorum could be formed: the gating pool was smaller than crosscheck.yml's " +
115
+ '`quorum.min_providers`. The finder is excluded from its own case, so one active ' +
116
+ 'external provider leaves a pool of one. Enable a second external provider, or ' +
117
+ 'lower `min_providers` and accept that a single judge decides.';
118
+ /** Split the debt by what answering it costs: a review each, or one edit for all of them. */
119
+ export function summariseEscalations(open) {
120
+ const disagreements = open.filter((e) => e.reason === 'disagreement');
121
+ const ungated = open.filter((e) => e.reason === 'insufficient-providers');
122
+ const exitCode = disagreements.length > 0 ? 1 : ungated.length > 0 ? 2 : 0;
123
+ return {
124
+ disagreements,
125
+ ungated: {
126
+ count: ungated.length,
127
+ hint: ungated.length > 0 ? UNGATED_HINT : null,
128
+ cases: ungated,
129
+ },
130
+ exitCode,
131
+ };
132
+ }
@@ -0,0 +1,65 @@
1
+ import { SmithError } from './errors.js';
2
+ export class ResultError extends SmithError {
3
+ }
4
+ /**
5
+ * Fields the dispatcher owns on a worker's result document. An agent that sets
6
+ * any of them has overstepped the contract, and the value it wrote cannot be
7
+ * trusted even when it looks right (interview N-1, answer (b)).
8
+ *
9
+ * `token_usage` is here for the reason the other four are: an agent has no way
10
+ * to read its own meter. Whatever it writes is invented — wave 2 invented
11
+ * zeros, wave 3 invented round numbers — and both shapes satisfy
12
+ * `result.schema.json`, because a schema validates shape and never provenance.
13
+ * The harness knows the real counts; the dispatcher stamps them.
14
+ */
15
+ export const ORCHESTRATOR_OWNED_RESULT_FIELDS = [
16
+ 'task_id',
17
+ 'agent',
18
+ 'provider',
19
+ 'model_tier',
20
+ 'token_usage',
21
+ ];
22
+ function requireTokenCount(name, value) {
23
+ if (!Number.isInteger(value) || value < 0) {
24
+ throw new ResultError('results.invalid-token-count', `${name} must be a non-negative integer, got ${String(value)}.`, { field: name, value });
25
+ }
26
+ return value;
27
+ }
28
+ /**
29
+ * Merge a worker's half of a result document with the envelope the dispatcher
30
+ * owns, and refuse the merge if the worker wrote any of the dispatcher's
31
+ * fields.
32
+ *
33
+ * The refusal is a throw rather than a gate event: a result whose provenance is
34
+ * unknown should never reach the gate at all, and this mirrors
35
+ * `mintFindings`' `findings.evidence-carries-identity` on the judge side. As
36
+ * there, no `agent-result.schema.json` is added — the ownership check lives in
37
+ * code where it can name the offending field, and `result.schema.json`
38
+ * validates the merged document.
39
+ */
40
+ export function stampResultEnvelope(agentResult, envelope) {
41
+ if (agentResult === null || typeof agentResult !== 'object' || Array.isArray(agentResult)) {
42
+ throw new ResultError('results.not-an-object', `A result file must be a JSON object, got ${Array.isArray(agentResult) ? 'an array' : String(agentResult === null ? 'null' : typeof agentResult)}.`, { received: Array.isArray(agentResult) ? 'array' : typeof agentResult });
43
+ }
44
+ const half = agentResult;
45
+ const overstep = ORCHESTRATOR_OWNED_RESULT_FIELDS.filter((field) => half[field] !== undefined);
46
+ if (overstep.length > 0) {
47
+ throw new ResultError('results.agent-wrote-owned-field', `Result set dispatcher-owned field(s) ${overstep.join(', ')}. Agents return run_status, structured_output and artifacts only.`, { fields: [...overstep] });
48
+ }
49
+ const inputTokens = requireTokenCount('input_tokens', envelope.inputTokens);
50
+ const outputTokens = requireTokenCount('output_tokens', envelope.outputTokens);
51
+ return {
52
+ ...half,
53
+ task_id: envelope.taskId,
54
+ agent: envelope.agent,
55
+ provider: envelope.provider,
56
+ model_tier: envelope.modelTier,
57
+ // Derived, not accepted: a third number that can contradict the other two
58
+ // is a third thing to get wrong.
59
+ token_usage: {
60
+ input_tokens: inputTokens,
61
+ output_tokens: outputTokens,
62
+ total_tokens: inputTokens + outputTokens,
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,437 @@
1
+ // Parses `factory/specs/roadmap.md` (architecture §10 "Roadmap" page's
2
+ // source) into milestone rows. The roadmap is a declaration, like specs/
3
+ // policies elsewhere in this repo (agent-constraints.md: "committed files
4
+ // are declarations") — not event-sourced, so this is a plain file parser,
5
+ // not a fold over StoredEvent[] like the rest of db/projector.ts.
6
+ //
7
+ // Format: one `## <name>` heading per milestone, followed by `- key: value`
8
+ // bullet lines (order-independent). `milestone status` is a small closed
9
+ // set distinct from taxonomy.yml's `plan_status`/`task_status` — a roadmap
10
+ // phase isn't a plan or a task, it's a coarser grouping with no taxonomy.yml
11
+ // entry (documented as a deviation in ui/docs/DESIGN.md).
12
+ import { readFileSync } from 'node:fs';
13
+ import { SmithError } from './errors.js';
14
+ import { ROADMAP_PATH } from './paths.js';
15
+ export class RoadmapError extends SmithError {
16
+ }
17
+ /**
18
+ * The `- project:` value that means "this clone", not a project this factory
19
+ * built. It is the default a milestone written before Phase 6b carries, so
20
+ * every roadmap.md that predates the bullet still parses as the factory's own.
21
+ *
22
+ * Exported because a factory that maintains the projects it built has to be
23
+ * able to tell them from itself, and by name is the only handle the roadmap
24
+ * gives. Reading it as a directory is the one thing nothing may do: this
25
+ * clone's parent holds a `black-smith` sibling that is a DIFFERENT repository,
26
+ * so the factory's own checkout is `REPO_ROOT` and never a name lookup.
27
+ *
28
+ * Deliberately not imported from `db/queries.ts`'s `DEFAULT_PROJECT`, which
29
+ * says the same word: that module reaches drizzle, and `test/cliBoot.test.ts`
30
+ * pins that the CLI's module graph never does.
31
+ */
32
+ export const FACTORY_PROJECT = 'black-smith';
33
+ export const MILESTONE_STATUSES = [
34
+ 'planned',
35
+ 'in-progress',
36
+ 'completed',
37
+ ];
38
+ export const MILESTONE_KINDS = ['factory', 'dogfood', 'product'];
39
+ /**
40
+ * The accepted vocabulary for `- error_issues:`, matched case-insensitively
41
+ * after trimming. Shown here, not just referenced, so a reader never has to
42
+ * go hunting for what the bullet accepts.
43
+ */
44
+ export const ERROR_ISSUES_VALUES = ['on', 'off'];
45
+ /**
46
+ * The kind a project carries when no milestone of its own declares one.
47
+ *
48
+ * `product` for everything that is not this clone, so a project registered by
49
+ * `smith new` needs no bullet at all -- and so neither scaffold.ts's
50
+ * registerProjectInRoadmap() nor mcp.ts's registerMcpMilestone() has to learn
51
+ * to write one. Marking a project `dogfood` stays a hand edit, because it is
52
+ * a statement about why the project exists that no writer can infer.
53
+ */
54
+ export function defaultKindFor(project) {
55
+ return project === FACTORY_PROJECT ? 'factory' : 'product';
56
+ }
57
+ function bulletValue(line, key) {
58
+ const match = line.match(new RegExp(`^-\\s*${key}\\s*:\\s*(.*)$`));
59
+ if (!match)
60
+ return null;
61
+ return (match[1] ?? '').trim();
62
+ }
63
+ /**
64
+ * The epic ids a milestone declares, or a typed refusal.
65
+ *
66
+ * `[]` is not this parser's "nothing readable here" value -- it is a
67
+ * declaration with a documented meaning, spelled out in roadmap.md's own
68
+ * header: "`[]` when no epic has been mapped to this phase yet -- the join
69
+ * yields zero tasks until an epic is tagged here". Anything that did not
70
+ * start and end with a bracket used to return it anyway, so a value the
71
+ * parser could not read arrived downstream as a phase the planner had
72
+ * deliberately left empty, and the milestone parsed clean.
73
+ *
74
+ * That empty list is load-bearing. It is what queries.ts's roadmapPage()
75
+ * joins tasks on (tasksTotal, tasksCompleted, tokensBudget all collapse to
76
+ * zero/null), what kanban() builds its epicId -> milestoneId map from, and
77
+ * what ownsEpic() reads to decide whether the MCP surface gate applies --
78
+ * the failure that comment records, "skipped the MCP surface gate for every
79
+ * project, silently, from the day it shipped", was an empty epicIds list.
80
+ *
81
+ * The un-bracketed spelling is the one to expect rather than an exotic typo:
82
+ * `- goal:` and `- project:` on the lines beside it are bare scalars, so
83
+ * `- epics: phase-9-hardening` reads as the obvious way to tag one epic. A
84
+ * forgotten comma is the other half -- `[epic-a epic-b]` is one id that
85
+ * matches nothing, and an epic id cannot hold whitespace in any case, since
86
+ * it becomes a git branch (`smith/<epic>/integration`) and a worktree
87
+ * directory name.
88
+ *
89
+ * Refusing is the same answer this file already gives a missing id, a
90
+ * duplicate id and an unknown status, and it lands where those land:
91
+ * db/projector.ts's projectMilestones() catches a parse error, logs it to
92
+ * stderr and leaves the existing rows as-is, "so a markdown typo must
93
+ * degrade the Roadmap/Overview milestone data only, not the write path".
94
+ * A named refusal on stderr, not a phase that quietly reports no work.
95
+ */
96
+ function parseEpicIds(milestoneId, raw) {
97
+ const trimmed = (raw ?? '').trim();
98
+ if (trimmed === '')
99
+ return [];
100
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
101
+ throw new RoadmapError('roadmap.invalid-epics', `Milestone "${milestoneId}" has epics "${trimmed}" — expected a bracketed, comma-separated list such as [epic-a, epic-b], or [] for none.`, { id: milestoneId, epics: trimmed });
102
+ }
103
+ const inner = trimmed.slice(1, -1).trim();
104
+ if (inner === '')
105
+ return [];
106
+ const ids = inner
107
+ .split(',')
108
+ .map((s) => s.trim())
109
+ .filter((s) => s.length > 0);
110
+ const spaced = ids.find((id) => /\s/.test(id));
111
+ if (spaced !== undefined) {
112
+ throw new RoadmapError('roadmap.invalid-epics', `Milestone "${milestoneId}" declares the epic id "${spaced}" — epic ids hold no whitespace (each is a git branch and a worktree directory), so this is a missing comma.`, { id: milestoneId, epics: trimmed, epicId: spaced });
113
+ }
114
+ return ids;
115
+ }
116
+ function isMilestoneStatus(value) {
117
+ return MILESTONE_STATUSES.includes(value);
118
+ }
119
+ function isMilestoneKind(value) {
120
+ return MILESTONE_KINDS.includes(value);
121
+ }
122
+ function isErrorIssuesValue(value) {
123
+ return ERROR_ISSUES_VALUES.includes(value);
124
+ }
125
+ /**
126
+ * Settle every milestone's kind project-wide, and independently of the order
127
+ * the milestones happen to sit in.
128
+ *
129
+ * `kind` describes a project but is written on a milestone, so one bullet has
130
+ * to settle every milestone that names that project. Resolving per-milestone
131
+ * instead would mean `smith mcp init` appending an undeclared milestone to a
132
+ * dogfood project silently made that one milestone a product -- the project
133
+ * would then show on the Roadmap page for one row and vanish for the rest,
134
+ * which reads as missing data rather than as the drift it is.
135
+ *
136
+ * Two passes rather than one because the answer must not depend on which
137
+ * milestone the operator wrote the bullet on: the bullets inside a block are
138
+ * already order-independent, and the blocks should be too. An explicit
139
+ * disagreement is a refusal, not a precedence rule -- there is no reading of
140
+ * two different kinds for one project that is more likely than a typo.
141
+ */
142
+ function resolveKinds(milestones, explicit) {
143
+ const declared = new Map();
144
+ for (let i = 0; i < milestones.length; i += 1) {
145
+ const m = milestones[i];
146
+ const kind = explicit[i] ?? null;
147
+ if (kind === null)
148
+ continue;
149
+ const first = declared.get(m.project);
150
+ if (first === undefined) {
151
+ declared.set(m.project, { kind, by: m.milestoneId });
152
+ continue;
153
+ }
154
+ if (first.kind !== kind) {
155
+ throw new RoadmapError('roadmap.conflicting-kind', `Project "${m.project}" is kind "${first.kind}" on milestone "${first.by}" but "${kind}" on "${m.milestoneId}" — every milestone naming one project must agree.`, {
156
+ project: m.project,
157
+ id: m.milestoneId,
158
+ kind,
159
+ conflictsWith: first.by,
160
+ conflictsWithKind: first.kind,
161
+ });
162
+ }
163
+ }
164
+ for (const m of milestones) {
165
+ m.kind = declared.get(m.project)?.kind ?? defaultKindFor(m.project);
166
+ }
167
+ }
168
+ /**
169
+ * Settle every milestone's `error_issues` project-wide, the same shape as
170
+ * resolveKinds() above and for the same reason: the bullet describes a
171
+ * project while being written on a milestone, so the answer must not depend
172
+ * on which milestone carries it, or on which milestone in the project a
173
+ * writer later appends without the bullet.
174
+ *
175
+ * A second, differently-behaving pass rather than generalising resolveKinds()
176
+ * itself, because resolveKinds() is typed to MilestoneKind -- restating the
177
+ * shape is acceptable, a second RULE is not.
178
+ */
179
+ function resolveErrorIssues(milestones, explicit) {
180
+ const declared = new Map();
181
+ for (let i = 0; i < milestones.length; i += 1) {
182
+ const m = milestones[i];
183
+ const value = explicit[i] ?? null;
184
+ if (value === null)
185
+ continue;
186
+ const first = declared.get(m.project);
187
+ if (first === undefined) {
188
+ declared.set(m.project, { value, by: m.milestoneId });
189
+ continue;
190
+ }
191
+ if (first.value !== value) {
192
+ throw new RoadmapError('roadmap.conflicting-error-issues', `Project "${m.project}" is error_issues "${first.value}" on milestone "${first.by}" but "${value}" on "${m.milestoneId}" — every milestone naming one project must agree.`, {
193
+ project: m.project,
194
+ id: m.milestoneId,
195
+ value,
196
+ conflictsWith: first.by,
197
+ conflictsWithValue: first.value,
198
+ });
199
+ }
200
+ }
201
+ for (const m of milestones) {
202
+ m.errorIssuesEnabled = (declared.get(m.project)?.value ?? 'on') === 'on';
203
+ }
204
+ }
205
+ /** Parse the full roadmap.md text into milestone rows, in file order. */
206
+ export function parseRoadmap(markdown) {
207
+ const milestones = [];
208
+ const explicitKinds = [];
209
+ const explicitErrorIssues = [];
210
+ const seenIds = new Set();
211
+ const sections = markdown.split(/\n(?=## )/);
212
+ let sequence = 0;
213
+ for (const section of sections) {
214
+ const trimmedSection = section.trimStart();
215
+ if (!trimmedSection.startsWith('## '))
216
+ continue;
217
+ const lines = trimmedSection.split('\n');
218
+ const heading = (lines[0] ?? '').replace(/^##\s*/, '').trim();
219
+ if (!heading)
220
+ continue;
221
+ let id = null;
222
+ let status = null;
223
+ let goal = null;
224
+ let epicsRaw = null;
225
+ let project = FACTORY_PROJECT;
226
+ let kindRaw = null;
227
+ let errorIssuesRaw = null;
228
+ for (const rawLine of lines.slice(1)) {
229
+ const line = rawLine.trim();
230
+ const idVal = bulletValue(line, 'id');
231
+ if (idVal !== null) {
232
+ id = idVal || null;
233
+ continue;
234
+ }
235
+ const statusVal = bulletValue(line, 'status');
236
+ if (statusVal !== null) {
237
+ status = statusVal || null;
238
+ continue;
239
+ }
240
+ const goalVal = bulletValue(line, 'goal');
241
+ if (goalVal !== null) {
242
+ goal = goalVal || null;
243
+ continue;
244
+ }
245
+ const epicsVal = bulletValue(line, 'epics');
246
+ if (epicsVal !== null) {
247
+ epicsRaw = epicsVal;
248
+ continue;
249
+ }
250
+ const projectVal = bulletValue(line, 'project');
251
+ if (projectVal !== null && projectVal !== '') {
252
+ project = projectVal;
253
+ continue;
254
+ }
255
+ // An empty `- kind:` is absent, not invalid -- the same reading
256
+ // `- project:` gives it, so a half-written bullet falls back to the
257
+ // derived answer rather than taking the whole roadmap away.
258
+ const kindVal = bulletValue(line, 'kind');
259
+ if (kindVal !== null && kindVal !== '') {
260
+ kindRaw = kindVal;
261
+ continue;
262
+ }
263
+ // Absent and empty both mean "on" -- the same reading `- kind:` and
264
+ // `- project:` give an empty bullet, and unlike `- epics:` (where `[]`
265
+ // is a declaration with meaning), empty carries no meaning of its own.
266
+ const errorIssuesVal = bulletValue(line, 'error_issues');
267
+ if (errorIssuesVal !== null && errorIssuesVal !== '') {
268
+ errorIssuesRaw = errorIssuesVal;
269
+ }
270
+ }
271
+ if (!id) {
272
+ throw new RoadmapError('roadmap.missing-id', `Milestone "${heading}" is missing a required "- id:" field.`, { heading });
273
+ }
274
+ if (seenIds.has(id)) {
275
+ // milestoneId is the milestones table's primary key (db/schema.ts) —
276
+ // a duplicate here would otherwise surface as a raw UNIQUE constraint
277
+ // crash deep inside db/projector.ts's projectMilestones(), which runs
278
+ // after every write (rebuild()/apply()). Fail with a typed error at
279
+ // parse time instead, before it ever reaches the database.
280
+ throw new RoadmapError('roadmap.duplicate-id', `Milestone id "${id}" is declared more than once in roadmap.md — ids must be unique.`, { id });
281
+ }
282
+ seenIds.add(id);
283
+ if (!status || !isMilestoneStatus(status)) {
284
+ throw new RoadmapError('roadmap.invalid-status', `Milestone "${id}" has status "${status ?? ''}" — expected one of ${MILESTONE_STATUSES.join(', ')}.`, { id, status });
285
+ }
286
+ let kind = null;
287
+ if (kindRaw !== null) {
288
+ if (!isMilestoneKind(kindRaw)) {
289
+ throw new RoadmapError('roadmap.invalid-kind', `Milestone "${id}" has kind "${kindRaw}" — expected one of ${MILESTONE_KINDS.join(', ')}.`, { id, kind: kindRaw });
290
+ }
291
+ // The factory's own kind is structural, not declarative: which project
292
+ // is this clone is settled by FACTORY_PROJECT and REPO_ROOT (see
293
+ // projects.ts), and a roadmap bullet able to contradict that would be a
294
+ // second source of truth for the one question the factory must never
295
+ // get wrong about itself.
296
+ if (project === FACTORY_PROJECT && kindRaw !== 'factory') {
297
+ throw new RoadmapError('roadmap.factory-kind-fixed', `Milestone "${id}" declares kind "${kindRaw}" on project "${FACTORY_PROJECT}" — the factory's own milestones are always kind "factory". Drop the bullet, or name a different project.`, { id, kind: kindRaw, project });
298
+ }
299
+ kind = kindRaw;
300
+ }
301
+ explicitKinds.push(kind);
302
+ let errorIssuesValue = null;
303
+ if (errorIssuesRaw !== null) {
304
+ const normalized = errorIssuesRaw.toLowerCase();
305
+ // A value the parser cannot read must refuse, never degrade to the
306
+ // default -- an unreadable value arriving downstream as a deliberate
307
+ // "on" would be the same bug parseEpicIds()'s docblock records.
308
+ if (!isErrorIssuesValue(normalized)) {
309
+ throw new RoadmapError('roadmap.invalid-error-issues', `Milestone "${id}" has error_issues "${errorIssuesRaw}" — expected one of ${ERROR_ISSUES_VALUES.join(', ')}.`, { id, errorIssues: errorIssuesRaw });
310
+ }
311
+ errorIssuesValue = normalized;
312
+ }
313
+ explicitErrorIssues.push(errorIssuesValue);
314
+ // Read after the id is known and validated, so the refusal names the
315
+ // milestone the way invalid-status does -- the bullets are
316
+ // order-independent, and `- epics:` may well come first in the block.
317
+ const epicIds = parseEpicIds(id, epicsRaw);
318
+ sequence += 1;
319
+ milestones.push({
320
+ milestoneId: id,
321
+ name: heading,
322
+ status,
323
+ sequence,
324
+ goal,
325
+ epicIds,
326
+ project,
327
+ // Provisional -- resolveKinds() below settles it project-wide.
328
+ kind: defaultKindFor(project),
329
+ // Provisional -- resolveErrorIssues() below settles it project-wide.
330
+ errorIssuesEnabled: true,
331
+ });
332
+ }
333
+ resolveKinds(milestones, explicitKinds);
334
+ resolveErrorIssues(milestones, explicitErrorIssues);
335
+ return milestones;
336
+ }
337
+ /**
338
+ * Read roadmap.md from disk, naming the file when it cannot be read.
339
+ *
340
+ * Every other way this module can fail is a typed RoadmapError naming the
341
+ * milestone at fault; the file simply not being there was the one that
342
+ * escaped as a bare Node ENOENT, stack and all. It escaped into the
343
+ * epic-close gate, whose own resolveMcpSurface() promises that a manifest it
344
+ * cannot read "resolves to `check: null` rather than throwing ... so the gate
345
+ * reports the problem instead of crashing the verdict that was meant to
346
+ * report it" -- one line under a roadmap read that did exactly that. The
347
+ * guard is around the read rather than an existsSync in front of it so a
348
+ * directory, a permission error and a missing file all answer the same way.
349
+ */
350
+ export function readRoadmapText(roadmapPath = ROADMAP_PATH) {
351
+ try {
352
+ return readFileSync(roadmapPath, 'utf8');
353
+ }
354
+ catch (err) {
355
+ throw new RoadmapError('roadmap.unreadable', `Could not read the roadmap at ${roadmapPath}: ${err instanceof Error ? err.message : String(err)}`, { roadmapPath });
356
+ }
357
+ }
358
+ /**
359
+ * Does the roadmap already declare this milestone id?
360
+ *
361
+ * The writers (mcp.ts's registerMcpMilestone, scaffold.ts's
362
+ * registerProjectInRoadmap) are append-once and need to know what is already
363
+ * there. They each asked `text.includes('- id: ' + id)`, which is not what
364
+ * any *reader* here means by a declaration, and the two definitions agree on
365
+ * exactly the spelling the writers themselves emit -- while the roadmap is a
366
+ * hand-maintained file (architecture §12, "the planner maintains it"). Both
367
+ * directions of the disagreement are live: `- id:demo-mcp-surface` is the
368
+ * same milestone to bulletValue and invisible to the substring, so the writer
369
+ * appends a second copy and parseRoadmap then rejects the whole file on
370
+ * duplicate-id; and `- id: demo-mcp-surface-hardening` contains the substring
371
+ * while declaring a different milestone, so the writer reports a registration
372
+ * it never made.
373
+ *
374
+ * So this answers with the parser's own field reader, over the parser's own
375
+ * section scoping -- but without parseRoadmap's validation, deliberately: a
376
+ * section with no `- status:` is a real fixture in this repo's tests and
377
+ * refusing to register a milestone because a *different* one is incomplete
378
+ * would be a new failure of its own.
379
+ */
380
+ export function roadmapDeclaresId(markdown, id) {
381
+ let inSection = false;
382
+ for (const rawLine of markdown.split('\n')) {
383
+ // parseRoadmap splits sections on /\n(?=## )/, so a heading counts only at
384
+ // column 0; bullets are read off the trimmed line.
385
+ if (rawLine.startsWith('## ')) {
386
+ inSection = true;
387
+ continue;
388
+ }
389
+ if (inSection && bulletValue(rawLine.trim(), 'id') === id)
390
+ return true;
391
+ }
392
+ return false;
393
+ }
394
+ /** Read and parse roadmap.md from disk (defaults to the real repo path). */
395
+ export function loadRoadmap(roadmapPath = ROADMAP_PATH) {
396
+ return parseRoadmap(readRoadmapText(roadmapPath));
397
+ }
398
+ /**
399
+ * Is this project's issue tracker writable -- may the factory open issues on
400
+ * it for its own build-time errors? The only supported way to ask: reading
401
+ * `MilestoneDef.errorIssuesEnabled` off a single milestone directly would
402
+ * answer for that milestone's row rather than for the project, and
403
+ * `resolveErrorIssues` already settled that one bullet settles the whole
404
+ * project, so a second caller re-deriving that rule from raw fields would be
405
+ * the duplication this reader exists to prevent.
406
+ *
407
+ * Defaults to writable for a project that names no milestone at all, matching
408
+ * the bullet's own default.
409
+ */
410
+ export function isErrorTrackerWritable(milestones, project) {
411
+ const milestone = milestones.find((m) => m.project === project);
412
+ return milestone === undefined ? true : milestone.errorIssuesEnabled;
413
+ }
414
+ /**
415
+ * Whether a milestone claims this epic — the applicability test both
416
+ * milestone-scoped gates share (MCP surface, spec-vs-goal).
417
+ *
418
+ * `epicIds` alone was the original rule and it made the gate unreachable:
419
+ * registerMcpMilestone() writes `- epics: []`, nothing fills it in, and an
420
+ * empty list matches no epic. So `smith epic close` skipped the MCP surface
421
+ * gate for every project, silently, from the day it shipped — the whole
422
+ * roadmap has `epics: []` on all but one milestone.
423
+ *
424
+ * The id fallback keys on a convention this codebase creates rather than on a
425
+ * list it initializes to the disabling value: `smith mcp init <p>` names the
426
+ * milestone `<p>-mcp-surface`, and the epic that closes it carries the same id
427
+ * (envkit-config-loader/envkit-config-loader is the same pattern). An explicit
428
+ * `epics:` entry still works and still wins for any epic named otherwise.
429
+ *
430
+ * Note what this deliberately does NOT do: match every epic in the project.
431
+ * The standard scopes the gate to the epic under the surface milestone, and
432
+ * widening it would block unrelated epics — including ones that legitimately
433
+ * predate the surface — on a manifest they were never meant to own.
434
+ */
435
+ export function ownsEpic(milestone, epicId) {
436
+ return milestone.epicIds.includes(epicId) || milestone.milestoneId === epicId;
437
+ }