@juzser/blacksmith 0.2.0 → 0.4.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 +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +25 -9
- package/.claude/skills/bs/dispatch.md +60 -7
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +18 -13
- package/.claude/skills/bs/wave.md +68 -10
- package/README.md +4 -4
- package/factory/orchestrator/dist/agentsSync.js +178 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +25 -0
- package/factory/orchestrator/dist/budgets.js +91 -2
- package/factory/orchestrator/dist/claims.js +8 -4
- package/factory/orchestrator/dist/cli.js +394 -26
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +230 -41
- package/factory/orchestrator/dist/db/projector.js +93 -36
- package/factory/orchestrator/dist/db/queries.js +58 -35
- package/factory/orchestrator/dist/db/schema.js +24 -0
- package/factory/orchestrator/dist/dispatchAudit.js +10 -2
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +192 -39
- package/factory/orchestrator/dist/epicWidth.js +25 -20
- package/factory/orchestrator/dist/errorIssues.js +209 -24
- package/factory/orchestrator/dist/eventOrder.js +102 -0
- package/factory/orchestrator/dist/events.js +73 -91
- package/factory/orchestrator/dist/findingContext.js +1 -13
- package/factory/orchestrator/dist/findings.js +132 -11
- package/factory/orchestrator/dist/gh.js +29 -6
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/harness.js +92 -10
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/impact.js +24 -2
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +415 -0
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/judges.js +1 -1
- package/factory/orchestrator/dist/lessons.js +46 -18
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +138 -4
- package/factory/orchestrator/dist/planQuorum.js +1 -1
- package/factory/orchestrator/dist/policy.js +57 -11
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +477 -29
- package/factory/orchestrator/dist/scheduler.js +51 -0
- package/factory/orchestrator/dist/severity.js +40 -8
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taskStatus.js +74 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +32 -3
- package/factory/orchestrator/dist/usage.js +42 -9
- package/factory/orchestrator/dist/waivers.js +60 -9
- package/factory/orchestrator/dist/waveConcurrency.js +29 -0
- package/factory/orchestrator/dist/waveNext.js +12 -10
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +9 -2
- package/factory/policies/crosscheck.yml +2 -2
- package/factory/policies/lessons.md +298 -0
- package/factory/policies/scheduler.yml +27 -2
- package/factory/policies/taxonomy.yml +10 -3
- package/factory/specs/roadmap.md +70 -23
- package/package.json +8 -8
|
@@ -8,11 +8,14 @@ import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
|
|
8
8
|
import { isOperatorActor } from '../actors.js';
|
|
9
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
|
+
import { OPEN_FINDING_STATUSES, WAIVABLE_STATUSES } from '../findings.js';
|
|
11
12
|
import { waveLayers } from '../graph.js';
|
|
12
13
|
import { judgeFailureKind } from '../providers/types.js';
|
|
13
14
|
import { severityRank } from '../severity.js';
|
|
14
15
|
import { epicOfTaskId, taskIdsMatch } from '../taskId.js';
|
|
16
|
+
import { TERMINAL_OK_TASK_STATUSES, TERMINAL_TASK_STATUSES } from '../taskStatus.js';
|
|
15
17
|
import { loadTaxonomy } from '../taxonomy.js';
|
|
18
|
+
import { WAIVABLE_SEVERITIES } from '../waivers.js';
|
|
16
19
|
import { agents, artifacts, dispatches, edges, epics, errors, eventsRaw, findings, lessons, milestones, prompts, sessions, tasks, waivers, } from './schema.js';
|
|
17
20
|
/** Phase 6b default: every event/task/etc. logged before the project dimension existed. */
|
|
18
21
|
export const DEFAULT_PROJECT = 'black-smith';
|
|
@@ -216,15 +219,6 @@ function inLogOrder(rows) {
|
|
|
216
219
|
function distinctProjects(...rowSets) {
|
|
217
220
|
return [...new Set(rowSets.flat().map((r) => projectOf(r.project)))].sort();
|
|
218
221
|
}
|
|
219
|
-
const NON_TERMINAL_TASK_STATUSES = [
|
|
220
|
-
'todo',
|
|
221
|
-
'ready',
|
|
222
|
-
'in-progress',
|
|
223
|
-
'grading',
|
|
224
|
-
'reviewing',
|
|
225
|
-
'merging',
|
|
226
|
-
'blocked',
|
|
227
|
-
];
|
|
228
222
|
/** Every `tasks` row for `scope`, session-filtered in SQL and project-filtered in JS. */
|
|
229
223
|
function allTasksForScope(db, scope) {
|
|
230
224
|
const sessionCond = scopedToSessions(tasks.sessionId, scope);
|
|
@@ -343,8 +337,19 @@ function epicTokenMaps(db, scope, taskRows) {
|
|
|
343
337
|
}
|
|
344
338
|
return { budgetByEpic, spentByEpic };
|
|
345
339
|
}
|
|
346
|
-
|
|
347
|
-
|
|
340
|
+
/**
|
|
341
|
+
* NEXT hides the statuses the plan has nothing left to ask of — every
|
|
342
|
+
* terminal one except `escalated`, which is terminal and still waiting on a
|
|
343
|
+
* person: an escalated task is precisely the next thing the operator has to
|
|
344
|
+
* act on, so the Roadmap keeps showing it.
|
|
345
|
+
*
|
|
346
|
+
* Derived from the write-protection roster rather than retyped, because the
|
|
347
|
+
* two answers move together: a status somebody declares terminal there is one
|
|
348
|
+
* NEXT has nothing to say about, and a status nobody has classified keeps
|
|
349
|
+
* appearing here — visible, rather than dropped off the Roadmap silently.
|
|
350
|
+
*/
|
|
351
|
+
const MILESTONE_NEXT_STILL_WANTS_A_PERSON = 'escalated';
|
|
352
|
+
const MILESTONE_NEXT_EXCLUDED_STATUSES = new Set([...TERMINAL_TASK_STATUSES].filter((s) => s !== MILESTONE_NEXT_STILL_WANTS_A_PERSON));
|
|
348
353
|
const MINI_TIMELINE_LIMIT = 3;
|
|
349
354
|
/**
|
|
350
355
|
* Operator directive 4 (Phase 6b, ui/docs/design-spec.md addendum): up to 3
|
|
@@ -363,10 +368,10 @@ function milestoneTaskRefs(milestoneTasks, allTaskRows, edgeRows) {
|
|
|
363
368
|
}
|
|
364
369
|
const dependencyReady = (taskId) => (dependsOnByTask.get(taskId) ?? []).every((dep) => {
|
|
365
370
|
const status = statusById.get(dep);
|
|
366
|
-
return status !== undefined &&
|
|
371
|
+
return status !== undefined && TERMINAL_OK_TASK_STATUSES.has(status);
|
|
367
372
|
});
|
|
368
373
|
const recentDone = milestoneTasks
|
|
369
|
-
.filter((t) =>
|
|
374
|
+
.filter((t) => TERMINAL_OK_TASK_STATUSES.has(t.taskStatus))
|
|
370
375
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
371
376
|
.slice(0, MINI_TIMELINE_LIMIT)
|
|
372
377
|
.map((t) => ({
|
|
@@ -426,7 +431,7 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
|
|
|
426
431
|
const epicIds = JSON.parse(m.epicIds);
|
|
427
432
|
const epicSet = new Set(epicIds);
|
|
428
433
|
const milestoneTasks = taskRows.filter((t) => t.epicId !== null && epicSet.has(t.epicId));
|
|
429
|
-
const tasksCompleted = milestoneTasks.filter((t) =>
|
|
434
|
+
const tasksCompleted = milestoneTasks.filter((t) => TERMINAL_OK_TASK_STATUSES.has(t.taskStatus)).length;
|
|
430
435
|
let tokensSpent = 0;
|
|
431
436
|
let tokensBudget = 0;
|
|
432
437
|
let hasBudget = false;
|
|
@@ -454,6 +459,7 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
|
|
|
454
459
|
tokensBudget: hasBudget ? tokensBudget : null,
|
|
455
460
|
project: m.project,
|
|
456
461
|
kind: m.kind,
|
|
462
|
+
errorIssuesEnabled: m.errorIssues,
|
|
457
463
|
...(refs ? { recentDone: refs.recentDone, nextUp: refs.nextUp } : {}),
|
|
458
464
|
};
|
|
459
465
|
});
|
|
@@ -616,17 +622,36 @@ function closedEpicsForScope(db, scope) {
|
|
|
616
622
|
* so an epic closed by an operator overriding a hold (exactly the case where a
|
|
617
623
|
* task stays non-terminal) read as in flight forever. A close on the log ends
|
|
618
624
|
* the flight, whatever the task rows still say.
|
|
625
|
+
*
|
|
626
|
+
* The open side is asked as the complement of db/projector.ts's terminal set
|
|
627
|
+
* rather than listed here. Listing it inverts the default: a `task_status`
|
|
628
|
+
* taxonomy.yml declares and this list has not caught up with would answer
|
|
629
|
+
* "not in flight", and with no close on the log the epic falls out of
|
|
630
|
+
* `epicsInFlight` and `closedEpics` both — ui/src/lib/api.ts's
|
|
631
|
+
* `selectableEpics()` is their union, so the epic stops being pickable on
|
|
632
|
+
* either board, silently.
|
|
619
633
|
*/
|
|
620
634
|
function inFlightEpics(taskRows, closed) {
|
|
621
635
|
const closedIds = new Set(closed.map((e) => e.epicId));
|
|
622
636
|
return [
|
|
623
637
|
...new Set(taskRows
|
|
624
|
-
.filter((t) => t.epicId &&
|
|
625
|
-
NON_TERMINAL_TASK_STATUSES.includes(t.taskStatus) &&
|
|
626
|
-
!closedIds.has(t.epicId))
|
|
638
|
+
.filter((t) => t.epicId && !TERMINAL_TASK_STATUSES.has(t.taskStatus) && !closedIds.has(t.epicId))
|
|
627
639
|
.map((t) => t.epicId)),
|
|
628
640
|
].sort();
|
|
629
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* A finding the operator's pending-waiver count is about: one the waiver
|
|
644
|
+
* machinery would actually act on. Both rosters are imported from the modules
|
|
645
|
+
* that own them — the severities from waivers.ts, the statuses from findings.ts,
|
|
646
|
+
* where they are read off LEGAL_TRANSITIONS — rather than spelled out here.
|
|
647
|
+
* This count is a second *reader* of what can be waived and must not become a
|
|
648
|
+
* second opinion about it: a count that disagrees with the batch does not
|
|
649
|
+
* error, it just tells the operator a different number than `/bs waivers`
|
|
650
|
+
* will offer them.
|
|
651
|
+
*/
|
|
652
|
+
function awaitsWaiverDecision(f) {
|
|
653
|
+
return WAIVABLE_SEVERITIES.includes(f.severity) && WAIVABLE_STATUSES.includes(f.findingStatus);
|
|
654
|
+
}
|
|
630
655
|
/**
|
|
631
656
|
* One project's overview slice, computed by the same logic overview() itself
|
|
632
657
|
* uses (no drift). `nowIso` is the caller's single instant, not a fresh
|
|
@@ -643,8 +668,7 @@ function projectSummary(db, project, baseScope, nowIso) {
|
|
|
643
668
|
const tokensSpent = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
|
|
644
669
|
const tokensBudget = budgetByEpic.size > 0 ? [...budgetByEpic.values()].reduce((s, v) => s + v, 0) : null;
|
|
645
670
|
const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
|
|
646
|
-
const findingRows = allFindingsForScope(db, scope).filter(
|
|
647
|
-
(f.findingStatus === 'raised' || f.findingStatus === 'confirmed'));
|
|
671
|
+
const findingRows = allFindingsForScope(db, scope).filter(awaitsWaiverDecision);
|
|
648
672
|
const pendingWaivers = findingRows.filter((f) => f.waiverId === null).length;
|
|
649
673
|
return {
|
|
650
674
|
project,
|
|
@@ -829,8 +853,7 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
829
853
|
tokensBudget: budgetByEpic.get(epicId) ?? null,
|
|
830
854
|
}));
|
|
831
855
|
const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
|
|
832
|
-
const pendingWaiverFindings = allFindingsForScope(db, scope).filter(
|
|
833
|
-
(f.findingStatus === 'raised' || f.findingStatus === 'confirmed'));
|
|
856
|
+
const pendingWaiverFindings = allFindingsForScope(db, scope).filter(awaitsWaiverDecision);
|
|
834
857
|
const pendingWaivers = pendingWaiverFindings.filter((f) => f.waiverId === null).length;
|
|
835
858
|
const dispatchSessionCond = scopedToSessions(dispatches.sessionId, scope);
|
|
836
859
|
const dispatchRows = dispatchSessionCond
|
|
@@ -979,15 +1002,21 @@ export const FREE_TIMELINE_EVENT_TYPES = [
|
|
|
979
1002
|
// shadow verdict nobody can see is a shadow deployment nobody can evaluate,
|
|
980
1003
|
// which is the whole reason the mode exists.
|
|
981
1004
|
'cross-finding-reconciled',
|
|
982
|
-
// The scheduler's
|
|
1005
|
+
// The scheduler's four proposals. Architecture §12 has the scheduler
|
|
983
1006
|
// propose and the operator dispose, so the timeline is the only place the
|
|
984
1007
|
// offer is ever made -- drop the row and the proposal is never put to
|
|
985
1008
|
// anyone. They arrive through scheduler.ts eventTypeFor(), a helper return
|
|
986
1009
|
// rather than a literal at the `event_type:` position, which is how all
|
|
987
|
-
//
|
|
1010
|
+
// four stayed off this list without failing the P9-37 lint (Rule D).
|
|
988
1011
|
'recheck-proposed',
|
|
989
1012
|
'maintenance-proposed',
|
|
990
1013
|
'growth-review-due',
|
|
1014
|
+
// The scheduler's error-report proposal (task 6 of factory-error-log,
|
|
1015
|
+
// scheduler.ts eventTypeFor()'s 'error-report' case). Task 5's issue
|
|
1016
|
+
// reporter disposes it into 'issue-reported', which reaches the timeline
|
|
1017
|
+
// via the taxonomy's gate_event dimension instead -- this entry is the
|
|
1018
|
+
// proposal itself, not the outcome.
|
|
1019
|
+
'error-report-proposed',
|
|
991
1020
|
// The audit verbs (audit.ts, docs/specs/audit-command-scope.md §6). An
|
|
992
1021
|
// audit runs against a project outside this factory and carries no task_id,
|
|
993
1022
|
// so nothing else on the timeline would show that it happened; each verb's
|
|
@@ -1186,18 +1215,6 @@ export function timeline(db, filter = {}) {
|
|
|
1186
1215
|
entries = applyDecisionsLens(entries);
|
|
1187
1216
|
return entries;
|
|
1188
1217
|
}
|
|
1189
|
-
// D-127: `amend-pending` is open. The amendment has been written but the tasks
|
|
1190
|
-
// it obligates have not landed, so nothing is discharged yet. This query has no
|
|
1191
|
-
// obligation data — that lives in epic.ts's summary, which checks each named
|
|
1192
|
-
// task id — so it treats the status as unconditionally open: correct for a
|
|
1193
|
-
// per-task severity chip, and it fails closed.
|
|
1194
|
-
const OPEN_FINDING_STATUSES = new Set([
|
|
1195
|
-
'raised',
|
|
1196
|
-
'confirmed',
|
|
1197
|
-
'fix-pending',
|
|
1198
|
-
'fix-landed',
|
|
1199
|
-
'amend-pending',
|
|
1200
|
-
]);
|
|
1201
1218
|
function worstSeverity(severities) {
|
|
1202
1219
|
let worst = null;
|
|
1203
1220
|
let worstIndex = Number.POSITIVE_INFINITY;
|
|
@@ -1240,6 +1257,12 @@ export function kanban(db, epicId, scope = {}, opts = {}) {
|
|
|
1240
1257
|
for (const f of findingRows) {
|
|
1241
1258
|
if (f.epicId !== null && !epicIdsInScope.has(f.epicId))
|
|
1242
1259
|
continue;
|
|
1260
|
+
// D-127: `amend-pending` is one of these. The amendment has been written
|
|
1261
|
+
// but the tasks it obligates have not landed, so nothing is discharged
|
|
1262
|
+
// yet. This query has no obligation data — that lives in epic.ts's
|
|
1263
|
+
// summary, which checks each named task id — so it treats the status as
|
|
1264
|
+
// unconditionally open: correct for a per-task severity chip, and it fails
|
|
1265
|
+
// closed.
|
|
1243
1266
|
if (!OPEN_FINDING_STATUSES.has(f.findingStatus))
|
|
1244
1267
|
continue;
|
|
1245
1268
|
const list = openSeverityByTask.get(f.taskId) ?? [];
|
|
@@ -298,7 +298,31 @@ export const milestones = sqliteTable('milestones', {
|
|
|
298
298
|
// roadmap.ts resolves for a milestone that declares neither bullet, and
|
|
299
299
|
// for those two that answer is the factory's own.
|
|
300
300
|
kind: text('kind').notNull().default('factory'),
|
|
301
|
+
// Task 7 (factory-error-log) -- roadmap.ts's MilestoneDef.errorIssuesEnabled,
|
|
302
|
+
// resolved per-project from the roadmap's `- error_issues: on|off` bullet
|
|
303
|
+
// (default 'on' when absent, same default roadmap.ts itself applies).
|
|
304
|
+
errorIssues: integer('error_issues', { mode: 'boolean' }).notNull().default(true),
|
|
301
305
|
}, (t) => [index('milestones_project_idx').on(t.project)]);
|
|
306
|
+
/** `issue-reported` events (architecture §8, task 5 of factory-error-log). */
|
|
307
|
+
export const issue_reports = sqliteTable('issue_reports', {
|
|
308
|
+
eventId: text('event_id').primaryKey(),
|
|
309
|
+
sessionId: text('session_id').notNull(),
|
|
310
|
+
ts: text('ts').notNull(),
|
|
311
|
+
taskRef: text('task_ref'),
|
|
312
|
+
errorClass: text('error_class').notNull(),
|
|
313
|
+
fingerprint: text('fingerprint').notNull(),
|
|
314
|
+
issueUrl: text('issue_url'),
|
|
315
|
+
latestEventId: text('latest_event_id').notNull(),
|
|
316
|
+
outcome: text('outcome').notNull(),
|
|
317
|
+
reason: text('reason'),
|
|
318
|
+
repoSlug: text('repo_slug'),
|
|
319
|
+
source: text('source').notNull(),
|
|
320
|
+
project: text('project'), // Phase 6b — see eventsRaw's project comment above.
|
|
321
|
+
}, (t) => [
|
|
322
|
+
index('issue_reports_session_idx').on(t.sessionId),
|
|
323
|
+
index('issue_reports_fingerprint_idx').on(t.fingerprint),
|
|
324
|
+
index('issue_reports_outcome_idx').on(t.outcome),
|
|
325
|
+
]);
|
|
302
326
|
/** One row per artifact entry on a `task-result-recorded` event's payload. */
|
|
303
327
|
export const artifacts = sqliteTable('artifacts', {
|
|
304
328
|
id: text('id').primaryKey(), // `${event_id}#${index}`
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { eventTaskId } from './events.js';
|
|
2
|
+
import { GOAL_CHECK_EVENT } from './goalCheck.js';
|
|
3
|
+
import { SPEC_REVIEW_EVENT } from './spec.js';
|
|
2
4
|
import { taskIdsMatch } from './taskId.js';
|
|
3
5
|
/**
|
|
4
6
|
* Critic work that reaches the event log without a `dispatch_decision`.
|
|
@@ -7,14 +9,20 @@ import { taskIdsMatch } from './taskId.js';
|
|
|
7
9
|
* is how the operator hears it in a detail line. Adding an entry widens the
|
|
8
10
|
* audit's domain; leaving one off is how D-124 happened, so a new event type
|
|
9
11
|
* that records a critic's output belongs here at the moment it is written.
|
|
12
|
+
*
|
|
13
|
+
* The keys are the producers' own exported constants, not copies of their
|
|
14
|
+
* values. `readCriticWorkRecords` skips any event type this map does not
|
|
15
|
+
* hold, so a key that had drifted from its producer would empty the domain
|
|
16
|
+
* rather than fail: the pair would fall to `not-applicable`, which `ok`
|
|
17
|
+
* counts as fine, and D-124 would go unchecked with nothing red to say so.
|
|
10
18
|
*/
|
|
11
19
|
const CRITIC_WORK_EVENTS = {
|
|
12
|
-
|
|
20
|
+
[SPEC_REVIEW_EVENT]: { key: 'reviewed_by', label: 'spec review' },
|
|
13
21
|
// `smith epic goal-check` reaches the log the same way `epic spec-review`
|
|
14
22
|
// does — a judge's verdict on the planner's output, written by a command
|
|
15
23
|
// rather than by a dispatch — so it is in the domain for the same reason,
|
|
16
24
|
// and listed here at the moment it was written rather than after a D-124.
|
|
17
|
-
|
|
25
|
+
[GOAL_CHECK_EVENT]: { key: 'checked_by', label: 'spec-vs-goal check' },
|
|
18
26
|
};
|
|
19
27
|
function payloadString(payload, key) {
|
|
20
28
|
const value = payload[key];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `smith dispatch lint` — check a dispatch prompt against two things a
|
|
3
|
+
* dispatcher can get wrong without any signal until the agent runs:
|
|
4
|
+
*
|
|
5
|
+
* - the turn budget stated in the prompt vs the role template's
|
|
6
|
+
* `maxTurns:`, read fresh off disk (dispatch.md "Carry into the
|
|
7
|
+
* prompt" — a prompt that promises more than the template is a fiction
|
|
8
|
+
* the agent plans against and gets cut in the middle of).
|
|
9
|
+
* - a judge prompt's declared-artifact line vs what the ledger recorded
|
|
10
|
+
* for its `judge dispatch` (dispatch.md "Declare each judge's
|
|
11
|
+
* artifact" — a judge prompt missing that line is undetectable until
|
|
12
|
+
* the report never lands).
|
|
13
|
+
*
|
|
14
|
+
* Both parsers and both comparisons live here so the two checks stay one
|
|
15
|
+
* source of truth for "what does a compliant dispatch prompt say", rather
|
|
16
|
+
* than every dispatcher re-deriving it.
|
|
17
|
+
*/
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { readTemplateMaxTurns } from './agentsSync.js';
|
|
20
|
+
import { readJudgeTurns } from './judges.js';
|
|
21
|
+
import { AGENTS_DIR } from './paths.js';
|
|
22
|
+
/**
|
|
23
|
+
* The epic criterion's six judge roles (task 5 and task 6 objectives both
|
|
24
|
+
* name this exact set: spec-reviewer, reviewer, verifier, grader,
|
|
25
|
+
* security-reviewer, auditor) -- the roles whose dispatch declares an
|
|
26
|
+
* artifact and whose SubagentStop is guarded by judge-stop.sh. Kept as its
|
|
27
|
+
* own list rather than read out of judges.ts: `foldJudgeTurns` folds any
|
|
28
|
+
* role with a `declared_artifact` on its dispatch into a turn, but this
|
|
29
|
+
* module is stricter on purpose — only these six get an artifact check, so
|
|
30
|
+
* a coder's dispatch (never carries one) reads `not-applicable` rather than
|
|
31
|
+
* a false `undeclared`.
|
|
32
|
+
*
|
|
33
|
+
* Not the same six as dispatch.md's "Fingerprint the worktree around every
|
|
34
|
+
* judge" list, which swaps `auditor` for `uiux`: that section is about
|
|
35
|
+
* which judges run inside a task's worktree (auditor runs over a whole
|
|
36
|
+
* project at HEAD, never per-task, so a worktree fingerprint does not apply
|
|
37
|
+
* to it; uiux does run per-task). This list is about which roles owe a
|
|
38
|
+
* declared artifact and a blocked Stop, and `auditor` is one of them.
|
|
39
|
+
*/
|
|
40
|
+
export const JUDGE_ROLES = [
|
|
41
|
+
'reviewer',
|
|
42
|
+
'verifier',
|
|
43
|
+
'grader',
|
|
44
|
+
'spec-reviewer',
|
|
45
|
+
'security-reviewer',
|
|
46
|
+
'auditor',
|
|
47
|
+
];
|
|
48
|
+
const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
|
|
49
|
+
const TURN_BUDGET_LINE = /Turn budget:[ \t]*(\S*)/g;
|
|
50
|
+
/**
|
|
51
|
+
* `Turn budget: <n>` in a dispatch prompt — mid-sentence or on its own
|
|
52
|
+
* line, since a dispatch's leading line names the role and the budget in
|
|
53
|
+
* one breath ("Role: coder. Turn budget: 40 ..."). Null for no occurrence,
|
|
54
|
+
* more than one (two numbers is no number), or a value that is not a bare
|
|
55
|
+
* non-negative integer — every one of those is "cannot state a turn count",
|
|
56
|
+
* not "states zero".
|
|
57
|
+
*/
|
|
58
|
+
export function parseStatedTurns(prompt) {
|
|
59
|
+
const matches = [...prompt.matchAll(TURN_BUDGET_LINE)];
|
|
60
|
+
if (matches.length !== 1)
|
|
61
|
+
return null;
|
|
62
|
+
const raw = matches[0]?.[1] ?? '';
|
|
63
|
+
return /^[0-9]+$/.test(raw) ? Number(raw) : null;
|
|
64
|
+
}
|
|
65
|
+
const DECLARED_ARTIFACT_LINE = /^Declared artifact:[ \t]*(\S+)[ \t]*$/m;
|
|
66
|
+
/** `Declared artifact: <path>` in a dispatch prompt, or null when the line is not there. */
|
|
67
|
+
export function parseDeclaredArtifactLine(prompt) {
|
|
68
|
+
const match = DECLARED_ARTIFACT_LINE.exec(prompt);
|
|
69
|
+
return match?.[1] ?? null;
|
|
70
|
+
}
|
|
71
|
+
/** The line a dispatch is expected to carry verbatim, given the ledger's declared path. */
|
|
72
|
+
export function formatDeclaredArtifactLine(artifactPath) {
|
|
73
|
+
return `Declared artifact: ${artifactPath}`;
|
|
74
|
+
}
|
|
75
|
+
function checkTurns(prompt, role, agentsDir) {
|
|
76
|
+
const stated = parseStatedTurns(prompt);
|
|
77
|
+
const template = readTemplateMaxTurns(agentsDir, role) ?? null;
|
|
78
|
+
if (template === null)
|
|
79
|
+
return { status: 'unverifiable', stated, template: null };
|
|
80
|
+
if (stated === null)
|
|
81
|
+
return { status: 'missing', stated: null, template };
|
|
82
|
+
if (stated === template)
|
|
83
|
+
return { status: 'ok', stated, template };
|
|
84
|
+
return { status: stated < template ? 'under' : 'over', stated, template };
|
|
85
|
+
}
|
|
86
|
+
async function checkArtifact(prompt, role, taskId, sessionId, eventOpts) {
|
|
87
|
+
if (!JUDGE_ROLE_SET.has(role)) {
|
|
88
|
+
return { status: 'not-applicable', expected_line: null, declared_line: null };
|
|
89
|
+
}
|
|
90
|
+
const declaredLine = parseDeclaredArtifactLine(prompt);
|
|
91
|
+
const turns = await readJudgeTurns(taskId, { sessionId }, eventOpts);
|
|
92
|
+
const turn = turns.find((t) => t.role === role);
|
|
93
|
+
if (turn === undefined) {
|
|
94
|
+
return { status: 'undeclared', expected_line: null, declared_line: declaredLine };
|
|
95
|
+
}
|
|
96
|
+
const expectedLine = formatDeclaredArtifactLine(turn.declaredArtifact);
|
|
97
|
+
if (declaredLine === null) {
|
|
98
|
+
return { status: 'missing', expected_line: expectedLine, declared_line: null };
|
|
99
|
+
}
|
|
100
|
+
if (declaredLine === turn.declaredArtifact) {
|
|
101
|
+
return { status: 'ok', expected_line: expectedLine, declared_line: declaredLine };
|
|
102
|
+
}
|
|
103
|
+
if (!path.isAbsolute(declaredLine)) {
|
|
104
|
+
return { status: 'relative', expected_line: expectedLine, declared_line: declaredLine };
|
|
105
|
+
}
|
|
106
|
+
return { status: 'mismatch', expected_line: expectedLine, declared_line: declaredLine };
|
|
107
|
+
}
|
|
108
|
+
// Fail closed: every status but "ok" exits non-zero, including "under" (a
|
|
109
|
+
// prompt that understates the template's budget still disagrees with it) and
|
|
110
|
+
// "unverifiable" (an unreadable template is not a pass, exactly like `dispatch
|
|
111
|
+
// check`, `tester check`, `delegation check`, and `escalation check` already
|
|
112
|
+
// treat "cannot tell" as a failure, never a maybe).
|
|
113
|
+
const TURNS_FAIL = new Set(['under', 'over', 'missing', 'unverifiable']);
|
|
114
|
+
const ARTIFACT_FAIL = new Set([
|
|
115
|
+
'missing',
|
|
116
|
+
'relative',
|
|
117
|
+
'mismatch',
|
|
118
|
+
'undeclared',
|
|
119
|
+
]);
|
|
120
|
+
/**
|
|
121
|
+
* Lint one dispatch prompt. Never throws on a bad prompt — a bad prompt is
|
|
122
|
+
* exactly the finding this reports — only on a missing task/session (the
|
|
123
|
+
* caller's own mistake, same as every other verb that reads the ledger).
|
|
124
|
+
*/
|
|
125
|
+
export async function lintDispatchPrompt(input) {
|
|
126
|
+
const agentsDir = input.agentsDir ?? AGENTS_DIR;
|
|
127
|
+
const eventOpts = input.eventOpts ?? {};
|
|
128
|
+
const turns = checkTurns(input.prompt, input.role, agentsDir);
|
|
129
|
+
const artifact = await checkArtifact(input.prompt, input.role, input.taskId, input.sessionId, eventOpts);
|
|
130
|
+
const exitCode = TURNS_FAIL.has(turns.status) || ARTIFACT_FAIL.has(artifact.status) ? 1 : 0;
|
|
131
|
+
return { turns, artifact, exitCode };
|
|
132
|
+
}
|