@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,400 @@
1
+ // factory/policies/delegation.yml loader and the two audits it feeds
2
+ // (`smith delegation check`, D13 step 3).
3
+ //
4
+ // Every check in this factory that asks "did two different turns happen?"
5
+ // answers it the same way: by finding two `dispatch_decision` events. That
6
+ // reading holds only while dispatches are written by a node that owns the log
7
+ // it writes into -- otherwise an agent can write a dispatch about itself, and
8
+ // `smith tester check` reports a tester's turn that was really the coder's.
9
+ //
10
+ // Granting `Agent` to a role does not break the reading; it makes it
11
+ // conditional, on two things this module checks:
12
+ //
13
+ // 1. The grant itself must not hand a role its own auditor, its own critic,
14
+ // or itself. Those are the shapes where the graded party gets to choose
15
+ // and prompt its grader -- a D-119 hole: the gate stays green because it
16
+ // is now reading evidence the graded party produced.
17
+ // 2. The grantee must open its own session before it dispatches. A wave that
18
+ // writes into the epic's log under its own actor name is one log with two
19
+ // writers, and the epic's causal chain stops being a chain.
20
+ //
21
+ // Deliberate non-rule: a dispatch written under an agent-role actor in an
22
+ // ordinary session raises nothing here. actors.ts documents that agent roles
23
+ // legitimately author decision-shaped events, and a rule that fired on every
24
+ // such dispatch would report years of honest history as a finding. This module
25
+ // is scoped to granted roles and to the sessions a grant actually opened.
26
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
27
+ import path from 'node:path';
28
+ import { parse as parseYaml } from 'yaml';
29
+ import { DISPATCH_EVENT_TYPE, ERROR_EVENT_TYPE, TASK_RESULT_EVENT_TYPE, } from './agents-registry.js';
30
+ import { SmithError } from './errors.js';
31
+ import { ROOT_EVENT_TYPE } from './events.js';
32
+ import { AGENTS_DIR, DELEGATION_POLICY_PATH } from './paths.js';
33
+ import { taskIdsMatch } from './taskId.js';
34
+ export class DelegationError extends SmithError {
35
+ }
36
+ function invalid(message, details = {}) {
37
+ return new DelegationError('delegation.invalid-policy', message, details);
38
+ }
39
+ /**
40
+ * Parses delegation.yml.
41
+ *
42
+ * `must_open_session` defaults to **true** rather than false. A grant that
43
+ * forgets the field is a grant somebody wrote in a hurry, and of the two
44
+ * readings only one of them can be caught later: a grantee wrongly required to
45
+ * open a session fails a check loudly, while a grantee wrongly excused from it
46
+ * writes into someone else's log and nothing notices.
47
+ */
48
+ export function parseDelegationPolicy(yamlText) {
49
+ const doc = (parseYaml(yamlText) ?? {});
50
+ if (typeof doc.version !== 'number') {
51
+ throw invalid('delegation.yml has no numeric `version`.');
52
+ }
53
+ const rawGrants = doc.grants;
54
+ if (rawGrants !== undefined && rawGrants !== null && typeof rawGrants !== 'object') {
55
+ throw invalid('delegation.yml `grants` must be a map of role to grant.');
56
+ }
57
+ const grants = [];
58
+ for (const [role, value] of Object.entries((rawGrants ?? {}))) {
59
+ const grant = value ?? {};
60
+ const list = grant.may_dispatch;
61
+ if (!Array.isArray(list) || list.length === 0 || !list.every((v) => typeof v === 'string')) {
62
+ throw invalid(`delegation.yml grant \`${role}\` must list at least one role in \`may_dispatch\`. A grant that dispatches nothing is a grant nobody can use and everybody has to reason about.`, { role });
63
+ }
64
+ const mustOpen = grant.must_open_session;
65
+ if (mustOpen !== undefined && typeof mustOpen !== 'boolean') {
66
+ throw invalid(`delegation.yml grant \`${role}\` has a non-boolean \`must_open_session\`.`, {
67
+ role,
68
+ });
69
+ }
70
+ grants.push({
71
+ role,
72
+ mayDispatch: list,
73
+ mustOpenSession: mustOpen ?? true,
74
+ });
75
+ }
76
+ return { version: doc.version, grants };
77
+ }
78
+ export function loadDelegationPolicy(filePath = DELEGATION_POLICY_PATH) {
79
+ return parseDelegationPolicy(readFileSync(filePath, 'utf8'));
80
+ }
81
+ /**
82
+ * The Agent tool named as a whole word, so that `AgentOutput` is not a grant.
83
+ * Matched against the raw `tools:` line rather than its comma-split entries,
84
+ * because a scoped grant contains commas of its own.
85
+ */
86
+ const AGENT_TOOL = /\bAgent\b/;
87
+ /** The roles a scoped grant names, e.g. `Agent(coder, tester)`. */
88
+ const AGENT_SCOPE = /\bAgent\s*\(([^)]*)\)/;
89
+ /** The raw `tools:` line of a role template's frontmatter. Null when there is no template. */
90
+ function templateTools(dir, role) {
91
+ const file = path.join(dir, `${role}.md`);
92
+ if (!existsSync(file))
93
+ return null;
94
+ return toolsOf(readFileSync(file, 'utf8'));
95
+ }
96
+ function toolsOf(markdown) {
97
+ const line = markdown.split('\n').find((l) => l.startsWith('tools:'));
98
+ return line === undefined ? '' : line.slice('tools:'.length);
99
+ }
100
+ function holdsAgent(tools) {
101
+ return AGENT_TOOL.test(tools);
102
+ }
103
+ function agentScope(tools) {
104
+ const match = AGENT_SCOPE.exec(tools);
105
+ if (match === null)
106
+ return null;
107
+ return (match[1] ?? '')
108
+ .split(',')
109
+ .map((r) => r.trim())
110
+ .filter((r) => r.length > 0);
111
+ }
112
+ /**
113
+ * Reads the policy against every file that has to agree with it.
114
+ *
115
+ * This is the half that runs in CI rather than against a log: the shipped
116
+ * policy is checked by delegation.test.ts, so widening a grant into a shape
117
+ * the audits cannot see fails the build rather than a run.
118
+ */
119
+ export function checkDelegationGrants(policy, options) {
120
+ const dir = options.agentsDir ?? AGENTS_DIR;
121
+ const roles = new Set(options.agentRoles);
122
+ const checks = [];
123
+ for (const grant of policy.grants) {
124
+ const problems = [];
125
+ if (!roles.has(grant.role)) {
126
+ problems.push(`\`${grant.role}\` is not a taxonomy \`agent\` value, so no dispatch can ever name it and the grant is unreachable.`);
127
+ }
128
+ for (const target of grant.mayDispatch) {
129
+ if (!roles.has(target)) {
130
+ problems.push(`\`${grant.role}\` may dispatch \`${target}\`, which is not a taxonomy \`agent\` value, so that dispatch would be rejected at write time.`);
131
+ }
132
+ }
133
+ if (grant.mayDispatch.includes(grant.role)) {
134
+ problems.push(`\`${grant.role}\` may dispatch itself. A role that dispatches itself writes \`dispatch_decision\` events about itself, and every check that reads a second dispatch as a second turn starts counting one turn twice.`);
135
+ }
136
+ for (const pair of options.isolationPairs) {
137
+ if (pair.worker === grant.role && grant.mayDispatch.includes(pair.auditor)) {
138
+ problems.push(`\`${grant.role}\` may dispatch \`${pair.auditor}\`, its own auditor under crosscheck.yml \`role_isolation\`. The worker would choose and prompt its grader, and \`smith tester check\` would report the isolation it was reading evidence for.`);
139
+ }
140
+ }
141
+ for (const pair of options.asymmetricPairs) {
142
+ if (pair.finder === grant.role && grant.mayDispatch.includes(pair.critic)) {
143
+ problems.push(`\`${grant.role}\` may dispatch \`${pair.critic}\`, its own critic under crosscheck.yml \`asymmetric_roles\`. The finder would pick the model meant to refute it, which is the tunnel vision the pair exists to break.`);
144
+ }
145
+ }
146
+ const tools = templateTools(dir, grant.role);
147
+ if (tools === null) {
148
+ problems.push(`\`${grant.role}\` holds a grant but ships no template at ${path.join(path.basename(dir), `${grant.role}.md`)}, so the grant reaches no agent (D-191).`);
149
+ }
150
+ else if (!holdsAgent(tools)) {
151
+ problems.push(`${grant.role}.md does not list \`Agent\` in \`tools\`, so the grant reaches no agent (D-191).`);
152
+ }
153
+ else {
154
+ // A scoped grant is enforced by the harness at dispatch time and this
155
+ // policy is enforced after the fact, so the two disagreeing is the worst
156
+ // of both: the file an operator reads is not the file that binds.
157
+ const scope = agentScope(tools);
158
+ if (scope !== null) {
159
+ const declared = [...grant.mayDispatch].sort().join(', ');
160
+ const scoped = [...scope].sort().join(', ');
161
+ if (declared !== scoped) {
162
+ problems.push(`${grant.role}.md scopes \`Agent\` to (${scoped}) while delegation.yml grants (${declared}). The template is what the harness enforces and the policy is what the audits read, so a disagreement is a rule nobody applies.`);
163
+ }
164
+ }
165
+ }
166
+ if (problems.length === 0) {
167
+ checks.push({
168
+ role: grant.role,
169
+ status: 'ok',
170
+ detail: `${grant.role} may dispatch ${grant.mayDispatch.join(', ')}${grant.mustOpenSession ? ' and must open its own session first' : ''}; its template holds \`Agent\` and the grant contradicts no crosscheck.yml pair.`,
171
+ });
172
+ }
173
+ else {
174
+ for (const detail of problems)
175
+ checks.push({ role: grant.role, status: 'violation', detail });
176
+ }
177
+ }
178
+ // The converse of D-191, and the one that actually widens the topology: a
179
+ // template may hand itself `Agent` without any grant naming it, and nothing
180
+ // else in the repo reads the frontmatter closely enough to notice.
181
+ const templates = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
182
+ const granted = new Set(policy.grants.map((g) => g.role));
183
+ let ungrantedHolders = 0;
184
+ for (const file of templates) {
185
+ const role = path.basename(file, '.md');
186
+ if (granted.has(role))
187
+ continue;
188
+ if (!holdsAgent(toolsOf(readFileSync(path.join(dir, file), 'utf8'))))
189
+ continue;
190
+ ungrantedHolders += 1;
191
+ checks.push({
192
+ role,
193
+ status: 'violation',
194
+ detail: `${file} lists \`Agent\` in \`tools\` but delegation.yml holds no grant for \`${role}\`, so it may dispatch anything and no check knows what to hold it to.`,
195
+ });
196
+ }
197
+ if (policy.grants.length === 0 && ungrantedHolders === 0) {
198
+ checks.push({
199
+ role: '*',
200
+ status: 'not-applicable',
201
+ detail: 'delegation.yml grants nobody `Agent` and no role template lists it, so dispatch is flat: one session dispatches, one log, one causal chain.',
202
+ });
203
+ }
204
+ return {
205
+ grantsExamined: policy.grants.length,
206
+ templatesExamined: templates.length,
207
+ checks,
208
+ ok: checks.every((c) => c.status === 'ok' || c.status === 'not-applicable'),
209
+ };
210
+ }
211
+ /**
212
+ * How a dispatched turn is recorded as over, and which payload field names the
213
+ * role that ended it -- testerAudit.ts's table, for its reason. The fallback to
214
+ * `actor` is that file's too: the older half of the log names the role there
215
+ * and nowhere else, and without it a finished wave-runner reads as one still
216
+ * running, which is the difference between `violation` and `unverifiable`.
217
+ */
218
+ const TERMINAL_ROLE_KEY = {
219
+ [TASK_RESULT_EVENT_TYPE]: 'agent',
220
+ [ERROR_EVENT_TYPE]: 'agent',
221
+ };
222
+ function str(payload, key) {
223
+ const value = payload[key];
224
+ return typeof value === 'string' && value.length > 0 ? value : null;
225
+ }
226
+ function readDispatches(events) {
227
+ const rows = [];
228
+ for (const { event_id, record } of events) {
229
+ if (record.event_type !== DISPATCH_EVENT_TYPE)
230
+ continue;
231
+ const payload = (record.payload ?? {});
232
+ const role = str(payload, 'agent_role');
233
+ if (role === null)
234
+ continue;
235
+ rows.push({
236
+ eventId: event_id,
237
+ sessionId: record.session_id,
238
+ ts: record.ts,
239
+ actor: record.actor,
240
+ role,
241
+ taskId: record.task_id ?? str(payload, 'task_id'),
242
+ });
243
+ }
244
+ return rows;
245
+ }
246
+ function readTerminals(events) {
247
+ const rows = [];
248
+ for (const { record } of events) {
249
+ const key = TERMINAL_ROLE_KEY[record.event_type];
250
+ if (key === undefined)
251
+ continue;
252
+ const payload = (record.payload ?? {});
253
+ const role = str(payload, key) ?? record.actor;
254
+ if (role === null || role.length === 0)
255
+ continue;
256
+ rows.push({
257
+ sessionId: record.session_id,
258
+ ts: record.ts,
259
+ role,
260
+ taskId: record.task_id ?? str(payload, 'task_id'),
261
+ });
262
+ }
263
+ return rows;
264
+ }
265
+ /** The session a dispatch opened, keyed by the dispatch's event id. */
266
+ function sessionsOpenedByDispatch(events) {
267
+ const opened = new Map();
268
+ for (const { record } of events) {
269
+ if (record.event_type !== ROOT_EVENT_TYPE)
270
+ continue;
271
+ if (record.causal_parent === null || record.causal_parent === undefined)
272
+ continue;
273
+ opened.set(record.causal_parent, record.session_id);
274
+ }
275
+ return opened;
276
+ }
277
+ /**
278
+ * Asserts delegation.yml against a lineage's log.
279
+ *
280
+ * D-181: scope is `taskIdsMatch`'s question, not `===`'s -- the log writes
281
+ * `E1/t-1` and an operator types `t-1`.
282
+ */
283
+ export function checkDelegationLog(events, policy, options) {
284
+ const taskId = options.taskId ?? null;
285
+ const base = {
286
+ sessionId: options.sessionId,
287
+ taskId,
288
+ };
289
+ const byRole = new Map(policy.grants.map((g) => [g.role, g]));
290
+ if (byRole.size === 0) {
291
+ return {
292
+ ...base,
293
+ dispatchesExamined: 0,
294
+ delegatedSessions: 0,
295
+ checks: [
296
+ {
297
+ role: '*',
298
+ sessionId: null,
299
+ taskId: null,
300
+ eventId: null,
301
+ status: 'not-applicable',
302
+ detail: 'delegation.yml grants nobody `Agent`, so every dispatch in this lineage was written by a session that owns its own log and there is nothing here to violate.',
303
+ },
304
+ ],
305
+ ok: true,
306
+ };
307
+ }
308
+ const inScope = (rowTask) => taskId === null || (rowTask !== null && taskIdsMatch(rowTask, taskId));
309
+ const dispatches = readDispatches(events).filter((d) => inScope(d.taskId));
310
+ const terminals = readTerminals(events);
311
+ const opened = sessionsOpenedByDispatch(events);
312
+ const checks = [];
313
+ // Which sessions a grant opened, and for whom. Built before the per-dispatch
314
+ // rules so rule 3 can ask "whose session is this?" of any dispatch.
315
+ const delegated = new Map();
316
+ for (const d of dispatches) {
317
+ if (!byRole.has(d.role))
318
+ continue;
319
+ const child = opened.get(d.eventId);
320
+ if (child !== undefined)
321
+ delegated.set(child, d);
322
+ }
323
+ for (const d of dispatches) {
324
+ // Rule 1 -- a granted role dispatches only inside its grant.
325
+ const actorGrant = byRole.get(d.actor);
326
+ if (actorGrant !== undefined && !actorGrant.mayDispatch.includes(d.role)) {
327
+ checks.push({
328
+ role: d.actor,
329
+ sessionId: d.sessionId,
330
+ taskId: d.taskId,
331
+ eventId: d.eventId,
332
+ status: 'violation',
333
+ detail: `${d.actor} dispatched \`${d.role}\` at ${d.eventId}, which its delegation.yml grant does not list (${actorGrant.mayDispatch.join(', ')}).`,
334
+ });
335
+ }
336
+ // Rule 2 -- a grantee that must open a log did.
337
+ const grant = byRole.get(d.role);
338
+ if (grant === undefined || !grant.mustOpenSession)
339
+ continue;
340
+ const child = opened.get(d.eventId);
341
+ if (child !== undefined) {
342
+ checks.push({
343
+ role: d.role,
344
+ sessionId: child,
345
+ taskId: d.taskId,
346
+ eventId: d.eventId,
347
+ status: 'ok',
348
+ detail: `${d.role} was dispatched at ${d.eventId} and opened session ${child} against it, so the dispatches it writes are its own log's.`,
349
+ });
350
+ continue;
351
+ }
352
+ const finished = terminals.some((t) => t.sessionId === d.sessionId &&
353
+ t.role === d.role &&
354
+ t.ts >= d.ts &&
355
+ (t.taskId === null || d.taskId === null || taskIdsMatch(t.taskId, d.taskId)));
356
+ checks.push({
357
+ role: d.role,
358
+ sessionId: d.sessionId,
359
+ taskId: d.taskId,
360
+ eventId: d.eventId,
361
+ status: finished ? 'violation' : 'unverifiable',
362
+ detail: finished
363
+ ? `${d.role} was dispatched at ${d.eventId}, opened no session against it, and has already reported, so whatever it dispatched was written into ${d.sessionId}'s log by something that does not own it.`
364
+ : `${d.role} was dispatched at ${d.eventId} and has opened no session against it yet. It may still do so, which is exactly why this is not a pass.`,
365
+ });
366
+ }
367
+ // Rule 3 -- inside a delegated session, the grantee is the only dispatcher.
368
+ // Without this, the grant is enforced against an actor string the delegated
369
+ // agent picks for itself.
370
+ for (const d of dispatches) {
371
+ const owner = delegated.get(d.sessionId);
372
+ if (owner === undefined || d.actor === owner.role)
373
+ continue;
374
+ checks.push({
375
+ role: owner.role,
376
+ sessionId: d.sessionId,
377
+ taskId: d.taskId,
378
+ eventId: d.eventId,
379
+ status: 'violation',
380
+ detail: `${d.sessionId} is ${owner.role}'s delegated session (opened against ${owner.eventId}), but the dispatch at ${d.eventId} is written by \`${d.actor}\`. One dispatching node, one log: a second writer in here is a second author of the same causal chain.`,
381
+ });
382
+ }
383
+ if (checks.length === 0) {
384
+ checks.push({
385
+ role: '*',
386
+ sessionId: null,
387
+ taskId: null,
388
+ eventId: null,
389
+ status: 'not-applicable',
390
+ detail: `No dispatch in this lineage names or is written by a role delegation.yml grants (${[...byRole.keys()].join(', ')}), so this audit asserts nothing about it.`,
391
+ });
392
+ }
393
+ return {
394
+ ...base,
395
+ dispatchesExamined: dispatches.length,
396
+ delegatedSessions: delegated.size,
397
+ checks,
398
+ ok: checks.every((c) => c.status === 'ok' || c.status === 'not-applicable'),
399
+ };
400
+ }
@@ -0,0 +1,164 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { SmithError } from './errors.js';
3
+ export class DiffstatError extends SmithError {
4
+ }
5
+ /**
6
+ * `git diff --numstat -z` emits NUL-separated records, and a record is not
7
+ * always one field:
8
+ *
9
+ * ordinary: "12\t3\tsrc/parse.ts\0"
10
+ * rename: "1\t0\t\0src/old.ts\0src/new.ts\0" <- empty path, then from, then to
11
+ * binary: "-\t-\tassets/logo.png\0"
12
+ *
13
+ * Read naively, a rename looks like a nameless file followed by two more
14
+ * records — which is how a diff reader ends up counting phantom files. Hence a
15
+ * parser, and hence `-z`: the default format quotes and backslash-escapes any
16
+ * path with a space or non-ASCII byte, so the path you compare is not the path
17
+ * git changed.
18
+ */
19
+ export function parseNumstat(output) {
20
+ const fields = output.split('\0');
21
+ // A trailing NUL leaves one empty field behind; an empty diff is one empty field.
22
+ if (fields[fields.length - 1] === '')
23
+ fields.pop();
24
+ const stats = [];
25
+ for (let i = 0; i < fields.length; i++) {
26
+ const record = fields[i];
27
+ const firstTab = record.indexOf('\t');
28
+ const secondTab = firstTab === -1 ? -1 : record.indexOf('\t', firstTab + 1);
29
+ if (firstTab === -1 || secondTab === -1) {
30
+ throw new DiffstatError('diffstat.unparseable-numstat', `Expected "<added>\\t<deleted>\\t<path>" from git diff --numstat -z, got ${JSON.stringify(record)}.`, { record });
31
+ }
32
+ const addedRaw = record.slice(0, firstTab);
33
+ const deletedRaw = record.slice(firstTab + 1, secondTab);
34
+ const pathField = record.slice(secondTab + 1);
35
+ const binary = addedRaw === '-' && deletedRaw === '-';
36
+ const added = binary ? 0 : parseCount(addedRaw, record);
37
+ const deleted = binary ? 0 : parseCount(deletedRaw, record);
38
+ if (pathField === '') {
39
+ const renamedFrom = fields[i + 1];
40
+ const renamedTo = fields[i + 2];
41
+ if (renamedFrom === undefined || renamedTo === undefined) {
42
+ throw new DiffstatError('diffstat.unparseable-numstat', `Rename record ${JSON.stringify(record)} is missing its source or destination path.`, { record });
43
+ }
44
+ i += 2;
45
+ stats.push({ path: renamedTo, added, deleted, binary, renamedFrom });
46
+ continue;
47
+ }
48
+ stats.push({ path: pathField, added, deleted, binary });
49
+ }
50
+ return stats;
51
+ }
52
+ function parseCount(raw, record) {
53
+ if (!/^\d+$/.test(raw)) {
54
+ throw new DiffstatError('diffstat.unparseable-numstat', `Expected a line count or "-" in ${JSON.stringify(record)}, got ${JSON.stringify(raw)}.`, { record, field: raw });
55
+ }
56
+ return Number.parseInt(raw, 10);
57
+ }
58
+ /**
59
+ * Lockfiles and generated output move by hundreds of lines for a one-line
60
+ * source change. Counting them against a diff cap punishes the honest version
61
+ * of the task, so they are listed but not totalled — the same carve-out
62
+ * result.schema.json already claims for `diff_lines_changed`.
63
+ */
64
+ const EXCLUDED_BASENAMES = new Set([
65
+ 'pnpm-lock.yaml',
66
+ 'package-lock.json',
67
+ 'npm-shrinkwrap.json',
68
+ 'yarn.lock',
69
+ 'bun.lock',
70
+ 'bun.lockb',
71
+ 'Cargo.lock',
72
+ 'poetry.lock',
73
+ 'uv.lock',
74
+ 'composer.lock',
75
+ 'Gemfile.lock',
76
+ 'go.sum',
77
+ ]);
78
+ const EXCLUDED_DIR_SEGMENTS = new Set([
79
+ 'dist',
80
+ 'build',
81
+ 'coverage',
82
+ 'node_modules',
83
+ '.next',
84
+ '__generated__',
85
+ ]);
86
+ /** Whether a changed path is generated noise rather than authored work. */
87
+ export function isExcludedDiffPath(filePath) {
88
+ const segments = filePath.split('/').filter((s) => s.length > 0);
89
+ const basename = segments[segments.length - 1];
90
+ if (basename !== undefined && EXCLUDED_BASENAMES.has(basename))
91
+ return true;
92
+ return segments.slice(0, -1).some((segment) => EXCLUDED_DIR_SEGMENTS.has(segment));
93
+ }
94
+ /** A numstat over a whole epic can be large; 1 MiB (execFileSync's default) is not enough. */
95
+ const GIT_OUTPUT_MAX_BUFFER = 32 * 1024 * 1024;
96
+ /**
97
+ * The declared diff cap is only worth writing down if something measures the
98
+ * real diff. This does: committed work on a task branch, against the
99
+ * integration branch it was cut from (`smith/<epic>/<task-id>` ->
100
+ * `smith/<epic>/integration`, the same convention `collectCommittedChanges`
101
+ * relies on), or against an explicit `baseRef`.
102
+ *
103
+ * It throws rather than returning zero when it cannot measure. A zero that
104
+ * means "no diff" and a zero that means "I could not look" are the same number
105
+ * to a budget check, and only one of them should pass. The same zero can also
106
+ * arrive one file at a time — git counts no lines in a binary file — so those
107
+ * paths come back in `unmeasuredFiles` instead of vanishing into the total.
108
+ */
109
+ export function measureDiff(worktreeDir, options = {}) {
110
+ const baseRef = options.baseRef ?? deriveBaseRef(worktreeDir);
111
+ try {
112
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`], {
113
+ cwd: worktreeDir,
114
+ stdio: ['ignore', 'ignore', 'pipe'],
115
+ });
116
+ }
117
+ catch {
118
+ throw new DiffstatError('diffstat.cannot-resolve-base-ref', `Base ref "${baseRef}" does not resolve to a commit in ${worktreeDir}.`, { baseRef, worktreeDir });
119
+ }
120
+ const output = execFileSync('git', ['diff', '--numstat', '-z', `${baseRef}...HEAD`], {
121
+ cwd: worktreeDir,
122
+ encoding: 'utf8',
123
+ maxBuffer: GIT_OUTPUT_MAX_BUFFER,
124
+ });
125
+ const files = parseNumstat(output).map((stat) => ({
126
+ ...stat,
127
+ excluded: isExcludedDiffPath(stat.path),
128
+ }));
129
+ let diffLines = 0;
130
+ let excludedLines = 0;
131
+ const unmeasuredFiles = [];
132
+ for (const file of files) {
133
+ if (file.excluded)
134
+ excludedLines += file.added + file.deleted;
135
+ else {
136
+ diffLines += file.added + file.deleted;
137
+ if (file.binary)
138
+ unmeasuredFiles.push(file.path);
139
+ }
140
+ }
141
+ return { baseRef, diffLines, excludedLines, unmeasuredFiles, files };
142
+ }
143
+ const TASK_BRANCH_PATTERN = /^smith\/(?<epic>[^/]+)\/(?<task>[^/]+)$/;
144
+ function deriveBaseRef(worktreeDir) {
145
+ let branch;
146
+ try {
147
+ branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
148
+ cwd: worktreeDir,
149
+ encoding: 'utf8',
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ }).trim();
152
+ }
153
+ catch (err) {
154
+ // A raw shell error would reach the caller as an untyped string and read
155
+ // like a bug in the gate. It is not: it is the ordinary answer for a
156
+ // directory git has never heard of, and it deserves a code like any other.
157
+ throw new DiffstatError('diffstat.not-a-git-worktree', `Cannot read the current branch in ${worktreeDir} — it is not a git worktree, or git refused to answer.`, { worktreeDir, cause: err instanceof Error ? err.message : String(err) });
158
+ }
159
+ const epic = TASK_BRANCH_PATTERN.exec(branch)?.groups?.epic;
160
+ if (epic === undefined) {
161
+ throw new DiffstatError('diffstat.cannot-derive-base-ref', `Branch "${branch}" does not follow smith/<epic>/<task-id>, so there is no integration branch to diff against. Pass an explicit baseRef.`, { branch, worktreeDir });
162
+ }
163
+ return `smith/${epic}/integration`;
164
+ }