@juzser/blacksmith 0.4.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} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- 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} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -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} +4 -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 +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- 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 +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- 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 +109 -53
- 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 +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- 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 +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- 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 +13 -37
- 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 +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -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/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 +4 -4
- 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/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- 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 +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- 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 +7 -7
- 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
|
@@ -4,7 +4,7 @@ import { claimCoversPath } from './claims.js';
|
|
|
4
4
|
import { SmithError } from './errors.js';
|
|
5
5
|
import { appendEvent, readLineageEvents } from './events.js';
|
|
6
6
|
import { describeType } from './plan.js';
|
|
7
|
-
import { compileSchemas, validateRecord } from './schemas.js';
|
|
7
|
+
import { compileSchemas, validateEachShape, validateRecord, } from './schemas.js';
|
|
8
8
|
import { readAddedTasks } from './taskEvents.js';
|
|
9
9
|
import { bareTaskId, epicOfTaskId, taskIdsMatch } from './taskId.js';
|
|
10
10
|
import { loadTaxonomy, validateTag } from './taxonomy.js';
|
|
@@ -326,6 +326,27 @@ const ORCHESTRATOR_OWNED_FINDING_FIELDS = [
|
|
|
326
326
|
function mintFindingId(taskId, fingerprint) {
|
|
327
327
|
return `f-${taskId}-${fingerprint.slice(0, 8)}`;
|
|
328
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* A shallow clone with the orchestrator-owned fields removed, so the
|
|
331
|
+
* schema-shape check below runs against exactly the judge's half of the
|
|
332
|
+
* contract. `finding-evidence.schema.json` declares `additionalProperties:
|
|
333
|
+
* false` and never lists these six fields — they are the orchestrator's to
|
|
334
|
+
* mint, never the judge's to write (interview N-2) — so validating a raw
|
|
335
|
+
* evidence item against it, un-stripped, would reject every legitimate item
|
|
336
|
+
* that happens to be re-minted from a stored finding and would make the
|
|
337
|
+
* dedicated `findings.evidence-carries-identity` error below unreachable.
|
|
338
|
+
*/
|
|
339
|
+
function stripOwnedFields(item) {
|
|
340
|
+
const clone = { ...item };
|
|
341
|
+
for (const field of ORCHESTRATOR_OWNED_FINDING_FIELDS)
|
|
342
|
+
delete clone[field];
|
|
343
|
+
return clone;
|
|
344
|
+
}
|
|
345
|
+
function describeShapeFailures(failures) {
|
|
346
|
+
return failures
|
|
347
|
+
.map((f) => `index ${f.index} (${f.errors.map((e) => e.message).join('; ')})`)
|
|
348
|
+
.join(', ');
|
|
349
|
+
}
|
|
329
350
|
/**
|
|
330
351
|
* Guess which taxonomy value a judge meant when it wrote something close but
|
|
331
352
|
* not equal to one. Derived from the vocabulary itself rather than a synonym
|
|
@@ -367,7 +388,20 @@ function suggestCanonical(values, written) {
|
|
|
367
388
|
* `test-gap` for `test-coverage` cost a whole gate round that way.
|
|
368
389
|
*/
|
|
369
390
|
export function mintFindings(evidence, ctx, opts = {}) {
|
|
370
|
-
const { taxonomy } = resolveTaxonomyAndSchemas(opts);
|
|
391
|
+
const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
|
|
392
|
+
// Issue #233: a wrong field name (`category`/`title`/`evidence` for
|
|
393
|
+
// `finding_category`/`summary`/none-of-those) or a wrong-shaped field
|
|
394
|
+
// (`failure_scenario` given as a string instead of {inputs, expected,
|
|
395
|
+
// actual}) used to reach `suggestCanonical`/`computeFingerprint` below and
|
|
396
|
+
// crash with a raw TypeError instead of a FindingError. Checked here,
|
|
397
|
+
// against the schema `finding-evidence.schema.json` already documents as
|
|
398
|
+
// the judge's half of the contract, every item's shape is settled before
|
|
399
|
+
// any item's fingerprint is computed or any finding is minted — so a batch
|
|
400
|
+
// with one bad item mints nothing, not "the good ones, then a crash."
|
|
401
|
+
const shapeFailures = validateEachShape(schemas, 'finding-evidence', evidence.map(stripOwnedFields));
|
|
402
|
+
if (shapeFailures.length > 0) {
|
|
403
|
+
throw new FindingError('findings.invalid-evidence', `Finding evidence does not match finding-evidence.schema.json at ${describeShapeFailures(shapeFailures)}. Expected {file_path, finding_category, severity, summary, failure_scenario: {inputs, expected, actual}} (criterion_ref optional).`, { failures: shapeFailures });
|
|
404
|
+
}
|
|
371
405
|
const severities = taxonomy.dimensions.severity ?? [];
|
|
372
406
|
const categories = taxonomy.dimensions.finding_category ?? [];
|
|
373
407
|
return evidence.map((item, index) => {
|
|
@@ -531,7 +565,7 @@ export async function raiseFinding(input, ctx, opts = {}) {
|
|
|
531
565
|
}, opts);
|
|
532
566
|
return { suppressed: true, fingerprint, taskId: finding.task_id };
|
|
533
567
|
}
|
|
534
|
-
await appendEvent({
|
|
568
|
+
const stored = await appendEvent({
|
|
535
569
|
session_id: ctx.sessionId,
|
|
536
570
|
actor: ctx.actor ?? finding.found_by,
|
|
537
571
|
event_type: 'finding-raised',
|
|
@@ -540,7 +574,7 @@ export async function raiseFinding(input, ctx, opts = {}) {
|
|
|
540
574
|
causal_parent: ctx.causalParent,
|
|
541
575
|
payload: finding,
|
|
542
576
|
}, opts);
|
|
543
|
-
return { suppressed: false, finding };
|
|
577
|
+
return { suppressed: false, finding, event_id: stored.event_id };
|
|
544
578
|
}
|
|
545
579
|
/**
|
|
546
580
|
* Current state of every finding in this session's LINEAGE: a fold, never a
|
|
@@ -870,7 +904,7 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
|
|
|
870
904
|
// answer to a caller who named none, rather than a status complaint.
|
|
871
905
|
if (current.finding_status === 'waived') {
|
|
872
906
|
if (extra.waiverRevokedBy === undefined) {
|
|
873
|
-
throw new FindingError('findings.waiver-revocation-unproven', `Finding "${findingId}" is waived, and a waiver is undone by revoking it, not by typing a status at it. Record the operator's reversal first — "
|
|
907
|
+
throw new FindingError('findings.waiver-revocation-unproven', `Finding "${findingId}" is waived, and a waiver is undone by revoking it, not by typing a status at it. Record the operator's reversal first — "bs waiver apply" with a "denied" decision on this fingerprint reopens the finding as part of writing the denial.`, { findingId, to: newStatus });
|
|
874
908
|
}
|
|
875
909
|
if (newStatus !== preWaiverStatus) {
|
|
876
910
|
throw new FindingError('findings.waiver-revocation-wrong-status', `Finding "${findingId}" was waived out of "${preWaiverStatus ?? 'an unrecorded status'}", so revoking that waiver returns it there, not to "${newStatus}". A revocation restores what the waiver closed over; it does not re-grade the finding.`, { findingId, to: newStatus, preWaiverStatus: preWaiverStatus ?? null });
|
|
@@ -883,14 +917,14 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
|
|
|
883
917
|
throw new FindingError('findings.not-amendable', `Finding "${findingId}" is scoped "${findingScope(current)}", so it cannot take the amendment path at "${newStatus}": only a spec finding closes by amending the plan. A defect in the diff is closed by fixing the diff.`, { findingId, scope: findingScope(current), to: newStatus });
|
|
884
918
|
}
|
|
885
919
|
if (newStatus === AMEND_PENDING_STATUS && (extra.amendsTaskIds?.length ?? 0) === 0) {
|
|
886
|
-
throw new FindingError('findings.amendment-without-obligation', `Finding "${findingId}" cannot enter "${AMEND_PENDING_STATUS}" naming no task ids. An amendment that obligates nothing discharges the finding the moment it is written, which is D-127 — say which added or superseded task this finding is now waiting on. "
|
|
920
|
+
throw new FindingError('findings.amendment-without-obligation', `Finding "${findingId}" cannot enter "${AMEND_PENDING_STATUS}" naming no task ids. An amendment that obligates nothing discharges the finding the moment it is written, which is D-127 — say which added or superseded task this finding is now waiting on. "bs plan amend" derives that list from the plan diff and is the only verb that does (D-136).`, { findingId });
|
|
887
921
|
}
|
|
888
922
|
if (newStatus === AMENDED_STATUS) {
|
|
889
923
|
const outstanding = outstandingObligations(current, extra.amendsSatisfiedBy ?? []);
|
|
890
924
|
if (outstanding === null || outstanding.length > 0) {
|
|
891
925
|
throw new FindingError('findings.amendment-not-discharged', outstanding === null
|
|
892
926
|
? `Finding "${findingId}" is at "${AMEND_PENDING_STATUS}" but names no task ids at a plan version, so no evidence can discharge it. Expire it at the epic boundary instead.`
|
|
893
|
-
: `Finding "${findingId}" cannot close at "${AMENDED_STATUS}": ${outstanding.join(', ')} has not been shown to have landed at plan version ${current.amends_plan_version} or later. An amendment is discharged by its tasks landing, not by being told they did — this is D-127. "
|
|
927
|
+
: `Finding "${findingId}" cannot close at "${AMENDED_STATUS}": ${outstanding.join(', ')} has not been shown to have landed at plan version ${current.amends_plan_version} or later. An amendment is discharged by its tasks landing, not by being told they did — this is D-127. "bs epic close" computes and supplies that evidence.`, {
|
|
894
928
|
findingId,
|
|
895
929
|
outstanding: outstanding ?? [],
|
|
896
930
|
amendsPlanVersion: current.amends_plan_version,
|
|
@@ -918,7 +952,7 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
|
|
|
918
952
|
// landed tasks and their versions rather than having to re-derive them.
|
|
919
953
|
if (extra.amendsSatisfiedBy !== undefined)
|
|
920
954
|
payload.amends_satisfied_by = extra.amendsSatisfiedBy.map((row) => ({ ...row }));
|
|
921
|
-
await appendEvent({
|
|
955
|
+
const stored = await appendEvent({
|
|
922
956
|
session_id: ctx.sessionId,
|
|
923
957
|
actor: ctx.actor ?? 'system',
|
|
924
958
|
event_type: 'finding-transitioned',
|
|
@@ -935,6 +969,12 @@ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {
|
|
|
935
969
|
...(extra.amendsPlanVersion !== undefined
|
|
936
970
|
? { amends_plan_version: extra.amendsPlanVersion }
|
|
937
971
|
: {}),
|
|
972
|
+
// #286: the id of the `finding-transitioned` event this call just
|
|
973
|
+
// appended, the same shape #238 gave `judge report` -- so a caller
|
|
974
|
+
// chaining transitions as `--causal-parent` has it without falling back
|
|
975
|
+
// to "log line count - 1", which names a sibling's event under parallel
|
|
976
|
+
// writers.
|
|
977
|
+
event_id: stored.event_id,
|
|
938
978
|
};
|
|
939
979
|
// A reopened finding carrying the id of the waiver that no longer holds it
|
|
940
980
|
// is the same split state D-180 is about, one field smaller.
|
|
@@ -1068,7 +1108,7 @@ export async function repairObligation(input, ctx, opts = {}) {
|
|
|
1068
1108
|
if (malformedEntries.length === 0) {
|
|
1069
1109
|
throw new FindingError('findings.repair-not-corrupt', `Finding "${input.findingId}"'s obligation names ${original.length} task id(s), all ` +
|
|
1070
1110
|
'well-formed — there is nothing to repair. This verb repairs malformed amends_task_ids ' +
|
|
1071
|
-
'data; it never renegotiates a well-formed obligation. Use "
|
|
1111
|
+
'data; it never renegotiates a well-formed obligation. Use "bs plan amend" to change ' +
|
|
1072
1112
|
'what a finding is waiting on.', { findingId: input.findingId });
|
|
1073
1113
|
}
|
|
1074
1114
|
// Guard 4: reason required, non-empty. An unaudited repair is not a repair.
|
|
@@ -1116,7 +1156,7 @@ export async function repairObligation(input, ctx, opts = {}) {
|
|
|
1116
1156
|
if (current.amends_plan_version !== undefined) {
|
|
1117
1157
|
payload.amends_plan_version = current.amends_plan_version;
|
|
1118
1158
|
}
|
|
1119
|
-
await appendEvent({
|
|
1159
|
+
const stored = await appendEvent({
|
|
1120
1160
|
session_id: ctx.sessionId,
|
|
1121
1161
|
actor: ctx.actor ?? 'user',
|
|
1122
1162
|
event_type: FINDING_OBLIGATION_REPAIRED_EVENT_TYPE,
|
|
@@ -1125,7 +1165,12 @@ export async function repairObligation(input, ctx, opts = {}) {
|
|
|
1125
1165
|
causal_parent: ctx.causalParent,
|
|
1126
1166
|
payload,
|
|
1127
1167
|
}, opts);
|
|
1128
|
-
return {
|
|
1168
|
+
return {
|
|
1169
|
+
...current,
|
|
1170
|
+
amends_task_ids: toObligation,
|
|
1171
|
+
obligation_repair_reason: reason,
|
|
1172
|
+
event_id: stored.event_id,
|
|
1173
|
+
};
|
|
1129
1174
|
}
|
|
1130
1175
|
/**
|
|
1131
1176
|
* Which open findings are answering about code that no longer exists.
|
|
@@ -1204,9 +1249,34 @@ export async function staleFindings(sessionId, opts = {}) {
|
|
|
1204
1249
|
const filePath = finding.file_path;
|
|
1205
1250
|
if (filePath === undefined)
|
|
1206
1251
|
continue;
|
|
1252
|
+
// #217: the finding's OWN task merging its file is not a rewrite — it is
|
|
1253
|
+
// exactly the diff the reviewer read when raising the finding, so
|
|
1254
|
+
// treating it as staleness meant a waiver batch refused to grant over
|
|
1255
|
+
// evidence a merge had not touched at all. But a re-cut does not always
|
|
1256
|
+
// get a new task_id: a same-id supersede (spec.ts's "a same-id supersede
|
|
1257
|
+
// needs no pairing") keeps the original id, so the same task_id CAN
|
|
1258
|
+
// appear in `merges` twice — once for the diff already read, once for a
|
|
1259
|
+
// genuine later rewrite. Only that one reviewed-diff merge is excluded;
|
|
1260
|
+
// a second one is real evidence of a rewrite and must still be able to
|
|
1261
|
+
// mark the finding stale.
|
|
1262
|
+
//
|
|
1263
|
+
// The reviewed-diff merge is anchored to `raisedIndex`, not `freshUntil`:
|
|
1264
|
+
// it is the first merge of the finding's own task after the RAISE, and a
|
|
1265
|
+
// later reverify must not shift which merge that is. Anchoring to
|
|
1266
|
+
// `freshUntil` instead let raise → v1 merge → reverify → v2 merge skip
|
|
1267
|
+
// TWO merges — `freshUntil` (moved past v1 by the reverify) filtered v1
|
|
1268
|
+
// out of the loop before the self-merge check ever saw it, and the
|
|
1269
|
+
// loop's "first self-merge encountered" rule then wrongly treated v2,
|
|
1270
|
+
// the genuine post-reverify rewrite, as if it were the reviewed diff.
|
|
1271
|
+
// Computed once by identity (its event index) rather than re-derived
|
|
1272
|
+
// inside the loop, so the freshUntil filter below cannot shift which
|
|
1273
|
+
// merge counts as "already read".
|
|
1274
|
+
const reviewedMergeIndex = merges.find((merge) => merge.index > raisedIndex && taskIdsMatch(merge.taskId, finding.task_id))?.index;
|
|
1207
1275
|
for (const merge of merges) {
|
|
1208
1276
|
if (merge.index <= freshUntil)
|
|
1209
1277
|
continue;
|
|
1278
|
+
if (merge.index === reviewedMergeIndex)
|
|
1279
|
+
continue;
|
|
1210
1280
|
if (merge.filesChanged) {
|
|
1211
1281
|
const hit = merge.filesChanged.find((changed) => normalizeFilePath(changed) === filePath);
|
|
1212
1282
|
if (hit === undefined)
|
|
@@ -1260,7 +1330,7 @@ export async function reverifyFinding(findingId, note, ctx, opts = {}) {
|
|
|
1260
1330
|
findingId,
|
|
1261
1331
|
});
|
|
1262
1332
|
}
|
|
1263
|
-
await appendEvent({
|
|
1333
|
+
const stored = await appendEvent({
|
|
1264
1334
|
session_id: ctx.sessionId,
|
|
1265
1335
|
actor: ctx.actor ?? 'system',
|
|
1266
1336
|
event_type: 'finding-reverified',
|
|
@@ -1274,4 +1344,5 @@ export async function reverifyFinding(findingId, note, ctx, opts = {}) {
|
|
|
1274
1344
|
note,
|
|
1275
1345
|
},
|
|
1276
1346
|
}, opts);
|
|
1347
|
+
return { event_id: stored.event_id };
|
|
1277
1348
|
}
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { TASK_RESULT_EVENT_TYPE } from './agents-registry.js';
|
|
5
5
|
import { checkArtifacts } from './artifacts.js';
|
|
6
6
|
import { recordReattribution, routeFindings } from './attribution.js';
|
|
7
|
+
import { readTokenUsage } from './budgetAlarm.js';
|
|
7
8
|
import { checkTaskBudget } from './budgets.js';
|
|
8
9
|
import { certifyCommit } from './commit.js';
|
|
9
10
|
import { collectCoverageEvidence } from './coverage.js';
|
|
@@ -11,6 +12,7 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
|
11
12
|
import { DiffstatError, measureDiff } from './diffstat.js';
|
|
12
13
|
import { appendEvent, readLineageEvents } from './events.js';
|
|
13
14
|
import { findingScope, raiseFinding, SPEC_FINDING_SCOPE, transition, } from './findings.js';
|
|
15
|
+
import { runGit } from './git.js';
|
|
14
16
|
import { outstandingJudges, readJudgeTurns } from './judges.js';
|
|
15
17
|
import { enabledExternalProviders, findingJudgeRequest, nativeProviderName, runQuorumCase, } from './quorum.js';
|
|
16
18
|
import { compileSchemas, validateRecord, } from './schemas.js';
|
|
@@ -19,6 +21,7 @@ import { readAddedTasks } from './taskEvents.js';
|
|
|
19
21
|
import { taskIdsMatch } from './taskId.js';
|
|
20
22
|
import { loadTaxonomy } from './taxonomy.js';
|
|
21
23
|
import { run as runTestgate } from './testgate.js';
|
|
24
|
+
import { checkUiux } from './uiuxGate.js';
|
|
22
25
|
let cachedTaxonomy;
|
|
23
26
|
let cachedSchemas;
|
|
24
27
|
function resolveTaxonomyAndSchemas(opts) {
|
|
@@ -31,7 +34,7 @@ function resolveTaxonomyAndSchemas(opts) {
|
|
|
31
34
|
return { taxonomy, schemas };
|
|
32
35
|
}
|
|
33
36
|
async function emit(eventType, payload, taskId, ctx, opts) {
|
|
34
|
-
|
|
37
|
+
return appendEvent({
|
|
35
38
|
session_id: ctx.sessionId,
|
|
36
39
|
actor: ctx.actor ?? 'system',
|
|
37
40
|
event_type: eventType,
|
|
@@ -97,7 +100,7 @@ function resultHash(result) {
|
|
|
97
100
|
async function recordResult(result, taskId, ctx, opts) {
|
|
98
101
|
const hash = resultHash(result);
|
|
99
102
|
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
100
|
-
for (const { record } of events) {
|
|
103
|
+
for (const { event_id, record } of events) {
|
|
101
104
|
if (record.event_type !== TASK_RESULT_EVENT_TYPE)
|
|
102
105
|
continue;
|
|
103
106
|
// Matched, not compared: the same task reaches the log under both
|
|
@@ -111,9 +114,78 @@ async function recordResult(result, taskId, ctx, opts) {
|
|
|
111
114
|
// Result verbatim (db/projector.ts reads it as one), and a hash field
|
|
112
115
|
// wedged in beside it would be a Result that no longer validates.
|
|
113
116
|
if (resultHash(record.payload) === hash)
|
|
114
|
-
return;
|
|
117
|
+
return { eventId: event_id, deduped: true };
|
|
115
118
|
}
|
|
116
|
-
await emit(TASK_RESULT_EVENT_TYPE, result, taskId, ctx, opts);
|
|
119
|
+
const stored = await emit(TASK_RESULT_EVENT_TYPE, result, taskId, ctx, opts);
|
|
120
|
+
return { eventId: stored.event_id, deduped: false };
|
|
121
|
+
}
|
|
122
|
+
/** `name-desktop-light.png` and the three siblings a visual pass has to cover. */
|
|
123
|
+
const SCREENSHOT_RE = /-(desktop|mobile)-(light|dark)\.png$/;
|
|
124
|
+
function screenshotsFromArtifacts(artifacts) {
|
|
125
|
+
const shots = [];
|
|
126
|
+
for (const a of artifacts) {
|
|
127
|
+
const match = SCREENSHOT_RE.exec(a.path);
|
|
128
|
+
const viewport = match?.[1];
|
|
129
|
+
const theme = match?.[2];
|
|
130
|
+
if (viewport !== undefined && theme !== undefined)
|
|
131
|
+
shots.push({ path: a.path, viewport, theme });
|
|
132
|
+
}
|
|
133
|
+
return shots;
|
|
134
|
+
}
|
|
135
|
+
export async function recordTaskResult(input, ctx, opts = {}) {
|
|
136
|
+
const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
|
|
137
|
+
const schemaResult = validateRecord(schemas, taxonomy, 'result', input.result);
|
|
138
|
+
await emit('schema-check-result', { valid: schemaResult.valid, errors: schemaResult.valid ? [] : schemaResult.errors }, input.taskId, ctx, opts);
|
|
139
|
+
if (!schemaResult.valid) {
|
|
140
|
+
return {
|
|
141
|
+
outcome: 'blocked',
|
|
142
|
+
taskId: input.taskId,
|
|
143
|
+
reason: 'schema-invalid',
|
|
144
|
+
schemaErrors: schemaResult.errors,
|
|
145
|
+
artifactIssues: [],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// The worker's Result is a fact about a run that already happened, so it is
|
|
149
|
+
// recorded before the artifact check can block on it (P9-12) — a tester
|
|
150
|
+
// whose screenshot path was wrong still ran, and the projector should see
|
|
151
|
+
// the Result even when the artifact check below refuses it.
|
|
152
|
+
const recorded = await recordResult(input.result, input.taskId, ctx, opts);
|
|
153
|
+
const artifacts = input.result.artifacts ?? [];
|
|
154
|
+
const artifactCheck = checkArtifacts(artifacts, {
|
|
155
|
+
taskId: input.taskId,
|
|
156
|
+
artifactsDir: input.artifactsDir,
|
|
157
|
+
});
|
|
158
|
+
const freshness = input.worktreeDir !== undefined
|
|
159
|
+
? {
|
|
160
|
+
head: runGit(input.worktreeDir, ['rev-parse', 'HEAD']),
|
|
161
|
+
screenshots: screenshotsFromArtifacts(artifacts),
|
|
162
|
+
}
|
|
163
|
+
: {};
|
|
164
|
+
await emit('artifact-check-result', {
|
|
165
|
+
ok: artifactCheck.ok,
|
|
166
|
+
checked: artifactCheck.checked,
|
|
167
|
+
home: artifactCheck.home,
|
|
168
|
+
issues: artifactCheck.issues,
|
|
169
|
+
...freshness,
|
|
170
|
+
}, input.taskId, ctx, opts);
|
|
171
|
+
if (!artifactCheck.ok) {
|
|
172
|
+
return {
|
|
173
|
+
outcome: 'blocked',
|
|
174
|
+
taskId: input.taskId,
|
|
175
|
+
reason: 'artifacts-missing',
|
|
176
|
+
schemaErrors: [],
|
|
177
|
+
artifactIssues: artifactCheck.issues,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
outcome: 'recorded',
|
|
182
|
+
taskId: input.taskId,
|
|
183
|
+
eventId: recorded.eventId,
|
|
184
|
+
// Additive alongside the existing `eventId` (camelCase, pre-existing):
|
|
185
|
+
// same value, snake_case, matching every other write verb's printed id.
|
|
186
|
+
event_id: recorded.eventId,
|
|
187
|
+
deduped: recorded.deduped,
|
|
188
|
+
};
|
|
117
189
|
}
|
|
118
190
|
/** A judge call is a network round-trip, not a test: generous enough that a reasoning model finishes, bounded so one hung provider can't hold the gate open. */
|
|
119
191
|
const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
|
|
@@ -231,7 +303,7 @@ function isRecord(value) {
|
|
|
231
303
|
* load-bearing only if something loads it.
|
|
232
304
|
*
|
|
233
305
|
* The verdict lives at `.structured_output` of the result envelope the grader
|
|
234
|
-
* writes (.claude/agents/grader.md, "Output contract"); the envelope itself is
|
|
306
|
+
* writes (.claude/agents/bs-grader.md, "Output contract"); the envelope itself is
|
|
235
307
|
* a Result and is not re-checked here. Three ways to not pass: the file is not
|
|
236
308
|
* shaped like a grader result (`grader-invalid`), the run came back `dead`
|
|
237
309
|
* because the task spec had no checkable acceptance criteria (`grader-fail`,
|
|
@@ -707,6 +779,38 @@ export async function runGate(input, ctx, opts = {}) {
|
|
|
707
779
|
outstandingJudges: outstanding,
|
|
708
780
|
}, ctx, opts);
|
|
709
781
|
}
|
|
782
|
+
// U2 S3: a ui-affecting task owes a reviewed, fresh screenshot set before
|
|
783
|
+
// the diff goes any further, measured against this run's own commit (D2).
|
|
784
|
+
// `uiAffecting` absent means no `--plan` was given at all, so the flag
|
|
785
|
+
// cannot be resolved either way — the stage records that honestly rather
|
|
786
|
+
// than guessing, and does not block (D6). `uiAffecting: false` (including a
|
|
787
|
+
// task with no flag at all, D3) means the stage has nothing to check.
|
|
788
|
+
let uiuxCheck = 'skipped';
|
|
789
|
+
if (input.uiAffecting === undefined) {
|
|
790
|
+
uiuxCheck = 'unverifiable';
|
|
791
|
+
}
|
|
792
|
+
else if (input.uiAffecting) {
|
|
793
|
+
uiuxCheck = 'checked';
|
|
794
|
+
const uiux = await checkUiux({
|
|
795
|
+
taskId: input.taskId,
|
|
796
|
+
...(commitCheck.head === null ? {} : { head: commitCheck.head }),
|
|
797
|
+
artifactsDir: input.artifactsDir,
|
|
798
|
+
}, ctx, opts);
|
|
799
|
+
if (uiux.outcome === 'blocked') {
|
|
800
|
+
return finalize({
|
|
801
|
+
outcome: 'blocked',
|
|
802
|
+
taskId: input.taskId,
|
|
803
|
+
reason: uiux.reason,
|
|
804
|
+
testResult: null,
|
|
805
|
+
schemaErrors: [],
|
|
806
|
+
blockingFindings: [],
|
|
807
|
+
artifactIssues: [],
|
|
808
|
+
commitCheck,
|
|
809
|
+
uiuxCheck,
|
|
810
|
+
}, ctx, opts);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const uiuxStatus = { uiuxCheck };
|
|
710
814
|
// Before the tests, not after: the grader has already run, and if its rubric
|
|
711
815
|
// says the criteria were not met the diff bounces whatever the suite says —
|
|
712
816
|
// so a full test run here is spent for nothing. It sits behind the commit
|
|
@@ -724,6 +828,7 @@ export async function runGate(input, ctx, opts = {}) {
|
|
|
724
828
|
blockingFindings: [],
|
|
725
829
|
artifactIssues: [],
|
|
726
830
|
commitCheck,
|
|
831
|
+
...uiuxStatus,
|
|
727
832
|
}, ctx, opts);
|
|
728
833
|
}
|
|
729
834
|
}
|
|
@@ -753,6 +858,7 @@ export async function runGate(input, ctx, opts = {}) {
|
|
|
753
858
|
artifactIssues: [],
|
|
754
859
|
commitCheck,
|
|
755
860
|
...budget,
|
|
861
|
+
...uiuxStatus,
|
|
756
862
|
}, ctx, opts);
|
|
757
863
|
}
|
|
758
864
|
// After the tests pass, before the findings are judged: a green coverage
|
|
@@ -789,6 +895,7 @@ export async function runGate(input, ctx, opts = {}) {
|
|
|
789
895
|
commitCheck,
|
|
790
896
|
...budget,
|
|
791
897
|
...withCoverage,
|
|
898
|
+
...uiuxStatus,
|
|
792
899
|
}, ctx, opts);
|
|
793
900
|
}
|
|
794
901
|
}
|
|
@@ -822,6 +929,7 @@ export async function runGate(input, ctx, opts = {}) {
|
|
|
822
929
|
...diverted,
|
|
823
930
|
...budget,
|
|
824
931
|
...withCoverage,
|
|
932
|
+
...uiuxStatus,
|
|
825
933
|
}, ctx, opts);
|
|
826
934
|
}
|
|
827
935
|
if (pendingWaivers.length > 0) {
|
|
@@ -836,6 +944,7 @@ export async function runGate(input, ctx, opts = {}) {
|
|
|
836
944
|
...diverted,
|
|
837
945
|
...budget,
|
|
838
946
|
...withCoverage,
|
|
947
|
+
...uiuxStatus,
|
|
839
948
|
}, ctx, opts);
|
|
840
949
|
}
|
|
841
950
|
return finalize({
|
|
@@ -848,12 +957,13 @@ export async function runGate(input, ctx, opts = {}) {
|
|
|
848
957
|
...diverted,
|
|
849
958
|
...budget,
|
|
850
959
|
...withCoverage,
|
|
960
|
+
...uiuxStatus,
|
|
851
961
|
}, ctx, opts);
|
|
852
962
|
}
|
|
853
|
-
/** Real token spend off the already-schema-valid result
|
|
963
|
+
/** Real token spend off the already-schema-valid result; undefined when absent or an unmeasured placeholder. */
|
|
854
964
|
function tokensSpent(result) {
|
|
855
|
-
const
|
|
856
|
-
return
|
|
965
|
+
const read = readTokenUsage(result?.token_usage);
|
|
966
|
+
return read.measured ? read.total : undefined;
|
|
857
967
|
}
|
|
858
968
|
/**
|
|
859
969
|
* Declared budget vs. measured spend, for one task. The check never blocks: an
|
|
@@ -906,6 +1016,6 @@ function runBudgetCheck(input) {
|
|
|
906
1016
|
}
|
|
907
1017
|
}
|
|
908
1018
|
async function finalize(outcome, ctx, opts) {
|
|
909
|
-
await emit('gate-outcome', { outcome: outcome.outcome, reason: 'reason' in outcome ? outcome.reason : null }, outcome.taskId, ctx, opts);
|
|
910
|
-
return outcome;
|
|
1019
|
+
const stored = await emit('gate-outcome', { outcome: outcome.outcome, reason: 'reason' in outcome ? outcome.reason : null }, outcome.taskId, ctx, opts);
|
|
1020
|
+
return { ...outcome, event_id: stored.event_id };
|
|
911
1021
|
}
|
|
@@ -197,7 +197,7 @@ export function goalCheckBlockers(epicId, status, planVersion, livePlanTaskIds)
|
|
|
197
197
|
}
|
|
198
198
|
if (check === null) {
|
|
199
199
|
return [
|
|
200
|
-
`Epic "${epicId}" has no spec-vs-goal check on record: nothing has asked whether its plan answers the goal milestone "${goal.milestoneId}" declares. Run \`
|
|
200
|
+
`Epic "${epicId}" has no spec-vs-goal check on record: nothing has asked whether its plan answers the goal milestone "${goal.milestoneId}" declares. Run \`bs epic goal-check --epic ${epicId}\`.`,
|
|
201
201
|
];
|
|
202
202
|
}
|
|
203
203
|
if (check.goalDigest !== goal.digest) {
|
|
@@ -246,7 +246,7 @@ export function goalCheckBlockers(epicId, status, planVersion, livePlanTaskIds)
|
|
|
246
246
|
const uncovered = check.coverage.filter((entry) => entry.verdict === 'uncovered');
|
|
247
247
|
if (uncovered.length > 0) {
|
|
248
248
|
return [
|
|
249
|
-
`The spec-vs-goal check for "${epicId}" left ${uncovered.length} goal clause(s) uncovered by plan v${check.planVersion}: ${uncovered.map((entry) => JSON.stringify(entry.clause)).join('; ')}. Answer them with \`
|
|
249
|
+
`The spec-vs-goal check for "${epicId}" left ${uncovered.length} goal clause(s) uncovered by plan v${check.planVersion}: ${uncovered.map((entry) => JSON.stringify(entry.clause)).join('; ')}. Answer them with \`bs plan amend\` — no task diff can contain the fix.`,
|
|
250
250
|
];
|
|
251
251
|
}
|
|
252
252
|
return [];
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
|
-
import {
|
|
4
|
+
import { agentNameFor, templateFileFor } from './agentNames.js';
|
|
5
|
+
import { loadBudgetPolicy, roleCapTokens } from './budgets.js';
|
|
6
|
+
import { epicBudgetPolicies } from './epicBudget.js';
|
|
5
7
|
import { SmithError } from './errors.js';
|
|
6
8
|
import { AGENTS_DIR, HARNESS_POLICY_PATH, REPO_ROOT } from './paths.js';
|
|
7
9
|
import { INSPECTED_FILE_TOOLS, loadGuardrailPolicy } from './policy.js';
|
|
10
|
+
import { epicOfTaskId } from './taskId.js';
|
|
8
11
|
import { loadTaxonomy } from './taxonomy.js';
|
|
9
12
|
/**
|
|
10
13
|
* The worker harness port: *how* a turn runs, named at last.
|
|
@@ -298,7 +301,7 @@ export function roleAccess(role, guardrails = loadGuardrailPolicy()) {
|
|
|
298
301
|
}
|
|
299
302
|
/** Where a role's prompt template lives, and whether it is there. */
|
|
300
303
|
function templateFor(role, agentsDir) {
|
|
301
|
-
const file = path.join(agentsDir,
|
|
304
|
+
const file = path.join(agentsDir, templateFileFor(role));
|
|
302
305
|
return { file, exists: existsSync(file) };
|
|
303
306
|
}
|
|
304
307
|
function serves(harness, role) {
|
|
@@ -347,17 +350,22 @@ function resolveSchema(name, harnessName) {
|
|
|
347
350
|
}
|
|
348
351
|
return { name, file };
|
|
349
352
|
}
|
|
350
|
-
function capTokensFor(role,
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
353
|
+
function capTokensFor(role, budgets) {
|
|
354
|
+
// budgets.yml prices each role on its own line, sized for the policy's
|
|
355
|
+
// effort tier. A role it prices nowhere (merger, uiux, scribe, wave-runner)
|
|
356
|
+
// gets null, which says so rather than inventing a number nobody set.
|
|
357
|
+
return roleCapTokens(budgets, role);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* budgets.yml sized for the effort tier of the epic `taskId` belongs to — the
|
|
361
|
+
* tier `cap_tokens` (and so the runner's `over_budget`) is judged at. A bare
|
|
362
|
+
* task id names no epic, and is priced at effort.yml's default tier.
|
|
363
|
+
*/
|
|
364
|
+
function budgetsForTask(taskId, planOpts) {
|
|
365
|
+
const epicId = epicOfTaskId(taskId);
|
|
366
|
+
return epicId === null
|
|
367
|
+
? loadBudgetPolicy()
|
|
368
|
+
: epicBudgetPolicies(planOpts === undefined ? {} : { planOpts })(epicId);
|
|
361
369
|
}
|
|
362
370
|
function substitute(template, values, harness) {
|
|
363
371
|
return template.replace(PLACEHOLDER_RE, (_match, token) => {
|
|
@@ -471,7 +479,7 @@ export function planWorkerTurn(request, options = {}) {
|
|
|
471
479
|
throw new HarnessError('harness.unknown-tier', `"${request.tier}" is not a model tier. It is one of ${MODEL_TIERS.join(', ')}.`, { tier: request.tier });
|
|
472
480
|
}
|
|
473
481
|
if (request.promptFile === undefined || request.promptFile === '') {
|
|
474
|
-
throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`
|
|
482
|
+
throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`bs prompt record`), then plan the turn that carries it.', { role });
|
|
475
483
|
}
|
|
476
484
|
const name = request.harness ?? policy.defaultHarness;
|
|
477
485
|
const harness = policy.harnesses.find((h) => h.name === name);
|
|
@@ -509,7 +517,7 @@ export function planWorkerTurn(request, options = {}) {
|
|
|
509
517
|
return {
|
|
510
518
|
...base,
|
|
511
519
|
kind: 'in-process',
|
|
512
|
-
subagentType: role,
|
|
520
|
+
subagentType: agentNameFor(role),
|
|
513
521
|
template: templateRelative,
|
|
514
522
|
};
|
|
515
523
|
}
|
|
@@ -536,11 +544,11 @@ export function planWorkerTurn(request, options = {}) {
|
|
|
536
544
|
const roleArgs = access === 'judge' ? harness.judgeArgs : harness.workerArgs;
|
|
537
545
|
const rawArgs = [...harness.args, ...roleArgs, ...(schema !== null ? harness.schemaArgs : [])];
|
|
538
546
|
const args = rawArgs.map((arg) => substitute(arg, values, harness.name));
|
|
539
|
-
const budgets = options.budgets ??
|
|
547
|
+
const budgets = options.budgets ?? budgetsForTask(taskId, options.planOpts);
|
|
540
548
|
const budget = {
|
|
541
549
|
timeout_ms: harness.timeoutMs,
|
|
542
550
|
max_output_bytes: harness.maxOutputBytes ?? HARNESS_DEFAULT_MAX_OUTPUT_BYTES,
|
|
543
|
-
cap_tokens: capTokensFor(role,
|
|
551
|
+
cap_tokens: capTokensFor(role, budgets),
|
|
544
552
|
};
|
|
545
553
|
return {
|
|
546
554
|
...base,
|