@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,408 @@
1
+ // Deterministic scheduler pass (architecture §12 "Recheck scheduling" +
2
+ // "Scheduled growth passes"; `smith scheduler run`). Reads the event log
3
+ // (via db/projector.ts's already-owned task fold — no reason to re-derive
4
+ // task-lifecycle state a second time) and proposes work; it NEVER dispatches
5
+ // an agent and never widens scope itself — every output here is a
6
+ // `*-proposed`/`growth-review-due` event for a human or a later planner
7
+ // session to act on.
8
+ import { execFileSync } from 'node:child_process';
9
+ import { readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { parse as parseYaml } from 'yaml';
12
+ import { globsOverlap } from './claims.js';
13
+ import { foldTasks, taskIdCanonicalizer } from './db/projector.js';
14
+ import { SmithError } from './errors.js';
15
+ import { appendEvent } from './events.js';
16
+ import { SCHEDULER_POLICY_PATH } from './paths.js';
17
+ export class SchedulerError extends SmithError {
18
+ }
19
+ /**
20
+ * Every knob, checked rather than trusted (D-203 for the two `lessons` ones).
21
+ *
22
+ * `??` defaults on null/undefined only, so a YAML typo -- `days_elapsed:
23
+ * fourteen`, `cadence_days: monthly` -- arrived at the comparison as itself,
24
+ * with the declared `number` type saying otherwise. Nothing then failed. The
25
+ * comparison simply answered differently, in a different direction at each
26
+ * knob it reaches, which is the same defect cli.ts already reasoned out for a
27
+ * mistyped `--now` (D-209):
28
+ *
29
+ * - `999 >= 'fourteen'` is false, so a task untouched for three years never
30
+ * proposes `time-elapsed`. Fails CLOSED: the operator asks what is due and
31
+ * the typo answers "nothing".
32
+ * - `0.001 < 'monthly'` is false, so `proposeGrowthReview` never returns
33
+ * null and the review fires on every pass. Fails OPEN, from the same typo.
34
+ * - `shingle_size: 0` makes `shingles()` emit one empty gram per statement,
35
+ * so any two score a Jaccard of 1.0 and `smith dream` auto-rejects every
36
+ * checkpoint it extracts.
37
+ * - a non-numeric novelty threshold makes `score >= threshold` a NaN
38
+ * comparison, always false, so nothing is ever redundant.
39
+ *
40
+ * A wrong *number* still passes, deliberately: `days_elapsed: 1` proposes more
41
+ * rechecks and the operator reads them, so it argues with itself in the open.
42
+ * A wrong *type* argues in silence. Finiteness is part of the type check, not
43
+ * a range rule -- `.inf` is a number that no count can ever reach.
44
+ *
45
+ * The refusal names the value because a policy file is hand-edited.
46
+ */
47
+ const READS_SILENTLY = 'A value the comparison cannot read does not fail the run, it changes what the run proposes.';
48
+ const VOIDS_THE_GATE = 'The novelty gate reads this directly and a degenerate value voids it silently.';
49
+ const TRUTHY_IS_NOT_TRUE = 'YAML 1.2 reads `on`/`off`/`yes`/`no` as strings, and every non-empty string is truthy — an operator who typed `off` would have switched the gate ON.';
50
+ function checkKnob(field, value, expected, note, ok = () => true) {
51
+ if (typeof value !== 'number' || !Number.isFinite(value) || !ok(value)) {
52
+ throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} must be ${expected}; got ${JSON.stringify(value)}. ${note}`, { field, value });
53
+ }
54
+ return value;
55
+ }
56
+ /** A knob whose only rule is that a comparison can read it. */
57
+ function count(field, value) {
58
+ return checkKnob(field, value, 'a finite number', READS_SILENTLY);
59
+ }
60
+ /** A knob the code branches on directly, where any string at all would read as `true`. */
61
+ function flag(field, value, note) {
62
+ if (typeof value !== 'boolean') {
63
+ throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} must be true or false; got ${JSON.stringify(value)}. ${note}`, { field, value });
64
+ }
65
+ return value;
66
+ }
67
+ /**
68
+ * The closed vocabularies the `autonomy:` whitelists draw from. Closed on
69
+ * purpose: a name that matches nothing would fail closed, which is the safe
70
+ * direction and exactly why it has to be loud — `maintenence` would leave
71
+ * autonomy switched on and admitting nothing, and the log would say only that
72
+ * the operator was never asked.
73
+ */
74
+ const PROPOSAL_KINDS = [
75
+ 'recheck',
76
+ 'maintenance',
77
+ 'growth-review-due',
78
+ ];
79
+ const RECHECK_REASONS = [
80
+ 'merge-threshold',
81
+ 'time-elapsed',
82
+ 'low-confidence',
83
+ ];
84
+ const A_LIST_IS_NOT_A_STRING = 'A bare scalar spreads into its characters here, so `recheck` would read as seven kinds that match nothing.';
85
+ /** A whitelist: a list of strings, every one of them a name this code knows. */
86
+ function vocabList(field, value, vocabulary) {
87
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
88
+ throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} must be a list of strings; got ${JSON.stringify(value)}. ${A_LIST_IS_NOT_A_STRING}`, { field, value });
89
+ }
90
+ const entries = value;
91
+ const unknown = entries.filter((entry) => !vocabulary.includes(entry));
92
+ if (unknown.length > 0) {
93
+ throw new SchedulerError('scheduler.invalid-policy', `scheduler.yml ${field} lists ${unknown.map((u) => JSON.stringify(u)).join(', ')}, which is not one of: ${vocabulary.join(', ')}. A name nothing matches disables silently.`, { field, value: unknown });
94
+ }
95
+ return [...entries];
96
+ }
97
+ export function parseSchedulerPolicy(yamlText) {
98
+ const doc = (parseYaml(yamlText) ?? {});
99
+ if (!doc.recheck || !doc.maintenance || !doc.growth) {
100
+ throw new SchedulerError('scheduler.invalid-policy', 'scheduler.yml is missing one of recheck/maintenance/growth.');
101
+ }
102
+ return {
103
+ recheck: {
104
+ mergeThreshold: count('recheck.merge_threshold', doc.recheck.merge_threshold ?? 5),
105
+ daysElapsed: count('recheck.days_elapsed', doc.recheck.days_elapsed ?? 14),
106
+ confidenceThreshold: count('recheck.confidence_threshold', doc.recheck.confidence_threshold ?? 0.6),
107
+ },
108
+ maintenance: {
109
+ autoScheduleConfidence: count('maintenance.auto_schedule_confidence', doc.maintenance.auto_schedule_confidence ?? 0.8),
110
+ majorBumpConfidence: count('maintenance.major_bump_confidence', doc.maintenance.major_bump_confidence ?? 0.5),
111
+ minorOrPatchConfidence: count('maintenance.minor_or_patch_confidence', doc.maintenance.minor_or_patch_confidence ?? 0.9),
112
+ },
113
+ growth: { cadenceDays: count('growth.cadence_days', doc.growth.cadence_days ?? 30) },
114
+ // Every default fails closed. A scheduler.yml written before autonomy
115
+ // existed, or one a downstream project trimmed, must not inherit this
116
+ // repo's answer to "what may run without me": absent means off, and off
117
+ // means an empty whitelist behind it.
118
+ autonomy: {
119
+ enabled: flag('autonomy.enabled', doc.autonomy?.enabled ?? false, TRUTHY_IS_NOT_TRUE),
120
+ autoDispatchKinds: vocabList('autonomy.auto_dispatch_kinds', doc.autonomy?.auto_dispatch_kinds ?? [], PROPOSAL_KINDS),
121
+ autoDispatchRecheckReasons: vocabList('autonomy.auto_dispatch_recheck_reasons', doc.autonomy?.auto_dispatch_recheck_reasons ?? [], RECHECK_REASONS),
122
+ confidenceFloor: checkKnob('autonomy.confidence_floor', doc.autonomy?.confidence_floor ?? 0.8, 'a number in [0, 1]', READS_SILENTLY, (n) => n >= 0 && n <= 1),
123
+ },
124
+ lessons: {
125
+ noveltyJaccardThreshold: checkKnob('lessons.novelty_jaccard_threshold', doc.lessons?.novelty_jaccard_threshold ?? 0.8, 'a number in (0, 1]', VOIDS_THE_GATE, (n) => n > 0 && n <= 1),
126
+ shingleSize: checkKnob('lessons.shingle_size', doc.lessons?.shingle_size ?? 3, 'an integer >= 1', VOIDS_THE_GATE, (n) => Number.isInteger(n) && n >= 1),
127
+ noveltyLengthAware: flag('lessons.novelty_length_aware', doc.lessons?.novelty_length_aware ?? true, TRUTHY_IS_NOT_TRUE),
128
+ },
129
+ };
130
+ }
131
+ export function loadSchedulerPolicy(filePath = SCHEDULER_POLICY_PATH) {
132
+ return parseSchedulerPolicy(readFileSync(filePath, 'utf8'));
133
+ }
134
+ /**
135
+ * task_id -> confidence from its task-result-recorded event, default 1 (full
136
+ * confidence) when absent.
137
+ *
138
+ * Keyed by the canonical id, because `smith gate run <taskId>` stamps whatever
139
+ * spelling the operator typed: the same task reaches the log qualified from
140
+ * `task-added` and bare from its result. `proposeRechecks` reads this map with
141
+ * the canonical id `foldTasks` folded the row under, so a raw key would miss,
142
+ * fall back to full confidence, and make `low-confidence` unfireable (D-182).
143
+ */
144
+ function confidenceByTask(events) {
145
+ const byTask = new Map();
146
+ const canonical = taskIdCanonicalizer(events);
147
+ for (const { record } of events) {
148
+ if (record.event_type !== 'task-result-recorded')
149
+ continue;
150
+ const p = record.payload;
151
+ const taskId = record.task_id ?? p.task_id;
152
+ const confidence = p.structured_output?.confidence;
153
+ if (taskId !== undefined && typeof confidence === 'number') {
154
+ byTask.set(canonical(taskId), confidence);
155
+ }
156
+ }
157
+ return byTask;
158
+ }
159
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
160
+ /**
161
+ * `taskId` -> the event-array index of its MOST RECENT `recheck-proposed`
162
+ * event (this scheduler's own prior output — `runScheduler()` appends this
163
+ * event type verbatim from `RecheckProposal`, which uses `taskId`, camelCase
164
+ * — a deliberate deviation from this file's `task_id` snake_case elsewhere,
165
+ * since the payload here isn't a required-taxonomy-dimension record; noted,
166
+ * not renamed, to avoid an unrelated payload-shape change in this fix).
167
+ */
168
+ function latestRecheckProposalIndex(events) {
169
+ const latest = new Map();
170
+ events.forEach(({ record }, index) => {
171
+ if (record.event_type !== 'recheck-proposed')
172
+ return;
173
+ const p = record.payload;
174
+ if (p.taskId)
175
+ latest.set(p.taskId, index);
176
+ });
177
+ return latest;
178
+ }
179
+ /**
180
+ * A prior `recheck-proposed` for `taskId` counts as RESOLVED once one of two
181
+ * events for it appears LATER in the log:
182
+ * (a) `task-added` whose payload.recheck_of === taskId — a real recheck
183
+ * task was created for it (the convention `/bs plan`'s playbook
184
+ * follows when it turns a proposal into a task), or
185
+ * (b) `recheck-declined` whose payload.task_id === taskId — the operator
186
+ * explicitly said no.
187
+ * Deliberately NARROW: no other event touching taskId resolves a pending
188
+ * proposal. Idempotency must fail closed — re-proposing the same recheck is
189
+ * harmless noise the operator/planner can ignore; silently losing a real,
190
+ * still-open recheck need because some unrelated event happened to
191
+ * reference the task would not be.
192
+ */
193
+ function isRecheckResolved(taskId, eventsAfterProposal) {
194
+ for (const { record } of eventsAfterProposal) {
195
+ if (record.event_type === 'task-added') {
196
+ const p = record.payload;
197
+ if (p.recheck_of === taskId)
198
+ return true;
199
+ }
200
+ else if (record.event_type === 'recheck-declined') {
201
+ const p = record.payload;
202
+ if (p.task_id === taskId)
203
+ return true;
204
+ }
205
+ }
206
+ return false;
207
+ }
208
+ /**
209
+ * §12 recheck policy over already-completed features: for each completed,
210
+ * non-recheck-origin task, count LATER-completed tasks whose claims overlap
211
+ * it (mergeCount), days elapsed since it completed, and its completion
212
+ * confidence (from task-result-recorded, default 1). Proposes a recheck when
213
+ * any one of the three thresholds trips; `reasons` lists every one that did
214
+ * — UNLESS an earlier `recheck-proposed` for the same task is still
215
+ * unresolved (isRecheckResolved), in which case it is skipped: re-running
216
+ * this pass must never emit a duplicate proposal for the same open recheck.
217
+ */
218
+ export function proposeRechecks(events, now, policy) {
219
+ const tasks = foldTasks(events);
220
+ const completed = tasks.filter((t) => t.taskStatus === 'completed' && t.origin !== 'recheck');
221
+ const confidence = confidenceByTask(events);
222
+ const latestProposalIndex = latestRecheckProposalIndex(events);
223
+ const proposals = [];
224
+ for (const task of completed) {
225
+ const mergeCount = countLaterOverlappingMerges(task, tasks);
226
+ const daysElapsed = (now.getTime() - Date.parse(task.updatedAt)) / MS_PER_DAY;
227
+ const taskConfidence = confidence.get(task.taskId) ?? 1;
228
+ const reasons = [];
229
+ if (mergeCount >= policy.mergeThreshold)
230
+ reasons.push('merge-threshold');
231
+ if (daysElapsed >= policy.daysElapsed)
232
+ reasons.push('time-elapsed');
233
+ if (taskConfidence < policy.confidenceThreshold)
234
+ reasons.push('low-confidence');
235
+ if (reasons.length === 0)
236
+ continue;
237
+ const priorIndex = latestProposalIndex.get(task.taskId);
238
+ if (priorIndex !== undefined) {
239
+ const eventsAfter = events.slice(priorIndex + 1);
240
+ if (!isRecheckResolved(task.taskId, eventsAfter))
241
+ continue; // still pending -> skip, idempotent
242
+ }
243
+ proposals.push({
244
+ kind: 'recheck',
245
+ taskId: task.taskId,
246
+ epicId: task.epicId,
247
+ reasons,
248
+ mergeCount,
249
+ daysElapsed: Math.floor(daysElapsed),
250
+ confidence: taskConfidence,
251
+ });
252
+ }
253
+ return proposals.sort((a, b) => a.taskId.localeCompare(b.taskId));
254
+ }
255
+ function countLaterOverlappingMerges(task, allTasks) {
256
+ if (!task.claims || task.claims.length === 0)
257
+ return 0;
258
+ let count = 0;
259
+ for (const other of allTasks) {
260
+ if (other.taskId === task.taskId)
261
+ continue;
262
+ if (other.taskStatus !== 'completed')
263
+ continue;
264
+ if (Date.parse(other.updatedAt) <= Date.parse(task.updatedAt))
265
+ continue;
266
+ if (!other.claims || other.claims.length === 0)
267
+ continue;
268
+ const overlaps = task.claims.some((a) => other.claims?.some((b) => globsOverlap(a, b)));
269
+ if (overlaps)
270
+ count++;
271
+ }
272
+ return count;
273
+ }
274
+ export function parsePnpmOutdated(json) {
275
+ const doc = JSON.parse(json);
276
+ return Object.entries(doc)
277
+ .filter(([, v]) => v.current && v.wanted && v.latest)
278
+ .map(([name, v]) => ({
279
+ name,
280
+ current: v.current,
281
+ wanted: v.wanted,
282
+ latest: v.latest,
283
+ }));
284
+ }
285
+ /** Best-effort `pnpm outdated --json` in `projectDir`; null when pnpm/lockfile is unavailable ("when available"). */
286
+ export function runPnpmOutdated(projectDir) {
287
+ let stdout;
288
+ try {
289
+ // pnpm outdated exits 1 when it finds outdated packages — the JSON is
290
+ // still on stdout, so a non-zero exit is read from the error, not treated
291
+ // as failure. A genuinely missing pnpm/lockfile throws with no stdout.
292
+ // stdio explicit for the same reason as git.ts: without it execFileSync
293
+ // sends pnpm's stderr to the operator's terminal, and "No dependencies
294
+ // found" on a fresh project reads as a factory failure (P9-16b).
295
+ stdout = execFileSync('pnpm', ['outdated', '--json'], {
296
+ cwd: projectDir,
297
+ encoding: 'utf8',
298
+ stdio: ['ignore', 'pipe', 'pipe'],
299
+ });
300
+ }
301
+ catch (err) {
302
+ const withStdout = err;
303
+ if (!withStdout.stdout)
304
+ return null;
305
+ stdout = withStdout.stdout;
306
+ }
307
+ try {
308
+ return parsePnpmOutdated(stdout);
309
+ }
310
+ catch {
311
+ return null; // not valid JSON — nothing to propose, not a crash
312
+ }
313
+ }
314
+ function majorVersion(semver) {
315
+ const match = semver.replace(/^[^0-9]*/, '').match(/^(\d+)/);
316
+ return match ? Number.parseInt(match[1], 10) : 0;
317
+ }
318
+ /** origin: inferred (agent-constraints.md "planner / Growth passes") — auto-schedulable only at high confidence. */
319
+ export function proposeMaintenance(projectDir, packages, policy) {
320
+ if (packages.length === 0)
321
+ return null;
322
+ const hasMajorBump = packages.some((p) => majorVersion(p.latest) > majorVersion(p.current));
323
+ const confidence = hasMajorBump ? policy.majorBumpConfidence : policy.minorOrPatchConfidence;
324
+ return {
325
+ kind: 'maintenance',
326
+ projectDir: path.resolve(projectDir),
327
+ packages: [...packages],
328
+ confidence,
329
+ autoSchedulable: confidence >= policy.autoScheduleConfidence,
330
+ };
331
+ }
332
+ /**
333
+ * Emits the TRIGGER only — architecture §12: "product-growth proposals
334
+ * always wait for an operator tick"; the planner session (not this
335
+ * scheduler) reads the living spec/analytics and proposes scope. Fires once
336
+ * cadenceDays have elapsed since the last growth-review-due event in this
337
+ * log (or immediately, if none has ever fired).
338
+ */
339
+ export function proposeGrowthReview(events, now, policy) {
340
+ let lastReviewAt = null;
341
+ for (const { record } of events) {
342
+ if (record.event_type === 'growth-review-due')
343
+ lastReviewAt = record.ts;
344
+ }
345
+ if (lastReviewAt === null) {
346
+ return { kind: 'growth-review-due', cadenceDays: policy.cadenceDays, lastReviewAt: null };
347
+ }
348
+ const daysSince = (now.getTime() - Date.parse(lastReviewAt)) / MS_PER_DAY;
349
+ if (daysSince < policy.cadenceDays)
350
+ return null;
351
+ return { kind: 'growth-review-due', cadenceDays: policy.cadenceDays, lastReviewAt };
352
+ }
353
+ /** Pure: computes every proposal this pass would make, without touching the event log. */
354
+ export function computeProposals(input) {
355
+ const policy = input.policy ?? loadSchedulerPolicy();
356
+ const now = input.now ?? new Date();
357
+ const readOutdated = input.readOutdated ?? runPnpmOutdated;
358
+ const proposals = [...proposeRechecks(input.events, now, policy.recheck)];
359
+ // In the order the repos were given, and one repo's silence costs only that
360
+ // repo: "when available" is a property of each lockfile, so a child project
361
+ // that has never been installed must not take the reading on all the others
362
+ // down with it.
363
+ for (const projectDir of input.projectDirs ?? []) {
364
+ const outdated = readOutdated(projectDir);
365
+ if (!outdated)
366
+ continue;
367
+ const maintenance = proposeMaintenance(projectDir, outdated, policy.maintenance);
368
+ if (maintenance)
369
+ proposals.push(maintenance);
370
+ }
371
+ const growth = proposeGrowthReview(input.events, now, policy.growth);
372
+ if (growth)
373
+ proposals.push(growth);
374
+ return proposals;
375
+ }
376
+ /** event_type per proposal kind — free strings, same taxonomy precedent as dispatch_decision/gate-outcome. */
377
+ function eventTypeFor(proposal) {
378
+ if (proposal.kind === 'recheck')
379
+ return 'recheck-proposed';
380
+ if (proposal.kind === 'maintenance')
381
+ return 'maintenance-proposed';
382
+ return 'growth-review-due';
383
+ }
384
+ /**
385
+ * Runs computeProposals() and, unless `dryRun`, appends one event per
386
+ * proposal (chained causal_parent) — the scheduler's only write. Never
387
+ * dispatches an agent itself (architecture §12).
388
+ */
389
+ export async function runScheduler(input, ctx, opts = {}, dryRun = false) {
390
+ const proposals = computeProposals(input);
391
+ const eventIds = [];
392
+ if (!dryRun) {
393
+ let parent = ctx.causalParent;
394
+ for (const proposal of proposals) {
395
+ const event = await appendEvent({
396
+ session_id: ctx.sessionId,
397
+ actor: ctx.actor ?? 'system',
398
+ event_type: eventTypeFor(proposal),
399
+ plan_version: ctx.planVersion,
400
+ causal_parent: parent,
401
+ payload: proposal,
402
+ }, opts);
403
+ parent = event.event_id;
404
+ eventIds.push(event.event_id);
405
+ }
406
+ }
407
+ return { proposals, eventIds };
408
+ }
@@ -0,0 +1,195 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Ajv2020 } from 'ajv/dist/2020.js';
4
+ import addFormatsImport from 'ajv-formats';
5
+ // ajv-formats ships a .d.ts written as an ES module (`export default`) but its
6
+ // package.json has no "type": "module", so under NodeNext resolution its
7
+ // declared type is the whole CJS module namespace, not the callable plugin
8
+ // function it actually is at runtime (verified: module.exports is the
9
+ // function itself). Narrow it back to the callable shape here, once.
10
+ const addFormats = addFormatsImport;
11
+ import { SmithError } from './errors.js';
12
+ import { SCHEMA_DIR } from './paths.js';
13
+ import { validateTag } from './taxonomy.js';
14
+ export class SchemaError extends SmithError {
15
+ }
16
+ function schemaNameFromFile(fileName) {
17
+ // "task-spec.schema.json" -> "task-spec"
18
+ return fileName.replace(/\.schema\.json$/, '');
19
+ }
20
+ /**
21
+ * Recursively collect every `x-taxonomy`-annotated field path in a JSON Schema.
22
+ * A `[]` segment stands for "every element of the array here" — see
23
+ * `resolveAtPath`, which is the half that has to know how to walk it.
24
+ */
25
+ function collectTaxonomyPointers(node, segments = [], schemaPath = '') {
26
+ if (!node || typeof node !== 'object')
27
+ return [];
28
+ const found = [];
29
+ const obj = node;
30
+ const properties = obj.properties;
31
+ if (properties && typeof properties === 'object') {
32
+ for (const [key, propSchema] of Object.entries(properties)) {
33
+ const childSegments = [...segments, key];
34
+ const childPath = `${schemaPath}/properties/${key}`;
35
+ const prop = propSchema;
36
+ if (typeof prop['x-taxonomy'] === 'string') {
37
+ found.push({
38
+ segments: childSegments,
39
+ dimension: prop['x-taxonomy'],
40
+ schemaPath: childPath,
41
+ });
42
+ }
43
+ found.push(...collectTaxonomyPointers(prop, childSegments, childPath));
44
+ }
45
+ }
46
+ const items = obj.items;
47
+ if (items && typeof items === 'object') {
48
+ const itemSegments = [...segments, '[]'];
49
+ const itemPath = `${schemaPath}/items`;
50
+ // An array of tag strings has no property to hang the annotation on, so
51
+ // it goes on `items` itself. Only entries of `properties` were read for
52
+ // one, which meant that whole shape was invisible (D-194).
53
+ const item = items;
54
+ if (typeof item['x-taxonomy'] === 'string') {
55
+ found.push({ segments: itemSegments, dimension: item['x-taxonomy'], schemaPath: itemPath });
56
+ }
57
+ found.push(...collectTaxonomyPointers(items, itemSegments, itemPath));
58
+ }
59
+ return found;
60
+ }
61
+ /**
62
+ * Every `x-taxonomy` in the document, wherever it sits, as JSON pointers.
63
+ * Deliberately keyword-blind: it walks the raw JSON, so it sees the ones
64
+ * `collectTaxonomyPointers` walks past as well as the ones it collects. The
65
+ * difference between the two lists is the hole.
66
+ */
67
+ function annotationPathsAnywhere(node, schemaPath = '') {
68
+ if (!node || typeof node !== 'object')
69
+ return [];
70
+ if (Array.isArray(node)) {
71
+ return node.flatMap((child, i) => annotationPathsAnywhere(child, `${schemaPath}/${i}`));
72
+ }
73
+ const found = [];
74
+ for (const [key, value] of Object.entries(node)) {
75
+ if (key === 'x-taxonomy' && typeof value === 'string') {
76
+ found.push(schemaPath);
77
+ continue;
78
+ }
79
+ found.push(...annotationPathsAnywhere(value, `${schemaPath}/${key}`));
80
+ }
81
+ return found;
82
+ }
83
+ /**
84
+ * Every value a pointer names. One for a plain field; one per element for a
85
+ * `[]` segment, which is why this returns a list rather than a value — a
86
+ * pointer under an array is a question about each element, and the answer has
87
+ * to say which one.
88
+ */
89
+ function resolveAtPath(data, segments, prefix = []) {
90
+ const [segment, ...rest] = segments;
91
+ if (segment === undefined)
92
+ return [{ path: prefix, value: data }];
93
+ if (segment === '[]') {
94
+ if (!Array.isArray(data))
95
+ return [];
96
+ return data.flatMap((element, i) => resolveAtPath(element, rest, [...prefix, String(i)]));
97
+ }
98
+ if (data === null || data === undefined || typeof data !== 'object')
99
+ return [];
100
+ return resolveAtPath(data[segment], rest, [...prefix, segment]);
101
+ }
102
+ export function loadSchemaFiles(dir = SCHEMA_DIR) {
103
+ const files = readdirSync(dir).filter((f) => f.endsWith('.schema.json'));
104
+ const schemas = new Map();
105
+ for (const file of files) {
106
+ const text = readFileSync(path.join(dir, file), 'utf8');
107
+ schemas.set(schemaNameFromFile(file), JSON.parse(text));
108
+ }
109
+ return schemas;
110
+ }
111
+ export function compileSchemas(_taxonomy, dir = SCHEMA_DIR) {
112
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
113
+ addFormats(ajv);
114
+ const raw = loadSchemaFiles(dir);
115
+ const compiled = new Map();
116
+ for (const [name, schema] of raw) {
117
+ const taxonomyPointers = collectTaxonomyPointers(schema);
118
+ assertEveryAnnotationReachable(name, schema, taxonomyPointers);
119
+ compiled.set(name, {
120
+ name,
121
+ validate: ajv.compile(schema),
122
+ taxonomyPointers,
123
+ });
124
+ }
125
+ return compiled;
126
+ }
127
+ /**
128
+ * Refuse a schema that annotates a field the pointer walker cannot reach.
129
+ *
130
+ * The walker reads `properties` and `items`. A subschema can also live under
131
+ * `$defs` behind a `$ref`, in a `oneOf`/`anyOf`/`allOf` branch, under
132
+ * `patternProperties` -- and mcp-manifest.schema.json already uses four of
133
+ * those. An annotation there used to be collected by nobody and reported by
134
+ * nobody: the record validated, the unknown tag went in, and the field simply
135
+ * stopped being governed by the taxonomy. Nothing distinguishes that from a
136
+ * field nobody annotated.
137
+ *
138
+ * The fix is the error, not reference resolution. Resolving `$ref` means
139
+ * owning a second schema engine -- cycles, remote ids, `$dynamicRef` -- to
140
+ * serve a shape no committed schema uses. Refusing at compile time costs one
141
+ * comparison and tells the schema author the one thing they need to know, at
142
+ * the moment they need it, instead of leaving them a validator that silently
143
+ * agrees with everything.
144
+ */
145
+ function assertEveryAnnotationReachable(name, schema, pointers) {
146
+ const reachable = new Set(pointers.map((p) => p.schemaPath));
147
+ const unreachable = annotationPathsAnywhere(schema).filter((p) => !reachable.has(p));
148
+ if (unreachable.length === 0)
149
+ return;
150
+ throw new SchemaError('schema.unreachable-taxonomy', `Schema "${name}" annotates ${unreachable.length} field(s) with x-taxonomy where the ` +
151
+ `pointer walker cannot see them: ${unreachable.join(', ')}. It reads \`properties\` and ` +
152
+ '`items` only, so an annotation under `$defs`, `$ref`, `oneOf`, `anyOf`, `allOf` or ' +
153
+ '`patternProperties` would never be checked and the field would go ungoverned. Move it ' +
154
+ 'onto the property (or onto `items`) that carries the value.', { schema: name, paths: unreachable });
155
+ }
156
+ function ajvErrorToIssue(err) {
157
+ return { path: err.instancePath || '/', message: `${err.instancePath} ${err.message}`.trim() };
158
+ }
159
+ /**
160
+ * Validate a record against a compiled schema by name, then resolve every
161
+ * x-taxonomy annotated field against the taxonomy so a schema-valid record
162
+ * with an unknown taxonomy value still fails.
163
+ */
164
+ export function validateRecord(schemas, taxonomy, schemaName, record) {
165
+ const compiled = schemas.get(schemaName);
166
+ if (!compiled) {
167
+ throw new SchemaError('schema.unknown-name', `Unknown schema "${schemaName}".`, {
168
+ schemaName,
169
+ known: [...schemas.keys()],
170
+ });
171
+ }
172
+ const errors = [];
173
+ const schemaValid = compiled.validate(record);
174
+ if (!schemaValid) {
175
+ for (const err of compiled.validate.errors ?? []) {
176
+ errors.push(ajvErrorToIssue(err));
177
+ }
178
+ }
179
+ for (const pointer of compiled.taxonomyPointers) {
180
+ for (const hit of resolveAtPath(record, pointer.segments)) {
181
+ if (hit.value === undefined)
182
+ continue; // absence is the schema's job, not ours
183
+ const pathStr = `/${hit.path.join('/')}`;
184
+ try {
185
+ validateTag(taxonomy, pointer.dimension, String(hit.value));
186
+ }
187
+ catch (err) {
188
+ errors.push({ path: pathStr, message: err instanceof Error ? err.message : String(err) });
189
+ }
190
+ }
191
+ }
192
+ if (errors.length > 0)
193
+ return { valid: false, errors };
194
+ return { valid: true };
195
+ }