@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which scheduler proposals may run themselves, and which wait for a person.
|
|
3
|
+
*
|
|
4
|
+
* The scheduler already decided WHAT is due (architecture §12); this decides
|
|
5
|
+
* WHO gets to say yes to it. Keeping the two apart is the point: `scheduler.ts`
|
|
6
|
+
* can be read as "what does the log say is owed" without also encoding trust,
|
|
7
|
+
* and this file can be read as trust alone.
|
|
8
|
+
*
|
|
9
|
+
* It enacts nothing. No dispatch, no worktree, no event — the same doctrine
|
|
10
|
+
* `daemon.ts` states for the scheduler ("never dispatches an agent, never
|
|
11
|
+
* merges, never writes to a worktree") and `wave next` follows: compute, hand
|
|
12
|
+
* back, let the caller act. `smith scheduler admit` prints this and stops, so
|
|
13
|
+
* the classification can be read, disagreed with, and diffed before anything
|
|
14
|
+
* moves.
|
|
15
|
+
*
|
|
16
|
+
* EVERY RULE HERE CAN ONLY DENY. There is no branch that promotes a proposal
|
|
17
|
+
* the policy did not already name, so the answer to "what can run without me?"
|
|
18
|
+
* is the whitelist in scheduler.yml, not this code. A reader who trusts that
|
|
19
|
+
* sentence never has to audit the file to bound the blast radius, which is the
|
|
20
|
+
* only reason auto-dispatch is safe to have at all.
|
|
21
|
+
*
|
|
22
|
+
* Two consequences worth stating because they are easy to get backwards:
|
|
23
|
+
*
|
|
24
|
+
* - A recheck is whitelisted BY REASON, never by confidence. A
|
|
25
|
+
* `RecheckProposal.confidence` is the completed task's confidence, and a
|
|
26
|
+
* low one is why the recheck exists at all (`proposeRechecks`:
|
|
27
|
+
* `taskConfidence < policy.confidenceThreshold` -> 'low-confidence').
|
|
28
|
+
* Applying `MaintenanceProposal`'s floor to it would hold back precisely
|
|
29
|
+
* the rechecks that matter and wave through the routine ones. The two
|
|
30
|
+
* fields share a name and mean opposite things.
|
|
31
|
+
* - The security keywords are crosscheck.yml's list, passed in rather than
|
|
32
|
+
* copied, so promoting a word to security-sensitive moves the cross-check
|
|
33
|
+
* trigger and this gate together. Matching is case-insensitive substring,
|
|
34
|
+
* as `plan_quorum` already matches, and it over-matches on purpose:
|
|
35
|
+
* "author" contains "auth". A false positive costs one operator tick; a
|
|
36
|
+
* false negative auto-runs a change to an auth path.
|
|
37
|
+
*/
|
|
38
|
+
function firstSecurityHit(fields, keywords) {
|
|
39
|
+
for (const { field, value } of fields) {
|
|
40
|
+
const haystack = value.toLowerCase();
|
|
41
|
+
for (const keyword of keywords) {
|
|
42
|
+
const needle = keyword.toLowerCase();
|
|
43
|
+
if (needle.length > 0 && haystack.includes(needle))
|
|
44
|
+
return { field, value, keyword };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function subjectOf(proposal) {
|
|
50
|
+
switch (proposal.kind) {
|
|
51
|
+
case 'recheck':
|
|
52
|
+
return proposal.epicId
|
|
53
|
+
? `recheck of task ${proposal.taskId} (epic ${proposal.epicId})`
|
|
54
|
+
: `recheck of task ${proposal.taskId}`;
|
|
55
|
+
case 'maintenance': {
|
|
56
|
+
const names = proposal.packages.map((p) => p.name);
|
|
57
|
+
const shown = names.slice(0, 5).join(', ');
|
|
58
|
+
const rest = names.length > 5 ? `, +${names.length - 5} more` : '';
|
|
59
|
+
return `maintenance bump of ${names.length} package(s): ${shown}${rest}`;
|
|
60
|
+
}
|
|
61
|
+
case 'growth-review-due':
|
|
62
|
+
return `product-growth review, ${proposal.cadenceDays}-day cadence`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The text the security keywords are matched against, named by field so a hit can say where. */
|
|
66
|
+
function securityFields(proposal, ctx) {
|
|
67
|
+
switch (proposal.kind) {
|
|
68
|
+
case 'recheck': {
|
|
69
|
+
const claims = ctx.claimsByTask?.get(proposal.taskId) ?? [];
|
|
70
|
+
return [
|
|
71
|
+
...claims.map((claim) => ({ field: 'claim', value: claim })),
|
|
72
|
+
{ field: 'task id', value: proposal.taskId },
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
case 'maintenance':
|
|
76
|
+
return proposal.packages.map((p) => ({ field: 'package', value: p.name }));
|
|
77
|
+
case 'growth-review-due':
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function classify(proposal, policy, ctx) {
|
|
82
|
+
if (!policy.enabled) {
|
|
83
|
+
return {
|
|
84
|
+
decision: 'operator',
|
|
85
|
+
code: 'autonomy-disabled',
|
|
86
|
+
reason: 'scheduler.yml autonomy.enabled is false: every proposal waits for an operator tick.',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Ahead of the whitelist, not inside it: architecture §12 puts growth scope
|
|
90
|
+
// with the operator unconditionally, so listing the kind must not be enough.
|
|
91
|
+
if (proposal.kind === 'growth-review-due') {
|
|
92
|
+
return {
|
|
93
|
+
decision: 'operator',
|
|
94
|
+
code: 'growth-never-auto',
|
|
95
|
+
reason: 'A product-growth review proposes SCOPE, and scope is the operator\'s (architecture §12: "product-growth proposals always wait for an operator tick"). Listing the kind in auto_dispatch_kinds changes nothing.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (!policy.autoDispatchKinds.includes(proposal.kind)) {
|
|
99
|
+
return {
|
|
100
|
+
decision: 'operator',
|
|
101
|
+
code: 'kind-not-whitelisted',
|
|
102
|
+
reason: `scheduler.yml autonomy.auto_dispatch_kinds does not list "${proposal.kind}".`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const hit = firstSecurityHit(securityFields(proposal, ctx), ctx.securityKeywords);
|
|
106
|
+
if (hit) {
|
|
107
|
+
return {
|
|
108
|
+
decision: 'operator',
|
|
109
|
+
code: 'security-surface',
|
|
110
|
+
reason: `The ${hit.field} "${hit.value}" matches security keyword "${hit.keyword}" (crosscheck.yml plan_quorum.security_keywords). A security surface always waits, whatever the confidence.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (proposal.kind === 'recheck') {
|
|
114
|
+
const held = proposal.reasons.filter((r) => !policy.autoDispatchRecheckReasons.includes(r));
|
|
115
|
+
if (held.length > 0) {
|
|
116
|
+
return {
|
|
117
|
+
decision: 'operator',
|
|
118
|
+
code: 'reason-not-whitelisted',
|
|
119
|
+
reason: `Reason(s) ${held.map((r) => `"${r}"`).join(', ')} are not in autonomy.auto_dispatch_recheck_reasons. One unlisted reason holds the proposal even when the others are listed: a recheck is dispatched once, and it would carry the unlisted reason with it.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
decision: 'auto',
|
|
124
|
+
code: 'admitted',
|
|
125
|
+
reason: `Every reason (${proposal.reasons.join(', ')}) is whitelisted.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (proposal.confidence < policy.confidenceFloor) {
|
|
129
|
+
return {
|
|
130
|
+
decision: 'operator',
|
|
131
|
+
code: 'below-confidence-floor',
|
|
132
|
+
reason: `Confidence ${proposal.confidence} is below autonomy.confidence_floor ${policy.confidenceFloor} — scheduler.ts scores a major-version bump low precisely so a person reads it.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
decision: 'auto',
|
|
137
|
+
code: 'admitted',
|
|
138
|
+
reason: `Confidence ${proposal.confidence} meets autonomy.confidence_floor ${policy.confidenceFloor}, and no package name touches a security keyword.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Pure: one admission per proposal, in the order given, nothing written and
|
|
143
|
+
* nothing mutated. The caller decides what to do with the `auto` ones.
|
|
144
|
+
*/
|
|
145
|
+
export function admitProposals(proposals, policy, ctx) {
|
|
146
|
+
return proposals.map((proposal) => ({
|
|
147
|
+
proposal,
|
|
148
|
+
subject: subjectOf(proposal),
|
|
149
|
+
...classify(proposal, policy, ctx),
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { eventTaskId } from './events.js';
|
|
2
|
+
import { bareTaskId, epicOfTaskId } from './taskId.js';
|
|
3
|
+
/**
|
|
4
|
+
* The roles budgets.yml prices under `task.judges` — its line 53 comment is the
|
|
5
|
+
* enumeration: "# spec-reviewer, reviewer, verifier, grader".
|
|
6
|
+
*
|
|
7
|
+
* Hardcoded here rather than added to the YAML as a `roles:` list, for the same
|
|
8
|
+
* reason escalation.ts derives BUILDER_ROLES from the policy's own prose: the
|
|
9
|
+
* list is already written down, and a second copy in a different file is a
|
|
10
|
+
* second thing to keep true. What matters is that a role absent from *both*
|
|
11
|
+
* this list and the priced worker roles is reported rather than assumed free —
|
|
12
|
+
* see rolesWithoutCap. `security-reviewer` and `merger` are the live examples:
|
|
13
|
+
* both appear in budgets.yml's `narrowing_roles`, neither has a declared cap.
|
|
14
|
+
*/
|
|
15
|
+
const JUDGE_ROLES = ['spec-reviewer', 'reviewer', 'verifier', 'grader'];
|
|
16
|
+
function priceRole(role, policy) {
|
|
17
|
+
if (role === 'coder')
|
|
18
|
+
return { kind: 'worker', capTokens: policy.task.coder.capTokens };
|
|
19
|
+
if (role === 'researcher')
|
|
20
|
+
return { kind: 'worker', capTokens: policy.task.researcher.capTokens };
|
|
21
|
+
if (JUDGE_ROLES.includes(role))
|
|
22
|
+
return { kind: 'judge', capTokens: policy.task.judges.capTokens };
|
|
23
|
+
return { kind: 'unpriced' };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The most the projection can charge one unmeasured dispatch.
|
|
27
|
+
*
|
|
28
|
+
* Read across every priced role rather than off `coder`, which merely happens
|
|
29
|
+
* to be the largest today: a policy that priced a researcher higher would
|
|
30
|
+
* otherwise let spend above the real ceiling pass unnamed.
|
|
31
|
+
*/
|
|
32
|
+
function largestDeclaredPrice(policy) {
|
|
33
|
+
return Math.max(policy.task.coder.capTokens, policy.task.researcher.capTokens, policy.task.judges.capTokens);
|
|
34
|
+
}
|
|
35
|
+
function payloadString(payload, key) {
|
|
36
|
+
const value = payload[key];
|
|
37
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
38
|
+
}
|
|
39
|
+
function payloadNumber(payload, key) {
|
|
40
|
+
const value = payload[key];
|
|
41
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Task -> epic, from every trace in the log that carries the edge.
|
|
45
|
+
*
|
|
46
|
+
* Two traces, because either can be absent (the P9-32 lesson: an audit that
|
|
47
|
+
* reads one trace reports that trace's gaps as the world's clean state):
|
|
48
|
+
*
|
|
49
|
+
* 1. A qualified id names its own epic — `<epic>/<task>`.
|
|
50
|
+
* 2. `wave-admitted` / `wave-merged` carry `epic_id` plus the `task_ids` they
|
|
51
|
+
* moved, which is the only trace for a bare id.
|
|
52
|
+
*
|
|
53
|
+
* Every admitted id is indexed under both its own spelling and its bare form.
|
|
54
|
+
* dogfood-envkit-1 admitted wave 1 as `envkit-config-loader/task-0-toolchain`
|
|
55
|
+
* and recorded every one of that task's later events under the bare
|
|
56
|
+
* `task-0-toolchain` (D-14, deliberately). A map keyed on the admitted spelling
|
|
57
|
+
* alone attributes none of them.
|
|
58
|
+
*/
|
|
59
|
+
export function readEpicMembership(events) {
|
|
60
|
+
const membership = new Map();
|
|
61
|
+
const remember = (taskId, epicId) => {
|
|
62
|
+
if (!membership.has(taskId))
|
|
63
|
+
membership.set(taskId, epicId);
|
|
64
|
+
const bare = bareTaskId(taskId);
|
|
65
|
+
if (!membership.has(bare))
|
|
66
|
+
membership.set(bare, epicId);
|
|
67
|
+
};
|
|
68
|
+
for (const event of events) {
|
|
69
|
+
const { record } = event;
|
|
70
|
+
if (record.event_type === 'wave-admitted' || record.event_type === 'wave-merged') {
|
|
71
|
+
const epicId = payloadString(record.payload, 'epic_id');
|
|
72
|
+
const taskIds = record.payload.task_ids;
|
|
73
|
+
if (epicId !== null && Array.isArray(taskIds)) {
|
|
74
|
+
for (const taskId of taskIds) {
|
|
75
|
+
if (typeof taskId === 'string' && taskId.length > 0)
|
|
76
|
+
remember(taskId, epicId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const taskId = eventTaskId(event.record);
|
|
81
|
+
if (taskId === null)
|
|
82
|
+
continue;
|
|
83
|
+
const epicId = epicOfTaskId(taskId);
|
|
84
|
+
if (epicId !== null)
|
|
85
|
+
remember(taskId, epicId);
|
|
86
|
+
}
|
|
87
|
+
return membership;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Task -> tokens recorded, from both traces that carry a spend number.
|
|
91
|
+
*
|
|
92
|
+
* 1. `task-result-recorded.token_usage.total_tokens` — the worker's own count.
|
|
93
|
+
* 2. `budget-check-result.tokensUsed` — the gate's copy of that same number.
|
|
94
|
+
*
|
|
95
|
+
* The larger of the two wins rather than their sum. They describe one spend: the
|
|
96
|
+
* gate reads `tokensUsed` off the very result it is checking, so adding them
|
|
97
|
+
* doubles the bill, and taking either alone reads zero whenever that trace is
|
|
98
|
+
* the one missing. On dogfood-envkit-1 *both* are missing for every task — the
|
|
99
|
+
* log holds neither event type — which is what makes measured-only reporting
|
|
100
|
+
* useless there and the projection load-bearing.
|
|
101
|
+
*
|
|
102
|
+
* Max also collapses re-dispatch, which is a different claim and a wrong one.
|
|
103
|
+
* dogfood-mcp-1 recorded `task-2-path-guard` seven times: three distinct
|
|
104
|
+
* rounds, each re-recorded under both the qualified and the bare id spelling.
|
|
105
|
+
* Summing would bill a round two or three times; max bills one round of three.
|
|
106
|
+
* Both are wrong about the total, and max is the safe direction to be wrong in
|
|
107
|
+
* only because this map is a floor — the projection is what has to carry the
|
|
108
|
+
* rounds it drops (D-171).
|
|
109
|
+
*/
|
|
110
|
+
export function readMeasuredSpend(events) {
|
|
111
|
+
const spend = new Map();
|
|
112
|
+
const record = (taskId, tokens) => {
|
|
113
|
+
const seen = spend.get(taskId);
|
|
114
|
+
if (seen === undefined || tokens > seen)
|
|
115
|
+
spend.set(taskId, tokens);
|
|
116
|
+
};
|
|
117
|
+
for (const event of events) {
|
|
118
|
+
const taskId = eventTaskId(event.record);
|
|
119
|
+
if (taskId === null)
|
|
120
|
+
continue;
|
|
121
|
+
const { payload, event_type: eventType } = event.record;
|
|
122
|
+
if (eventType === 'task-result-recorded') {
|
|
123
|
+
const usage = payload.token_usage;
|
|
124
|
+
if (usage !== null && typeof usage === 'object') {
|
|
125
|
+
const total = payloadNumber(usage, 'total_tokens');
|
|
126
|
+
if (total !== null)
|
|
127
|
+
record(taskId, total);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (eventType === 'budget-check-result') {
|
|
132
|
+
const used = payloadNumber(payload, 'tokensUsed');
|
|
133
|
+
if (used !== null)
|
|
134
|
+
record(taskId, used);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return spend;
|
|
138
|
+
}
|
|
139
|
+
const NUMBER = new Intl.NumberFormat('en-US');
|
|
140
|
+
function fmt(n) {
|
|
141
|
+
return NUMBER.format(n);
|
|
142
|
+
}
|
|
143
|
+
function emptyAccumulator() {
|
|
144
|
+
return {
|
|
145
|
+
tasks: new Set(),
|
|
146
|
+
measuredTasks: new Set(),
|
|
147
|
+
measuredTokens: 0,
|
|
148
|
+
projectedFrom: new Map(),
|
|
149
|
+
rolesWithoutCap: new Set(),
|
|
150
|
+
tasksOverPrice: new Set(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function decide(measured, projected, alarmTokens, capTokens, hasHoles) {
|
|
154
|
+
// Order matters, and it is the monotonicity argument in code. A crossing the
|
|
155
|
+
// log can already prove survives any hole in the log, so it is reported as
|
|
156
|
+
// the fact it is rather than downgraded to unverifiable by an unrelated
|
|
157
|
+
// unknown. Only when nothing has crossed does the quality of the record
|
|
158
|
+
// decide between "under" and "we cannot tell".
|
|
159
|
+
if (measured >= capTokens)
|
|
160
|
+
return 'over-cap';
|
|
161
|
+
if (measured >= alarmTokens)
|
|
162
|
+
return 'alarm';
|
|
163
|
+
if (projected >= alarmTokens)
|
|
164
|
+
return 'at-risk';
|
|
165
|
+
if (hasHoles)
|
|
166
|
+
return 'unverifiable';
|
|
167
|
+
return 'under';
|
|
168
|
+
}
|
|
169
|
+
function describe(check, unattributed, largestPrice) {
|
|
170
|
+
const head = `${fmt(check.measuredTokens)} tokens measured across ${check.measuredTaskCount} of ` +
|
|
171
|
+
`${check.taskCount} task(s), ${fmt(check.projectedTokens)} projected, against a ` +
|
|
172
|
+
`${fmt(check.alarmTokens)} alarm and a ${fmt(check.capTokens)} cap.`;
|
|
173
|
+
const holes = [];
|
|
174
|
+
if (check.rolesWithoutCap.length > 0) {
|
|
175
|
+
holes.push(`budgets.yml declares no cap for ${check.rolesWithoutCap.join(', ')}, so those ` +
|
|
176
|
+
`dispatches are in neither number`);
|
|
177
|
+
}
|
|
178
|
+
if (unattributed > 0) {
|
|
179
|
+
holes.push(`${unattributed} dispatch(es) name no epic, so their tokens are in nobody's total`);
|
|
180
|
+
}
|
|
181
|
+
if (check.tasksOverPrice.length > 0) {
|
|
182
|
+
holes.push(`${check.tasksOverPrice.join(', ')} measured above the ${fmt(largestPrice)} this ` +
|
|
183
|
+
`projection charges an unmeasured dispatch, so a cap does not bound one here`);
|
|
184
|
+
}
|
|
185
|
+
const holeText = holes.length > 0 ? ` Holes: ${holes.join('; ')}.` : '';
|
|
186
|
+
switch (check.status) {
|
|
187
|
+
case 'over-cap':
|
|
188
|
+
return `${head} Measured spend alone is at or past the cap.${holeText}`;
|
|
189
|
+
case 'alarm':
|
|
190
|
+
return (`${head} Measured spend alone has reached the alarm — re-plan the remaining work to ` +
|
|
191
|
+
`fit, or ask the operator to extend. Unrecorded spend can only add to this.${holeText}`);
|
|
192
|
+
case 'at-risk':
|
|
193
|
+
return (`${head} Measured spend is under the alarm but the projected ceiling is not: the ` +
|
|
194
|
+
`visible half of the bill reads clear while the whole bill does not (D-9).${holeText}`);
|
|
195
|
+
case 'unverifiable':
|
|
196
|
+
return (`${head} Neither bound has crossed the alarm, but the projection is not an upper ` +
|
|
197
|
+
`bound, so "under" would be a claim about the record rather than the spend.${holeText}`);
|
|
198
|
+
default:
|
|
199
|
+
return (`${head} The projected ceiling is under the alarm, so the real spend is too.` +
|
|
200
|
+
` Measured spend remains a floor: judge tokens never reach a task result.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Epic spend against `epic.alarm_ratio`, counted from the log.
|
|
205
|
+
*
|
|
206
|
+
* Fails the report on anything but `under`, and on an empty epic set: a session
|
|
207
|
+
* the log attributes no epic to has not been shown to be within budget, it has
|
|
208
|
+
* been shown to be unreadable. Same reason dispatchAudit.ts emits a synthetic
|
|
209
|
+
* check for an empty pair set — an empty check set must never be mistaken for
|
|
210
|
+
* a clean one.
|
|
211
|
+
*/
|
|
212
|
+
export function checkBudgetAlarm(events, policy, options) {
|
|
213
|
+
const capTokens = policy.epic.capTokens;
|
|
214
|
+
const alarmRatio = policy.epic.alarmRatio;
|
|
215
|
+
const alarmTokens = Math.floor(capTokens * alarmRatio);
|
|
216
|
+
const largestPrice = largestDeclaredPrice(policy);
|
|
217
|
+
const membership = readEpicMembership(events);
|
|
218
|
+
const rawSpend = readMeasuredSpend(events);
|
|
219
|
+
const epicOf = (taskId) => membership.get(taskId) ?? membership.get(bareTaskId(taskId)) ?? null;
|
|
220
|
+
// Fold spend onto the bare id, so a task recorded under both spellings is one
|
|
221
|
+
// task with one bill rather than two of each.
|
|
222
|
+
const spendByTask = new Map();
|
|
223
|
+
for (const [taskId, tokens] of rawSpend) {
|
|
224
|
+
const bare = bareTaskId(taskId);
|
|
225
|
+
const seen = spendByTask.get(bare);
|
|
226
|
+
if (seen === undefined || tokens > seen)
|
|
227
|
+
spendByTask.set(bare, tokens);
|
|
228
|
+
}
|
|
229
|
+
const inScope = (epicId) => options.epicId === undefined || options.epicId === epicId;
|
|
230
|
+
const accumulators = new Map();
|
|
231
|
+
const accumulatorFor = (epicId) => {
|
|
232
|
+
let acc = accumulators.get(epicId);
|
|
233
|
+
if (acc === undefined) {
|
|
234
|
+
acc = emptyAccumulator();
|
|
235
|
+
accumulators.set(epicId, acc);
|
|
236
|
+
}
|
|
237
|
+
return acc;
|
|
238
|
+
};
|
|
239
|
+
// An epic's task set comes from evidence a task existed — a wave that
|
|
240
|
+
// admitted it, spend recorded against it, or a dispatch charged to it. Not
|
|
241
|
+
// from the membership map, which is deliberately broader: epic-level events
|
|
242
|
+
// carry synthetic `<epic>/epic` ids (dogfood's `epic-closed` does), and those
|
|
243
|
+
// belong in the attribution index without inflating a task count.
|
|
244
|
+
for (const event of events) {
|
|
245
|
+
const { record } = event;
|
|
246
|
+
if (record.event_type !== 'wave-admitted' && record.event_type !== 'wave-merged')
|
|
247
|
+
continue;
|
|
248
|
+
const epicId = payloadString(record.payload, 'epic_id');
|
|
249
|
+
const taskIds = record.payload.task_ids;
|
|
250
|
+
if (epicId === null || !inScope(epicId) || !Array.isArray(taskIds))
|
|
251
|
+
continue;
|
|
252
|
+
const acc = accumulatorFor(epicId);
|
|
253
|
+
for (const taskId of taskIds) {
|
|
254
|
+
if (typeof taskId === 'string' && taskId.length > 0)
|
|
255
|
+
acc.tasks.add(bareTaskId(taskId));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const [bare, tokens] of spendByTask) {
|
|
259
|
+
const epicId = epicOf(bare);
|
|
260
|
+
if (epicId === null || !inScope(epicId))
|
|
261
|
+
continue;
|
|
262
|
+
const acc = accumulatorFor(epicId);
|
|
263
|
+
acc.tasks.add(bare);
|
|
264
|
+
acc.measuredTasks.add(bare);
|
|
265
|
+
acc.measuredTokens += tokens;
|
|
266
|
+
if (tokens > largestPrice)
|
|
267
|
+
acc.tasksOverPrice.add(bare);
|
|
268
|
+
}
|
|
269
|
+
let unattributedDispatches = 0;
|
|
270
|
+
const unattributedRoles = new Set();
|
|
271
|
+
// Tasks whose measured number has already paid for one worker dispatch.
|
|
272
|
+
const accountedByMeasured = new Set();
|
|
273
|
+
for (const event of events) {
|
|
274
|
+
if (event.record.event_type !== 'dispatch_decision')
|
|
275
|
+
continue;
|
|
276
|
+
const role = payloadString(event.record.payload, 'agent_role');
|
|
277
|
+
if (role === null)
|
|
278
|
+
continue;
|
|
279
|
+
const taskId = eventTaskId(event.record);
|
|
280
|
+
const epicId = taskId === null ? null : epicOf(taskId);
|
|
281
|
+
if (epicId === null) {
|
|
282
|
+
unattributedDispatches += 1;
|
|
283
|
+
unattributedRoles.add(role);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (!inScope(epicId))
|
|
287
|
+
continue;
|
|
288
|
+
const acc = accumulatorFor(epicId);
|
|
289
|
+
if (taskId !== null)
|
|
290
|
+
acc.tasks.add(bareTaskId(taskId));
|
|
291
|
+
const pricing = priceRole(role, policy);
|
|
292
|
+
if (pricing.kind === 'unpriced') {
|
|
293
|
+
acc.rolesWithoutCap.add(role);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// A judge's tokens are in no result and never will be, so a judge dispatch
|
|
297
|
+
// is always projected. A worker's are inside a result — but `spendByTask`
|
|
298
|
+
// holds one number per task, the largest, so it accounts for exactly one of
|
|
299
|
+
// that task's worker dispatches. Exempting all of them (D-171) put a
|
|
300
|
+
// re-dispatched round in neither number: the max had discarded its tokens
|
|
301
|
+
// and the exemption then declined to price them.
|
|
302
|
+
if (pricing.kind === 'worker' && taskId !== null) {
|
|
303
|
+
const bare = bareTaskId(taskId);
|
|
304
|
+
if (spendByTask.has(bare) && !accountedByMeasured.has(bare)) {
|
|
305
|
+
accountedByMeasured.add(bare);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
acc.projectedFrom.set(role, (acc.projectedFrom.get(role) ?? 0) + pricing.capTokens);
|
|
310
|
+
}
|
|
311
|
+
const epics = [...accumulators.entries()]
|
|
312
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
313
|
+
.map(([epicId, acc]) => {
|
|
314
|
+
const projectedFrom = Object.fromEntries([...acc.projectedFrom.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
315
|
+
const projectedTokens = acc.measuredTokens + Object.values(projectedFrom).reduce((sum, n) => sum + n, 0);
|
|
316
|
+
const rolesWithoutCap = [...acc.rolesWithoutCap].sort();
|
|
317
|
+
const tasksOverPrice = [...acc.tasksOverPrice].sort();
|
|
318
|
+
const status = decide(acc.measuredTokens, projectedTokens, alarmTokens, capTokens, rolesWithoutCap.length > 0 || unattributedDispatches > 0 || tasksOverPrice.length > 0);
|
|
319
|
+
const partial = {
|
|
320
|
+
epicId,
|
|
321
|
+
capTokens,
|
|
322
|
+
alarmTokens,
|
|
323
|
+
measuredTokens: acc.measuredTokens,
|
|
324
|
+
projectedTokens,
|
|
325
|
+
taskCount: acc.tasks.size,
|
|
326
|
+
measuredTaskCount: acc.measuredTasks.size,
|
|
327
|
+
projectedFrom,
|
|
328
|
+
rolesWithoutCap,
|
|
329
|
+
tasksOverPrice,
|
|
330
|
+
status,
|
|
331
|
+
};
|
|
332
|
+
return { ...partial, detail: describe(partial, unattributedDispatches, largestPrice) };
|
|
333
|
+
});
|
|
334
|
+
if (epics.length === 0) {
|
|
335
|
+
const partial = {
|
|
336
|
+
epicId: '*',
|
|
337
|
+
capTokens,
|
|
338
|
+
alarmTokens,
|
|
339
|
+
measuredTokens: 0,
|
|
340
|
+
projectedTokens: 0,
|
|
341
|
+
taskCount: 0,
|
|
342
|
+
measuredTaskCount: 0,
|
|
343
|
+
projectedFrom: {},
|
|
344
|
+
rolesWithoutCap: [],
|
|
345
|
+
tasksOverPrice: [],
|
|
346
|
+
status: 'unverifiable',
|
|
347
|
+
};
|
|
348
|
+
epics.push({
|
|
349
|
+
...partial,
|
|
350
|
+
detail: options.epicId === undefined
|
|
351
|
+
? 'No epic in this session has a task the log attributes to it, so there is no ' +
|
|
352
|
+
'spend to measure. An unreadable session is not a session within budget.'
|
|
353
|
+
: `No task in this session is attributed to epic "${options.epicId}". Either the ` +
|
|
354
|
+
'id is wrong or the log never recorded the wave that admitted its tasks.',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
sessionId: options.sessionId,
|
|
359
|
+
alarmRatio,
|
|
360
|
+
capTokens,
|
|
361
|
+
epics,
|
|
362
|
+
unattributedDispatches,
|
|
363
|
+
unattributedRoles: [...unattributedRoles].sort(),
|
|
364
|
+
ok: epics.every((epic) => epic.status === 'under'),
|
|
365
|
+
};
|
|
366
|
+
}
|