@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,308 @@
|
|
|
1
|
+
import { eventTaskId } from './events.js';
|
|
2
|
+
import { epicOfTaskId } from './taskId.js';
|
|
3
|
+
/**
|
|
4
|
+
* Live agent registry (architecture §7 "active agent count by role/model/
|
|
5
|
+
* provider"). An agent is live between its `dispatch_decision` event and
|
|
6
|
+
* whichever terminal event closes it: `task-result-recorded` (a worker's
|
|
7
|
+
* schema-valid Result landed — Phase 5 addition, see db/projector.ts header
|
|
8
|
+
* comment) or `error-logged` referencing the same task (an agent ref, per
|
|
9
|
+
* the task's terminal-event options).
|
|
10
|
+
*
|
|
11
|
+
* D-23/P9-12 — the correlation key is `(task_id, agent_role)`, not `task_id`
|
|
12
|
+
* alone. The old note here claimed "a real dispatcher never runs two agents
|
|
13
|
+
* on one task_id concurrently"; `/bs run` does exactly that, deliberately,
|
|
14
|
+
* up to four times per task (coder, grader, reviewer, security-reviewer).
|
|
15
|
+
* Under the old key each dispatch superseded the previous one, so in wave 3
|
|
16
|
+
* three agents that finished were recorded as abandoned and the fourth
|
|
17
|
+
* stayed `live` forever. `agent_id` is still only a label — the role is what
|
|
18
|
+
* the events already carry, so nothing new has to be threaded through.
|
|
19
|
+
*
|
|
20
|
+
* `round` rides along on the record because a judge redispatch is a genuine
|
|
21
|
+
* second round and the UI should be able to say which one it is looking at.
|
|
22
|
+
* It is deliberately NOT part of the key: a round-2 dispatch arriving while
|
|
23
|
+
* round 1 is still open means round 1 was given up on, and keying on the
|
|
24
|
+
* round would leave it live forever — the same bug, one level down.
|
|
25
|
+
*
|
|
26
|
+
* A second `dispatch_decision` for the same `(task_id, agent_role)` whose
|
|
27
|
+
* prior dispatch is STILL open (no terminal event yet — the escalation ladder
|
|
28
|
+
* redispatching without the first attempt reporting back) closes the old
|
|
29
|
+
* entry as `superseded` rather than leaving it orphaned at `live`: one role
|
|
30
|
+
* does not run twice on one task at once, so an open-on-redispatch entry is
|
|
31
|
+
* stale bookkeeping, not a genuinely live agent — counting it would corrupt
|
|
32
|
+
* the Overview live-agent count and detectStale().
|
|
33
|
+
*
|
|
34
|
+
* Terminal events name a role where they can: `task-result-recorded` carries
|
|
35
|
+
* the Result's `agent`, `judge-reported` carries `agent_role`, `judge-verdict`
|
|
36
|
+
* carries `agent` (D-160). One that names no role is a statement about the
|
|
37
|
+
* whole task — the queue blocking it, the gate refusing it — and closes every
|
|
38
|
+
* entry still open under that task, because after a task-level failure nobody
|
|
39
|
+
* is still running.
|
|
40
|
+
*
|
|
41
|
+
* Above all of those sits `epic-closed` (D-187). Every close named so far
|
|
42
|
+
* needs a task id to speak through, so an agent whose task simply never
|
|
43
|
+
* reported — and an epic-level dispatch, which has no task id to speak
|
|
44
|
+
* through at all — stayed `live` past the verdict, past the end of the run,
|
|
45
|
+
* forever. The epic's own terminal event is the only thing that can say
|
|
46
|
+
* otherwise, and it closes what is still open under that epic in that session
|
|
47
|
+
* as `abandoned`: not superseded (nothing replaced them) and not an error
|
|
48
|
+
* (they were never judged), just outrun by the run they belonged to.
|
|
49
|
+
*/
|
|
50
|
+
export const DISPATCH_EVENT_TYPE = 'dispatch_decision';
|
|
51
|
+
/** Phase 5 addition (deviation, noted in the final report): the terminal counterpart to dispatch_decision. */
|
|
52
|
+
export const TASK_RESULT_EVENT_TYPE = 'task-result-recorded';
|
|
53
|
+
/**
|
|
54
|
+
* P9-11's judge half. A judge's return is its artifact, not a Result: it has
|
|
55
|
+
* no `token_usage`, and minting a zeroed one purely to reuse
|
|
56
|
+
* `task-result-recorded` would put fabricated numbers into the very rows
|
|
57
|
+
* budget accounting sums. So the registry learns a second terminal event
|
|
58
|
+
* rather than the event learning to lie.
|
|
59
|
+
*/
|
|
60
|
+
export const JUDGE_REPORT_EVENT_TYPE = 'judge-reported';
|
|
61
|
+
/**
|
|
62
|
+
* The cross-provider judge's return (D-160). `recordJudgeRun` writes a real
|
|
63
|
+
* `dispatch_decision` for every external judge, deliberately, so the run shows
|
|
64
|
+
* up in the same registry and timeline as any other agent (architecture §7) —
|
|
65
|
+
* but an external judge never writes a Result and never closes a judge turn,
|
|
66
|
+
* so neither terminal event above could ever speak for it. Its terminal fact
|
|
67
|
+
* is the `judge-verdict` chained off its own dispatch, and until this was
|
|
68
|
+
* read, an API call that came back in eight seconds stayed `live` until the
|
|
69
|
+
* end of the session and turned up in `detectStale` hours later.
|
|
70
|
+
*
|
|
71
|
+
* The role is under `agent`, not `agent_role` — the key `recordJudgeRun`
|
|
72
|
+
* writes (quorum.ts) — which is why this close is spelled out rather than
|
|
73
|
+
* folded in with judge-reported.
|
|
74
|
+
*/
|
|
75
|
+
export const JUDGE_VERDICT_EVENT_TYPE = 'judge-verdict';
|
|
76
|
+
export const ERROR_EVENT_TYPE = 'error-logged';
|
|
77
|
+
/**
|
|
78
|
+
* The epic's own terminal fact (D-187). Every close above speaks for a single
|
|
79
|
+
* task, so an agent whose task never reported — and an epic-level dispatch,
|
|
80
|
+
* whose `task_id` is absent by design (event.schema.json: "absent for
|
|
81
|
+
* session-level or epic-level events") and which therefore has no task any
|
|
82
|
+
* close could name — stayed `live` for good. `epic-closed` is the one event
|
|
83
|
+
* that says the run those agents belong to is over: whatever was still open
|
|
84
|
+
* under that epic was abandoned when the verdict landed, not left running.
|
|
85
|
+
*/
|
|
86
|
+
export const EPIC_CLOSED_EVENT_TYPE = 'epic-closed';
|
|
87
|
+
/**
|
|
88
|
+
* Every event type `foldAgents` acts on — the dispatch that opens an entry and
|
|
89
|
+
* every terminal that closes one. Exported for callers that slice the event
|
|
90
|
+
* log before folding it (D-161): a slice narrower than this list feeds the
|
|
91
|
+
* fold a history in which agents are dispatched and never finish, and the
|
|
92
|
+
* result is not a smaller answer but a wrong one. Anything that reuses the
|
|
93
|
+
* fold has to reuse its alphabet too.
|
|
94
|
+
*/
|
|
95
|
+
export const REGISTRY_EVENT_TYPES = [
|
|
96
|
+
DISPATCH_EVENT_TYPE,
|
|
97
|
+
TASK_RESULT_EVENT_TYPE,
|
|
98
|
+
JUDGE_REPORT_EVENT_TYPE,
|
|
99
|
+
JUDGE_VERDICT_EVENT_TYPE,
|
|
100
|
+
ERROR_EVENT_TYPE,
|
|
101
|
+
EPIC_CLOSED_EVENT_TYPE,
|
|
102
|
+
];
|
|
103
|
+
/** Runtime mirror of `AgentStatus` (a type-only union has nothing to iterate
|
|
104
|
+
* at runtime) — lets ui/test/taxonomy.test.ts assert its status->tone map
|
|
105
|
+
* covers this list exactly, same drift-guard pattern as roadmap.ts's
|
|
106
|
+
* MILESTONE_STATUSES. */
|
|
107
|
+
export const AGENT_STATUSES = [
|
|
108
|
+
'live',
|
|
109
|
+
'done',
|
|
110
|
+
'error',
|
|
111
|
+
'superseded',
|
|
112
|
+
'abandoned',
|
|
113
|
+
];
|
|
114
|
+
const STATUS_FOR_TERMINAL = {
|
|
115
|
+
result: 'done',
|
|
116
|
+
error: 'error',
|
|
117
|
+
superseded: 'superseded',
|
|
118
|
+
abandoned: 'abandoned',
|
|
119
|
+
};
|
|
120
|
+
/** The open-entry key — the correlation pair, `(task_id, agent_role)`. */
|
|
121
|
+
function openKey(taskId, role) {
|
|
122
|
+
return `${taskId}\u0000${role}`;
|
|
123
|
+
}
|
|
124
|
+
function closeEntry(open, eventId, ts, terminalType) {
|
|
125
|
+
open.terminalEventId = eventId;
|
|
126
|
+
open.terminalAt = ts;
|
|
127
|
+
open.terminalType = terminalType;
|
|
128
|
+
open.status = STATUS_FOR_TERMINAL[terminalType];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Close what this terminal event actually speaks for: one role when it names
|
|
132
|
+
* one, every open entry under the task when it does not.
|
|
133
|
+
*/
|
|
134
|
+
function closeOpen(open, taskId, role, eventId, ts, terminalType) {
|
|
135
|
+
if (role) {
|
|
136
|
+
const entry = open.get(openKey(taskId, role));
|
|
137
|
+
if (!entry)
|
|
138
|
+
return;
|
|
139
|
+
closeEntry(entry, eventId, ts, terminalType);
|
|
140
|
+
open.delete(openKey(taskId, role));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const [key, entry] of open) {
|
|
144
|
+
if (entry.taskId !== taskId)
|
|
145
|
+
continue;
|
|
146
|
+
closeEntry(entry, eventId, ts, terminalType);
|
|
147
|
+
open.delete(key);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Fold a session's full event history into agent registry rows. Pure
|
|
152
|
+
* function over the event list — used identically by db/projector.ts's
|
|
153
|
+
* full rebuild and incremental apply paths (no separate incremental state
|
|
154
|
+
* machine to drift from this one).
|
|
155
|
+
*/
|
|
156
|
+
export function foldAgents(events) {
|
|
157
|
+
const open = new Map();
|
|
158
|
+
// Dispatches with no task id have no correlation key, so they cannot live in
|
|
159
|
+
// `open` — but they are still open, and `epic-closed` still speaks for them.
|
|
160
|
+
let openEpicLevel = [];
|
|
161
|
+
const records = [];
|
|
162
|
+
for (const { event_id, record } of events) {
|
|
163
|
+
if (record.event_type === DISPATCH_EVENT_TYPE) {
|
|
164
|
+
const payload = record.payload;
|
|
165
|
+
if (!payload.agent_role || !payload.provider || !payload.model_tier)
|
|
166
|
+
continue;
|
|
167
|
+
// Both levels, because a hand-written dispatch names its task in the
|
|
168
|
+
// payload and leaves the envelope null (D-245) — and D-244's rule that
|
|
169
|
+
// `task_id: ""` names no task now holds at either level. Read one level,
|
|
170
|
+
// or read `''` as an id, and the agent is filed as epic-level while
|
|
171
|
+
// every task-scoped terminal branch below stays guarded on the task id:
|
|
172
|
+
// nothing can ever close it. taskId.ts draws the same line for `''`.
|
|
173
|
+
const taskId = eventTaskId(record);
|
|
174
|
+
// A still-open dispatch for the same (task, role) is superseded, not
|
|
175
|
+
// left live forever — see the "second dispatch_decision" note above.
|
|
176
|
+
// Different roles on one task run side by side and are left alone.
|
|
177
|
+
if (taskId) {
|
|
178
|
+
closeOpen(open, taskId, payload.agent_role, event_id, record.ts, 'superseded');
|
|
179
|
+
}
|
|
180
|
+
const agent = {
|
|
181
|
+
id: event_id,
|
|
182
|
+
sessionId: record.session_id,
|
|
183
|
+
taskId,
|
|
184
|
+
// The payload's own claim first; otherwise the task id spells it. A
|
|
185
|
+
// bare task id (D-130) places nothing, and guessing is what the id
|
|
186
|
+
// rules exist to prevent, so it stays null.
|
|
187
|
+
epicId: payload.epic_id ?? (taskId ? epicOfTaskId(taskId) : null),
|
|
188
|
+
agentId: record.agent_id ?? null,
|
|
189
|
+
agentRole: payload.agent_role,
|
|
190
|
+
round: typeof payload.round === 'number' ? payload.round : 1,
|
|
191
|
+
provider: payload.provider,
|
|
192
|
+
modelTier: payload.model_tier,
|
|
193
|
+
dispatchedAt: record.ts,
|
|
194
|
+
terminalEventId: null,
|
|
195
|
+
terminalAt: null,
|
|
196
|
+
terminalType: null,
|
|
197
|
+
status: 'live',
|
|
198
|
+
};
|
|
199
|
+
records.push(agent);
|
|
200
|
+
if (taskId)
|
|
201
|
+
open.set(openKey(taskId, payload.agent_role), agent);
|
|
202
|
+
else
|
|
203
|
+
openEpicLevel.push(agent);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (record.event_type === TASK_RESULT_EVENT_TYPE) {
|
|
207
|
+
// The Result names its author in `agent` (result.schema.json's required
|
|
208
|
+
// field). A hand-appended one that omits it still closes the task.
|
|
209
|
+
const payload = record.payload;
|
|
210
|
+
// Both levels: result.schema.json names the task in the payload, and the
|
|
211
|
+
// dispatch this closes may have named it only there too (D-245).
|
|
212
|
+
const taskId = eventTaskId(record);
|
|
213
|
+
if (taskId) {
|
|
214
|
+
closeOpen(open, taskId, payload.agent ?? null, event_id, record.ts, 'result');
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (record.event_type === JUDGE_REPORT_EVENT_TYPE) {
|
|
219
|
+
const payload = record.payload;
|
|
220
|
+
const taskId = eventTaskId(record);
|
|
221
|
+
if (taskId) {
|
|
222
|
+
closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'result');
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (record.event_type === JUDGE_VERDICT_EVENT_TYPE) {
|
|
227
|
+
const payload = record.payload;
|
|
228
|
+
const taskId = eventTaskId(record);
|
|
229
|
+
if (taskId) {
|
|
230
|
+
// A run that produced no schema-valid verdict did not do its job, and
|
|
231
|
+
// `ok: false` is how the log says so. Closing it as a result would let
|
|
232
|
+
// a provider failing every call read exactly like one answering every
|
|
233
|
+
// call — the distinction providerAgreement() exists to measure.
|
|
234
|
+
const terminal = payload.ok === false ? 'error' : 'result';
|
|
235
|
+
closeOpen(open, taskId, payload.agent ?? null, event_id, record.ts, terminal);
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (record.event_type === ERROR_EVENT_TYPE) {
|
|
240
|
+
const payload = record.payload;
|
|
241
|
+
// `task_ref` is this event's own spelling; eventTaskId covers the two
|
|
242
|
+
// the rest of the log uses (D-245).
|
|
243
|
+
const taskId = eventTaskId(record) ?? payload.task_ref;
|
|
244
|
+
if (taskId) {
|
|
245
|
+
closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'error');
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (record.event_type === EPIC_CLOSED_EVENT_TYPE) {
|
|
250
|
+
const payload = record.payload;
|
|
251
|
+
if (!payload.epic_id)
|
|
252
|
+
continue;
|
|
253
|
+
// Scoped to the session that closed the epic: another session's agents
|
|
254
|
+
// are not this verdict's to speak for. Within it, the epic each entry
|
|
255
|
+
// was dispatched for is now recorded (D-234), so the task-keyed half
|
|
256
|
+
// asks that instead of matching the id's prefix by hand.
|
|
257
|
+
for (const [key, entry] of open) {
|
|
258
|
+
if (entry.sessionId !== record.session_id)
|
|
259
|
+
continue;
|
|
260
|
+
if (entry.epicId !== payload.epic_id)
|
|
261
|
+
continue;
|
|
262
|
+
closeEntry(entry, event_id, record.ts, 'abandoned');
|
|
263
|
+
open.delete(key);
|
|
264
|
+
}
|
|
265
|
+
// The epic-level half keeps D-187's reasoning for an entry nothing
|
|
266
|
+
// places -- no task id and no `epic_id` on its dispatch means no other
|
|
267
|
+
// terminal event can ever name it, so the run's own verdict is the last
|
|
268
|
+
// thing that can close it. But an entry that DID name an epic is placed,
|
|
269
|
+
// and one session runs several epics in a row: sweeping the lot closed
|
|
270
|
+
// the next epic's planner, live and working, on the previous epic's
|
|
271
|
+
// verdict (D-234).
|
|
272
|
+
openEpicLevel = openEpicLevel.filter((entry) => {
|
|
273
|
+
if (entry.sessionId !== record.session_id)
|
|
274
|
+
return true;
|
|
275
|
+
if (entry.epicId !== null && entry.epicId !== payload.epic_id)
|
|
276
|
+
return true;
|
|
277
|
+
closeEntry(entry, event_id, record.ts, 'abandoned');
|
|
278
|
+
return false;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return records;
|
|
283
|
+
}
|
|
284
|
+
/** Default staleness threshold (hours) when the caller doesn't specify one. */
|
|
285
|
+
export const DEFAULT_STALE_HOURS = 4;
|
|
286
|
+
/**
|
|
287
|
+
* Still-live agents whose dispatch is older than `staleHours` as of `nowIso`
|
|
288
|
+
* (architecture §4 "live agent registry ... stale detection"). Pure over
|
|
289
|
+
* the fold's output — `nowIso` is threaded in rather than read from the
|
|
290
|
+
* clock so this is deterministic and testable.
|
|
291
|
+
*/
|
|
292
|
+
export function detectStale(agents, nowIso, staleHours = DEFAULT_STALE_HOURS) {
|
|
293
|
+
const now = new Date(nowIso).getTime();
|
|
294
|
+
const stale = [];
|
|
295
|
+
for (const agent of agents) {
|
|
296
|
+
if (agent.status !== 'live')
|
|
297
|
+
continue;
|
|
298
|
+
const dispatchedMs = new Date(agent.dispatchedAt).getTime();
|
|
299
|
+
const liveHours = (now - dispatchedMs) / (1000 * 60 * 60);
|
|
300
|
+
if (liveHours > staleHours)
|
|
301
|
+
stale.push({ ...agent, liveHours });
|
|
302
|
+
}
|
|
303
|
+
return stale;
|
|
304
|
+
}
|
|
305
|
+
/** Currently live agents (no terminal event yet), the overview page's "live agents" set. */
|
|
306
|
+
export function liveAgents(agents) {
|
|
307
|
+
return agents.filter((a) => a.status === 'live');
|
|
308
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argv → flags, positionals, and the flags nobody declared (D-131/D-132).
|
|
3
|
+
*
|
|
4
|
+
* This lived in cli.ts until D-131, which is most of why D-131 lived as long as
|
|
5
|
+
* it did: cli.ts calls `main()` at import time, so nothing here could be
|
|
6
|
+
* exercised except by building the binary and inferring the parse from a
|
|
7
|
+
* subcommand's output. `--flag=value` was therefore dropped on the floor by
|
|
8
|
+
* every command, and for a flag with a default the whole symptom was that the
|
|
9
|
+
* default won — exit 0, well-formed JSON, the wrong target read.
|
|
10
|
+
*
|
|
11
|
+
* usage.ts is the register of what each command accepts; this module is the
|
|
12
|
+
* only thing that reads a command line against it.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Split `--name=value` at the FIRST `=`, so a value may contain one.
|
|
16
|
+
* Returns null for a token that is not a flag.
|
|
17
|
+
*/
|
|
18
|
+
function splitToken(arg) {
|
|
19
|
+
if (!arg.startsWith('--') || arg === '--')
|
|
20
|
+
return null;
|
|
21
|
+
const body = arg.slice(2);
|
|
22
|
+
const eq = body.indexOf('=');
|
|
23
|
+
if (eq < 0)
|
|
24
|
+
return { key: body, inlineValue: undefined };
|
|
25
|
+
// `--=x` names no flag; leave it to the unknown-flag path rather than
|
|
26
|
+
// inventing an empty-named one.
|
|
27
|
+
return { key: body.slice(0, eq), inlineValue: body.slice(eq + 1) };
|
|
28
|
+
}
|
|
29
|
+
export function parseArgs(argv, spec) {
|
|
30
|
+
const positional = [];
|
|
31
|
+
const flags = {};
|
|
32
|
+
const repeated = {};
|
|
33
|
+
const ordered = [];
|
|
34
|
+
const unknown = [];
|
|
35
|
+
const missingValue = [];
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const arg = argv[i];
|
|
38
|
+
const token = splitToken(arg);
|
|
39
|
+
if (token === null) {
|
|
40
|
+
positional.push(arg);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const { key, inlineValue } = token;
|
|
44
|
+
const declared = spec === undefined ? undefined : spec.get(key);
|
|
45
|
+
if (spec !== undefined && declared === undefined && !unknown.includes(key))
|
|
46
|
+
unknown.push(key);
|
|
47
|
+
let value;
|
|
48
|
+
if (inlineValue !== undefined) {
|
|
49
|
+
value = inlineValue;
|
|
50
|
+
}
|
|
51
|
+
else if (declared === false) {
|
|
52
|
+
// Documented as taking nothing: whatever follows belongs to someone else.
|
|
53
|
+
value = 'true';
|
|
54
|
+
}
|
|
55
|
+
else if (spec !== undefined && declared === undefined) {
|
|
56
|
+
// An unknown flag is about to be refused. Consuming the token behind it
|
|
57
|
+
// would turn one clear "no such flag" into that plus a missing positional.
|
|
58
|
+
value = 'true';
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const next = argv[i + 1];
|
|
62
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
63
|
+
value = next;
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Declared to take a value and handed none. `'true'` is still stored,
|
|
68
|
+
// so a caller that ignores the report parses exactly as it did before
|
|
69
|
+
// and cli.ts is the one place that has to refuse. `declared === true`
|
|
70
|
+
// is load-bearing: `undefined` here means no spec was given, and there
|
|
71
|
+
// `'true'` is the only answer available.
|
|
72
|
+
if (declared === true && !missingValue.includes(key))
|
|
73
|
+
missingValue.push(key);
|
|
74
|
+
value = 'true';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
flags[key] = value;
|
|
78
|
+
const seen = repeated[key] ?? [];
|
|
79
|
+
seen.push(value);
|
|
80
|
+
repeated[key] = seen;
|
|
81
|
+
ordered.push({ key, value });
|
|
82
|
+
}
|
|
83
|
+
return { positional, flags, repeated, ordered, unknown, missingValue };
|
|
84
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SmithError } from './errors.js';
|
|
4
|
+
import { STATE_ARTIFACTS_DIR } from './paths.js';
|
|
5
|
+
/**
|
|
6
|
+
* Where a task's artifacts belong: `state/artifacts/<task-id>/`, beside
|
|
7
|
+
* `state/results/<task-id>.json`. One home per task, named by the task, so the
|
|
8
|
+
* evidence for a verdict is findable from the verdict without a search.
|
|
9
|
+
*
|
|
10
|
+
* `taskId` arrives from a worker-written result file, so it is not trusted to
|
|
11
|
+
* be a plain name: a `..` segment would compute a home outside the artifacts
|
|
12
|
+
* dir, and every later check would then be reporting about somewhere else.
|
|
13
|
+
*/
|
|
14
|
+
export function artifactHome(taskId, artifactsDir = STATE_ARTIFACTS_DIR) {
|
|
15
|
+
return homeUnder(path.resolve(artifactsDir), taskId);
|
|
16
|
+
}
|
|
17
|
+
function homeUnder(root, taskId) {
|
|
18
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') {
|
|
19
|
+
throw new SmithError('artifacts.missing-task-id', `Cannot locate an artifact home without a task id (got ${JSON.stringify(taskId)}). A blank id resolves to ${root}, which holds every task's home — the check would then pass on any path at all.`, { task_id: taskId ?? null, artifacts_dir: root });
|
|
20
|
+
}
|
|
21
|
+
const home = path.resolve(root, taskId);
|
|
22
|
+
if (!contains(root, home)) {
|
|
23
|
+
throw new SmithError('artifacts.unsafe-task-id', `Task id '${taskId}' resolves outside the artifacts directory (${home}). A task id names a directory under ${root}; it cannot climb out of it.`, { task_id: taskId, artifacts_dir: root, resolved: home });
|
|
24
|
+
}
|
|
25
|
+
return home;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `realpathSync`, falling back to the lexical path. A path can exist and still
|
|
29
|
+
* fail to resolve — a permission wall on a parent is enough — and the answer
|
|
30
|
+
* to "did this leave the home" is then the lexical one rather than a throw out
|
|
31
|
+
* of a check that is supposed to return an issue list.
|
|
32
|
+
*/
|
|
33
|
+
function realOf(p) {
|
|
34
|
+
try {
|
|
35
|
+
return realpathSync(p);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** True when `child` is `root` itself or lives beneath it. */
|
|
42
|
+
function contains(root, child) {
|
|
43
|
+
if (child === root)
|
|
44
|
+
return true;
|
|
45
|
+
const rel = path.relative(root, child);
|
|
46
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check every path a result declares as evidence. Relative paths resolve
|
|
50
|
+
* against the task's home, so `coverage.txt` means the obvious thing and the
|
|
51
|
+
* short spelling is also the correct one.
|
|
52
|
+
*
|
|
53
|
+
* Two properties, in this order. Inside the home first: a path in the wrong
|
|
54
|
+
* place is wrong whether or not the file happens to be there today, and saying
|
|
55
|
+
* `missing` about a `/tmp` file that exists would be false. Then existence:
|
|
56
|
+
* an artifact that cannot be opened when the verdict is written is not
|
|
57
|
+
* evidence.
|
|
58
|
+
*
|
|
59
|
+
* Inside is asked twice, of two different things. `path.resolve` answers it
|
|
60
|
+
* about the string, which is what refuses `../elsewhere/coverage.txt`.
|
|
61
|
+
* `realpathSync` answers it about the file, which is what refuses the same
|
|
62
|
+
* file reached through a symlink named `evidence/` — and refuses a home that
|
|
63
|
+
* is itself a link into a worktree, where every relative path under it spells
|
|
64
|
+
* correctly and none of it survives the branch (D-193).
|
|
65
|
+
*
|
|
66
|
+
* Directories pass. An html coverage report and a Playwright trace are real
|
|
67
|
+
* artifacts and neither is a file. What this does not check is whether the
|
|
68
|
+
* bytes support the claim — that is a reviewer's job, not a stat call's.
|
|
69
|
+
*/
|
|
70
|
+
export function checkArtifacts(artifacts, opts) {
|
|
71
|
+
const root = path.resolve(opts.artifactsDir ?? STATE_ARTIFACTS_DIR);
|
|
72
|
+
const home = homeUnder(root, opts.taskId);
|
|
73
|
+
const issues = [];
|
|
74
|
+
// Where the home really is, and where it is supposed to be. They differ when
|
|
75
|
+
// the home is itself a link — into a worktree, say — and then every relative
|
|
76
|
+
// path under it spells correctly while none of it outlives the branch. A
|
|
77
|
+
// home that was never created cannot mislead anyone: nothing resolves
|
|
78
|
+
// through it, so every declaration under it comes back `missing` below.
|
|
79
|
+
const realHome = existsSync(home) ? realOf(home) : home;
|
|
80
|
+
const expectedHome = existsSync(home) ? homeUnder(realOf(root), opts.taskId) : home;
|
|
81
|
+
const homeEscaped = realHome !== expectedHome;
|
|
82
|
+
for (const decl of artifacts) {
|
|
83
|
+
const resolved = path.resolve(home, decl.path);
|
|
84
|
+
if (resolved === home) {
|
|
85
|
+
issues.push({ declared: decl.path, resolved, problem: 'no-path' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!contains(home, resolved)) {
|
|
89
|
+
issues.push({ declared: decl.path, resolved, problem: 'outside-home' });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (homeEscaped) {
|
|
93
|
+
const real = path.resolve(realHome, decl.path);
|
|
94
|
+
issues.push({ declared: decl.path, resolved: real, problem: 'outside-home' });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!existsSync(resolved)) {
|
|
98
|
+
issues.push({ declared: decl.path, resolved, problem: 'missing' });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Asked of the file now, not of the string: `evidence/coverage.txt` is
|
|
102
|
+
// inside the home lexically and outside it in every sense that matters
|
|
103
|
+
// when `evidence` is a link. Report where it went, since an issue naming
|
|
104
|
+
// a correct-looking path is one the operator cannot act on.
|
|
105
|
+
const real = realOf(resolved);
|
|
106
|
+
if (!contains(realHome, real)) {
|
|
107
|
+
issues.push({ declared: decl.path, resolved: real, problem: 'outside-home' });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { home, checked: artifacts.length, issues, ok: issues.length === 0 };
|
|
111
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { decideFindingAttribution, resolveFindingOwner, } from './claims.js';
|
|
2
|
+
import { foldTasks } from './db/projector.js';
|
|
3
|
+
import { appendEvent, readLineageEvents } from './events.js';
|
|
4
|
+
import { computeFingerprint, reattributeFinding, } from './findings.js';
|
|
5
|
+
import { emitFollowUpTask, followUpTaskId } from './taskEvents.js';
|
|
6
|
+
import { requireEpicOfTaskId } from './taskId.js';
|
|
7
|
+
/**
|
|
8
|
+
* Every epic this module derives becomes a durable artifact — the `epic_id`
|
|
9
|
+
* on a routed finding, and the `<epic>/followup-<fp>` id of a task that gets
|
|
10
|
+
* dispatched. So an id with no epic is refused here rather than answered with
|
|
11
|
+
* itself: `epicOf("task-1")` used to return "task-1", minting the follow-up
|
|
12
|
+
* `task-1/followup-ab12` under an epic that does not exist (D-49/P9-10).
|
|
13
|
+
*/
|
|
14
|
+
const epicOf = requireEpicOfTaskId;
|
|
15
|
+
/** Every claim the ownership map records for a task id. */
|
|
16
|
+
function claimsOf(taskId, ownership) {
|
|
17
|
+
return ownership.filter((t) => t.task_id === taskId).flatMap((t) => t.claims);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The claims a follow-up inherits (D-48/P9-31).
|
|
21
|
+
*
|
|
22
|
+
* A follow-up minted with only the file the finding named is a task that
|
|
23
|
+
* cannot fix the bug: the fix lands in the implementation AND its test, and
|
|
24
|
+
* the second edit is out of claim. So the follow-up inherits from whoever
|
|
25
|
+
* resolved its ownership — the owner's whole claim set when one task owns the
|
|
26
|
+
* file, the union of the candidates' sets when several could, and the named
|
|
27
|
+
* file alone only when nobody claims it and there is nothing to inherit.
|
|
28
|
+
*
|
|
29
|
+
* The candidate records carry only the single matching claim, so the full set
|
|
30
|
+
* is looked up by id in the ownership map. Sorted and de-duplicated: the
|
|
31
|
+
* claim set is a set, and a task spec that differs run to run is not one.
|
|
32
|
+
*/
|
|
33
|
+
function followUpClaims(owner, filePath, ownership) {
|
|
34
|
+
const inherited = owner.owner === 'resolved'
|
|
35
|
+
? claimsOf(owner.taskId, ownership)
|
|
36
|
+
: owner.owner === 'ambiguous'
|
|
37
|
+
? owner.candidates.flatMap((c) => claimsOf(c.taskId, ownership))
|
|
38
|
+
: [];
|
|
39
|
+
return inherited.length === 0 ? [filePath] : [...new Set(inherited)].sort();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether any finding's owner needs looking up in the log at all. Only one
|
|
43
|
+
* that resolves to a DIFFERENT task does — the common case is a judge speaking
|
|
44
|
+
* about the very task at the gate, and that case should not pay for a fold of
|
|
45
|
+
* the whole session.
|
|
46
|
+
*/
|
|
47
|
+
function needsTaskStatuses(items, ownership, defaultTaskId) {
|
|
48
|
+
return items.some((item) => {
|
|
49
|
+
const owner = resolveFindingOwner(item.filePath, ownership);
|
|
50
|
+
return owner.owner === 'resolved' && owner.taskId !== defaultTaskId;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Decide where each finding goes, writing nothing. Every id is derived (the
|
|
55
|
+
* follow-up's from the fingerprint), so a caller can compute the whole routing
|
|
56
|
+
* and then discard it if the finding turns out to be waived or refuted — and
|
|
57
|
+
* mint no task for a bug nobody is going to fix.
|
|
58
|
+
*/
|
|
59
|
+
export async function routeFindings(items, options, ctx, opts = {}) {
|
|
60
|
+
const { defaultTaskId, ownership } = options;
|
|
61
|
+
// Asked once, up front, so an epic that cannot be derived fails before any
|
|
62
|
+
// routing exists rather than on whichever finding happens to need it.
|
|
63
|
+
const defaultEpicId = options.epicId ?? epicOf(defaultTaskId);
|
|
64
|
+
const gated = (item) => ({
|
|
65
|
+
input: item,
|
|
66
|
+
attribution: 'gated',
|
|
67
|
+
taskId: defaultTaskId,
|
|
68
|
+
epicId: defaultEpicId,
|
|
69
|
+
fromTaskId: defaultTaskId,
|
|
70
|
+
reason: '',
|
|
71
|
+
claims: [],
|
|
72
|
+
});
|
|
73
|
+
if (ownership === undefined || ownership.length === 0)
|
|
74
|
+
return items.map(gated);
|
|
75
|
+
// Current status of every task the log knows about, folded once. A task the
|
|
76
|
+
// log has never heard of is simply absent, and reads as open: a task in the
|
|
77
|
+
// plan but not yet in the log is `todo`, not merged.
|
|
78
|
+
//
|
|
79
|
+
// The lineage's log (D-119), because "absent reads as open" is the reading
|
|
80
|
+
// that goes wrong across a session boundary: a task the parent session
|
|
81
|
+
// merged is absent from the child's, so a finding against it would be
|
|
82
|
+
// reassigned to a task that is already done rather than gated.
|
|
83
|
+
const statuses = needsTaskStatuses(items, ownership, defaultTaskId)
|
|
84
|
+
? new Map(foldTasks(await readLineageEvents(ctx.sessionId, opts)).map((t) => [
|
|
85
|
+
t.taskId,
|
|
86
|
+
t.taskStatus,
|
|
87
|
+
]))
|
|
88
|
+
: undefined;
|
|
89
|
+
return items.map((item) => {
|
|
90
|
+
const owner = resolveFindingOwner(item.filePath, ownership);
|
|
91
|
+
const decided = decideFindingAttribution(owner, defaultTaskId, (id) => statuses?.get(id));
|
|
92
|
+
if (decided.attribution === 'gated')
|
|
93
|
+
return gated(item);
|
|
94
|
+
if (decided.attribution === 'reassigned') {
|
|
95
|
+
return {
|
|
96
|
+
input: reattributeFinding(item, decided.taskId),
|
|
97
|
+
attribution: 'reassigned',
|
|
98
|
+
taskId: decided.taskId,
|
|
99
|
+
epicId: epicOf(decided.taskId),
|
|
100
|
+
fromTaskId: defaultTaskId,
|
|
101
|
+
reason: `${decided.taskId} claims ${decided.claim}; ${defaultTaskId} does not own this file.`,
|
|
102
|
+
claims: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const epicId = owner.owner === 'resolved' ? epicOf(owner.taskId) : defaultEpicId;
|
|
106
|
+
const taskId = followUpTaskId(epicId, computeFingerprint({
|
|
107
|
+
filePath: item.filePath,
|
|
108
|
+
category: item.finding.finding_category,
|
|
109
|
+
summary: item.finding.summary,
|
|
110
|
+
}));
|
|
111
|
+
return {
|
|
112
|
+
input: reattributeFinding(item, taskId),
|
|
113
|
+
attribution: 'follow-up',
|
|
114
|
+
taskId,
|
|
115
|
+
epicId,
|
|
116
|
+
fromTaskId: defaultTaskId,
|
|
117
|
+
reason: decided.reason,
|
|
118
|
+
claims: followUpClaims(owner, item.filePath, ownership),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Write the two facts a re-attributed finding produces: the follow-up task it
|
|
124
|
+
* needs (only when nobody open could take it), and the move itself. The
|
|
125
|
+
* `finding-reattributed` event is what makes a clean gate outcome readable —
|
|
126
|
+
* without it the log shows a finding under a task that was never gated, and no
|
|
127
|
+
* trace of how it got there.
|
|
128
|
+
*
|
|
129
|
+
* Call this only once the finding has survived intake. A waived or refuted
|
|
130
|
+
* finding minting a `todo` follow-up would block the epic verdict on a bug the
|
|
131
|
+
* team already settled.
|
|
132
|
+
*/
|
|
133
|
+
export async function recordReattribution(routed, finding, ctx, opts = {}) {
|
|
134
|
+
if (routed.attribution === 'gated')
|
|
135
|
+
return;
|
|
136
|
+
if (routed.attribution === 'follow-up') {
|
|
137
|
+
await emitFollowUpTask({
|
|
138
|
+
epicId: routed.epicId,
|
|
139
|
+
taskId: routed.taskId,
|
|
140
|
+
objective: `Fix: ${finding.summary}`,
|
|
141
|
+
claims: routed.claims,
|
|
142
|
+
}, ctx, opts);
|
|
143
|
+
}
|
|
144
|
+
await appendEvent({
|
|
145
|
+
session_id: ctx.sessionId,
|
|
146
|
+
actor: ctx.actor ?? 'system',
|
|
147
|
+
event_type: 'finding-reattributed',
|
|
148
|
+
task_id: routed.taskId,
|
|
149
|
+
plan_version: ctx.planVersion,
|
|
150
|
+
causal_parent: ctx.causalParent,
|
|
151
|
+
payload: {
|
|
152
|
+
from_task_id: routed.fromTaskId,
|
|
153
|
+
to_task_id: routed.taskId,
|
|
154
|
+
attribution: routed.attribution,
|
|
155
|
+
file_path: routed.input.filePath,
|
|
156
|
+
finding_id: finding.finding_id,
|
|
157
|
+
reason: routed.reason,
|
|
158
|
+
},
|
|
159
|
+
}, opts);
|
|
160
|
+
}
|