@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.
Files changed (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. 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
- const MILESTONE_COMPLETE_TASK_STATUSES = new Set(['completed', 'waived']);
347
- const MILESTONE_NEXT_EXCLUDED_STATUSES = new Set(['completed', 'waived', 'failed', 'superseded']);
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 && MILESTONE_COMPLETE_TASK_STATUSES.has(status);
371
+ return status !== undefined && TERMINAL_OK_TASK_STATUSES.has(status);
367
372
  });
368
373
  const recentDone = milestoneTasks
369
- .filter((t) => MILESTONE_COMPLETE_TASK_STATUSES.has(t.taskStatus))
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) => MILESTONE_COMPLETE_TASK_STATUSES.has(t.taskStatus)).length;
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((f) => (f.severity === 'S3-minor' || f.severity === 'S4-nit') &&
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((f) => (f.severity === 'S3-minor' || f.severity === 'S4-nit') &&
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 three proposals. Architecture §12 has the scheduler
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
- // three stayed off this list without failing the P9-37 lint (Rule D).
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
- 'spec-review-recorded': { key: 'reviewed_by', label: 'spec review' },
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
- 'goal-check-recorded': { key: 'checked_by', label: 'spec-vs-goal check' },
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
+ }