@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.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- 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
|
+
}
|