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