@juzser/blacksmith 0.1.0 → 0.1.1
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/coder.md +9 -3
- package/.claude/agents/grader.md +13 -5
- package/.claude/agents/merger.md +2 -0
- package/.claude/agents/planner.md +1 -1
- package/.claude/agents/researcher.md +3 -1
- package/.claude/agents/reviewer.md +9 -2
- package/.claude/agents/security-reviewer.md +15 -7
- package/.claude/agents/spec-reviewer.md +12 -3
- package/.claude/agents/tester.md +5 -2
- package/.claude/agents/uiux.md +10 -3
- package/.claude/agents/verifier.md +8 -3
- package/.claude/skills/bs/SKILL.md +103 -0
- package/.claude/skills/bs/audit.md +250 -0
- package/.claude/skills/bs/dispatch.md +394 -0
- package/.claude/skills/bs/lessons.md +73 -0
- package/.claude/skills/bs/mcp.md +29 -0
- package/.claude/skills/bs/new.md +33 -0
- package/.claude/skills/bs/plan.md +155 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +325 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +12 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +261 -0
- package/README.md +119 -65
- package/factory/orchestrator/dist/actors.js +4 -3
- package/factory/orchestrator/dist/agents-registry.js +96 -19
- package/factory/orchestrator/dist/artifacts.js +36 -4
- package/factory/orchestrator/dist/budgets.js +7 -5
- package/factory/orchestrator/dist/cli.js +107 -7
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +14 -0
- package/factory/orchestrator/dist/db/projector.js +272 -90
- package/factory/orchestrator/dist/db/queries.js +83 -14
- package/factory/orchestrator/dist/db/schema.js +7 -0
- package/factory/orchestrator/dist/events.js +38 -4
- package/factory/orchestrator/dist/harness.js +367 -0
- package/factory/orchestrator/dist/judges.js +49 -8
- package/factory/orchestrator/dist/lessons.js +4 -4
- package/factory/orchestrator/dist/mcp.js +4 -3
- package/factory/orchestrator/dist/paths.js +167 -15
- package/factory/orchestrator/dist/plan.js +1 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- package/factory/orchestrator/dist/providers/index.js +1 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
- package/factory/orchestrator/dist/providers/types.js +5 -0
- package/factory/orchestrator/dist/roadmap.js +3 -3
- package/factory/orchestrator/dist/scaffold.js +3 -2
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +24 -5
- package/factory/orchestrator/dist/workroot.js +113 -0
- package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +6 -3
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/package.json +8 -6
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// is the normal case; a session filter is for debugging one run).
|
|
7
7
|
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
|
8
8
|
import { isOperatorActor } from '../actors.js';
|
|
9
|
-
import { foldAgents, liveAgents as foldLiveAgents, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
|
|
9
|
+
import { foldAgents, liveAgents as foldLiveAgents, isWorkingAt, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
|
|
10
10
|
import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
|
|
11
11
|
import { waveLayers } from '../graph.js';
|
|
12
12
|
import { judgeFailureKind } from '../providers/types.js';
|
|
@@ -514,14 +514,19 @@ function allAgentsForScope(db, scope) {
|
|
|
514
514
|
* Ties on `lastEventAt` break on `sessionId` so the order is stable under the
|
|
515
515
|
* dashboard's 5s poll rather than shuffling between renders.
|
|
516
516
|
*/
|
|
517
|
-
export function runningSessions(db, scope = {}) {
|
|
517
|
+
export function runningSessions(db, scope = {}, opts = {}) {
|
|
518
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
518
519
|
const sessionCond = scopedToSessions(sessions.sessionId, scope);
|
|
519
520
|
const rows = sessionCond
|
|
520
521
|
? db.select().from(sessions).where(sessionCond).all()
|
|
521
522
|
: db.select().from(sessions).all();
|
|
522
523
|
const liveBySession = new Map();
|
|
524
|
+
const workingBySession = new Map();
|
|
523
525
|
for (const a of allAgentsForScope(db, scope)) {
|
|
524
526
|
liveBySession.set(a.sessionId, (liveBySession.get(a.sessionId) ?? 0) + 1);
|
|
527
|
+
if (isWorkingAt(a.dispatchedAt, nowIso)) {
|
|
528
|
+
workingBySession.set(a.sessionId, (workingBySession.get(a.sessionId) ?? 0) + 1);
|
|
529
|
+
}
|
|
525
530
|
}
|
|
526
531
|
const projectsBySession = new Map();
|
|
527
532
|
for (const t of allTasksForScope(db, scope)) {
|
|
@@ -555,6 +560,7 @@ export function runningSessions(db, scope = {}) {
|
|
|
555
560
|
lastEventAt: s.lastEventAt,
|
|
556
561
|
eventCount: s.eventCount,
|
|
557
562
|
liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
|
|
563
|
+
workingAgentCount: workingBySession.get(s.sessionId) ?? 0,
|
|
558
564
|
lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
|
|
559
565
|
projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
|
|
560
566
|
}))
|
|
@@ -621,8 +627,14 @@ function inFlightEpics(taskRows, closed) {
|
|
|
621
627
|
.map((t) => t.epicId)),
|
|
622
628
|
].sort();
|
|
623
629
|
}
|
|
624
|
-
/**
|
|
625
|
-
|
|
630
|
+
/**
|
|
631
|
+
* One project's overview slice, computed by the same logic overview() itself
|
|
632
|
+
* uses (no drift). `nowIso` is the caller's single instant, not a fresh
|
|
633
|
+
* read: overview() hands every per-project summary the same "now" it used
|
|
634
|
+
* for its own counts, so the project rows and the global card can never
|
|
635
|
+
* disagree about which agents are working.
|
|
636
|
+
*/
|
|
637
|
+
function projectSummary(db, project, baseScope, nowIso) {
|
|
626
638
|
const scope = { ...baseScope, project };
|
|
627
639
|
const liveRows = allAgentsForScope(db, scope);
|
|
628
640
|
const taskRows = allTasksForScope(db, scope);
|
|
@@ -637,6 +649,7 @@ function projectSummary(db, project, baseScope) {
|
|
|
637
649
|
return {
|
|
638
650
|
project,
|
|
639
651
|
liveAgentCount: liveRows.length,
|
|
652
|
+
workingAgentCount: liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso)).length,
|
|
640
653
|
epicsInFlight,
|
|
641
654
|
tokensSpent,
|
|
642
655
|
tokensBudget,
|
|
@@ -664,7 +677,7 @@ const SNAPSHOT_EVENT_TYPES = REGISTRY_EVENT_TYPES;
|
|
|
664
677
|
* project, so its agent stayed in the live count and vanished from the
|
|
665
678
|
* historical one, and the StatCard reported an arrival that never happened.
|
|
666
679
|
*/
|
|
667
|
-
function
|
|
680
|
+
function liveAgentsAt(db, scope, cutoffIso) {
|
|
668
681
|
const conds = [lte(eventsRaw.ts, cutoffIso), inArray(eventsRaw.eventType, SNAPSHOT_EVENT_TYPES)];
|
|
669
682
|
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
670
683
|
if (sessionCond)
|
|
@@ -690,9 +703,25 @@ function liveAgentCountAt(db, scope, cutoffIso) {
|
|
|
690
703
|
}));
|
|
691
704
|
const live = foldLiveAgents(foldAgents(storedEvents));
|
|
692
705
|
if (scope.project === undefined)
|
|
693
|
-
return live
|
|
706
|
+
return live;
|
|
694
707
|
const scopedTaskIds = new Set(allTasksForScope(db, scope).map((t) => t.taskId));
|
|
695
|
-
return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId))
|
|
708
|
+
return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId));
|
|
709
|
+
}
|
|
710
|
+
function liveAgentCountAt(db, scope, cutoffIso) {
|
|
711
|
+
return liveAgentsAt(db, scope, cutoffIso).length;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The "working" end of workingAgentCountDelta5m's subtraction: the agents
|
|
715
|
+
* live as of `cutoffIso` (the very re-fold liveAgentCountAt does, project
|
|
716
|
+
* scoping included) that were ALSO within DEFAULT_STALE_HOURS of dispatch
|
|
717
|
+
* *at that cutoff*. Judging the historical rows against today's clock
|
|
718
|
+
* instead would be D-170 again in a new disguise — the "now" end would
|
|
719
|
+
* apply a 4h window and the "ago" end a 4h05m one, and an agent that
|
|
720
|
+
* stalled since the cutoff would read as no change.
|
|
721
|
+
*/
|
|
722
|
+
function workingAgentCountAt(db, scope, cutoffIso) {
|
|
723
|
+
return liveAgentsAt(db, scope, cutoffIso).filter((a) => isWorkingAt(a.dispatchedAt, cutoffIso))
|
|
724
|
+
.length;
|
|
696
725
|
}
|
|
697
726
|
/**
|
|
698
727
|
* Total tokens spent (task-result-recorded token_usage) across `epicByTask`'s tasks, as of `cutoffIso`.
|
|
@@ -781,7 +810,14 @@ function tokensBudgetedAt(db, scope, cutoffIso, epicByTask) {
|
|
|
781
810
|
return total;
|
|
782
811
|
}
|
|
783
812
|
export function overview(db, scope = {}, opts = {}) {
|
|
813
|
+
// One instant for everything this call reports. runningSessions(), the
|
|
814
|
+
// per-project summaries and the deltas each take the clock as an argument
|
|
815
|
+
// rather than reading it, so a request that straddles a threshold cannot
|
|
816
|
+
// count an agent as working in one field and stalled in the next.
|
|
817
|
+
const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
|
|
818
|
+
const nowIso = now.toISOString();
|
|
784
819
|
const liveRows = allAgentsForScope(db, scope);
|
|
820
|
+
const workingRows = liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso));
|
|
785
821
|
const taskRows = allTasksForScope(db, scope);
|
|
786
822
|
const closedEpics = closedEpicsForScope(db, scope);
|
|
787
823
|
const epicsInFlight = inFlightEpics(taskRows, closedEpics);
|
|
@@ -835,12 +871,12 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
835
871
|
// Milestones carry no session_id and milestoneProgressRows() never
|
|
836
872
|
// filters them by one, so they join the list under a session scope too.
|
|
837
873
|
const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
|
|
838
|
-
projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope));
|
|
874
|
+
projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso));
|
|
839
875
|
}
|
|
840
|
-
const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
|
|
841
876
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
|
|
842
877
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
|
843
878
|
const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
|
|
879
|
+
const workingAgentCountDelta5m = workingRows.length - workingAgentCountAt(db, scope, fiveMinAgo);
|
|
844
880
|
const epicByTask = new Map(taskRows.filter((t) => t.epicId !== null).map((t) => [t.taskId, t.epicId]));
|
|
845
881
|
const totalBudgetNow = [...budgetByEpic.values()].reduce((s, v) => s + v, 0);
|
|
846
882
|
const totalSpentNow = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
|
|
@@ -873,7 +909,9 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
873
909
|
dispatchedAt: a.dispatchedAt,
|
|
874
910
|
})),
|
|
875
911
|
liveAgentCount: liveRows.length,
|
|
876
|
-
|
|
912
|
+
workingAgentCount: workingRows.length,
|
|
913
|
+
stalledAgentCount: liveRows.length - workingRows.length,
|
|
914
|
+
runningSessions: runningSessions(db, scope, { nowIso }),
|
|
877
915
|
epicsInFlight,
|
|
878
916
|
closedEpics,
|
|
879
917
|
tokensByEpic,
|
|
@@ -881,6 +919,7 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
881
919
|
milestoneProgress: milestoneProgressRows(db, scope),
|
|
882
920
|
recentDispatches,
|
|
883
921
|
liveAgentCountDelta5m,
|
|
922
|
+
workingAgentCountDelta5m,
|
|
884
923
|
budgetUsedPctPointDelta1h,
|
|
885
924
|
...(projects ? { projects } : {}),
|
|
886
925
|
};
|
|
@@ -959,6 +998,12 @@ export const FREE_TIMELINE_EVENT_TYPES = [
|
|
|
959
998
|
'audit-cut',
|
|
960
999
|
'audit-resolved',
|
|
961
1000
|
'audit-closed',
|
|
1001
|
+
// The integration PR run.md step 17 opens, appended by hand through `smith
|
|
1002
|
+
// event append` under `<epic>/integration`. It is the epic's terminal
|
|
1003
|
+
// deliverable -- the one thing the operator is asked to merge -- and the
|
|
1004
|
+
// only event written after `epic-closed`, so without it the timeline of a
|
|
1005
|
+
// closed epic ends at the closing and never shows what the closing was for.
|
|
1006
|
+
'integration-pr-opened',
|
|
962
1007
|
];
|
|
963
1008
|
let cachedTaxonomy;
|
|
964
1009
|
/**
|
|
@@ -1174,7 +1219,8 @@ function worstSeverity(severities) {
|
|
|
1174
1219
|
* task_status, tagged with case/origin/worst-open-finding severity plus
|
|
1175
1220
|
* (Phase 6b) title/agent-role/milestone.
|
|
1176
1221
|
*/
|
|
1177
|
-
export function kanban(db, epicId, scope = {}) {
|
|
1222
|
+
export function kanban(db, epicId, scope = {}, opts = {}) {
|
|
1223
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
1178
1224
|
const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
|
|
1179
1225
|
const sessionCond = scopedToSessions(tasks.sessionId, scope);
|
|
1180
1226
|
const taskConds = [epicCond, sessionCond].filter((c) => c !== undefined);
|
|
@@ -1221,6 +1267,18 @@ export function kanban(db, epicId, scope = {}) {
|
|
|
1221
1267
|
modelTier: d.modelTier,
|
|
1222
1268
|
});
|
|
1223
1269
|
}
|
|
1270
|
+
// Who is still on each task, from the agents rows (live only — see
|
|
1271
|
+
// allAgentsForScope). A working row wins over a stalled one on the same
|
|
1272
|
+
// task for the reason flowGraph gives: the chip says who is on the task.
|
|
1273
|
+
const activityByTask = new Map();
|
|
1274
|
+
for (const a of allAgentsForScope(db, scope)) {
|
|
1275
|
+
if (!a.taskId)
|
|
1276
|
+
continue;
|
|
1277
|
+
if (isWorkingAt(a.dispatchedAt, nowIso))
|
|
1278
|
+
activityByTask.set(a.taskId, 'working');
|
|
1279
|
+
else if (!activityByTask.has(a.taskId))
|
|
1280
|
+
activityByTask.set(a.taskId, 'stalled');
|
|
1281
|
+
}
|
|
1224
1282
|
// epicId -> milestoneId, from roadmap.md's milestones table.
|
|
1225
1283
|
const milestoneRows = db.select().from(milestones).all();
|
|
1226
1284
|
const milestoneByEpic = new Map();
|
|
@@ -1237,6 +1295,7 @@ export function kanban(db, epicId, scope = {}) {
|
|
|
1237
1295
|
title: t.objective,
|
|
1238
1296
|
agentRole: latestAgentRoleByTask.get(t.taskId)?.agentRole ?? null,
|
|
1239
1297
|
agentModelTier: latestAgentRoleByTask.get(t.taskId)?.modelTier ?? null,
|
|
1298
|
+
agentActivity: activityByTask.get(t.taskId) ?? null,
|
|
1240
1299
|
milestoneId: t.epicId ? (milestoneByEpic.get(t.epicId) ?? null) : null,
|
|
1241
1300
|
tags: {
|
|
1242
1301
|
case: t.caseTag,
|
|
@@ -1462,7 +1521,8 @@ export function analytics(db, scope = {}) {
|
|
|
1462
1521
|
* stack.md: "no separate layout library" — this IS that orchestrator
|
|
1463
1522
|
* utility, exposed via this query/endpoint per the same doc's instruction).
|
|
1464
1523
|
*/
|
|
1465
|
-
export function flowGraph(db, filter = {}) {
|
|
1524
|
+
export function flowGraph(db, filter = {}, opts = {}) {
|
|
1525
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
1466
1526
|
let taskRows = allTasksForScope(db, filter);
|
|
1467
1527
|
if (filter.epicId)
|
|
1468
1528
|
taskRows = taskRows.filter((t) => t.epicId === filter.epicId);
|
|
@@ -1526,9 +1586,17 @@ export function flowGraph(db, filter = {}) {
|
|
|
1526
1586
|
const scopedEdges = edgeRows.filter((e) => taskIds.has(e.taskId) && taskIds.has(e.dependsOn));
|
|
1527
1587
|
const liveRows = allAgentsForScope(db, filter);
|
|
1528
1588
|
const liveRoleByTask = new Map();
|
|
1589
|
+
// The same last-write-wins walk, over the working rows only. Where a task
|
|
1590
|
+
// carries both a stalled live row and a working one, the working role is
|
|
1591
|
+
// the one to name: the marker says who is on the task, not who was
|
|
1592
|
+
// dispatched to it most recently.
|
|
1593
|
+
const workingRoleByTask = new Map();
|
|
1529
1594
|
for (const a of liveRows) {
|
|
1530
|
-
if (a.taskId)
|
|
1531
|
-
|
|
1595
|
+
if (!a.taskId)
|
|
1596
|
+
continue;
|
|
1597
|
+
liveRoleByTask.set(a.taskId, a.agentRole);
|
|
1598
|
+
if (isWorkingAt(a.dispatchedAt, nowIso))
|
|
1599
|
+
workingRoleByTask.set(a.taskId, a.agentRole);
|
|
1532
1600
|
}
|
|
1533
1601
|
const waveMap = waveLayers([...taskIds], scopedEdges.map((e) => ({ task: e.taskId, dependsOn: e.dependsOn })));
|
|
1534
1602
|
const nodes = taskRows.map((t) => ({
|
|
@@ -1536,6 +1604,7 @@ export function flowGraph(db, filter = {}) {
|
|
|
1536
1604
|
taskStatus: t.taskStatus,
|
|
1537
1605
|
title: t.objective,
|
|
1538
1606
|
liveAgentRole: liveRoleByTask.get(t.taskId) ?? null,
|
|
1607
|
+
workingAgentRole: workingRoleByTask.get(t.taskId) ?? null,
|
|
1539
1608
|
planVersion: t.planVersion,
|
|
1540
1609
|
wave: waveMap.get(t.taskId) ?? 0,
|
|
1541
1610
|
}));
|
|
@@ -189,6 +189,13 @@ export const findings = sqliteTable('findings', {
|
|
|
189
189
|
raisedAt: text('raised_at').notNull(),
|
|
190
190
|
updatedAt: text('updated_at').notNull(),
|
|
191
191
|
project: text('project'), // Phase 6b — see eventsRaw's project comment above.
|
|
192
|
+
// What a spec finding is about (findings.ts SpecRef). The scope is filled
|
|
193
|
+
// in from findingScope(), so an absent field lands as 'diff' rather than
|
|
194
|
+
// as a null the reader has to know the default for; the two spec_ref
|
|
195
|
+
// columns are null on every diff finding because there is no criterion.
|
|
196
|
+
findingScope: text('finding_scope').notNull().default('diff'),
|
|
197
|
+
specPlanVersion: integer('spec_plan_version'),
|
|
198
|
+
criterionRef: text('criterion_ref'),
|
|
192
199
|
}, (t) => [
|
|
193
200
|
index('findings_session_idx').on(t.sessionId),
|
|
194
201
|
index('findings_task_idx').on(t.taskId),
|
|
@@ -203,10 +203,20 @@ async function readEventsAtPath(filePath, sessionId) {
|
|
|
203
203
|
.trimEnd()
|
|
204
204
|
.split('\n')
|
|
205
205
|
.filter((line) => line.length > 0);
|
|
206
|
-
return lines.map((line, index) =>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
return lines.map((line, index) => {
|
|
207
|
+
let record;
|
|
208
|
+
try {
|
|
209
|
+
record = JSON.parse(line);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
// A bare SyntaxError says "Unexpected token" and nothing else: not which
|
|
213
|
+
// log, not which line. The dashboard prints this for every session it
|
|
214
|
+
// then fails to fold (the global folds read every log), so the message
|
|
215
|
+
// has to carry the path the operator needs to open.
|
|
216
|
+
throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
|
|
217
|
+
}
|
|
218
|
+
return { event_id: `${sessionId}#${index}`, record };
|
|
219
|
+
});
|
|
210
220
|
}
|
|
211
221
|
/**
|
|
212
222
|
* Per-log serialization *inside one process*: concurrent appendEvent calls on
|
|
@@ -504,7 +514,31 @@ function validatePayloadDimensions(taxonomy, record) {
|
|
|
504
514
|
const TYPED_PAYLOAD_SCHEMAS = {
|
|
505
515
|
'finding-raised': 'finding',
|
|
506
516
|
};
|
|
517
|
+
/**
|
|
518
|
+
* The one field of a `task-result-recorded` payload a reader iterates. The
|
|
519
|
+
* projector writes `artifacts[]` into rows inside the session's transaction,
|
|
520
|
+
* so an object here (csb-audit-1#100 carried `{ claude_half, external_half,
|
|
521
|
+
* repair_brief }`) rolled back the whole session and the dashboard drew
|
|
522
|
+
* nothing for it. Pinning only this field is deliberate: the full result
|
|
523
|
+
* schema is not enforced on the event, because real logs carry many
|
|
524
|
+
* hand-written result payloads that no reader rehydrates -- refusing them
|
|
525
|
+
* would refuse history for a shape nothing reads.
|
|
526
|
+
*/
|
|
527
|
+
function validateResultArtifactsShape(record) {
|
|
528
|
+
if (record.event_type !== 'task-result-recorded')
|
|
529
|
+
return;
|
|
530
|
+
const payload = record.payload;
|
|
531
|
+
const artifacts = payload?.artifacts;
|
|
532
|
+
if (artifacts === undefined || Array.isArray(artifacts))
|
|
533
|
+
return;
|
|
534
|
+
throw new EventError('events.invalid-typed-payload', 'Event payload for "task-result-recorded" carries an `artifacts` that is not an array. The projector iterates this list to write artifact rows, so any other shape becomes a crash that rolls back the whole session rather than an error here.', {
|
|
535
|
+
event_type: record.event_type,
|
|
536
|
+
schema: 'result',
|
|
537
|
+
errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
|
|
538
|
+
});
|
|
539
|
+
}
|
|
507
540
|
function validateTypedPayload(schemas, taxonomy, record) {
|
|
541
|
+
validateResultArtifactsShape(record);
|
|
508
542
|
const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
|
|
509
543
|
if (schemaName === undefined)
|
|
510
544
|
return;
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { AGENTS_DIR, REPO_ROOT } from './paths.js';
|
|
6
|
+
import { loadGuardrailPolicy } from './policy.js';
|
|
7
|
+
import { loadTaxonomy } from './taxonomy.js';
|
|
8
|
+
/**
|
|
9
|
+
* The worker harness port: *how* a turn runs, named at last.
|
|
10
|
+
*
|
|
11
|
+
* Every worker turn this factory has ever dispatched ran under one harness —
|
|
12
|
+
* Claude Code's `Agent` tool — and nothing anywhere says so. It is true by
|
|
13
|
+
* accident of deployment: the orchestrator is itself a Claude Code session, so
|
|
14
|
+
* the only thing it can spawn is another one. `dispatch_decision` records the
|
|
15
|
+
* role, the provider, the model tier and the model (P9-23), which is to say it
|
|
16
|
+
* records *who answered* and with *what weight*, and never *what ran the
|
|
17
|
+
* turn*. The two are independent axes and the factory has been folding them
|
|
18
|
+
* into one:
|
|
19
|
+
*
|
|
20
|
+
* - **provider** — taxonomy.yml `provider: [claude, codex, deepseek]` — is
|
|
21
|
+
* the vendor whose model produced the words.
|
|
22
|
+
* - **harness** — this module — is the program that held the session: gave
|
|
23
|
+
* the agent its tools, its working directory, its turn limit.
|
|
24
|
+
*
|
|
25
|
+
* `claude-code` + `claude` is the only pair that exists today. `codex-cli` +
|
|
26
|
+
* `codex` is the pair that makes the distinction load-bearing, and the point
|
|
27
|
+
* of writing the port before that pair exists is that the second harness
|
|
28
|
+
* should be a config entry and an invocation shape, not a rewrite of dispatch.
|
|
29
|
+
*
|
|
30
|
+
* ## This module renders an invocation. It never runs one.
|
|
31
|
+
*
|
|
32
|
+
* Architecture §18 rule 3 — "nothing that observes may dispatch". `smith` is
|
|
33
|
+
* an observer: it reads logs, folds them, and prints. If `smith harness plan`
|
|
34
|
+
* spawned the process it describes, the CLI would close its own loop, and
|
|
35
|
+
* every check that reads a `dispatch_decision` as evidence that a separate
|
|
36
|
+
* session ran would be reading evidence `smith` produced about itself.
|
|
37
|
+
*
|
|
38
|
+
* So `planWorkerTurn()` returns a `WorkerInvocation` — a description, in JSON,
|
|
39
|
+
* of a process someone else starts. The orchestrator session (or a runner that
|
|
40
|
+
* is not Claude Code at all) reads it and acts. That separation is the whole
|
|
41
|
+
* reason this is a port rather than a spawner.
|
|
42
|
+
*
|
|
43
|
+
* ## The default policy lives here, in code, on purpose
|
|
44
|
+
*
|
|
45
|
+
* There is no `factory/policies/harness.yml`, and this module does not name
|
|
46
|
+
* one. Two reasons, both checkable:
|
|
47
|
+
*
|
|
48
|
+
* - Writing a policy file is the operator's decision, not a side effect of
|
|
49
|
+
* adding a seam. The built-in policy below describes what the factory
|
|
50
|
+
* already does; it invents no capability.
|
|
51
|
+
* - paths.test.ts holds `factory/policies/` and `paths.ts` to each other in
|
|
52
|
+
* both directions — a constant with no file on disk fails the build. A
|
|
53
|
+
* path constant for a file we are not shipping would be exactly that.
|
|
54
|
+
*
|
|
55
|
+
* An operator who wants a second harness writes a YAML file wherever they like
|
|
56
|
+
* and names it: `--policy <file>`, the same shape `stack show` already uses.
|
|
57
|
+
* Absent that flag the built-in policy answers, which is the honest reading of
|
|
58
|
+
* a factory that has one harness.
|
|
59
|
+
*
|
|
60
|
+
* ## A cli harness is outside the trust boundary; a judge gets no worktree
|
|
61
|
+
*
|
|
62
|
+
* `providers/types.ts` states the rule for external judge transports: they are
|
|
63
|
+
* handed nothing but `prompt` — "no worktree paths, credentials beyond its own
|
|
64
|
+
* key, or anything callable". A `cli` harness is the same kind of thing one
|
|
65
|
+
* step over: a process this factory starts, on the far side of its own memory,
|
|
66
|
+
* holding whatever the command line hands it. So the same boundary applies,
|
|
67
|
+
* and `planWorkerTurn()` refuses to render a `cli` invocation that would put a
|
|
68
|
+
* worktree path in a judge role's hands (§18 rule 5, "Judges read; they never
|
|
69
|
+
* gain write access").
|
|
70
|
+
*
|
|
71
|
+
* An `in-process` harness is inside the boundary: a reviewer subagent does
|
|
72
|
+
* read the worktree, under a lease `smith sandbox open` holds for exactly as
|
|
73
|
+
* long as the verdict takes. The invocation says so — `sandboxRequired` is the
|
|
74
|
+
* obligation, stated where the caller will see it rather than left to the
|
|
75
|
+
* playbook to remember.
|
|
76
|
+
*
|
|
77
|
+
* Which roles are judges is not a list this file keeps. It is
|
|
78
|
+
* guardrails.yml's `judge_sandbox.roles` and `role_write_scopes.scopes`, read
|
|
79
|
+
* through policy.ts, which already refuses a role declared as both. A second
|
|
80
|
+
* hand-written copy here would be a copy that drifts, and the direction it
|
|
81
|
+
* would drift in is a judge quietly reclassified as a worker.
|
|
82
|
+
*
|
|
83
|
+
* ## Env is an allowlist of names. Values never pass through here.
|
|
84
|
+
*
|
|
85
|
+
* A harness that shells out needs credentials, and a rendered invocation is
|
|
86
|
+
* printed as JSON to a terminal and into logs. So the invocation carries the
|
|
87
|
+
* *names* a harness may read from the environment and never their values: the
|
|
88
|
+
* runner applies the allowlist against its own `process.env`. Naming a path
|
|
89
|
+
* and never a value is the same rule every artifact in this factory writes
|
|
90
|
+
* under.
|
|
91
|
+
*/
|
|
92
|
+
export class HarnessError extends SmithError {
|
|
93
|
+
}
|
|
94
|
+
/** The policy shape this module parses. Bumped when the YAML shape changes. */
|
|
95
|
+
export const HARNESS_POLICY_VERSION = 1;
|
|
96
|
+
/**
|
|
97
|
+
* What a rendered invocation is allowed to interpolate. Closed on purpose: a
|
|
98
|
+
* typo'd `{worktre}` in an args template would otherwise reach the runner as a
|
|
99
|
+
* literal argument, and a harness would start in the wrong directory rather
|
|
100
|
+
* than fail to start.
|
|
101
|
+
*/
|
|
102
|
+
const PLACEHOLDERS = ['prompt_file', 'worktree', 'role', 'task'];
|
|
103
|
+
const PLACEHOLDER_RE = /\{([a-z_]+)\}/g;
|
|
104
|
+
/**
|
|
105
|
+
* Today's factory, written down.
|
|
106
|
+
*
|
|
107
|
+
* One harness, serving every role, starting no program — because the Claude
|
|
108
|
+
* Code `Agent` tool is a call inside the orchestrator's own session, not an
|
|
109
|
+
* exec. `roles: []` rather than the fifteen taxonomy values: the constraint
|
|
110
|
+
* that actually binds is whether `.claude/agents/<role>.md` exists, and
|
|
111
|
+
* `planWorkerTurn()` checks that against the directory rather than against a
|
|
112
|
+
* list here that would go stale the next time a role is added.
|
|
113
|
+
*/
|
|
114
|
+
export const BUILT_IN_HARNESS_POLICY = Object.freeze({
|
|
115
|
+
version: HARNESS_POLICY_VERSION,
|
|
116
|
+
defaultHarness: 'claude-code',
|
|
117
|
+
harnesses: Object.freeze([
|
|
118
|
+
Object.freeze({
|
|
119
|
+
name: 'claude-code',
|
|
120
|
+
kind: 'in-process',
|
|
121
|
+
roles: Object.freeze([]),
|
|
122
|
+
command: null,
|
|
123
|
+
args: Object.freeze([]),
|
|
124
|
+
envAllowlist: Object.freeze([]),
|
|
125
|
+
}),
|
|
126
|
+
]),
|
|
127
|
+
source: 'built-in',
|
|
128
|
+
});
|
|
129
|
+
function invalid(message, details) {
|
|
130
|
+
return new HarnessError('harness.invalid-policy', message, details);
|
|
131
|
+
}
|
|
132
|
+
function requireStringArray(raw, key) {
|
|
133
|
+
if (raw === undefined || raw === null)
|
|
134
|
+
return [];
|
|
135
|
+
if (!Array.isArray(raw) || raw.some((v) => typeof v !== 'string')) {
|
|
136
|
+
throw invalid(`harness policy ${key} is not a list of strings.`);
|
|
137
|
+
}
|
|
138
|
+
return raw;
|
|
139
|
+
}
|
|
140
|
+
/** Every placeholder an args template names, in order of appearance. */
|
|
141
|
+
function placeholdersIn(text) {
|
|
142
|
+
return [...text.matchAll(PLACEHOLDER_RE)].map((m) => m[1]);
|
|
143
|
+
}
|
|
144
|
+
export function parseHarnessPolicy(yamlText) {
|
|
145
|
+
const doc = (parseYaml(yamlText) ?? {});
|
|
146
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
147
|
+
throw invalid('harness policy is not a mapping.');
|
|
148
|
+
}
|
|
149
|
+
const version = doc.version ?? HARNESS_POLICY_VERSION;
|
|
150
|
+
if (typeof version !== 'number' || !Number.isInteger(version)) {
|
|
151
|
+
throw invalid('harness policy `version` is not an integer.');
|
|
152
|
+
}
|
|
153
|
+
const rawHarnesses = doc.harnesses;
|
|
154
|
+
if (!Array.isArray(rawHarnesses) || rawHarnesses.length === 0) {
|
|
155
|
+
throw invalid('harness policy declares no `harnesses`. A file that names none says less than shipping no file at all, which is why the built-in policy is what answers when no --policy is given.');
|
|
156
|
+
}
|
|
157
|
+
const harnesses = [];
|
|
158
|
+
for (const [index, entry] of rawHarnesses.entries()) {
|
|
159
|
+
const key = `harnesses[${index}]`;
|
|
160
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
161
|
+
throw invalid(`harness policy ${key} is not a mapping.`);
|
|
162
|
+
}
|
|
163
|
+
const item = entry;
|
|
164
|
+
const name = item.name;
|
|
165
|
+
if (typeof name !== 'string' || name === '') {
|
|
166
|
+
throw invalid(`harness policy ${key} is missing \`name\`.`);
|
|
167
|
+
}
|
|
168
|
+
if (harnesses.some((h) => h.name === name)) {
|
|
169
|
+
throw invalid(`harness policy declares "${name}" twice.`, { harness: name });
|
|
170
|
+
}
|
|
171
|
+
const kind = item.kind;
|
|
172
|
+
if (kind !== 'in-process' && kind !== 'cli') {
|
|
173
|
+
throw invalid(`harness policy ${key} has kind "${String(kind)}"; it is one of in-process, cli.`, { harness: name });
|
|
174
|
+
}
|
|
175
|
+
const args = requireStringArray(item.args, `${key}.args`);
|
|
176
|
+
const command = item.command ?? null;
|
|
177
|
+
if (kind === 'cli') {
|
|
178
|
+
if (typeof command !== 'string' || command === '') {
|
|
179
|
+
throw invalid(`harness policy ${key} is a cli harness with no \`command\`, so nothing would start.`, { harness: name });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (command !== null) {
|
|
183
|
+
// An in-process harness runs inside the caller's session. A command here
|
|
184
|
+
// would be a line nothing executes, read by the next person as a
|
|
185
|
+
// promise the port does not keep.
|
|
186
|
+
throw invalid(`harness policy ${key} is in-process but names a \`command\`; an in-process harness starts no program.`, { harness: name });
|
|
187
|
+
}
|
|
188
|
+
for (const arg of args) {
|
|
189
|
+
for (const found of placeholdersIn(arg)) {
|
|
190
|
+
if (!PLACEHOLDERS.includes(found)) {
|
|
191
|
+
throw invalid(`harness policy ${key}.args names {${found}}, which no invocation substitutes. Known placeholders: ${PLACEHOLDERS.map((p) => `{${p}}`).join(', ')}.`, { harness: name, placeholder: found });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
harnesses.push({
|
|
196
|
+
name,
|
|
197
|
+
kind,
|
|
198
|
+
roles: requireStringArray(item.roles, `${key}.roles`),
|
|
199
|
+
command: kind === 'cli' ? command : null,
|
|
200
|
+
args,
|
|
201
|
+
envAllowlist: requireStringArray(item.env, `${key}.env`),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const declaredDefault = doc.default;
|
|
205
|
+
const defaultHarness = declaredDefault === undefined || declaredDefault === null
|
|
206
|
+
? harnesses[0].name
|
|
207
|
+
: declaredDefault;
|
|
208
|
+
if (typeof defaultHarness !== 'string' || defaultHarness === '') {
|
|
209
|
+
throw invalid('harness policy `default` is not a harness name.');
|
|
210
|
+
}
|
|
211
|
+
if (!harnesses.some((h) => h.name === defaultHarness)) {
|
|
212
|
+
throw invalid(`harness policy names "${defaultHarness}" as the default, and declares no harness by that name.`, { harness: defaultHarness });
|
|
213
|
+
}
|
|
214
|
+
return { version, defaultHarness, harnesses, source: 'file' };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The built-in policy, or the file the caller named.
|
|
218
|
+
*
|
|
219
|
+
* No default path: see the header. An absent `filePath` is not a missing file,
|
|
220
|
+
* it is the statement that this factory runs one harness.
|
|
221
|
+
*/
|
|
222
|
+
export function loadHarnessPolicy(filePath) {
|
|
223
|
+
if (filePath === undefined)
|
|
224
|
+
return BUILT_IN_HARNESS_POLICY;
|
|
225
|
+
if (!existsSync(filePath)) {
|
|
226
|
+
throw new HarnessError('harness.policy-not-found', `No harness policy at ${filePath}. Omit --policy to use the built-in one.`, { filePath });
|
|
227
|
+
}
|
|
228
|
+
return parseHarnessPolicy(readFileSync(filePath, 'utf8'));
|
|
229
|
+
}
|
|
230
|
+
export function roleAccess(role, guardrails = loadGuardrailPolicy()) {
|
|
231
|
+
if (guardrails.judgeSandbox.roles.includes(role))
|
|
232
|
+
return 'judge';
|
|
233
|
+
if (guardrails.roleWriteScopes.some((s) => s.role === role))
|
|
234
|
+
return 'write-scoped';
|
|
235
|
+
return 'worker';
|
|
236
|
+
}
|
|
237
|
+
/** Where a role's prompt template lives, and whether it is there. */
|
|
238
|
+
function templateFor(role, agentsDir) {
|
|
239
|
+
const file = path.join(agentsDir, `${role}.md`);
|
|
240
|
+
return { file, exists: existsSync(file) };
|
|
241
|
+
}
|
|
242
|
+
function serves(harness, role) {
|
|
243
|
+
return harness.roles.length === 0 || harness.roles.includes(role);
|
|
244
|
+
}
|
|
245
|
+
function substitute(template, values, harness) {
|
|
246
|
+
return template.replace(PLACEHOLDER_RE, (_match, token) => {
|
|
247
|
+
const value = values[token];
|
|
248
|
+
if (value === undefined || value === null || value === '') {
|
|
249
|
+
throw new HarnessError('harness.missing-substitution', `Harness "${harness}" interpolates {${token}}, and this request carries no ${token.replace('_', ' ')}.`, { harness, placeholder: token });
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Describe the process that would run this turn. Start nothing.
|
|
256
|
+
*
|
|
257
|
+
* Every refusal below is a fact the caller could not have checked cheaply:
|
|
258
|
+
* whether the role exists in the taxonomy, whether a template ships for it,
|
|
259
|
+
* whether the harness serves it, and whether handing it a worktree would cross
|
|
260
|
+
* the judge boundary. A runner that skips this call is not faster — it is
|
|
261
|
+
* making the same four decisions with none of them written down.
|
|
262
|
+
*/
|
|
263
|
+
export function planWorkerTurn(request, options = {}) {
|
|
264
|
+
const policy = options.policy ?? BUILT_IN_HARNESS_POLICY;
|
|
265
|
+
const guardrails = options.guardrails ?? loadGuardrailPolicy();
|
|
266
|
+
const taxonomy = options.taxonomy ?? loadTaxonomy();
|
|
267
|
+
const agentsDir = options.agentsDir ?? AGENTS_DIR;
|
|
268
|
+
const { role, taskId } = request;
|
|
269
|
+
const agentRoles = taxonomy.dimensions.agent ?? [];
|
|
270
|
+
if (!agentRoles.includes(role)) {
|
|
271
|
+
throw new HarnessError('harness.unknown-role', `"${role}" is not a taxonomy \`agent\` value, so no dispatch about it could be written even if the turn ran.`, { role });
|
|
272
|
+
}
|
|
273
|
+
if (taskId === undefined || taskId === '') {
|
|
274
|
+
throw new HarnessError('harness.missing-task', 'A worker turn is about a task; the invocation carries its id so the dispatch the caller writes can name the same one.', { role });
|
|
275
|
+
}
|
|
276
|
+
if (request.promptFile === undefined || request.promptFile === '') {
|
|
277
|
+
throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`smith prompt record`), then plan the turn that carries it.', { role });
|
|
278
|
+
}
|
|
279
|
+
const name = request.harness ?? policy.defaultHarness;
|
|
280
|
+
const harness = policy.harnesses.find((h) => h.name === name);
|
|
281
|
+
if (harness === undefined) {
|
|
282
|
+
throw new HarnessError('harness.unknown', `No harness named "${name}". This policy declares: ${policy.harnesses.map((h) => h.name).join(', ')}.`, { harness: name, declared: policy.harnesses.map((h) => h.name) });
|
|
283
|
+
}
|
|
284
|
+
if (!serves(harness, role)) {
|
|
285
|
+
throw new HarnessError('harness.role-not-served', `Harness "${harness.name}" serves ${harness.roles.join(', ')}, and not ${role}.`, { harness: harness.name, role });
|
|
286
|
+
}
|
|
287
|
+
const worktree = request.worktree ?? null;
|
|
288
|
+
const access = roleAccess(role, guardrails);
|
|
289
|
+
const base = {
|
|
290
|
+
harness: harness.name,
|
|
291
|
+
role,
|
|
292
|
+
taskId,
|
|
293
|
+
access,
|
|
294
|
+
promptFile: request.promptFile,
|
|
295
|
+
worktree,
|
|
296
|
+
sandboxRequired: access === 'judge' && worktree !== null,
|
|
297
|
+
};
|
|
298
|
+
if (harness.kind === 'in-process') {
|
|
299
|
+
const template = templateFor(role, agentsDir);
|
|
300
|
+
if (!template.exists) {
|
|
301
|
+
// D-191's reading, applied to the harness rather than to a grant: a
|
|
302
|
+
// role with no template reaches no agent. `operator` is the standing
|
|
303
|
+
// case — taxonomy.yml carries it as a `found_by` for a defect a person
|
|
304
|
+
// read off the code, and it is never dispatched.
|
|
305
|
+
throw new HarnessError('harness.no-template', `${role} ships no template at ${path.relative(REPO_ROOT, template.file)}, so an in-process harness has no agent to start.`, { role, template: path.relative(REPO_ROOT, template.file) });
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
...base,
|
|
309
|
+
kind: 'in-process',
|
|
310
|
+
subagentType: role,
|
|
311
|
+
template: path.relative(REPO_ROOT, template.file),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (access === 'judge' && worktree !== null) {
|
|
315
|
+
// §18 rule 5, one step out from providers/types.ts. An external process
|
|
316
|
+
// holding a worktree path holds write access to it; no lease this side of
|
|
317
|
+
// the boundary can take that back.
|
|
318
|
+
throw new HarnessError('harness.judge-worktree', `Harness "${harness.name}" runs a separate program, and ${role} is a judge role. A judge outside this process gets the prompt and nothing else — a worktree path it holds is write access no sandbox lease can revoke (architecture §18 rule 5).`, { harness: harness.name, role });
|
|
319
|
+
}
|
|
320
|
+
const values = {
|
|
321
|
+
prompt_file: request.promptFile,
|
|
322
|
+
worktree,
|
|
323
|
+
role,
|
|
324
|
+
task: taskId,
|
|
325
|
+
};
|
|
326
|
+
return {
|
|
327
|
+
...base,
|
|
328
|
+
kind: 'cli',
|
|
329
|
+
command: harness.command,
|
|
330
|
+
args: harness.args.map((arg) => substitute(arg, values, harness.name)),
|
|
331
|
+
cwd: worktree,
|
|
332
|
+
envAllowlist: harness.envAllowlist,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* What `smith harness list` prints: not the policy as written, but the policy
|
|
337
|
+
* as it resolves. A harness whose `roles` is empty reads as "all" in YAML and
|
|
338
|
+
* means "every role that ships a template" in practice, and the difference
|
|
339
|
+
* between those two is the kind of thing an operator finds out at 2am
|
|
340
|
+
* otherwise.
|
|
341
|
+
*/
|
|
342
|
+
export function summarizeHarnesses(options = {}) {
|
|
343
|
+
const policy = options.policy ?? BUILT_IN_HARNESS_POLICY;
|
|
344
|
+
const taxonomy = options.taxonomy ?? loadTaxonomy();
|
|
345
|
+
const guardrails = options.guardrails ?? loadGuardrailPolicy();
|
|
346
|
+
const agentsDir = options.agentsDir ?? AGENTS_DIR;
|
|
347
|
+
const agentRoles = taxonomy.dimensions.agent ?? [];
|
|
348
|
+
return {
|
|
349
|
+
source: policy.source,
|
|
350
|
+
version: policy.version,
|
|
351
|
+
defaultHarness: policy.defaultHarness,
|
|
352
|
+
harnesses: policy.harnesses.map((harness) => ({
|
|
353
|
+
name: harness.name,
|
|
354
|
+
kind: harness.kind,
|
|
355
|
+
isDefault: harness.name === policy.defaultHarness,
|
|
356
|
+
roles: agentRoles.filter((role) => {
|
|
357
|
+
if (!serves(harness, role))
|
|
358
|
+
return false;
|
|
359
|
+
if (harness.kind === 'in-process')
|
|
360
|
+
return templateFor(role, agentsDir).exists;
|
|
361
|
+
return roleAccess(role, guardrails) !== 'judge';
|
|
362
|
+
}),
|
|
363
|
+
command: harness.command,
|
|
364
|
+
envAllowlist: harness.envAllowlist,
|
|
365
|
+
})),
|
|
366
|
+
};
|
|
367
|
+
}
|