@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,502 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { unreadTaskBudgetFields } from './budgets.js';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { topoSort } from './graph.js';
|
|
6
|
+
import { SPECS_ACTIVE_DIR } from './paths.js';
|
|
7
|
+
import { compileSchemas, validateRecord, } from './schemas.js';
|
|
8
|
+
import { loadTaxonomy, TaxonomyError, validateRequiredDimensions, } from './taxonomy.js';
|
|
9
|
+
export class PlanError extends SmithError {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* How much judgment an epic buys, cheapest first — factory/policies/effort.yml
|
|
13
|
+
* declares what each tier means; this is only the closed list of names, kept
|
|
14
|
+
* here beside the `PlanFile.effort` field it types. effort.ts is the semantic
|
|
15
|
+
* owner and re-exports these; it cannot own the list itself, because plan.ts
|
|
16
|
+
* would then have to import it to validate a plan and effort.ts already
|
|
17
|
+
* (transitively) imports plan.ts.
|
|
18
|
+
*/
|
|
19
|
+
export const EFFORT_TIERS = ['small', 'medium', 'huge'];
|
|
20
|
+
export function isEffortTier(value) {
|
|
21
|
+
return typeof value === 'string' && EFFORT_TIERS.includes(value);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Pseudo task-id ref for a PLAN-level (not task-level) event: planQuorum.ts's
|
|
25
|
+
* runPlanQuorum() stamps it onto the dispatch_decision/judge-verdict/
|
|
26
|
+
* quorum-decision events its quorum case emits, so provider-agreement
|
|
27
|
+
* analytics group a plan critique like any other dispatch. It is not a task —
|
|
28
|
+
* db/projector.ts's foldTasks() refuses to materialise a row for it, exactly
|
|
29
|
+
* as it does for worktree.ts's `<epic>/integration`. Lives here rather than in
|
|
30
|
+
* planQuorum.ts so the projector can recognise the shape without importing a
|
|
31
|
+
* quorum host (wrong dependency direction: db/ is below the hosts, not above).
|
|
32
|
+
*/
|
|
33
|
+
export function planRefTaskId(epicId, version) {
|
|
34
|
+
return `${epicId}/plan-v${version}`;
|
|
35
|
+
}
|
|
36
|
+
const PLAN_REF_PATTERN = /^plan-v\d+$/;
|
|
37
|
+
/** Counterpart to planRefTaskId(): does this task_id name a plan version rather than a task? */
|
|
38
|
+
export function isPlanRefTaskId(taskId) {
|
|
39
|
+
const last = taskId.split('/').pop();
|
|
40
|
+
return last !== undefined && PLAN_REF_PATTERN.test(last);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Mint a task id from the plan rather than trusting the one that was typed.
|
|
44
|
+
*
|
|
45
|
+
* D-46/P9-29: the dogfood epic ended with a phantom task row because one
|
|
46
|
+
* human admitted `envkit/task-1-parse-quotes` and another merged
|
|
47
|
+
* `task-1-parse-quotes`. Nothing in the system objected, because nothing in
|
|
48
|
+
* the system owned the id — it was whatever reached the keyboard. There is
|
|
49
|
+
* no "correct" convention to standardise on here; the plan file already IS
|
|
50
|
+
* the register of what exists, so the fix is to make every producer resolve
|
|
51
|
+
* through it. Both spellings map to the one string the plan holds, an id the
|
|
52
|
+
* plan does not contain is a typed error rather than a silent miss, and an
|
|
53
|
+
* id two tasks could equally mean is refused instead of guessed.
|
|
54
|
+
*
|
|
55
|
+
* D-48/P9-31: the plan is not the *only* register. A follow-up task minted by
|
|
56
|
+
* `findings raise` is written to the event log and nowhere else — the plan
|
|
57
|
+
* that was cut before the finding existed cannot name it. A resolver that
|
|
58
|
+
* consults the plan alone therefore refuses the very task the factory has
|
|
59
|
+
* just created, and the follow-up sits `todo` forever with `smith event
|
|
60
|
+
* append` as its only exit. `alsoKnown` is that second register: ids the log
|
|
61
|
+
* has added. A task found in either source is real; one found in neither is
|
|
62
|
+
* still a typed error, and the message names both places it was looked for.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* The epic-qualified/bare spelling rule of resolveTaskId, exported so callers
|
|
66
|
+
* that compare ids across two registers — the plan file and the event log —
|
|
67
|
+
* apply the same one. `epic-1/task-1` and `task-1` are the same task under
|
|
68
|
+
* plan `epic-1`; `epic-2/task-1` is not.
|
|
69
|
+
*/
|
|
70
|
+
export function bareTaskId(epicId, taskId) {
|
|
71
|
+
return taskId.startsWith(`${epicId}/`) ? taskId.slice(epicId.length + 1) : taskId;
|
|
72
|
+
}
|
|
73
|
+
export function resolveTaskId(plan, typed, alsoKnown = []) {
|
|
74
|
+
const qualified = `${plan.epic_id}/${typed}`;
|
|
75
|
+
const known = [...plan.tasks.map((t) => t.task_id), ...alsoKnown];
|
|
76
|
+
const matches = known.filter((id) => id === typed ||
|
|
77
|
+
id === qualified ||
|
|
78
|
+
bareTaskId(plan.epic_id, id) === bareTaskId(plan.epic_id, typed));
|
|
79
|
+
const unique = [...new Set(matches)];
|
|
80
|
+
if (unique.length === 1)
|
|
81
|
+
return unique[0];
|
|
82
|
+
if (unique.length === 0) {
|
|
83
|
+
const fromLog = alsoKnown.length === 0 ? '' : ` Added by the log: ${[...new Set(alsoKnown)].join(', ')}.`;
|
|
84
|
+
throw new PlanError('plan.unknown-task', `Plan "${plan.epic_id}" v${plan.version} has no task "${typed}". Known task ids: ${plan.tasks
|
|
85
|
+
.map((t) => t.task_id)
|
|
86
|
+
.join(', ')}.${fromLog}`, { epicId: plan.epic_id, version: plan.version, typed });
|
|
87
|
+
}
|
|
88
|
+
throw new PlanError('plan.ambiguous-task', `Task id "${typed}" is ambiguous in plan "${plan.epic_id}" v${plan.version} — it could mean ${unique.join(' or ')}. Use the full id the plan lists.`, { epicId: plan.epic_id, version: plan.version, typed, candidates: unique });
|
|
89
|
+
}
|
|
90
|
+
let cachedTaxonomy;
|
|
91
|
+
let cachedSchemas;
|
|
92
|
+
function resolveTaxonomyAndSchemas(opts) {
|
|
93
|
+
if (cachedTaxonomy === undefined)
|
|
94
|
+
cachedTaxonomy = loadTaxonomy();
|
|
95
|
+
if (cachedSchemas === undefined)
|
|
96
|
+
cachedSchemas = compileSchemas(cachedTaxonomy);
|
|
97
|
+
const taxonomy = opts.taxonomy ?? cachedTaxonomy;
|
|
98
|
+
const schemas = opts.schemas ?? cachedSchemas;
|
|
99
|
+
return { taxonomy, schemas };
|
|
100
|
+
}
|
|
101
|
+
function planFilePath(epicId, version, opts) {
|
|
102
|
+
const dir = opts.specsDir ?? SPECS_ACTIVE_DIR;
|
|
103
|
+
return path.join(dir, epicId, `plan-v${version}.json`);
|
|
104
|
+
}
|
|
105
|
+
const PLAN_FILE_PATTERN = /^plan-v(\d+)\.json$/;
|
|
106
|
+
/**
|
|
107
|
+
* The highest plan version written for an epic, or null when the epic has no
|
|
108
|
+
* plan directory — which is not an error. Phase 9 was driven as punch-list
|
|
109
|
+
* branches rather than from plan files, and `epic verdict` must still be able
|
|
110
|
+
* to close those epics (D-126: a missing plan casts no vote; a present one
|
|
111
|
+
* does).
|
|
112
|
+
*/
|
|
113
|
+
export function latestPlanVersion(epicId, opts = {}) {
|
|
114
|
+
const dir = path.join(opts.specsDir ?? SPECS_ACTIVE_DIR, epicId);
|
|
115
|
+
if (!existsSync(dir))
|
|
116
|
+
return null;
|
|
117
|
+
let latest = null;
|
|
118
|
+
for (const entry of readdirSync(dir)) {
|
|
119
|
+
const m = PLAN_FILE_PATTERN.exec(entry);
|
|
120
|
+
if (m === null)
|
|
121
|
+
continue;
|
|
122
|
+
const version = Number(m[1]);
|
|
123
|
+
if (latest === null || version > latest)
|
|
124
|
+
latest = version;
|
|
125
|
+
}
|
|
126
|
+
return latest;
|
|
127
|
+
}
|
|
128
|
+
export function loadPlan(epicId, version, opts = {}) {
|
|
129
|
+
const filePath = planFilePath(epicId, version, opts);
|
|
130
|
+
if (!existsSync(filePath)) {
|
|
131
|
+
throw new PlanError('plan.not-found', `No plan file at ${filePath}.`, { epicId, version });
|
|
132
|
+
}
|
|
133
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Reduce an `output_schema_ref` to the name the compiled schema set knows it
|
|
137
|
+
* by. `compileSchemas` keys on filename-minus-`.schema.json`, so a path, a
|
|
138
|
+
* bare filename, a `$id` URL and a bare name all have to land on the same
|
|
139
|
+
* key. Deliberately permissive about the shape of the ref and strict only
|
|
140
|
+
* about whether the thing it names exists (P9-20 / D-7).
|
|
141
|
+
*/
|
|
142
|
+
function schemaRefName(ref) {
|
|
143
|
+
const last = ref.split(/[/\\]/).pop() ?? ref;
|
|
144
|
+
return last.replace(/\.schema\.json$/, '').replace(/\.json$/, '');
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Append the legal vocabulary to a taxonomy rejection. validateTag already
|
|
148
|
+
* carries `allowed` in its details and drops it from the sentence; a plan
|
|
149
|
+
* author reading a validation report has no other way to see the list, and
|
|
150
|
+
* "unknown value" without the list is the exact shape of the finding.
|
|
151
|
+
*/
|
|
152
|
+
function describeAllowed(err) {
|
|
153
|
+
const allowed = err.details.allowed;
|
|
154
|
+
if (!Array.isArray(allowed) || allowed.length === 0)
|
|
155
|
+
return '';
|
|
156
|
+
return ` Valid: ${allowed.join(', ')}.`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Validate a plan: every task spec schema/taxonomy-valid, every declared
|
|
160
|
+
* taxonomy dimension on an edge a real value in that vocabulary, dependency
|
|
161
|
+
* edges reference real tasks, and the dependency graph is acyclic. A cycle is
|
|
162
|
+
* fatal and thrown immediately (typed error) rather than accumulated —
|
|
163
|
+
* everything else is collected and returned.
|
|
164
|
+
*
|
|
165
|
+
* P9-20 / D-6: taxonomy.yml has declared `edge_type` and `edge_provenance`
|
|
166
|
+
* required on an edge since v2, and nothing checked membership at the point
|
|
167
|
+
* of writing — a plan carrying `"edge_type": "NOT-A-REAL-EDGE-TYPE"`
|
|
168
|
+
* validated clean. Task specs got this for free through validateRecord's
|
|
169
|
+
* `x-taxonomy` pointers; edges have no schema, so they go through
|
|
170
|
+
* validateRequiredDimensions directly rather than acquiring a second register
|
|
171
|
+
* of what an edge must carry, which could then drift from taxonomy.yml's.
|
|
172
|
+
*/
|
|
173
|
+
export function validatePlan(plan, opts = {}) {
|
|
174
|
+
const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
|
|
175
|
+
const errors = [];
|
|
176
|
+
const taskIds = new Set(plan.tasks.map((t) => t.task_id));
|
|
177
|
+
// The effort tier is a closed vocabulary (factory/policies/effort.yml), and
|
|
178
|
+
// a typo in it is the worst kind of quiet: `smith effort show` would read
|
|
179
|
+
// "hgue" as "no tier named" and hand back the policy default, so an epic the
|
|
180
|
+
// operator meant to run at full depth would run at the default one instead.
|
|
181
|
+
// Caught here, at plan time, where the file is still being written.
|
|
182
|
+
if (plan.effort !== undefined && !isEffortTier(plan.effort)) {
|
|
183
|
+
errors.push({
|
|
184
|
+
path: '/effort',
|
|
185
|
+
message: `Effort tier "${String(plan.effort)}" is not one of ${EFFORT_TIERS.join(', ')} (factory/policies/effort.yml). Omit the field to take the policy default.`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const t of plan.tasks) {
|
|
189
|
+
const result = validateRecord(schemas, taxonomy, 'task-spec', t);
|
|
190
|
+
if (!result.valid) {
|
|
191
|
+
for (const issue of result.errors) {
|
|
192
|
+
errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (t.plan_version !== plan.version) {
|
|
196
|
+
errors.push({
|
|
197
|
+
path: `/tasks/${t.task_id}/plan_version`,
|
|
198
|
+
message: `Task plan_version ${t.plan_version} does not match plan version ${plan.version}.`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Shape is the schema's business; this only asks whether each declared
|
|
202
|
+
// field has anything that can read it.
|
|
203
|
+
const budget = (typeof t.budget === 'object' && t.budget !== null ? t.budget : {});
|
|
204
|
+
for (const field of unreadTaskBudgetFields(budget)) {
|
|
205
|
+
errors.push({
|
|
206
|
+
path: `/tasks/${t.task_id}/budget/${field}`,
|
|
207
|
+
message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it, so declaring it here states a limit the task will never be held to. Carry the number in the dispatch prompt instead, where it reads as the request it is (agent-interviews.md M-4).`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// D-7: the ref is what result.schema.json defers structured_output's shape
|
|
211
|
+
// to, so a ref naming a schema the factory does not have is a check that
|
|
212
|
+
// silently never runs. Reported here, at plan time, rather than at gate
|
|
213
|
+
// intake where the task is already coded.
|
|
214
|
+
const ref = t.output_schema_ref;
|
|
215
|
+
if (typeof ref === 'string' && ref !== '' && !schemas.has(schemaRefName(ref))) {
|
|
216
|
+
errors.push({
|
|
217
|
+
path: `/tasks/${t.task_id}/output_schema_ref`,
|
|
218
|
+
message: `output_schema_ref "${ref}" names no schema in factory/specs/schema. ` +
|
|
219
|
+
`Available: ${[...schemas.keys()].sort().join(', ')}.`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
plan.edges.forEach((edge, index) => {
|
|
224
|
+
if (!taskIds.has(edge.task)) {
|
|
225
|
+
errors.push({ path: '/edges', message: `Edge references unknown task "${edge.task}".` });
|
|
226
|
+
}
|
|
227
|
+
if (!taskIds.has(edge.dependsOn)) {
|
|
228
|
+
errors.push({
|
|
229
|
+
path: '/edges',
|
|
230
|
+
message: `Edge references unknown dependsOn "${edge.dependsOn}".`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
validateRequiredDimensions(taxonomy, 'edge', edge);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
// Accumulated, not thrown: a plan with three bad edges should name all
|
|
238
|
+
// three in one pass, the way three bad task specs already do. Only a
|
|
239
|
+
// cycle is fatal here.
|
|
240
|
+
if (!(err instanceof TaxonomyError))
|
|
241
|
+
throw err;
|
|
242
|
+
errors.push({
|
|
243
|
+
path: `/edges/${index}`,
|
|
244
|
+
message: `Edge ${edge.task} <- ${edge.dependsOn}: ${err.message}${describeAllowed(err)}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
const topo = topoSort([...taskIds], plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn })));
|
|
249
|
+
if (!topo.ok) {
|
|
250
|
+
throw new PlanError('plan.cyclic-dependency', `Plan "${plan.epic_id}" v${plan.version} has a cyclic dependency among tasks: ${topo.cycle.join(', ')}.`, { cycle: topo.cycle });
|
|
251
|
+
}
|
|
252
|
+
if (errors.length > 0)
|
|
253
|
+
return { valid: false, errors };
|
|
254
|
+
return { valid: true };
|
|
255
|
+
}
|
|
256
|
+
function writePlanFile(plan, opts) {
|
|
257
|
+
const filePath = planFilePath(plan.epic_id, plan.version, opts);
|
|
258
|
+
if (existsSync(filePath)) {
|
|
259
|
+
throw new PlanError('plan.version-exists', `Refusing to overwrite existing plan file ${filePath} — plans are immutable.`, { filePath });
|
|
260
|
+
}
|
|
261
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
262
|
+
writeFileSync(filePath, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The v(n+1) a set of changes WOULD produce: everything `nextVersion` does
|
|
266
|
+
* except put it on disk.
|
|
267
|
+
*
|
|
268
|
+
* Split out for D-127. `amendPlan` has to know the diff before it can decide
|
|
269
|
+
* whether the amendment is legal at all — an amendment that adds and
|
|
270
|
+
* supersedes nothing obligates nothing, and a finding obligating nothing is
|
|
271
|
+
* discharged the instant the version is cut. The diff is only computable
|
|
272
|
+
* against the new version, so either that check runs after a plan file exists
|
|
273
|
+
* (and nothing in this codebase deletes a plan file) or the construction is
|
|
274
|
+
* available without the write. This is that. Pure and deterministic: the
|
|
275
|
+
* caller that validates against the draft and then cuts through `nextVersion`
|
|
276
|
+
* gets the same file both times.
|
|
277
|
+
*/
|
|
278
|
+
/**
|
|
279
|
+
* A closed vocabulary, so it can be named in an error without quoting a
|
|
280
|
+
* value (D-198). Exported so epic.ts's summarizeEpic can name the same
|
|
281
|
+
* shape of corruption in a malformed `amends_task_ids` entry the same way.
|
|
282
|
+
*/
|
|
283
|
+
export function describeType(value) {
|
|
284
|
+
if (value === null)
|
|
285
|
+
return 'null';
|
|
286
|
+
if (Array.isArray(value))
|
|
287
|
+
return 'a list';
|
|
288
|
+
return typeof value;
|
|
289
|
+
}
|
|
290
|
+
function isPlainObject(value) {
|
|
291
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Refuse a `changes.supersede` no map-shaped read can make, before
|
|
295
|
+
* `draftNextVersion` iterates it as one.
|
|
296
|
+
*
|
|
297
|
+
* D-21: an operator passed `supersede` as an array of task-id strings.
|
|
298
|
+
* `task_id in supersede` was false for every task (arrays have numeric
|
|
299
|
+
* keys, not the task_id strings under test), and `Object.values(anArray)`
|
|
300
|
+
* handed back the array's own elements — a bare string — which
|
|
301
|
+
* `{ ...aString }` then spread character-by-character into a "task record"
|
|
302
|
+
* with no `task_id` at all. That record reached `amendPlan`, which wrote it
|
|
303
|
+
* to an immutable plan file and reported success; `smith plan validate`
|
|
304
|
+
* rejected the file the tool had just produced. Refusing the shape here,
|
|
305
|
+
* before anything is spread, is what closes that hole — and the message
|
|
306
|
+
* names the map-vs-array mistake explicitly, since that is the mistake that
|
|
307
|
+
* actually happened.
|
|
308
|
+
*/
|
|
309
|
+
function readSupersedeMap(epicId, version, supersede) {
|
|
310
|
+
if (supersede === undefined)
|
|
311
|
+
return {};
|
|
312
|
+
if (!isPlainObject(supersede)) {
|
|
313
|
+
const arrayNote = Array.isArray(supersede)
|
|
314
|
+
? ' supersede is a map keyed by the task_id each entry replaces (e.g. ' +
|
|
315
|
+
'{"epic-1/task-3": {...replacement}}) — a list of task ids is a different shape ' +
|
|
316
|
+
'and replaces nothing.'
|
|
317
|
+
: '';
|
|
318
|
+
throw new PlanError('plan.unreadable-supersede', `Refusing to draft plan "${epicId}" v${version}: changes.supersede is ${describeType(supersede)}, not a map of task_id to replacement task record.${arrayNote}`, { epicId, version, received: describeType(supersede) });
|
|
319
|
+
}
|
|
320
|
+
for (const [key, value] of Object.entries(supersede)) {
|
|
321
|
+
if (!isPlainObject(value) || typeof value.task_id !== 'string') {
|
|
322
|
+
throw new PlanError('plan.unreadable-supersede-entry', `Refusing to draft plan "${epicId}" v${version}: changes.supersede["${key}"] is ${describeType(value)}, not a task record carrying a string task_id.`, { epicId, version, key, received: describeType(value) });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return supersede;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Same guard as `readSupersedeMap`, for `changes.added`: a plain list whose
|
|
329
|
+
* entries are task-record-shaped objects carrying a string `task_id`. Same
|
|
330
|
+
* treatment because the failure is the same shape of mistake — an entry this
|
|
331
|
+
* codebase would otherwise spread into a plan file with no `task_id` to
|
|
332
|
+
* identify it by.
|
|
333
|
+
*/
|
|
334
|
+
function readAddedList(epicId, version, added) {
|
|
335
|
+
if (added === undefined)
|
|
336
|
+
return [];
|
|
337
|
+
if (!Array.isArray(added)) {
|
|
338
|
+
throw new PlanError('plan.unreadable-added', `Refusing to draft plan "${epicId}" v${version}: changes.added is ${describeType(added)}, not a list of task records.`, { epicId, version, received: describeType(added) });
|
|
339
|
+
}
|
|
340
|
+
added.forEach((t, index) => {
|
|
341
|
+
if (!isPlainObject(t) || typeof t.task_id !== 'string') {
|
|
342
|
+
throw new PlanError('plan.unreadable-added-entry', `Refusing to draft plan "${epicId}" v${version}: changes.added[${index}] is ${describeType(t)}, not a task record carrying a string task_id.`, { epicId, version, index, received: describeType(t) });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
return added;
|
|
346
|
+
}
|
|
347
|
+
export function draftNextVersion(prev, changes) {
|
|
348
|
+
const newVersion = prev.version + 1;
|
|
349
|
+
const supersede = readSupersedeMap(prev.epic_id, newVersion, changes.supersede);
|
|
350
|
+
const carried = [];
|
|
351
|
+
for (const t of prev.tasks) {
|
|
352
|
+
if (t.task_status === 'completed' && !(t.task_id in supersede)) {
|
|
353
|
+
continue; // done; not carried into the live backlog
|
|
354
|
+
}
|
|
355
|
+
if (t.task_id in supersede) {
|
|
356
|
+
carried.push({ ...t, task_status: 'superseded', plan_version: newVersion });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
carried.push({ ...t, plan_version: newVersion });
|
|
360
|
+
}
|
|
361
|
+
const replacements = Object.values(supersede).map((t) => ({ ...t, plan_version: newVersion }));
|
|
362
|
+
const added = readAddedList(prev.epic_id, newVersion, changes.added).map((t) => ({
|
|
363
|
+
...t,
|
|
364
|
+
plan_version: newVersion,
|
|
365
|
+
}));
|
|
366
|
+
const tasks = [...carried, ...replacements, ...added];
|
|
367
|
+
// An edge may only name a task this version declares -- `validatePlan`'s own
|
|
368
|
+
// rule. Dropping a completed task from the live backlog is what breaks it:
|
|
369
|
+
// the id leaves, the edges pointing at it did not, and v(n+1) then failed the
|
|
370
|
+
// validation the operator guide tells the operator to run, on an id the file
|
|
371
|
+
// no longer mentions. Plans are immutable and nothing deletes one, so that
|
|
372
|
+
// left no way out.
|
|
373
|
+
//
|
|
374
|
+
// Dropping the edge loses nothing: the only ids that leave are completed and
|
|
375
|
+
// not superseded, so the ordering constraint is already discharged, and a
|
|
376
|
+
// superseded task is carried forward (as `superseded`) and keeps its edges.
|
|
377
|
+
// Applied to the CARRIED edges only -- an edge this amendment adds is
|
|
378
|
+
// something the author wrote, and deleting it silently would answer a typo
|
|
379
|
+
// with a plan that no longer says what they asked for. `validatePlan` names
|
|
380
|
+
// that one instead.
|
|
381
|
+
const declared = new Set(tasks.map((t) => t.task_id));
|
|
382
|
+
const carriedEdges = prev.edges.filter((e) => declared.has(e.task) && declared.has(e.dependsOn));
|
|
383
|
+
return {
|
|
384
|
+
epic_id: prev.epic_id,
|
|
385
|
+
version: newVersion,
|
|
386
|
+
status: 'active',
|
|
387
|
+
tasks,
|
|
388
|
+
edges: [...carriedEdges, ...(changes.newEdges ?? [])],
|
|
389
|
+
...(prev.project ? { project: prev.project } : {}),
|
|
390
|
+
...(prev.effort ? { effort: prev.effort } : {}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Cut plan v(n+1): carries forward unfinished tasks (bumping plan_version),
|
|
395
|
+
* marks superseded tasks, adds new tasks, and writes the new immutable
|
|
396
|
+
* version file. Never mutates the previous version's file.
|
|
397
|
+
*
|
|
398
|
+
* The only function in this codebase that puts a plan version on disk — which
|
|
399
|
+
* is why `draftNextVersion` is exported for inspection and `writePlanFile`
|
|
400
|
+
* is not: a caller that could write half of a version could write a version
|
|
401
|
+
* no event explains.
|
|
402
|
+
*/
|
|
403
|
+
export function nextVersion(prev, changes, opts = {}) {
|
|
404
|
+
const newPlan = draftNextVersion(prev, changes);
|
|
405
|
+
writePlanFile(newPlan, opts);
|
|
406
|
+
return newPlan;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The task id's live spec in a plan version: the last record that has not
|
|
410
|
+
* been superseded, or undefined when every record for that id is dead.
|
|
411
|
+
*
|
|
412
|
+
* A plan version holds each superseded copy of a task *alongside* its
|
|
413
|
+
* replacement, and both carry the same `task_id` whenever the amendment kept
|
|
414
|
+
* the id (D-121) — so "the spec for this id" is only well defined once the
|
|
415
|
+
* dead copies are set aside. Indexing the whole task list into a Map by id
|
|
416
|
+
* silently answers with whichever record happened to be written last.
|
|
417
|
+
*/
|
|
418
|
+
function liveSpec(plan, id) {
|
|
419
|
+
let found;
|
|
420
|
+
for (const t of plan.tasks) {
|
|
421
|
+
if (t.task_id === id && t.task_status !== 'superseded')
|
|
422
|
+
found = t;
|
|
423
|
+
}
|
|
424
|
+
return found;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Every id's live spec, in plan order — the same `liveSpec` rule applied to
|
|
428
|
+
* the whole file rather than one id, so a reader can ask "what does this plan
|
|
429
|
+
* version still claim?" without re-deriving the supersede semantics.
|
|
430
|
+
*
|
|
431
|
+
* D-126: this is what `epic verdict` consults. `plan.tasks` itself is the
|
|
432
|
+
* wrong answer to that question, because it also holds every dead record the
|
|
433
|
+
* amendments left behind.
|
|
434
|
+
*/
|
|
435
|
+
export function livePlanTasks(plan) {
|
|
436
|
+
const seen = new Set();
|
|
437
|
+
const live = [];
|
|
438
|
+
for (const t of plan.tasks) {
|
|
439
|
+
if (seen.has(t.task_id))
|
|
440
|
+
continue;
|
|
441
|
+
seen.add(t.task_id);
|
|
442
|
+
const spec = liveSpec(plan, t.task_id);
|
|
443
|
+
if (spec !== undefined)
|
|
444
|
+
live.push(spec);
|
|
445
|
+
}
|
|
446
|
+
return live;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* What makes two records the same *spec*: everything except the version stamp
|
|
450
|
+
* and the workflow status. `nextVersion` rewrites `plan_version` on every
|
|
451
|
+
* record it carries, and a task moving todo → in-progress is not an
|
|
452
|
+
* amendment; neither may register as a spec change.
|
|
453
|
+
*
|
|
454
|
+
* Nested key order is inherited from whichever JSON the record was parsed
|
|
455
|
+
* from, so this compares as text rather than structurally. That is the safe
|
|
456
|
+
* direction: a reordered-but-identical spec reads as *superseded*, which
|
|
457
|
+
* tells the operator to look, whereas the failure being fixed here is a
|
|
458
|
+
* changed spec reading as *carried*, which tells them not to.
|
|
459
|
+
*/
|
|
460
|
+
function specSignature(t) {
|
|
461
|
+
const entries = Object.entries(t)
|
|
462
|
+
.filter(([k]) => k !== 'plan_version' && k !== 'task_status')
|
|
463
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
464
|
+
return JSON.stringify(entries);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Diff two plan versions: added / removed / superseded / carried task ids.
|
|
468
|
+
*
|
|
469
|
+
* `superseded` means "this id's live spec was replaced between A and B",
|
|
470
|
+
* which is the question an amendment is actually asking. It deliberately does
|
|
471
|
+
* not read `task_status === 'superseded'` off a single record: `supersede` is
|
|
472
|
+
* keyed by task_id and a replacement that keeps its id — the shape every real
|
|
473
|
+
* amendment has used — leaves the plan holding a dead record and a live one
|
|
474
|
+
* under the same key.
|
|
475
|
+
*/
|
|
476
|
+
export function diffPlans(vA, vB) {
|
|
477
|
+
const aIds = new Set(vA.tasks.map((t) => t.task_id));
|
|
478
|
+
const bIds = new Set(vB.tasks.map((t) => t.task_id));
|
|
479
|
+
const added = [];
|
|
480
|
+
const superseded = [];
|
|
481
|
+
const carried = [];
|
|
482
|
+
const removed = [];
|
|
483
|
+
for (const id of bIds) {
|
|
484
|
+
if (!aIds.has(id))
|
|
485
|
+
added.push(id);
|
|
486
|
+
}
|
|
487
|
+
for (const id of aIds) {
|
|
488
|
+
if (!bIds.has(id)) {
|
|
489
|
+
removed.push(id);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const liveA = liveSpec(vA, id);
|
|
493
|
+
const liveB = liveSpec(vB, id);
|
|
494
|
+
// A live spec that lost its replacement (renamed away, or retired) counts
|
|
495
|
+
// as superseded too — the id survives in the file only as a dead record.
|
|
496
|
+
const same = liveA === undefined
|
|
497
|
+
? liveB === undefined
|
|
498
|
+
: liveB !== undefined && specSignature(liveA) === specSignature(liveB);
|
|
499
|
+
(same ? carried : superseded).push(id);
|
|
500
|
+
}
|
|
501
|
+
return { added, removed, superseded, carried };
|
|
502
|
+
}
|