@juzser/blacksmith 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -19,7 +19,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
|
19
19
|
* operator far more often than any §8 dimension, and forcing a taxonomy PR
|
|
20
20
|
* per new project would defeat the point of a multi-project hub. Nullable
|
|
21
21
|
* for migration safety (every event logged before this column existed has
|
|
22
|
-
* no value here); queries.ts's helpers COALESCE it to '
|
|
22
|
+
* no value here); queries.ts's helpers COALESCE it to 'blacksmith' so old
|
|
23
23
|
* rows and new single-project fixtures both resolve to the same default
|
|
24
24
|
* project rather than an ungrouped null bucket.
|
|
25
25
|
*/
|
|
@@ -40,12 +40,19 @@ export const eventsRaw = sqliteTable('events_raw', {
|
|
|
40
40
|
// agent one of the same event_type. Nullable for migration safety —
|
|
41
41
|
// events logged before this column existed simply can't be attributed.
|
|
42
42
|
actor: text('actor'),
|
|
43
|
+
// The CLI session that wrote the event (EventRecord.cli_session_id,
|
|
44
|
+
// architecture §7), so a live CLI session can be linked to the factory
|
|
45
|
+
// sessions it drives. Nullable: absent outside a CLI session and on every
|
|
46
|
+
// event logged before the stamp existed. A link, never evidence -- see
|
|
47
|
+
// events.ts's cliStamp.
|
|
48
|
+
cliSessionId: text('cli_session_id'),
|
|
43
49
|
}, (t) => [
|
|
44
50
|
index('events_raw_session_idx').on(t.sessionId),
|
|
45
51
|
index('events_raw_task_idx').on(t.taskId),
|
|
46
52
|
index('events_raw_type_idx').on(t.eventType),
|
|
47
53
|
index('events_raw_ts_idx').on(t.ts),
|
|
48
54
|
index('events_raw_project_idx').on(t.project),
|
|
55
|
+
index('events_raw_cli_session_idx').on(t.cliSessionId),
|
|
49
56
|
]);
|
|
50
57
|
/** One row per session, folded from its session-start event + last-seen ts. */
|
|
51
58
|
export const sessions = sqliteTable('sessions', {
|
|
@@ -128,12 +135,30 @@ export const tasks = sqliteTable('tasks', {
|
|
|
128
135
|
taskStatus: text('task_status').notNull(),
|
|
129
136
|
planVersion: integer('plan_version'),
|
|
130
137
|
objective: text('objective'),
|
|
138
|
+
title: text('title'), // DS3 — optional human-facing title (task-spec field)
|
|
139
|
+
summary: text('summary'), // DS3 — optional human-facing summary (task-spec field)
|
|
131
140
|
claims: text('claims'), // JSON array
|
|
132
141
|
budgetTokens: integer('budget_tokens'),
|
|
133
142
|
branch: text('branch'),
|
|
134
143
|
createdAt: text('created_at').notNull(),
|
|
135
144
|
updatedAt: text('updated_at').notNull(),
|
|
136
145
|
project: text('project'), // Phase 6b — see eventsRaw's project comment above.
|
|
146
|
+
/**
|
|
147
|
+
* DS4 S5a round 2 — the `ts` of the event that FIRST moved this task into
|
|
148
|
+
* `TERMINAL_TASK_STATUSES` (wave-merged / task-superseded / an
|
|
149
|
+
* `error-logged` naming it `escalated` / an auditor's judge-reported or
|
|
150
|
+
* task-result-recorded, since an audit axis never joins a wave), null
|
|
151
|
+
* while the task is still open. `updatedAt` keeps moving after a task is
|
|
152
|
+
* done — a later `error-logged` naming an already-terminal task (e.g.
|
|
153
|
+
* from a following lessons/audit flow) rewrites it via `touch()` without
|
|
154
|
+
* reopening the task — so it is the wrong column for "when did this
|
|
155
|
+
* finish" (db/queries.ts's taskDateRange). Written once by
|
|
156
|
+
* db/projector.ts's foldTasks() and left alone after that, with one
|
|
157
|
+
* named exception: a later auditor dispatch_decision for a completed id
|
|
158
|
+
* (an audit axis re-run) resets this to null, since the row is open
|
|
159
|
+
* again.
|
|
160
|
+
*/
|
|
161
|
+
terminalAt: text('terminal_at'),
|
|
137
162
|
}, (t) => [
|
|
138
163
|
index('tasks_session_idx').on(t.sessionId),
|
|
139
164
|
index('tasks_epic_idx').on(t.epicId),
|
|
@@ -287,14 +312,14 @@ export const milestones = sqliteTable('milestones', {
|
|
|
287
312
|
goal: text('goal'),
|
|
288
313
|
epicIds: text('epic_ids').notNull(), // JSON array
|
|
289
314
|
// Phase 6b — see eventsRaw's project comment above. roadmap.ts defaults
|
|
290
|
-
// an unspecified `- project:` bullet to '
|
|
315
|
+
// an unspecified `- project:` bullet to 'blacksmith' at parse time (not
|
|
291
316
|
// left null here), since factory/specs/roadmap.md (ROADMAP_PATH) is a
|
|
292
317
|
// hand-authored declaration, not an event replay — there is no "old row"
|
|
293
318
|
// to migrate.
|
|
294
|
-
project: text('project').notNull().default('
|
|
319
|
+
project: text('project').notNull().default('blacksmith'),
|
|
295
320
|
// Phase 10 -- 'factory' | 'dogfood' | 'product' (roadmap.ts's
|
|
296
321
|
// MilestoneKind). Defaults to 'factory' here for the same reason project
|
|
297
|
-
// defaults to '
|
|
322
|
+
// defaults to 'blacksmith': the column default has to agree with what
|
|
298
323
|
// roadmap.ts resolves for a milestone that declares neither bullet, and
|
|
299
324
|
// for those two that answer is the factory's own.
|
|
300
325
|
kind: text('kind').notNull().default('factory'),
|
|
@@ -334,3 +359,31 @@ export const artifacts = sqliteTable('artifacts', {
|
|
|
334
359
|
path: text('path').notNull(),
|
|
335
360
|
description: text('description'),
|
|
336
361
|
}, (t) => [index('artifacts_session_idx').on(t.sessionId), index('artifacts_task_idx').on(t.taskId)]);
|
|
362
|
+
/**
|
|
363
|
+
* Operator feedback left mid-run on a task, from the dashboard or from
|
|
364
|
+
* GitHub (feedback.ts). A GLOBAL fold, the same shape as `lessons` and
|
|
365
|
+
* `findings` above and for the same reason (D-199/D-200): a comment can be
|
|
366
|
+
* recorded in one session and resolved in a later, continuing one, so the
|
|
367
|
+
* table's primary key is the feedback id, not (session, feedback) — one
|
|
368
|
+
* global key, one global fold, rewritten whole by
|
|
369
|
+
* db/projector.ts's projectOperatorFeedback().
|
|
370
|
+
*/
|
|
371
|
+
export const operatorFeedback = sqliteTable('operator_feedback', {
|
|
372
|
+
id: text('id').primaryKey(),
|
|
373
|
+
taskId: text('task_id').notNull(),
|
|
374
|
+
sessionId: text('session_id').notNull(),
|
|
375
|
+
body: text('body').notNull(),
|
|
376
|
+
kind: text('kind').notNull(),
|
|
377
|
+
source: text('source').notNull(),
|
|
378
|
+
externalId: text('external_id'),
|
|
379
|
+
author: text('author'),
|
|
380
|
+
recordedAt: text('recorded_at').notNull(),
|
|
381
|
+
recordedEventId: text('recorded_event_id').notNull(),
|
|
382
|
+
resolvedAt: text('resolved_at'),
|
|
383
|
+
resolution: text('resolution'),
|
|
384
|
+
followUpTaskId: text('follow_up_task_id'),
|
|
385
|
+
}, (t) => [
|
|
386
|
+
index('operator_feedback_task_idx').on(t.taskId),
|
|
387
|
+
index('operator_feedback_session_idx').on(t.sessionId),
|
|
388
|
+
index('operator_feedback_external_idx').on(t.externalId),
|
|
389
|
+
]);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Declared artifact: <path>` line a judge dispatch carries in its prompt.
|
|
3
|
+
*
|
|
4
|
+
* A leaf module with no local imports, so the PreToolUse hook (which must stay
|
|
5
|
+
* free of the database layer), `judgeStopHook.ts`, and `dispatchLint.ts` all
|
|
6
|
+
* read the line with one parser: what the dispatch guard accepts is exactly
|
|
7
|
+
* what judge-stop later reads. `dispatchLint.ts` re-exports these.
|
|
8
|
+
*/
|
|
9
|
+
const DECLARED_ARTIFACT_LINE = /^Declared artifact:[ \t]*(\S+)[ \t]*$/m;
|
|
10
|
+
/** `Declared artifact: <path>` in a dispatch prompt, or null when the line is not there. */
|
|
11
|
+
export function parseDeclaredArtifactLine(prompt) {
|
|
12
|
+
const match = DECLARED_ARTIFACT_LINE.exec(prompt);
|
|
13
|
+
return match?.[1] ?? null;
|
|
14
|
+
}
|
|
15
|
+
/** The line a dispatch is expected to carry verbatim, given the ledger's declared path. */
|
|
16
|
+
export function formatDeclaredArtifactLine(artifactPath) {
|
|
17
|
+
return `Declared artifact: ${artifactPath}`;
|
|
18
|
+
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { parse as parseYaml } from 'yaml';
|
|
29
|
+
import { AGENT_PREFIX, agentNameFor, roleOfAgentType, roleOfTemplateFile, templateFileFor, } from './agentNames.js';
|
|
29
30
|
import { DISPATCH_EVENT_TYPE, ERROR_EVENT_TYPE, TASK_RESULT_EVENT_TYPE, } from './agents-registry.js';
|
|
30
31
|
import { SmithError } from './errors.js';
|
|
31
32
|
import { ROOT_EVENT_TYPE } from './events.js';
|
|
@@ -84,11 +85,11 @@ export function loadDelegationPolicy(filePath = DELEGATION_POLICY_PATH) {
|
|
|
84
85
|
* because a scoped grant contains commas of its own.
|
|
85
86
|
*/
|
|
86
87
|
const AGENT_TOOL = /\bAgent\b/;
|
|
87
|
-
/** The
|
|
88
|
+
/** The agents a scoped grant names, e.g. `Agent(bs-coder, bs-tester)`. */
|
|
88
89
|
const AGENT_SCOPE = /\bAgent\s*\(([^)]*)\)/;
|
|
89
90
|
/** The raw `tools:` line of a role template's frontmatter. Null when there is no template. */
|
|
90
91
|
function templateTools(dir, role) {
|
|
91
|
-
const file = path.join(dir,
|
|
92
|
+
const file = path.join(dir, templateFileFor(role));
|
|
92
93
|
if (!existsSync(file))
|
|
93
94
|
return null;
|
|
94
95
|
return toolsOf(readFileSync(file, 'utf8'));
|
|
@@ -135,7 +136,7 @@ export function checkDelegationGrants(policy, options) {
|
|
|
135
136
|
}
|
|
136
137
|
for (const pair of options.isolationPairs) {
|
|
137
138
|
if (pair.worker === grant.role && grant.mayDispatch.includes(pair.auditor)) {
|
|
138
|
-
problems.push(`\`${grant.role}\` may dispatch \`${pair.auditor}\`, its own auditor under crosscheck.yml \`role_isolation\`. The worker would choose and prompt its grader, and \`
|
|
139
|
+
problems.push(`\`${grant.role}\` may dispatch \`${pair.auditor}\`, its own auditor under crosscheck.yml \`role_isolation\`. The worker would choose and prompt its grader, and \`bs tester check\` would report the isolation it was reading evidence for.`);
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
for (const pair of options.asymmetricPairs) {
|
|
@@ -145,10 +146,10 @@ export function checkDelegationGrants(policy, options) {
|
|
|
145
146
|
}
|
|
146
147
|
const tools = templateTools(dir, grant.role);
|
|
147
148
|
if (tools === null) {
|
|
148
|
-
problems.push(`\`${grant.role}\` holds a grant but ships no template at ${path.join(path.basename(dir),
|
|
149
|
+
problems.push(`\`${grant.role}\` holds a grant but ships no template at ${path.join(path.basename(dir), templateFileFor(grant.role))}, so the grant reaches no agent (D-191).`);
|
|
149
150
|
}
|
|
150
151
|
else if (!holdsAgent(tools)) {
|
|
151
|
-
problems.push(`${grant.role}
|
|
152
|
+
problems.push(`${templateFileFor(grant.role)} does not list \`Agent\` in \`tools\`, so the grant reaches no agent (D-191).`);
|
|
152
153
|
}
|
|
153
154
|
else {
|
|
154
155
|
// A scoped grant is enforced by the harness at dispatch time and this
|
|
@@ -156,10 +157,20 @@ export function checkDelegationGrants(policy, options) {
|
|
|
156
157
|
// of both: the file an operator reads is not the file that binds.
|
|
157
158
|
const scope = agentScope(tools);
|
|
158
159
|
if (scope !== null) {
|
|
159
|
-
const
|
|
160
|
+
for (const name of scope) {
|
|
161
|
+
if (name.startsWith(AGENT_PREFIX))
|
|
162
|
+
continue;
|
|
163
|
+
// Suggest a name only for a role this grant covers: `general-purpose`
|
|
164
|
+
// maps to itself, and `bs-general-purpose` is no agent either.
|
|
165
|
+
const role = roleOfAgentType(name);
|
|
166
|
+
problems.push(role !== null && grant.mayDispatch.includes(role)
|
|
167
|
+
? `${templateFileFor(grant.role)} scopes \`Agent\` to name \`${name}\`, which is no agent; use \`${agentNameFor(role)}\`. The harness enforces the agent names, and the bare role no longer exists as one.`
|
|
168
|
+
: `${templateFileFor(grant.role)} scopes \`Agent\` to name \`${name}\`, which is no Blacksmith agent. A scope names only the \`${AGENT_PREFIX}<role>\` agents of the roles delegation.yml grants.`);
|
|
169
|
+
}
|
|
170
|
+
const declared = [...grant.mayDispatch].map(agentNameFor).sort().join(', ');
|
|
160
171
|
const scoped = [...scope].sort().join(', ');
|
|
161
172
|
if (declared !== scoped) {
|
|
162
|
-
problems.push(`${grant.role}
|
|
173
|
+
problems.push(`${templateFileFor(grant.role)} scopes \`Agent\` to (${scoped}) while delegation.yml grants (${declared}). The template is what the harness enforces and the policy is what the audits read, so a disagreement is a rule nobody applies.`);
|
|
163
174
|
}
|
|
164
175
|
}
|
|
165
176
|
}
|
|
@@ -178,11 +189,13 @@ export function checkDelegationGrants(policy, options) {
|
|
|
178
189
|
// The converse of D-191, and the one that actually widens the topology: a
|
|
179
190
|
// template may hand itself `Agent` without any grant naming it, and nothing
|
|
180
191
|
// else in the repo reads the frontmatter closely enough to notice.
|
|
181
|
-
const templates = existsSync(dir)
|
|
192
|
+
const templates = existsSync(dir)
|
|
193
|
+
? readdirSync(dir).filter((f) => roleOfTemplateFile(f) !== null)
|
|
194
|
+
: [];
|
|
182
195
|
const granted = new Set(policy.grants.map((g) => g.role));
|
|
183
196
|
let ungrantedHolders = 0;
|
|
184
197
|
for (const file of templates) {
|
|
185
|
-
const role =
|
|
198
|
+
const role = roleOfTemplateFile(file);
|
|
186
199
|
if (granted.has(role))
|
|
187
200
|
continue;
|
|
188
201
|
if (!holdsAgent(toolsOf(readFileSync(path.join(dir, file), 'utf8'))))
|
|
@@ -210,19 +223,42 @@ export function checkDelegationGrants(policy, options) {
|
|
|
210
223
|
}
|
|
211
224
|
/**
|
|
212
225
|
* How a dispatched turn is recorded as over, and which payload field names the
|
|
213
|
-
* role that ended it -- testerAudit.ts's table, for its reason.
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
226
|
+
* role that ended it -- testerAudit.ts's table, for its reason. Each event type
|
|
227
|
+
* lists every key its role may be spelled under, tried in order, before the
|
|
228
|
+
* fallback to `actor`: that fallback is testerAudit.ts's too -- the older half
|
|
229
|
+
* of the log names the role there and nowhere else, and without it a finished
|
|
230
|
+
* wave-runner reads as one still running, which is the difference between
|
|
231
|
+
* `violation` and `unverifiable`.
|
|
232
|
+
*
|
|
233
|
+
* `error-logged` lists both `agent` (this module's long-standing read, the
|
|
234
|
+
* Result file's own spelling) and `agent_role` (the dispatch's) because a
|
|
235
|
+
* hand-written error event may carry only one of the two, so a role named
|
|
236
|
+
* either way still finds its terminal instead of falling through to
|
|
237
|
+
* whichever actor happened to log the error. agents-registry.ts's fold of
|
|
238
|
+
* the same event type accepts the same two keys, but tries `agent_role`
|
|
239
|
+
* first where this module tries `agent` first -- not symmetric in order,
|
|
240
|
+
* because each reader keeps its own historical key first rather than the two
|
|
241
|
+
* agreeing on one: `agent_role` was already this event type's only key in
|
|
242
|
+
* agents-registry.ts's fold before `agent` was added as a fallback there,
|
|
243
|
+
* same as `agent` was here before `agent_role` was added as this module's.
|
|
217
244
|
*/
|
|
218
|
-
const
|
|
219
|
-
[TASK_RESULT_EVENT_TYPE]: 'agent',
|
|
220
|
-
[ERROR_EVENT_TYPE]: 'agent',
|
|
245
|
+
const TERMINAL_ROLE_KEYS = {
|
|
246
|
+
[TASK_RESULT_EVENT_TYPE]: ['agent'],
|
|
247
|
+
[ERROR_EVENT_TYPE]: ['agent', 'agent_role'],
|
|
221
248
|
};
|
|
222
249
|
function str(payload, key) {
|
|
223
250
|
const value = payload[key];
|
|
224
251
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
225
252
|
}
|
|
253
|
+
/** The first of `keys` that names a non-empty string in `payload`, or null. */
|
|
254
|
+
function firstStr(payload, keys) {
|
|
255
|
+
for (const key of keys) {
|
|
256
|
+
const value = str(payload, key);
|
|
257
|
+
if (value !== null)
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
226
262
|
function readDispatches(events) {
|
|
227
263
|
const rows = [];
|
|
228
264
|
for (const { event_id, record } of events) {
|
|
@@ -246,11 +282,11 @@ function readDispatches(events) {
|
|
|
246
282
|
function readTerminals(events) {
|
|
247
283
|
const rows = [];
|
|
248
284
|
for (const { record } of events) {
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
285
|
+
const keys = TERMINAL_ROLE_KEYS[record.event_type];
|
|
286
|
+
if (keys === undefined)
|
|
251
287
|
continue;
|
|
252
288
|
const payload = (record.payload ?? {});
|
|
253
|
-
const role =
|
|
289
|
+
const role = firstStr(payload, keys) ?? record.actor;
|
|
254
290
|
if (role === null || role.length === 0)
|
|
255
291
|
continue;
|
|
256
292
|
rows.push({
|
|
@@ -274,6 +310,37 @@ function sessionsOpenedByDispatch(events) {
|
|
|
274
310
|
}
|
|
275
311
|
return opened;
|
|
276
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* The role that dispatched this session, if this session is that dispatch's
|
|
315
|
+
* own log -- the answer a write wants before it defaults its `actor` to a
|
|
316
|
+
* placeholder like `system` (issue #218). A session's `session-start` names
|
|
317
|
+
* its entry edge as `causal_parent`; when that edge is a `dispatch_decision`
|
|
318
|
+
* event elsewhere in `events`, this session was opened against it and its
|
|
319
|
+
* owner is the role that dispatch named.
|
|
320
|
+
*
|
|
321
|
+
* Returns null for every session nothing dispatched -- an operator's own
|
|
322
|
+
* top-level session, or a `--continues` split whose entry edge names some
|
|
323
|
+
* other event (P9-7's cross-session edge is not only for delegation) -- so a
|
|
324
|
+
* caller's existing `system` fallback for "no owner" is unchanged.
|
|
325
|
+
*
|
|
326
|
+
* Deliberately reads the event graph only, not delegation.yml: it answers
|
|
327
|
+
* "who dispatched this session" for a dispatch to any role, granted or not.
|
|
328
|
+
* That is wider than `checkDelegationLog`'s rule 3, which flags a mismatch
|
|
329
|
+
* only for sessions a grant opened -- but a wider *default* cannot loosen
|
|
330
|
+
* that check, because an explicit `--actor` always wins over this inference
|
|
331
|
+
* (every caller checks `ctx.actor` first) and a foreign or `system` actor
|
|
332
|
+
* stamped that way is still exactly what rule 3 flags.
|
|
333
|
+
*/
|
|
334
|
+
export function sessionOwnerRole(events, sessionId) {
|
|
335
|
+
const root = events.find((e) => e.record.session_id === sessionId && e.record.event_type === ROOT_EVENT_TYPE);
|
|
336
|
+
const parentId = root?.record.causal_parent;
|
|
337
|
+
if (parentId === null || parentId === undefined)
|
|
338
|
+
return null;
|
|
339
|
+
const parent = events.find((e) => e.event_id === parentId);
|
|
340
|
+
if (parent === undefined || parent.record.event_type !== DISPATCH_EVENT_TYPE)
|
|
341
|
+
return null;
|
|
342
|
+
return str((parent.record.payload ?? {}), 'agent_role');
|
|
343
|
+
}
|
|
277
344
|
/**
|
|
278
345
|
* Asserts delegation.yml against a lineage's log.
|
|
279
346
|
*
|
|
@@ -96,9 +96,10 @@ const GIT_OUTPUT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
|
96
96
|
/**
|
|
97
97
|
* The declared diff cap is only worth writing down if something measures the
|
|
98
98
|
* real diff. This does: committed work on a task branch, against the
|
|
99
|
-
* integration branch it was cut from (
|
|
100
|
-
*
|
|
101
|
-
* relies on
|
|
99
|
+
* integration branch it was cut from (`<prefix>/<epic>/<task-id>` ->
|
|
100
|
+
* `<prefix>/<epic>/integration`, the same convention `collectCommittedChanges`
|
|
101
|
+
* relies on — `<prefix>` is `bs` or the legacy `smith`, echoed back from the
|
|
102
|
+
* task branch itself), or against an explicit `baseRef`.
|
|
102
103
|
*
|
|
103
104
|
* It throws rather than returning zero when it cannot measure. A zero that
|
|
104
105
|
* means "no diff" and a zero that means "I could not look" are the same number
|
|
@@ -140,7 +141,11 @@ export function measureDiff(worktreeDir, options = {}) {
|
|
|
140
141
|
}
|
|
141
142
|
return { baseRef, diffLines, excludedLines, unmeasuredFiles, files };
|
|
142
143
|
}
|
|
143
|
-
|
|
144
|
+
// Operator decision 2 (bs-rename): a task branch is cut under whichever
|
|
145
|
+
// prefix its integration branch already used — bs/ for a new epic, smith/
|
|
146
|
+
// for one that still integrates there — so the base ref this derives must
|
|
147
|
+
// echo the same prefix back, not hardcode the new one.
|
|
148
|
+
const TASK_BRANCH_PATTERN = /^(?<prefix>smith|bs)\/(?<epic>[^/]+)\/(?<task>[^/]+)$/;
|
|
144
149
|
function deriveBaseRef(worktreeDir) {
|
|
145
150
|
let branch;
|
|
146
151
|
try {
|
|
@@ -156,9 +161,11 @@ function deriveBaseRef(worktreeDir) {
|
|
|
156
161
|
// directory git has never heard of, and it deserves a code like any other.
|
|
157
162
|
throw new DiffstatError('diffstat.not-a-git-worktree', `Cannot read the current branch in ${worktreeDir} — it is not a git worktree, or git refused to answer.`, { worktreeDir, cause: err instanceof Error ? err.message : String(err) });
|
|
158
163
|
}
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
const groups = TASK_BRANCH_PATTERN.exec(branch)?.groups;
|
|
165
|
+
const epic = groups?.epic;
|
|
166
|
+
const prefix = groups?.prefix;
|
|
167
|
+
if (epic === undefined || prefix === undefined) {
|
|
168
|
+
throw new DiffstatError('diffstat.cannot-derive-base-ref', `Branch "${branch}" does not follow <smith|bs>/<epic>/<task-id>, so there is no integration branch to diff against. Pass an explicit baseRef.`, { branch, worktreeDir });
|
|
162
169
|
}
|
|
163
|
-
return
|
|
170
|
+
return `${prefix}/${epic}/integration`;
|
|
164
171
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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 { formatDeclaredArtifactLine, parseDeclaredArtifactLine } from './declaredArtifactLine.js';
|
|
21
|
+
import { JUDGE_ROLES } from './judgeRoles.js';
|
|
22
|
+
import { readJudgeTurns } from './judges.js';
|
|
23
|
+
import { AGENTS_DIR } from './paths.js';
|
|
24
|
+
/**
|
|
25
|
+
* Re-exported, not re-declared (judgeRoles.ts is the canonical source, kept
|
|
26
|
+
* dependency-free so both this module and judges.ts can import it without a
|
|
27
|
+
* cycle -- see that file for the full set and why it is these six). This
|
|
28
|
+
* module still does its own, stricter artifact check on top of the shared
|
|
29
|
+
* set: only these six get a `mismatch`/`missing` verdict, so a coder's
|
|
30
|
+
* dispatch (never carries one) reads `not-applicable` rather than a false
|
|
31
|
+
* `undeclared`.
|
|
32
|
+
*/
|
|
33
|
+
export { formatDeclaredArtifactLine, JUDGE_ROLES, parseDeclaredArtifactLine };
|
|
34
|
+
const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
|
|
35
|
+
const TURN_BUDGET_LINE = /Turn budget:[ \t]*(\S*)/g;
|
|
36
|
+
/**
|
|
37
|
+
* `Turn budget: <n>` in a dispatch prompt — mid-sentence or on its own
|
|
38
|
+
* line, since a dispatch's leading line names the role and the budget in
|
|
39
|
+
* one breath ("Role: coder. Turn budget: 40 ..."). Null for no occurrence,
|
|
40
|
+
* more than one (two numbers is no number), or a value that is not a bare
|
|
41
|
+
* non-negative integer — every one of those is "cannot state a turn count",
|
|
42
|
+
* not "states zero".
|
|
43
|
+
*/
|
|
44
|
+
export function parseStatedTurns(prompt) {
|
|
45
|
+
const matches = [...prompt.matchAll(TURN_BUDGET_LINE)];
|
|
46
|
+
if (matches.length !== 1)
|
|
47
|
+
return null;
|
|
48
|
+
const raw = matches[0]?.[1] ?? '';
|
|
49
|
+
return /^[0-9]+$/.test(raw) ? Number(raw) : null;
|
|
50
|
+
}
|
|
51
|
+
function checkTurns(prompt, role, agentsDir) {
|
|
52
|
+
const stated = parseStatedTurns(prompt);
|
|
53
|
+
const template = readTemplateMaxTurns(agentsDir, role) ?? null;
|
|
54
|
+
if (template === null)
|
|
55
|
+
return { status: 'unverifiable', stated, template: null };
|
|
56
|
+
if (stated === null)
|
|
57
|
+
return { status: 'missing', stated: null, template };
|
|
58
|
+
if (stated === template)
|
|
59
|
+
return { status: 'ok', stated, template };
|
|
60
|
+
return { status: stated < template ? 'under' : 'over', stated, template };
|
|
61
|
+
}
|
|
62
|
+
async function checkArtifact(prompt, role, taskId, sessionId, eventOpts) {
|
|
63
|
+
if (!JUDGE_ROLE_SET.has(role)) {
|
|
64
|
+
return { status: 'not-applicable', expected_line: null, declared_line: null };
|
|
65
|
+
}
|
|
66
|
+
const declaredLine = parseDeclaredArtifactLine(prompt);
|
|
67
|
+
const turns = await readJudgeTurns(taskId, { sessionId }, eventOpts);
|
|
68
|
+
const turn = turns.find((t) => t.role === role);
|
|
69
|
+
if (turn === undefined) {
|
|
70
|
+
return { status: 'undeclared', expected_line: null, declared_line: declaredLine };
|
|
71
|
+
}
|
|
72
|
+
const expectedLine = formatDeclaredArtifactLine(turn.declaredArtifact);
|
|
73
|
+
if (declaredLine === null) {
|
|
74
|
+
return { status: 'missing', expected_line: expectedLine, declared_line: null };
|
|
75
|
+
}
|
|
76
|
+
if (!path.isAbsolute(declaredLine)) {
|
|
77
|
+
return { status: 'relative', expected_line: expectedLine, declared_line: declaredLine };
|
|
78
|
+
}
|
|
79
|
+
if (declaredLine === turn.declaredArtifact) {
|
|
80
|
+
return { status: 'ok', expected_line: expectedLine, declared_line: declaredLine };
|
|
81
|
+
}
|
|
82
|
+
return { status: 'mismatch', expected_line: expectedLine, declared_line: declaredLine };
|
|
83
|
+
}
|
|
84
|
+
// Fail closed: every status but "ok" exits non-zero, including "under" (a
|
|
85
|
+
// prompt that understates the template's budget still disagrees with it) and
|
|
86
|
+
// "unverifiable" (an unreadable template is not a pass, exactly like `dispatch
|
|
87
|
+
// check`, `tester check`, `delegation check`, and `escalation check` already
|
|
88
|
+
// treat "cannot tell" as a failure, never a maybe).
|
|
89
|
+
const TURNS_FAIL = new Set(['under', 'over', 'missing', 'unverifiable']);
|
|
90
|
+
const ARTIFACT_FAIL = new Set([
|
|
91
|
+
'missing',
|
|
92
|
+
'relative',
|
|
93
|
+
'mismatch',
|
|
94
|
+
'undeclared',
|
|
95
|
+
]);
|
|
96
|
+
/**
|
|
97
|
+
* Lint one dispatch prompt. Never throws on a bad prompt — a bad prompt is
|
|
98
|
+
* exactly the finding this reports — only on a missing task/session (the
|
|
99
|
+
* caller's own mistake, same as every other verb that reads the ledger).
|
|
100
|
+
*/
|
|
101
|
+
export async function lintDispatchPrompt(input) {
|
|
102
|
+
const agentsDir = input.agentsDir ?? AGENTS_DIR;
|
|
103
|
+
const eventOpts = input.eventOpts ?? {};
|
|
104
|
+
const turns = checkTurns(input.prompt, input.role, agentsDir);
|
|
105
|
+
const artifact = await checkArtifact(input.prompt, input.role, input.taskId, input.sessionId, eventOpts);
|
|
106
|
+
const exitCode = TURNS_FAIL.has(turns.status) || ARTIFACT_FAIL.has(artifact.status) ? 1 : 0;
|
|
107
|
+
return { turns, artifact, exitCode };
|
|
108
|
+
}
|
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { parse as parseYaml } from 'yaml';
|
|
3
3
|
import { SmithError } from './errors.js';
|
|
4
4
|
import { EFFORT_POLICY_PATH } from './paths.js';
|
|
5
|
-
import { EFFORT_TIERS, isEffortTier } from './plan.js';
|
|
5
|
+
import { EFFORT_TIERS, isEffortTier, isUiAffecting, } from './plan.js';
|
|
6
6
|
import { evaluatePlanSecurityTriggers } from './planQuorum.js';
|
|
7
7
|
/**
|
|
8
8
|
* How much judgment an epic buys — factory/policies/effort.yml's host.
|
|
@@ -51,7 +51,10 @@ function tierRank(tier) {
|
|
|
51
51
|
return EFFORT_TIERS.indexOf(tier);
|
|
52
52
|
}
|
|
53
53
|
const PRE_CODE_RESEARCH = ['when-needed', 'never'];
|
|
54
|
-
|
|
54
|
+
// 'never' is not a parseable value: wave.md already says preCodeUiux cannot
|
|
55
|
+
// be turned off, so a policy file that still spells it out should fail to
|
|
56
|
+
// parse rather than silently skip the uiux judge turn on every UI task (U2).
|
|
57
|
+
const PRE_CODE_UIUX = ['always', 'when-ui-criterion'];
|
|
55
58
|
const SPEC_REVIEW_ROUNDS = ['until-clean', 'single-pass'];
|
|
56
59
|
const PLAN_QUORUM = ['always', 'when-triggered'];
|
|
57
60
|
const CLOSING_SPEC_REVIEW = ['always', 'when-plan-amended'];
|
|
@@ -86,6 +89,15 @@ function requireStringList(value, where, minLength) {
|
|
|
86
89
|
}
|
|
87
90
|
return value.map(String);
|
|
88
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Whether a task must get the uiux judge turn: either the task itself is
|
|
94
|
+
* flagged `ui_affecting` (plan.ts isUiAffecting), or the tier's own profile
|
|
95
|
+
* says every task does (`preCodeUiux: 'always'`). `when-ui-criterion` adds
|
|
96
|
+
* nothing here — that arm is read off the flag already.
|
|
97
|
+
*/
|
|
98
|
+
export function requiresUiux(spec, profile) {
|
|
99
|
+
return isUiAffecting(spec) || profile.preCodeUiux === 'always';
|
|
100
|
+
}
|
|
89
101
|
function parseProfile(node, tier) {
|
|
90
102
|
const t = requireNode(node, `tiers.${tier}`);
|
|
91
103
|
const summary = typeof t.summary === 'string' ? t.summary.trim() : '';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much judgment an epic buys, cheapest first — factory/policies/effort.yml
|
|
3
|
+
* declares what each tier means; this is only the closed list of names.
|
|
4
|
+
*
|
|
5
|
+
* A leaf module on purpose: plan.ts types `PlanFile.effort` with it, budgets.ts
|
|
6
|
+
* sizes its caps by it, and effort.ts is the semantic owner. plan.ts imports
|
|
7
|
+
* budgets.ts and effort.ts (transitively) imports plan.ts, so the list can live
|
|
8
|
+
* in none of them without an import cycle. plan.ts and effort.ts re-export it.
|
|
9
|
+
*/
|
|
10
|
+
export const EFFORT_TIERS = ['small', 'medium', 'huge'];
|
|
11
|
+
export function isEffortTier(value) {
|
|
12
|
+
return typeof value === 'string' && EFFORT_TIERS.includes(value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The current and legacy namespace prefixes, for call sites that filter a whole env object rather than read one name (e.g. `testgate.ts`'s `projectCommandEnv`). */
|
|
2
|
+
export const BS_PREFIX = 'BS_';
|
|
3
|
+
export const LEGACY_PREFIX = 'SMITH_';
|
|
4
|
+
/**
|
|
5
|
+
* The one place that knows `BS_<X>` is the current name and `SMITH_<X>` is
|
|
6
|
+
* the transitional fallback (operator decision 3, bs-rename): `BS_` wins
|
|
7
|
+
* when both are set, `SMITH_` is read when `BS_` is unset, and neither set
|
|
8
|
+
* reads as unset — exactly what every direct `env.SMITH_*`/`env.BS_*` read
|
|
9
|
+
* in this codebase should go through instead of hand-rolling the pair.
|
|
10
|
+
*
|
|
11
|
+
* `name` may be spelled either way — as the new `BS_<X>` name, or as the
|
|
12
|
+
* legacy `SMITH_<X>` name a call site's own constant still carries — since
|
|
13
|
+
* both resolve to the same pair of variables either way.
|
|
14
|
+
*/
|
|
15
|
+
export function readEnv(env, name) {
|
|
16
|
+
const bsName = name.startsWith(LEGACY_PREFIX)
|
|
17
|
+
? `${BS_PREFIX}${name.slice(LEGACY_PREFIX.length)}`
|
|
18
|
+
: name;
|
|
19
|
+
const legacyName = name.startsWith(BS_PREFIX)
|
|
20
|
+
? `${LEGACY_PREFIX}${name.slice(BS_PREFIX.length)}`
|
|
21
|
+
: name;
|
|
22
|
+
const bsValue = env[bsName];
|
|
23
|
+
if (bsValue !== undefined)
|
|
24
|
+
return bsValue;
|
|
25
|
+
return env[legacyName];
|
|
26
|
+
}
|