@juzser/blacksmith 0.3.0 → 0.5.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 → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// budgets.yml sized for one epic's effort tier.
|
|
2
|
+
//
|
|
3
|
+
// budgets.ts cannot read a plan or effort.yml's resolution itself — plan.ts
|
|
4
|
+
// imports it, and effort.ts imports plan.ts — so the step from "a plan" or
|
|
5
|
+
// "an epic id seen in the event log" to "the policy for that epic's tier"
|
|
6
|
+
// lives here, one level up. Every budget reader that knows its plan goes
|
|
7
|
+
// through `budgetTierForPlan`: the tier a budget is sized for is the tier the
|
|
8
|
+
// epic RUNS at, after effort.yml's security floor, not the plan's raw ask.
|
|
9
|
+
import { loadBudgetPolicy, resolveBudgetTier } from './budgets.js';
|
|
10
|
+
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
11
|
+
import { loadEffortPolicy, resolveEffort } from './effort.js';
|
|
12
|
+
import { BUDGETS_POLICY_PATH } from './paths.js';
|
|
13
|
+
import { isEffortTier, latestPlanVersion, loadPlan, } from './plan.js';
|
|
14
|
+
/**
|
|
15
|
+
* An epic id is a directory name under the specs dir. One read out of the
|
|
16
|
+
* event log is data, not a path: anything that is not a plain name is treated
|
|
17
|
+
* as "no plan found" rather than joined onto a filesystem path.
|
|
18
|
+
*/
|
|
19
|
+
const PLAIN_EPIC_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
20
|
+
/**
|
|
21
|
+
* The effort tier `plan` is budgeted at: effort.ts `resolveEffort(...).effective`,
|
|
22
|
+
* so a `small` plan whose live tasks trip a crosscheck.yml security trigger is
|
|
23
|
+
* budgeted at effort.yml's `security_floor`, exactly the tier it runs at.
|
|
24
|
+
*
|
|
25
|
+
* A budget reader must not fail a run over a policy it only consults: when
|
|
26
|
+
* effort.yml or crosscheck.yml cannot be read, or the plan's tasks cannot be
|
|
27
|
+
* walked, the tier falls back to the plan's own `effort`, else effort.yml's
|
|
28
|
+
* `default_tier` (which budgets.ts refuses to guess when that is broken too).
|
|
29
|
+
*/
|
|
30
|
+
export function budgetTierForPlan(plan, opts = {}) {
|
|
31
|
+
let effortPolicy;
|
|
32
|
+
try {
|
|
33
|
+
effortPolicy = loadEffortPolicy(opts.effortPolicyFile);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return resolveBudgetTier(plan?.effort, opts.effortPolicyFile);
|
|
37
|
+
}
|
|
38
|
+
if (plan === null || plan === undefined)
|
|
39
|
+
return effortPolicy.defaultTier;
|
|
40
|
+
try {
|
|
41
|
+
const securityPolicy = opts.securityPolicy ?? loadCrosscheckPolicy(opts.crosscheckPolicyFile).planQuorum;
|
|
42
|
+
return resolveEffort(effortPolicy, securityPolicy, { plan }).effective;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return isEffortTier(plan.effort) ? plan.effort : effortPolicy.defaultTier;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** budgets.yml sized for `plan`'s effective tier (see `budgetTierForPlan`). */
|
|
49
|
+
export function budgetPolicyForPlan(plan, opts = {}) {
|
|
50
|
+
return loadBudgetPolicy(opts.policyFile, opts.env ?? process.env, budgetTierForPlan(plan, opts));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The epic's latest plan, or undefined when there is no plan to read — a log
|
|
54
|
+
* from another project, an epic planned outside this specs dir, an unreadable
|
|
55
|
+
* file. undefined sizes the budget at effort.yml's `default_tier`, which is
|
|
56
|
+
* what every reader did before tiers existed.
|
|
57
|
+
*/
|
|
58
|
+
export function latestPlan(epicId, planOpts = {}) {
|
|
59
|
+
if (!PLAIN_EPIC_ID.test(epicId) || epicId.includes('..'))
|
|
60
|
+
return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const version = latestPlanVersion(epicId, planOpts);
|
|
63
|
+
return version === null ? undefined : loadPlan(epicId, version, planOpts);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A lookup from epic id to that epic's budget policy, each tier loaded once.
|
|
71
|
+
* Env overrides apply per tier (`<NAME>_<TIER>` before `<NAME>`), so two
|
|
72
|
+
* epics of different tiers can be judged against different caps in one
|
|
73
|
+
* report.
|
|
74
|
+
*/
|
|
75
|
+
export function epicBudgetPolicies(opts = {}) {
|
|
76
|
+
const policyFile = opts.policyFile ?? BUDGETS_POLICY_PATH;
|
|
77
|
+
const env = opts.env ?? process.env;
|
|
78
|
+
const byTier = new Map();
|
|
79
|
+
const byEpic = new Map();
|
|
80
|
+
return (epicId) => {
|
|
81
|
+
const cached = byEpic.get(epicId);
|
|
82
|
+
if (cached !== undefined)
|
|
83
|
+
return cached;
|
|
84
|
+
const tier = budgetTierForPlan(latestPlan(epicId, opts.planOpts), opts);
|
|
85
|
+
let policy = byTier.get(tier);
|
|
86
|
+
if (policy === undefined) {
|
|
87
|
+
policy = loadBudgetPolicy(policyFile, env, tier);
|
|
88
|
+
byTier.set(tier, policy);
|
|
89
|
+
}
|
|
90
|
+
byEpic.set(epicId, policy);
|
|
91
|
+
return policy;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { UNOBSERVED_HINT, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
|
|
1
|
+
import { UNLINKED_HINT, UNOBSERVED_HINT, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
|
|
2
2
|
/**
|
|
3
3
|
* Does this factory build in parallel, or has every epic it closed been narrow?
|
|
4
4
|
*
|
|
@@ -55,9 +55,9 @@ export const ALL_VERDICTS = [...WAVE_VERDICTS, ...EPIC_ONLY_VERDICTS];
|
|
|
55
55
|
* opposite states of knowledge, and a factory that has never measured itself
|
|
56
56
|
* must not be able to read as a healthy one.
|
|
57
57
|
*/
|
|
58
|
-
export const UNMEASURED_HINT = 'No close read here carried a width. Either these epics were closed before `
|
|
59
|
-
'recorded one, or the closes were written by hand — close a current epic with `
|
|
60
|
-
'close`, or read a live log back with `
|
|
58
|
+
export const UNMEASURED_HINT = 'No close read here carried a width. Either these epics were closed before `bs epic close` ' +
|
|
59
|
+
'recorded one, or the closes were written by hand — close a current epic with `bs epic ' +
|
|
60
|
+
'close`, or read a live log back with `bs wave audit --session <id>`.';
|
|
61
61
|
/** Nothing was recorded, or nothing readable was. */
|
|
62
62
|
function blank(verdict, problem) {
|
|
63
63
|
return {
|
|
@@ -66,6 +66,7 @@ function blank(verdict, problem) {
|
|
|
66
66
|
byVerdict: zeroedCounts(WAVE_VERDICTS),
|
|
67
67
|
widest: { declared: 0, observed: 0 },
|
|
68
68
|
unobserved: [],
|
|
69
|
+
unlinked: [],
|
|
69
70
|
problem,
|
|
70
71
|
};
|
|
71
72
|
}
|
|
@@ -148,6 +149,9 @@ function widthOf(payload) {
|
|
|
148
149
|
unobserved: Array.isArray(record.unobserved)
|
|
149
150
|
? record.unobserved.filter((id) => typeof id === 'string')
|
|
150
151
|
: [],
|
|
152
|
+
unlinked: Array.isArray(record.unlinked)
|
|
153
|
+
? record.unlinked.filter((id) => typeof id === 'string')
|
|
154
|
+
: [],
|
|
151
155
|
problem,
|
|
152
156
|
};
|
|
153
157
|
}
|
|
@@ -220,6 +224,9 @@ export function summariseEpicWidth(events) {
|
|
|
220
224
|
const unobserved = epics
|
|
221
225
|
.filter((e) => e.byVerdict.unobserved > 0 || e.unobserved.length > 0)
|
|
222
226
|
.map((e) => e.epicId);
|
|
227
|
+
const unlinked = epics
|
|
228
|
+
.filter((e) => e.byVerdict.unlinked > 0 || e.unlinked.length > 0)
|
|
229
|
+
.map((e) => e.epicId);
|
|
223
230
|
const unmeasured = epics
|
|
224
231
|
.filter((e) => e.verdict === 'unmeasured' || e.verdict === 'unreadable')
|
|
225
232
|
.map((e) => e.epicId);
|
|
@@ -236,8 +243,18 @@ export function summariseEpicWidth(events) {
|
|
|
236
243
|
},
|
|
237
244
|
serialized,
|
|
238
245
|
unobserved,
|
|
246
|
+
unlinked,
|
|
239
247
|
unmeasured,
|
|
240
|
-
|
|
248
|
+
// `unlinked` names real work, so it earns a hint pointing the operator at
|
|
249
|
+
// it but never the exit code `unobserved` carries -- that code is reserved
|
|
250
|
+
// for "nothing was judged", and here something plainly was.
|
|
251
|
+
hint: unobserved.length > 0
|
|
252
|
+
? UNOBSERVED_HINT
|
|
253
|
+
: unlinked.length > 0
|
|
254
|
+
? UNLINKED_HINT
|
|
255
|
+
: judged
|
|
256
|
+
? ''
|
|
257
|
+
: UNMEASURED_HINT,
|
|
241
258
|
exitCode: serialized.length > 0 ? 1 : unobserved.length > 0 || !judged ? 2 : 0,
|
|
242
259
|
};
|
|
243
260
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { isLaterEvent } from './eventOrder.js';
|
|
3
|
+
import { normalizeProjectName } from './projectName.js';
|
|
3
4
|
/** A `Record` over every `GateBlockedReason`, not a switch with a default: a reason missing from `GateBlockedReason` fails to compile here. */
|
|
4
5
|
const GATE_BLOCKED_ERROR_CLASSES = {
|
|
5
6
|
'schema-invalid': 'gate.blocked.schema-invalid',
|
|
@@ -7,6 +8,10 @@ const GATE_BLOCKED_ERROR_CLASSES = {
|
|
|
7
8
|
'not-committed': 'gate.blocked.not-committed',
|
|
8
9
|
'deps-missing': 'gate.blocked.deps-missing',
|
|
9
10
|
'judges-outstanding': 'gate.blocked.judges-outstanding',
|
|
11
|
+
'uiux-spec-missing': 'gate.blocked.uiux-spec-missing',
|
|
12
|
+
'screenshots-missing': 'gate.blocked.screenshots-missing',
|
|
13
|
+
'screenshots-stale': 'gate.blocked.screenshots-stale',
|
|
14
|
+
'uiux-visual-missing': 'gate.blocked.uiux-visual-missing',
|
|
10
15
|
'grader-invalid': 'gate.blocked.grader-invalid',
|
|
11
16
|
'grader-fail': 'gate.blocked.grader-fail',
|
|
12
17
|
'tests-failed': 'gate.blocked.tests-failed',
|
|
@@ -20,6 +25,10 @@ const BLOCKING_SEVERITY = 'S2-major';
|
|
|
20
25
|
const GATE_OUTCOME_EVENT_TYPE = 'gate-outcome';
|
|
21
26
|
const ERROR_LOGGED_EVENT_TYPE = 'error-logged';
|
|
22
27
|
const TASK_ADDED_EVENT_TYPE = 'task-added';
|
|
28
|
+
/** `agents-registry.ts`'s `DISPATCH_EVENT_TYPE`, hand-restated rather than
|
|
29
|
+
* imported -- this module's only imports are `node:crypto` and two local,
|
|
30
|
+
* type-only siblings, on purpose (see the file banner). */
|
|
31
|
+
const DISPATCH_DECISION_EVENT_TYPE = 'dispatch_decision';
|
|
23
32
|
/**
|
|
24
33
|
* Source 2: `task_status: failed` is set in exactly one place in the whole
|
|
25
34
|
* projector, `db/projector.ts`'s `case 'task-added'` arm copying
|
|
@@ -29,8 +38,6 @@ const TASK_ADDED_EVENT_TYPE = 'task-added';
|
|
|
29
38
|
* carries no failure discriminator beyond that.
|
|
30
39
|
*/
|
|
31
40
|
const TASK_FAILED_ERROR_CLASS = 'task.failed';
|
|
32
|
-
/** Default project for an event stamped with none (events.ts's documented convention; the writer never sets it, only read helpers default it). */
|
|
33
|
-
const DEFAULT_PROJECT = 'black-smith';
|
|
34
41
|
function asString(value) {
|
|
35
42
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
36
43
|
}
|
|
@@ -118,20 +125,159 @@ const CANDIDATE_READERS = Object.freeze({
|
|
|
118
125
|
* would be reported outside the window the operator asked about.
|
|
119
126
|
*/
|
|
120
127
|
export const ISSUE_CANDIDATE_EVENT_TYPES = new Set(Object.keys(CANDIDATE_READERS));
|
|
128
|
+
/** The row's task ref, off the envelope first and the payload second -- the same two places every `CandidateReader` above already looks. */
|
|
129
|
+
function candidateTaskRef(record) {
|
|
130
|
+
const payload = (record.payload ?? {});
|
|
131
|
+
return asString(record.task_id) ?? asString(payload.task_ref);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Event types allowed to stamp a session's project for
|
|
135
|
+
* `sessionProjectStamps` below: the issue candidates themselves, plus
|
|
136
|
+
* `task-added` and `dispatch_decision` rows recording where a task was
|
|
137
|
+
* planned or dispatched. Deliberately excludes `issue-reported` and every
|
|
138
|
+
* other reporter-authored row -- those carry this module's own *guess* at a
|
|
139
|
+
* row's project (see `appendIssueReported` in `issueReporter.ts`), not an
|
|
140
|
+
* independent origin stamp, so folding them back in here would let one run's
|
|
141
|
+
* guess leak into the next run's resolution of an unrelated, unstamped row
|
|
142
|
+
* in the same session.
|
|
143
|
+
*/
|
|
144
|
+
const ORIGIN_STAMP_EVENT_TYPES = new Set([
|
|
145
|
+
...ISSUE_CANDIDATE_EVENT_TYPES,
|
|
146
|
+
DISPATCH_DECISION_EVENT_TYPE,
|
|
147
|
+
]);
|
|
148
|
+
/**
|
|
149
|
+
* The session's project, from its origin-stamped rows only -- and only when
|
|
150
|
+
* every one of them agrees. Real sessions mix dozens of stamped rows with
|
|
151
|
+
* unstamped ones (evidence: a foreign project's session stamps some events
|
|
152
|
+
* and not others); a unanimous stamped sibling in the same session is strong
|
|
153
|
+
* evidence for an unstamped one. A session whose origin stamps disagree
|
|
154
|
+
* tells us nothing safe to guess, so it answers unresolved rather than
|
|
155
|
+
* picking whichever stamp happened to come first.
|
|
156
|
+
*/
|
|
157
|
+
function sessionProjectStamps(events) {
|
|
158
|
+
const bySession = new Map();
|
|
159
|
+
for (const { record } of events) {
|
|
160
|
+
if (!ORIGIN_STAMP_EVENT_TYPES.has(record.event_type))
|
|
161
|
+
continue;
|
|
162
|
+
const stamped = asString(record.project);
|
|
163
|
+
if (stamped === undefined)
|
|
164
|
+
continue;
|
|
165
|
+
const project = normalizeProjectName(stamped);
|
|
166
|
+
const seen = bySession.get(record.session_id);
|
|
167
|
+
if (seen)
|
|
168
|
+
seen.add(project);
|
|
169
|
+
else
|
|
170
|
+
bySession.set(record.session_id, new Set([project]));
|
|
171
|
+
}
|
|
172
|
+
const stamps = new Map();
|
|
173
|
+
for (const [sessionId, projects] of bySession) {
|
|
174
|
+
if (projects.size !== 1)
|
|
175
|
+
continue;
|
|
176
|
+
const [project] = projects;
|
|
177
|
+
if (project !== undefined)
|
|
178
|
+
stamps.set(sessionId, project);
|
|
179
|
+
}
|
|
180
|
+
return stamps;
|
|
181
|
+
}
|
|
182
|
+
function taskAddedKey(sessionId, taskId) {
|
|
183
|
+
return `${sessionId}\u0000${taskId}`;
|
|
184
|
+
}
|
|
185
|
+
/** `task-added`'s own `payload.epic_id`, keyed by session and task id, so a bare ref (no epic segment) can still be widened to `<epic>/<ref>` before asking the resolver. */
|
|
186
|
+
function taskAddedEpicIds(events) {
|
|
187
|
+
const out = new Map();
|
|
188
|
+
for (const { record } of events) {
|
|
189
|
+
if (record.event_type !== TASK_ADDED_EVENT_TYPE)
|
|
190
|
+
continue;
|
|
191
|
+
const taskId = asString(record.task_id);
|
|
192
|
+
const epicId = asString(record.payload?.epic_id);
|
|
193
|
+
if (taskId !== undefined && epicId !== undefined) {
|
|
194
|
+
out.set(taskAddedKey(record.session_id, taskId), epicId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Widens a per-epic `ResolveProjectForTaskRef` into a `RowProjectResolver`
|
|
201
|
+
* with three session-scoped fallbacks a single task ref cannot answer alone,
|
|
202
|
+
* tried strictly in this order (S2-c):
|
|
203
|
+
*
|
|
204
|
+
* 1. `resolveProject`, the strict/direct resolver -- a bare ref (no epic
|
|
205
|
+
* segment) tried again as `<epic>/<ref>` using the same session's own
|
|
206
|
+
* `task-added` row when the bare ref alone cannot answer.
|
|
207
|
+
* 2. The session stamp: the ONE project every origin-stamped row in the same
|
|
208
|
+
* session agrees on (`sessionProjectStamps`), when unanimous.
|
|
209
|
+
* 3. `selfFallback`, tried last and only when neither of the above could
|
|
210
|
+
* answer -- same widening as step 1.
|
|
211
|
+
*
|
|
212
|
+
* Self-fallback sits last on purpose: it is "no plan says otherwise, and a
|
|
213
|
+
* plan exists locally for this epic, so guess this factory's own name" --
|
|
214
|
+
* the weakest of the three signals. A session's OWN unanimous stamp naming a
|
|
215
|
+
* DIFFERENT project is stronger evidence than that guess, and must not be
|
|
216
|
+
* overridden by it just because the same session also has an unrelated,
|
|
217
|
+
* unstamped row whose epic happens to have a local, unlabeled plan. Before
|
|
218
|
+
* this order existed, the self-fallback was folded into `resolveProject`
|
|
219
|
+
* itself (step 1) and so always outran the session stamp.
|
|
220
|
+
*
|
|
221
|
+
* Exported so `issueReporter.ts`'s `priorOpenReports` can apply the identical
|
|
222
|
+
* fallback to the same event snapshot.
|
|
223
|
+
*/
|
|
224
|
+
export function withSessionFallback(events, resolveProject, selfFallback = () => null) {
|
|
225
|
+
const stamps = sessionProjectStamps(events);
|
|
226
|
+
const epicIds = taskAddedEpicIds(events);
|
|
227
|
+
const tryResolve = (resolve, taskRef, sessionId) => {
|
|
228
|
+
if (taskRef === undefined)
|
|
229
|
+
return null;
|
|
230
|
+
const direct = resolve(taskRef);
|
|
231
|
+
if (direct !== null)
|
|
232
|
+
return direct;
|
|
233
|
+
if (sessionId === undefined)
|
|
234
|
+
return null;
|
|
235
|
+
const epicId = epicIds.get(taskAddedKey(sessionId, taskRef));
|
|
236
|
+
if (epicId === undefined)
|
|
237
|
+
return null;
|
|
238
|
+
return resolve(`${epicId}/${taskRef}`);
|
|
239
|
+
};
|
|
240
|
+
return (taskRef, sessionId) => {
|
|
241
|
+
const direct = tryResolve(resolveProject, taskRef, sessionId);
|
|
242
|
+
if (direct !== null)
|
|
243
|
+
return direct;
|
|
244
|
+
if (sessionId !== undefined) {
|
|
245
|
+
const stamp = stamps.get(sessionId);
|
|
246
|
+
if (stamp !== undefined)
|
|
247
|
+
return stamp;
|
|
248
|
+
}
|
|
249
|
+
return tryResolve(selfFallback, taskRef, sessionId);
|
|
250
|
+
};
|
|
251
|
+
}
|
|
121
252
|
/** One event to a `Candidate`, or `'ignore'` when no source claims its event type at all. */
|
|
122
|
-
function toCandidate(event) {
|
|
253
|
+
function toCandidate(event, resolveRow) {
|
|
123
254
|
const { record } = event;
|
|
124
255
|
const read = CANDIDATE_READERS[record.event_type];
|
|
125
256
|
if (!read)
|
|
126
257
|
return 'ignore';
|
|
258
|
+
const stamped = asString(record.project);
|
|
259
|
+
const taskRef = candidateTaskRef(record);
|
|
260
|
+
const sessionId = asString(record.session_id);
|
|
261
|
+
const project = stamped === undefined ? resolveRow(taskRef, sessionId) : normalizeProjectName(stamped);
|
|
127
262
|
return read(event, {
|
|
128
263
|
payload: record.payload ?? {},
|
|
129
|
-
project
|
|
130
|
-
sessionId
|
|
264
|
+
project,
|
|
265
|
+
sessionId,
|
|
131
266
|
planVersion: asNumber(record.plan_version) ?? null,
|
|
132
267
|
ts: asString(record.ts),
|
|
133
268
|
});
|
|
134
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* A row whose project cannot be resolved at all still needs a stable
|
|
272
|
+
* fingerprint-grouping key -- otherwise two unrelated foreign projects'
|
|
273
|
+
* rows sharing `(source, error_class, task_ref)` would incorrectly group
|
|
274
|
+
* together and leak one row's session/epic/severity metadata onto the
|
|
275
|
+
* other's report, inside this factory's own private log (both rows are
|
|
276
|
+
* always skipped before anything is filed publicly, but the log itself
|
|
277
|
+
* should stay correct). `task_ref` alone already disambiguates in practice
|
|
278
|
+
* (refs are namespaced by epic id), so a single fixed sentinel is enough.
|
|
279
|
+
*/
|
|
280
|
+
const UNRESOLVED_PROJECT_FINGERPRINT_KEY = '\u0000unresolved-project\u0000';
|
|
135
281
|
/**
|
|
136
282
|
* The first 16 hex characters of a SHA-256 over the NUL-joined tuple
|
|
137
283
|
* `project`, `source`, `error_class`, `task_ref`. Deliberately excludes
|
|
@@ -140,27 +286,52 @@ function toCandidate(event) {
|
|
|
140
286
|
* and including any of them would open one issue per round instead of one.
|
|
141
287
|
*/
|
|
142
288
|
function computeFingerprint(project, source, errorClass, taskRef) {
|
|
143
|
-
const material = [
|
|
289
|
+
const material = [
|
|
290
|
+
project ?? UNRESOLVED_PROJECT_FINGERPRINT_KEY,
|
|
291
|
+
source,
|
|
292
|
+
errorClass,
|
|
293
|
+
taskRef,
|
|
294
|
+
].join('\0');
|
|
144
295
|
return createHash('sha256').update(material).digest('hex').slice(0, 16);
|
|
145
296
|
}
|
|
146
297
|
/**
|
|
147
298
|
* Fold a stored event sequence into reportable errors. Pure: two calls with
|
|
148
299
|
* identical input return byte-identical output. `now` is threaded in rather
|
|
149
300
|
* than read off the clock; nothing in this contract branches on it yet.
|
|
301
|
+
*
|
|
302
|
+
* `resolveProject` and `selfFallback` both default to "no answer" so the
|
|
303
|
+
* ~50 existing call sites across this module's own tests and
|
|
304
|
+
* `issueReporter.ts`'s need no change. An unstamped row still unresolved
|
|
305
|
+
* after `withSessionFallback`'s three fallbacks reports `project: null`
|
|
306
|
+
* rather than this factory's own name -- `isProjectEnabled` is never asked
|
|
307
|
+
* about a row it cannot identify, and the decision to skip filing it
|
|
308
|
+
* belongs to `issueReporter.ts`, the one caller that goes on to call `gh`.
|
|
309
|
+
*
|
|
310
|
+
* `scopeEvents` defaults to `events` itself -- the session-stamp/`task-added`
|
|
311
|
+
* maps `withSessionFallback` builds read the same rows this fold iterates,
|
|
312
|
+
* same as before this parameter existed. A caller that narrows `events` to a
|
|
313
|
+
* candidate WINDOW (`cli.ts`'s `--epic`/`--since`) passes the full, unscoped
|
|
314
|
+
* lineage here instead (S3), so a session-mate's stamp sitting outside that
|
|
315
|
+
* window still counts as evidence for a row inside it -- scoping which rows
|
|
316
|
+
* get REPORTED must not also scope which rows get CONSULTED, or the same
|
|
317
|
+
* unstamped row resolves to a different project (and hashes a different
|
|
318
|
+
* fingerprint, S1-b) depending on whether the caller happened to pass
|
|
319
|
+
* `--epic`.
|
|
150
320
|
*/
|
|
151
|
-
export function foldErrorEvents(events, now, isProjectEnabled) {
|
|
321
|
+
export function foldErrorEvents(events, now, isProjectEnabled, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
|
|
152
322
|
void now;
|
|
323
|
+
const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
|
|
153
324
|
const candidates = [];
|
|
154
325
|
let skipped = 0;
|
|
155
326
|
for (const event of events) {
|
|
156
|
-
const outcome = toCandidate(event);
|
|
327
|
+
const outcome = toCandidate(event, resolveRow);
|
|
157
328
|
if (outcome === 'ignore')
|
|
158
329
|
continue;
|
|
159
330
|
if (outcome === null) {
|
|
160
331
|
skipped += 1;
|
|
161
332
|
continue;
|
|
162
333
|
}
|
|
163
|
-
if (!isProjectEnabled(outcome.project))
|
|
334
|
+
if (outcome.project !== null && !isProjectEnabled(outcome.project))
|
|
164
335
|
continue;
|
|
165
336
|
candidates.push(outcome);
|
|
166
337
|
}
|
|
@@ -204,7 +375,7 @@ function fingerprintLine(fingerprint) {
|
|
|
204
375
|
}
|
|
205
376
|
/** The local, read-only command an operator runs to see the detail this body/comment deliberately omit. */
|
|
206
377
|
function smithEventCommand(sessionId) {
|
|
207
|
-
return `
|
|
378
|
+
return `bs event tail ${sessionId} --lineage`;
|
|
208
379
|
}
|
|
209
380
|
/** Builds a body-renderer input record from a folded report -- the record's key set is the allowlist; nothing else can reach `renderBody`. */
|
|
210
381
|
export function toIssueBodyFields(report) {
|
|
@@ -245,7 +416,7 @@ export function renderBody(fields) {
|
|
|
245
416
|
`Session: ${fields.session_id}`,
|
|
246
417
|
`Epic: ${fields.epic_id ?? '(none)'}`,
|
|
247
418
|
`Plan version: ${fields.plan_version ?? '(unknown)'}`,
|
|
248
|
-
`Project: ${fields.project}`,
|
|
419
|
+
`Project: ${fields.project ?? '(unknown)'}`,
|
|
249
420
|
`Source: ${fields.source}`,
|
|
250
421
|
fingerprintLine(fields.fingerprint),
|
|
251
422
|
'',
|
|
@@ -3,8 +3,8 @@ import { bareTaskId, epicOfTaskId, isQualifiedTaskId, taskIdsMatch } from './tas
|
|
|
3
3
|
/**
|
|
4
4
|
* Roles whose dispatch constitutes a new round on the task.
|
|
5
5
|
*
|
|
6
|
-
* The split follows budgets.yml:
|
|
7
|
-
*
|
|
6
|
+
* The split follows budgets.yml: its judges (spec-reviewer, reviewer, verifier,
|
|
7
|
+
* grader) read a diff, and `context_window.narrowing_roles` adds
|
|
8
8
|
* security-reviewer and merger — those roles read a diff, they do not produce
|
|
9
9
|
* the one the gate scores. planner/researcher/scribe produce no task diff
|
|
10
10
|
* either. What is left is what a "round" means: someone built again.
|