@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,222 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import picomatch from 'picomatch';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { globsOverlap, synthesizeLiteralSegment } from './claims.js';
5
+ import { SmithError } from './errors.js';
6
+ import { SENSITIVE_PATHS_POLICY_PATH } from './paths.js';
7
+ export class SecurityError extends SmithError {
8
+ }
9
+ /**
10
+ * Every read below refuses a value of the wrong shape rather than substituting
11
+ * an empty one, for the reason the empty-`globs` throw already gives: a trigger
12
+ * the dispatcher cannot read does not fire weakly, it does not fire at all.
13
+ * `cases: infra` is valid YAML and a plausible slip -- the key takes a list,
14
+ * and a one-element list looks like a scalar -- and it turns
15
+ * `dispatchSecurityReviewer` from true to false with an empty trigger list.
16
+ *
17
+ * A *wrong* value stays legal: `cases: [infrastructure]` fires nothing either,
18
+ * but it is a sentence the operator wrote and can read back. Only shapes no
19
+ * comparison can read are refused.
20
+ */
21
+ const NEVER_FIRES = 'A trigger the dispatcher cannot read does not fire weakly, it does not fire at all.';
22
+ /** Copied, not aliased: two parses must not share one array. */
23
+ function stringList(field, value) {
24
+ if (value === undefined || value === null)
25
+ return [];
26
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
27
+ throw new SecurityError('security.invalid-policy', `sensitive-paths.yml ${field} must be a list of strings; got ${JSON.stringify(value)}. ${NEVER_FIRES}`, { field, value });
28
+ }
29
+ return [...value];
30
+ }
31
+ function flag(field, value) {
32
+ if (value === undefined || value === null)
33
+ return false;
34
+ if (typeof value !== 'boolean') {
35
+ throw new SecurityError('security.invalid-policy', `sensitive-paths.yml ${field} must be true or false; got ${JSON.stringify(value)}. ${NEVER_FIRES}`, { field, value });
36
+ }
37
+ return value;
38
+ }
39
+ /** The block, not just its keys: a scalar here takes all three arms dark at once. */
40
+ function otherTriggers(value) {
41
+ if (value === undefined || value === null)
42
+ return {};
43
+ if (typeof value !== 'object' || Array.isArray(value)) {
44
+ throw new SecurityError('security.invalid-policy', `sensitive-paths.yml other_triggers must be a block of keys; got ${JSON.stringify(value)}. ${NEVER_FIRES}`, { field: 'other_triggers', value });
45
+ }
46
+ return value;
47
+ }
48
+ export function parseSensitivePathsPolicy(yamlText) {
49
+ const doc = (parseYaml(yamlText) ?? {});
50
+ const globs = stringList('globs', doc.globs);
51
+ if (globs.length === 0) {
52
+ throw new SecurityError('security.invalid-policy', 'sensitive-paths.yml has no globs — a path trigger with an empty glob list never fires.');
53
+ }
54
+ const other = otherTriggers(doc.other_triggers);
55
+ // `epic_cases` was the original spelling, written before it was clear that
56
+ // `case` lives on the task spec and not on the epic. Both are read, and the
57
+ // error names whichever spelling the file actually used.
58
+ const usesCases = other.cases !== undefined && other.cases !== null;
59
+ return {
60
+ globs,
61
+ exclude: stringList('exclude', doc.exclude),
62
+ cases: usesCases
63
+ ? stringList('other_triggers.cases', other.cases)
64
+ : stringList('other_triggers.epic_cases', other.epic_cases),
65
+ epicTags: stringList('other_triggers.epic_tags', other.epic_tags),
66
+ scheduledRecheck: flag('other_triggers.scheduled_recheck', other.scheduled_recheck),
67
+ };
68
+ }
69
+ export function loadSensitivePathsPolicy(filePath = SENSITIVE_PATHS_POLICY_PATH) {
70
+ return parseSensitivePathsPolicy(readFileSync(filePath, 'utf8'));
71
+ }
72
+ /** Stands in for "some filename" when a pattern ends in `/**`. */
73
+ const PROBE_SEGMENT = '__smith_probe__';
74
+ /**
75
+ * Every literal a segment's brace set could pick, not just the leading one:
76
+ * `*auth*.{ts,py}` -> ['*auth*.ts', '*auth*.py'].
77
+ *
78
+ * `synthesizeLiteralSegment` keeps only the first alternative, which is the
79
+ * right call where it lives — it builds *a* candidate, and claims.ts asks
80
+ * whether one exists. Here the alternatives are the policy's nine-language
81
+ * extension list, so collapsing them to the leading `ts` made every file glob
82
+ * in the file silently TypeScript-only: a `.py` or `.tsx` claim had no
83
+ * candidate that could satisfy the glob at all (D-176).
84
+ */
85
+ function braceAlternatives(segment) {
86
+ const brace = /\{([^{}]*)\}/.exec(segment);
87
+ if (brace === null)
88
+ return [segment];
89
+ const whole = brace[0];
90
+ return (brace[1] ?? '')
91
+ .split(',')
92
+ .flatMap((choice) => braceAlternatives(segment.replace(whole, () => choice)));
93
+ }
94
+ /**
95
+ * The concrete filenames a pattern's own final segment would accept. A pattern
96
+ * ending in `/**` accepts any filename and proposes the probe.
97
+ */
98
+ function tailCandidates(pattern) {
99
+ const segments = pattern.split('/').filter((s) => s.length > 0);
100
+ const last = segments[segments.length - 1] ?? '';
101
+ if (last === '**')
102
+ return [PROBE_SEGMENT];
103
+ return braceAlternatives(last).map(synthesizeLiteralSegment);
104
+ }
105
+ /**
106
+ * `dot: true` throughout: the two entries this policy most needs to catch are
107
+ * `**\/*.env*` and `**\/.github/workflows/**`, and picomatch's default hides
108
+ * every dotfile from `*` and `**`. A trigger that cannot see `.env` is not a
109
+ * trigger.
110
+ */
111
+ function matches(pattern, value) {
112
+ return picomatch(pattern, { dot: true })(value);
113
+ }
114
+ /**
115
+ * Concrete paths built from a claim's static base plus a policy glob's literal
116
+ * segments, one per filename either side proposes: `src/**` + `**\/auth/**` ->
117
+ * `src/auth/__smith_probe__`, `ui/src/**\/*.tsx` + `**\/auth/**` ->
118
+ * `ui/src/auth/x.tsx`.
119
+ *
120
+ * This is the case `globsOverlap` gives up on. Its `couldJointlyMatch` only
121
+ * ever synthesizes a *tail* — `src/__smith_probe__` and `__smith_probe__` for
122
+ * that pair — and neither satisfies both sides, so it answers "no overlap" for
123
+ * a claim that plainly contains `src/auth/login.ts`. Policy globs are almost
124
+ * all of the `**\/<literal>/**` shape, so that gap is the common case here, not
125
+ * an edge one. It stays local to this module: `globsOverlap` is load-bearing
126
+ * for wave scheduling, where a wider answer means more serialization.
127
+ *
128
+ * Residual, named rather than closed: when both sides constrain the filename
129
+ * and neither side's proposal satisfies the other — claim `src/*.vue` against
130
+ * `**\/*jwt*`, where the path that satisfies both is `src/jwt.vue` — no
131
+ * candidate is synthesized and the answer is "no overlap". Closing it needs
132
+ * real glob intersection rather than a probe. It errs toward not firing, which
133
+ * is the unsafe direction here, so it is worth revisiting if the policy ever
134
+ * grows an extension-free glob with no directory-shaped sibling.
135
+ */
136
+ function sharesSynthesizedPath(claim, glob) {
137
+ const segments = glob.split('/').filter((s) => s.length > 0);
138
+ const trailingGlobstar = segments[segments.length - 1] === '**';
139
+ // The directories the glob names, without its own filename segment.
140
+ const dirs = (trailingGlobstar ? segments : segments.slice(0, -1))
141
+ .filter((s) => s !== '**')
142
+ .map(synthesizeLiteralSegment);
143
+ const base = picomatch
144
+ .scan(claim)
145
+ .base.split('/')
146
+ .filter((s) => s.length > 0);
147
+ // Both sides propose a filename, because either side may be the one that
148
+ // constrains it: a glob ending in `**` leaves it free and the claim's own
149
+ // tail is then the only constraint — which is how `ui/src/**/*.tsx` used to
150
+ // miss `**/auth/**` while the narrower `ui/src/auth/Login.tsx` fired it,
151
+ // the inversion this matcher exists to prevent (D-176). Widening the
152
+ // candidate list cannot produce a false positive: a candidate still has to
153
+ // satisfy both patterns.
154
+ return [...tailCandidates(glob), ...tailCandidates(claim)].some((tail) => {
155
+ const candidate = [...base, ...dirs, tail].join('/');
156
+ if (candidate.length === 0)
157
+ return false;
158
+ return matches(claim, candidate) && matches(glob, candidate);
159
+ });
160
+ }
161
+ /**
162
+ * Overlap, not containment: a claim fires a glob when *some* real path
163
+ * satisfies both. A task claiming `src/**` must fire against `**\/auth/**`
164
+ * even though it is the wider pattern — containment-only matching is the
165
+ * failure mode where a broadly scoped task escapes the review a narrowly
166
+ * scoped one gets.
167
+ *
168
+ * The direct consequence, stated so it is not mistaken for a bug: an
169
+ * open-ended `<dir>/**` claim fires every directory-shaped glob in the policy,
170
+ * because such a claim really could add `<dir>/auth/session.ts` tomorrow. For
171
+ * a security trigger that is the safe direction to err in.
172
+ */
173
+ export function claimTouchesGlob(claim, glob) {
174
+ if (matches(glob, claim))
175
+ return true; // a literal claim sitting inside the glob
176
+ if (globsOverlap(claim, glob))
177
+ return true;
178
+ return sharesSynthesizedPath(claim, glob);
179
+ }
180
+ /**
181
+ * Exclusions are containment, deliberately: a claim is silenced only when it
182
+ * lies entirely inside an exclusion. Overlap here would let `**\/*.test.ts`
183
+ * silence `src/**`, and every broadly scoped task could dodge the review by
184
+ * claiming a tree that happens to hold a test file.
185
+ */
186
+ function isExcluded(claim, exclude) {
187
+ return exclude.some((pattern) => matches(pattern, claim));
188
+ }
189
+ /**
190
+ * The security-reviewer's three dispatch triggers, computed instead of
191
+ * remembered (agent-interviews.md N-7, punch list P9-4). A trigger that fires
192
+ * only when the orchestrator remembers to check is not a trigger.
193
+ *
194
+ * At most one trigger per claim: the glob is evidence for "this claim is
195
+ * sensitive", not an enumeration of every glob it could match.
196
+ */
197
+ export function securityTriggers(task, policy = loadSensitivePathsPolicy(), context = {}) {
198
+ const triggers = [];
199
+ for (const claim of task.claims) {
200
+ if (isExcluded(claim, policy.exclude))
201
+ continue;
202
+ const glob = policy.globs.find((candidate) => claimTouchesGlob(claim, candidate));
203
+ if (glob !== undefined)
204
+ triggers.push({ trigger: 'sensitive-claim-path', claim, glob });
205
+ }
206
+ const taskCase = context.case ?? task.case;
207
+ if (taskCase !== undefined && policy.cases.includes(taskCase)) {
208
+ triggers.push({ trigger: 'case', case: taskCase });
209
+ }
210
+ for (const tag of context.epicTags ?? []) {
211
+ if (policy.epicTags.includes(tag))
212
+ triggers.push({ trigger: 'epic-tag', tag });
213
+ }
214
+ if (context.scheduledRecheck === true && policy.scheduledRecheck) {
215
+ triggers.push({ trigger: 'scheduled-recheck' });
216
+ }
217
+ return {
218
+ taskId: task.task_id ?? null,
219
+ dispatchSecurityReviewer: triggers.length > 0,
220
+ triggers,
221
+ };
222
+ }
@@ -0,0 +1,293 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import picomatch from 'picomatch';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { SmithError } from './errors.js';
5
+ import { SEVERITY_POLICY_PATH } from './paths.js';
6
+ export class SeverityError extends SmithError {
7
+ }
8
+ /** Escalation order, worst first — the same order severity.yml/taxonomy.yml document values in. */
9
+ export const SEVERITY_ORDER = [
10
+ 'S1-stop-the-line',
11
+ 'S2-major',
12
+ 'S3-minor',
13
+ 'S4-nit',
14
+ ];
15
+ /**
16
+ * Position in SEVERITY_ORDER, or `null` for a value that is not in it.
17
+ *
18
+ * `null` rather than a sentinel index, because both sentinels lie:
19
+ * `Number.POSITIVE_INFINITY` makes an unknown severity the mildest thing in the
20
+ * list and `-1` makes it the worst, and a caller that forgets to check gets a
21
+ * confident wrong answer either way. Callers that must not silently pass an
22
+ * unknown value — escalate() below — turn the `null` into a
23
+ * `severity.unknown-severity` throw.
24
+ */
25
+ export function severityRank(severity) {
26
+ const index = SEVERITY_ORDER.indexOf(severity);
27
+ return index === -1 ? null : index;
28
+ }
29
+ /**
30
+ * The worse of two severities, worst-wins. Used by crossFinding.ts's
31
+ * `highest-wins` resolution, where two providers raised the same fingerprint
32
+ * and disagree on how bad it is.
33
+ *
34
+ * An unknown value on either side throws rather than losing the comparison:
35
+ * mintFindings() already refuses a non-canonical severity, so an unknown one
36
+ * here means a stored finding predates a taxonomy edit, and quietly treating it
37
+ * as mild is how an S1 becomes an S4.
38
+ */
39
+ export function worseSeverity(a, b) {
40
+ const rankA = severityRank(a);
41
+ const rankB = severityRank(b);
42
+ for (const [value, rank] of [
43
+ [a, rankA],
44
+ [b, rankB],
45
+ ]) {
46
+ if (rank === null) {
47
+ throw new SeverityError('severity.unknown-severity', `Unknown severity "${value}".`, {
48
+ severity: value,
49
+ });
50
+ }
51
+ }
52
+ return rankA <= rankB ? a : b;
53
+ }
54
+ export function parseSeverityPolicy(yamlText) {
55
+ const doc = parseYaml(yamlText);
56
+ const rawLevels = doc.levels;
57
+ if (!rawLevels || typeof rawLevels !== 'object') {
58
+ throw new SeverityError('severity.invalid-document', 'severity.yml is missing `levels`.');
59
+ }
60
+ const levels = {};
61
+ for (const [name, level] of Object.entries(rawLevels)) {
62
+ if (typeof level.blocks_merge !== 'boolean') {
63
+ throw new SeverityError('severity.invalid-document', `severity.yml level "${name}" is missing boolean blocks_merge.`, { level: name });
64
+ }
65
+ levels[name] = { blocksMerge: level.blocks_merge };
66
+ }
67
+ return { levels };
68
+ }
69
+ export function loadSeverityPolicy(filePath = SEVERITY_POLICY_PATH) {
70
+ return parseSeverityPolicy(readFileSync(filePath, 'utf8'));
71
+ }
72
+ const ENTRY_HEADING = /^### /;
73
+ const SECTION_HEADING = /^## (.+)$/;
74
+ const BULLET = /^- ([a-z_]+):\s*(.*)$/;
75
+ /** An indented, non-bullet line — a continuation of the previous bullet's value. */
76
+ const CONTINUATION = /^\s+(\S.*)$/;
77
+ /**
78
+ * taxonomy.yml `lesson_scope`, in compiled-section order — only entries under
79
+ * one of these `##` sections are real lessons, which is also what stops a `##`
80
+ * heading smuggled into a statement from minting one. Single source for both
81
+ * the parse side (here) and the compile side (`lessons.ts` imports this);
82
+ * `test/severity.test.ts` asserts it still matches taxonomy.yml.
83
+ */
84
+ export const LESSON_SCOPES = [
85
+ 'agent-role',
86
+ 'claim-path',
87
+ 'case-type',
88
+ 'stack-wide',
89
+ 'security',
90
+ ];
91
+ const VALID_SCOPES = new Set(LESSON_SCOPES);
92
+ /**
93
+ * Parse the "Same-mistake entry format" documented in lessons.md itself
94
+ * (§ Same-mistake entry format): a level-3 heading starts an entry, followed
95
+ * by `- key: value` bullets until a blank line or the next heading. A bullet
96
+ * value may wrap onto indented continuation lines (lessons.md's own example
97
+ * does exactly this for `statement`) — those are appended, trimmed and
98
+ * space-joined, to the bullet they follow; without this, a multi-line
99
+ * statement round-trips silently truncated mid-sentence. Entries missing
100
+ * statement/lesson_id are skipped, not fatal — lessons.md also carries
101
+ * non-rule / placeholder content ("_(none yet)_", prose) this parser has no
102
+ * business rejecting the whole file over. `finding_category` is OPTIONAL
103
+ * (lesson.schema.json says so, and agent-role/case-type entries never carry
104
+ * one) and parses as `''`: this file has two readers, the same-mistake match
105
+ * below and dispatch-time injection (lessons.ts, P9-2), and dropping a
106
+ * category-less entry here made every agent-role/case-type lesson — including
107
+ * every lesson `smith dream` raises — compile into lessons.md and then reach
108
+ * no one. `findMatchingLesson` skips the empty category explicitly, so
109
+ * escalation still matches exactly what it matched before. Only
110
+ * `###` headings inside a real `lesson_scope` section (`##
111
+ * agent-role`/`claim-path`/`case-type`/`stack-wide`) are parsed as entries —
112
+ * this also keeps the illustrative fenced example in lessons.md's own
113
+ * "Same-mistake entry format" doc section (before any `##` scope heading)
114
+ * from being parsed as a real lesson.
115
+ *
116
+ * Three bullets are SELECTORS — the one field a scope is filtered by at
117
+ * dispatch (D-129): `claim_path` for `claim-path`, `agent_role` for
118
+ * `agent-role`, `case_type` for `case-type`. Each is optional here and parses
119
+ * to `''` when absent, except `claim_path`, which keeps its historical `'**'`
120
+ * default. That asymmetry is deliberate and `lessonsForScope` depends on it: a
121
+ * missing selector must match NOTHING rather than everything, so an entry that
122
+ * names no role reaches no role instead of every one.
123
+ */
124
+ export function parseLessons(markdown) {
125
+ const lines = markdown.split('\n');
126
+ const rules = [];
127
+ let scope = '';
128
+ let current = null;
129
+ let lastKey = null;
130
+ function flush() {
131
+ if (!current)
132
+ return;
133
+ const { lesson_id, finding_category, claim_path, agent_role, case_type, statement } = current;
134
+ if (lesson_id && statement) {
135
+ rules.push({
136
+ lessonId: lesson_id,
137
+ scope,
138
+ category: finding_category ?? '',
139
+ claimPath: claim_path ?? '**',
140
+ agentRole: agent_role ?? '',
141
+ caseType: case_type ?? '',
142
+ statement,
143
+ });
144
+ }
145
+ current = null;
146
+ lastKey = null;
147
+ }
148
+ for (const line of lines) {
149
+ const sectionMatch = line.match(SECTION_HEADING);
150
+ if (sectionMatch) {
151
+ flush();
152
+ scope = sectionMatch[1].trim();
153
+ continue;
154
+ }
155
+ if (ENTRY_HEADING.test(line)) {
156
+ flush();
157
+ current = VALID_SCOPES.has(scope) ? {} : null;
158
+ continue;
159
+ }
160
+ if (current) {
161
+ const bulletMatch = line.match(BULLET);
162
+ if (bulletMatch) {
163
+ const key = bulletMatch[1];
164
+ current[key] = bulletMatch[2].trim();
165
+ lastKey = key;
166
+ continue;
167
+ }
168
+ if (line.trim() === '') {
169
+ // A blank line doesn't close the entry (only the next heading, or
170
+ // EOF, does) but it does end any bullet's continuation run.
171
+ lastKey = null;
172
+ continue;
173
+ }
174
+ const continuationMatch = line.match(CONTINUATION);
175
+ if (continuationMatch && lastKey) {
176
+ const existingValue = current[lastKey] ?? '';
177
+ current[lastKey] = `${existingValue} ${continuationMatch[1].trim()}`.trim();
178
+ }
179
+ }
180
+ }
181
+ flush();
182
+ return rules;
183
+ }
184
+ /**
185
+ * Scopes whose claim_path is meaningful for the per-file same-mistake match
186
+ * (lessons.md convention). `security` is here because a security lesson is
187
+ * written against the paths it guards ("src/auth/**") — a repeat security
188
+ * finding on one of those paths is exactly what escalation is for. Left out, a
189
+ * security lesson would compile and be read by agents but never escalate
190
+ * anything (interview N-8).
191
+ */
192
+ const FILE_SCOPED = new Set(['claim-path', 'stack-wide', 'security']);
193
+ /**
194
+ * Whether this lesson's scope makes its `claim_path` mean anything. An
195
+ * `agent-role`/`case-type` entry has no file to check a finding against, so it
196
+ * never participates in the per-file match however it is written.
197
+ */
198
+ export function isFileScoped(lesson) {
199
+ return FILE_SCOPED.has(lesson.scope);
200
+ }
201
+ /**
202
+ * Whether this lesson's compiled `claim_path` covers `filePath` — the second
203
+ * half of the escalation match, exported so `lessonAudit.ts` can ask the match
204
+ * its own question rather than reimplementing the glob semantics beside it.
205
+ */
206
+ export function lessonCoversFile(lesson, filePath) {
207
+ if (!isFileScoped(lesson))
208
+ return false;
209
+ const isMatch = picomatch(lesson.claimPath);
210
+ return isMatch(filePath);
211
+ }
212
+ /**
213
+ * Whether this lesson can ever escalate anything — the two conditions of the
214
+ * match below that depend on the lesson alone, not on the finding it is being
215
+ * matched against.
216
+ *
217
+ * 1. It names a `finding_category`. The match is an equality against the new
218
+ * finding's category, so an entry naming none is skipped before its claim
219
+ * path is ever consulted. A category-less lesson (agent-role/case-type, or
220
+ * anything `smith dream` raised) is injected at dispatch and read by
221
+ * agents; it is simply never a *same mistake*, because there is no
222
+ * category for it to be the same mistake as.
223
+ * 2. Its scope is file-scoped. `agent-role`/`case-type` entries have no file
224
+ * to check a finding against.
225
+ *
226
+ * Exported because the same-mistake KPI has to count the instrument as well as
227
+ * the reading (sameMistakeKpi.ts), and a second copy of these two conditions is
228
+ * a second thing to keep true — interview N-8, where the compile side and the
229
+ * parse side each kept their own scope list and `security` vanished between
230
+ * them with no error anywhere.
231
+ */
232
+ export function canEscalate(lesson) {
233
+ return lesson.category !== '' && isFileScoped(lesson);
234
+ }
235
+ function findMatchingLesson(category, filePath, lessons) {
236
+ for (const lesson of lessons) {
237
+ if (!canEscalate(lesson))
238
+ continue;
239
+ if (lesson.category === category && lessonCoversFile(lesson, filePath))
240
+ return lesson;
241
+ }
242
+ return null;
243
+ }
244
+ function escalate(severity, policy) {
245
+ const index = severityRank(severity);
246
+ if (index === null) {
247
+ throw new SeverityError('severity.unknown-severity', `Unknown severity "${severity}".`, {
248
+ severity,
249
+ });
250
+ }
251
+ const escalated = SEVERITY_ORDER[Math.max(0, index - 1)];
252
+ if (!policy.levels[escalated]) {
253
+ throw new SeverityError('severity.unknown-severity', `Escalated severity "${escalated}" is not defined in severity.yml.`, { severity: escalated });
254
+ }
255
+ return escalated;
256
+ }
257
+ function actionFor(severity, level) {
258
+ if (level.blocksMerge)
259
+ return 'block';
260
+ return severity.startsWith('S3') ? 'waiver-batch' : 'log-only';
261
+ }
262
+ /**
263
+ * Pure, deterministic gate decision for one finding: S1/S2 block, S3
264
+ * waiver-batch, S4 log-only (severity.yml) — escalated one severity level,
265
+ * up-capped at S1, when the finding matches an approved lesson for the same
266
+ * file + category (architecture §9.7).
267
+ */
268
+ export function decide(finding, context, policy = loadSeverityPolicy()) {
269
+ // Corroboration first, escalation second, and the order is the decision:
270
+ // a repeat mistake is one level worse than whatever the finding actually
271
+ // IS, so the two readings have to be reconciled before the lesson has
272
+ // something to escalate. Reversed, a corroborated S2 that repeats a lesson
273
+ // would land at S2 instead of S1.
274
+ const corroborated = context.corroboratedSeverity === undefined
275
+ ? finding.severity
276
+ : worseSeverity(finding.severity, context.corroboratedSeverity);
277
+ const matched = findMatchingLesson(finding.finding_category, context.filePath, context.lessons);
278
+ const severity = matched ? escalate(corroborated, policy) : corroborated;
279
+ const level = policy.levels[severity];
280
+ if (!level) {
281
+ throw new SeverityError('severity.unknown-severity', `Unknown severity "${severity}".`, {
282
+ severity,
283
+ });
284
+ }
285
+ return {
286
+ severity,
287
+ blocks: level.blocksMerge,
288
+ action: actionFor(severity, level),
289
+ sameMistake: matched !== null,
290
+ matchedLessonId: matched?.lessonId ?? null,
291
+ corroborated: corroborated !== finding.severity,
292
+ };
293
+ }