@juzser/blacksmith 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +109 -53
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +13 -37
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +4 -4
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +7 -7
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// nothing at runtime. `test/cliBoot.test.ts` reads the built graph and fails if
|
|
16
16
|
// the database layer creeps back into it.
|
|
17
17
|
import { execFileSync, spawn } from 'node:child_process';
|
|
18
|
-
import { mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync, } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
19
20
|
import path from 'node:path';
|
|
20
21
|
import { pathToFileURL } from 'node:url';
|
|
21
22
|
import { resetAgentMaxTurns, syncAgentMaxTurns } from './agentsSync.js';
|
|
@@ -24,20 +25,25 @@ import { closeAudit, consolidateAudit, cutAudit, decideAudit, openAudit, recordA
|
|
|
24
25
|
import { checkBudgetAlarm } from './budgetAlarm.js';
|
|
25
26
|
import { budgetEnvOverrides, loadBudgetPolicy } from './budgets.js';
|
|
26
27
|
import { collectCommittedChanges, loadWorktreePolicy, postRunCheck, readClaimList, validateWave, writeRootCheck, } from './claims.js';
|
|
28
|
+
import { warnIfLegacyName } from './cliName.js';
|
|
27
29
|
import { collectCoverageEvidence } from './coverage.js';
|
|
28
30
|
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
29
31
|
import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
|
|
32
|
+
import { formatDeclaredArtifactLine } from './declaredArtifactLine.js';
|
|
30
33
|
import { checkDelegationGrants, checkDelegationLog, loadDelegationPolicy } from './delegation.js';
|
|
31
34
|
import { checkDispatchAsymmetry } from './dispatchAudit.js';
|
|
32
35
|
import { lintDispatchPrompt } from './dispatchLint.js';
|
|
33
36
|
import { loadDotEnv } from './dotenv.js';
|
|
34
37
|
import { loadEffortPolicy, resolveEffort } from './effort.js';
|
|
38
|
+
import { budgetPolicyForPlan, epicBudgetPolicies } from './epicBudget.js';
|
|
35
39
|
import { ISSUE_CANDIDATE_EVENT_TYPES } from './errorIssues.js';
|
|
36
40
|
import { SmithError } from './errors.js';
|
|
37
41
|
import { checkEscalationLadder } from './escalation.js';
|
|
38
42
|
import { appendEvent, filterEvents, followEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
|
|
43
|
+
import { listPendingFeedback, recordFeedback, resolveFeedback, } from './feedback.js';
|
|
39
44
|
import { findingsForDispatch } from './findingContext.js';
|
|
40
45
|
import { AMEND_PENDING_STATUS, AMENDED_STATUS, findingScope, listFindings, mintFindings, OPEN_FINDING_STATUSES, raiseFinding, repairObligation, reverifyFinding, SPEC_FINDING_SCOPE, transition as transitionFinding, } from './findings.js';
|
|
46
|
+
import { runGit } from './git.js';
|
|
41
47
|
import { recordGoalCheck, resolveEpicGoal } from './goalCheck.js';
|
|
42
48
|
import { loadHarnessPolicy, planWorkerTurn, summarizeHarnesses, } from './harness.js';
|
|
43
49
|
import { decideHookPayload } from './hookDecision.js';
|
|
@@ -46,13 +52,15 @@ import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
|
|
|
46
52
|
import { integrationHeadSha, runIntegrationCheck } from './integration.js';
|
|
47
53
|
import { previewOutcomes, reportErrors } from './issueReporter.js';
|
|
48
54
|
import { judgePreflight } from './judgePreflight.js';
|
|
49
|
-
import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
|
|
55
|
+
import { isJudgeKind, JudgeError, outstandingJudges, readJudgeArtifact, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, uiuxDeviationsToEvidence, } from './judges.js';
|
|
50
56
|
import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
|
|
51
|
-
import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
|
|
52
|
-
import {
|
|
57
|
+
import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, WORK_ROOT, } from './paths.js';
|
|
58
|
+
import { diffPlans, impliedSpecsDir, isUiAffecting, latestPlanVersion, livePlanTasks, liveSpec, loadPlan, planProjectResolverForTaskRef, planSelfFallbackForTaskRef, resolveTaskId, validatePlan, } from './plan.js';
|
|
53
59
|
import { runPlanQuorum } from './planQuorum.js';
|
|
54
60
|
import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
|
|
55
61
|
import { factoryProjects } from './projects.js';
|
|
62
|
+
import { captureContext, capturePrompt } from './promptCapture.js';
|
|
63
|
+
import { importTranscript } from './promptImport.js';
|
|
56
64
|
import { recordUserPrompt } from './prompts.js';
|
|
57
65
|
import { checkBrief, wrapIngested } from './provenance.js';
|
|
58
66
|
import { runJudge } from './providers/index.js';
|
|
@@ -65,20 +73,22 @@ import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './s
|
|
|
65
73
|
import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
|
|
66
74
|
import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
|
|
67
75
|
import { parseLessons } from './severity.js';
|
|
68
|
-
import { amendPlan, recordSpecReview, taskSuccessors } from './spec.js';
|
|
76
|
+
import { amendPlan, recordSpecReview, successorChainPath, taskSuccessors } from './spec.js';
|
|
69
77
|
import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
|
|
70
78
|
import { checkStack, loadStackAnswers } from './stack.js';
|
|
71
79
|
import { buildSymbolGraph, collectSources } from './symbols.js';
|
|
72
|
-
import { emitEdgesRecorded, emitTasksAdded, emitWaveAdmitted, readAddedTasks, } from './taskEvents.js';
|
|
80
|
+
import { emitEdgesRecorded, emitTasksAdded, emitWaveAdmitted, planIngestGaps, readAddedTasks, unadmissibleTasks, } from './taskEvents.js';
|
|
81
|
+
import { epicOfTaskId } from './taskId.js';
|
|
82
|
+
import { CLOSED_TO_FURTHER_WORK } from './taskStatus.js';
|
|
73
83
|
import { loadTaxonomy } from './taxonomy.js';
|
|
74
84
|
import { checkTesterIsolation } from './testerAudit.js';
|
|
75
85
|
import { assertSelectableTestCmd } from './testSelect.js';
|
|
76
86
|
import { COMMANDS, flagSpecFor, helpText, isDocumented, positionalNames, usageFor, usageLine, usageText, } from './usage.js';
|
|
77
|
-
import { applyBatch, pendingBatch } from './waivers.js';
|
|
87
|
+
import { applyBatch, approveTaskWaiver, pendingBatch, } from './waivers.js';
|
|
78
88
|
import { blocksAdmission, checkWaveBudget, } from './waveBudget.js';
|
|
79
89
|
import { computeNextWave, liveWaveTasks } from './waveNext.js';
|
|
80
90
|
import { initWorkRoot } from './workroot.js';
|
|
81
|
-
import { createTaskWorktree, listStale, RESERVED_TASK_ID, removeTaskWorktree, taskBranchName, } from './worktree.js';
|
|
91
|
+
import { createTaskWorktree, epicBranchPrefix, integrationBranchName, listStale, RESERVED_TASK_ID, removeTaskWorktree, taskBranchExists, taskBranchName, } from './worktree.js';
|
|
82
92
|
function printJson(value) {
|
|
83
93
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
84
94
|
}
|
|
@@ -96,6 +106,38 @@ function readJsonFile(filePath) {
|
|
|
96
106
|
* at the point the file is read, rather than let a gate run act on either.
|
|
97
107
|
*/
|
|
98
108
|
const MAX_CHECK_TIMEOUT_MS = 2_147_483_647;
|
|
109
|
+
/**
|
|
110
|
+
* `--findings <file>`'s intake, both places it's read (`findings raise`,
|
|
111
|
+
* `gate run`). The file is a pre-minted `RaiseFindingInput[]` (`{finding,
|
|
112
|
+
* filePath}`) — a replay, a fixture, a cross-check re-run — not the
|
|
113
|
+
* `FindingEvidence[]` (`{file_path, finding_category, ...}`, snake_case) a
|
|
114
|
+
* judge artifact actually holds, the shape `--evidence` expects. Reading it
|
|
115
|
+
* unvalidated used to crash deep inside routing: `normalizeRepoPath` reads
|
|
116
|
+
* `filePath` straight off the item, so a judge artifact's `undefined`
|
|
117
|
+
* `filePath` threw a raw TypeError (#285) instead of naming what was wrong.
|
|
118
|
+
*/
|
|
119
|
+
function readFindingsInputFile(filePath) {
|
|
120
|
+
const items = readJsonFile(filePath);
|
|
121
|
+
if (!Array.isArray(items)) {
|
|
122
|
+
throw new SmithError('cli.invalid-flag', `--findings ${filePath} must be a JSON array of {finding, filePath} items, got ${typeof items}.`, { flag: 'findings', file: filePath });
|
|
123
|
+
}
|
|
124
|
+
items.forEach((item, index) => {
|
|
125
|
+
const record = (item && typeof item === 'object' ? item : {});
|
|
126
|
+
const looksLikeEvidence = typeof record.file_path === 'string' &&
|
|
127
|
+
typeof record.finding_category === 'string' &&
|
|
128
|
+
record.finding === undefined;
|
|
129
|
+
if (looksLikeEvidence) {
|
|
130
|
+
throw new SmithError('cli.invalid-flag', `--findings ${filePath} item ${index} looks like a judge artifact (FindingEvidence: file_path/finding_category), not a raised finding ({finding, filePath}). Use --evidence <file> --found-by <role> instead.`, { flag: 'findings', file: filePath, index });
|
|
131
|
+
}
|
|
132
|
+
if (typeof record.filePath !== 'string') {
|
|
133
|
+
throw new SmithError('cli.invalid-flag', `--findings ${filePath} item ${index} is missing a string "filePath".`, { flag: 'findings', file: filePath, index, field: 'filePath' });
|
|
134
|
+
}
|
|
135
|
+
if (record.finding === null || typeof record.finding !== 'object') {
|
|
136
|
+
throw new SmithError('cli.invalid-flag', `--findings ${filePath} item ${index} is missing a "finding" object.`, { flag: 'findings', file: filePath, index, field: 'finding' });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return items;
|
|
140
|
+
}
|
|
99
141
|
function readChecksFile(filePath) {
|
|
100
142
|
const checks = readJsonFile(filePath);
|
|
101
143
|
for (const check of checks) {
|
|
@@ -166,6 +208,9 @@ function declaredWaveBudgets(plan, taskIds, policy) {
|
|
|
166
208
|
function admissionBudget(check, rationale) {
|
|
167
209
|
return {
|
|
168
210
|
status: check.status,
|
|
211
|
+
// Threaded from the policy the cap came from, never re-resolved here: a
|
|
212
|
+
// second resolution is a second answer that can drift from the cap's.
|
|
213
|
+
tier: check.tier,
|
|
169
214
|
cap_tokens: check.capTokens,
|
|
170
215
|
projected_tokens: check.projectedTokens,
|
|
171
216
|
wave_tokens: check.waveTokens,
|
|
@@ -181,23 +226,41 @@ function requireFlag(flags, name) {
|
|
|
181
226
|
return value;
|
|
182
227
|
}
|
|
183
228
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
229
|
+
* Resolves the project directory an OPTIONAL --project reads its
|
|
230
|
+
* `.blacksmith/crosscheck.yml` overlay from, for the verbs where --project
|
|
231
|
+
* is not required outright (unlike `epic verdict`/`epic close`, D-42/P9-26).
|
|
232
|
+
* --project wins when given; otherwise, when a --worktree is present, its
|
|
233
|
+
* main checkout is derived, because a task worktree is a git-worktree
|
|
234
|
+
* SIBLING of the project rather than a child (`.wt/<project>/<task>`, per
|
|
235
|
+
* `taskWorktreeDir()`), so `path.dirname()` cannot recover it -- only git
|
|
236
|
+
* knows the common dir a worktree branches off of.
|
|
237
|
+
*
|
|
238
|
+
* Returns undefined when neither flag is present or the worktree is not a
|
|
239
|
+
* git checkout yet, which is deliberate: `loadCrosscheckPolicy()` already
|
|
240
|
+
* treats an absent projectDir as "no overlay", so callers that spread this
|
|
241
|
+
* in conditionally keep the env+shipped-policy behaviour unchanged rather
|
|
242
|
+
* than regressing every call site that has no project to give.
|
|
187
243
|
*/
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
244
|
+
function projectDirFromFlags(flags, worktreeFlagName = 'worktree') {
|
|
245
|
+
if (flags.project)
|
|
246
|
+
return flags.project;
|
|
247
|
+
const worktreeDir = flags[worktreeFlagName];
|
|
248
|
+
if (!worktreeDir)
|
|
249
|
+
return undefined;
|
|
250
|
+
try {
|
|
251
|
+
const gitCommonDir = runGit(worktreeDir, ['rev-parse', '--git-common-dir']);
|
|
252
|
+
return path.dirname(path.resolve(worktreeDir, gitCommonDir));
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return undefined;
|
|
196
256
|
}
|
|
197
|
-
return value;
|
|
198
257
|
}
|
|
199
258
|
/**
|
|
200
|
-
*
|
|
259
|
+
* A flag that must parse as a whole number in range, but may also be absent
|
|
260
|
+
* entirely — the both-or-neither shape `--input-tokens`/`--output-tokens`
|
|
261
|
+
* need (#220): a dispatcher with no API onto a subagent's token spend omits
|
|
262
|
+
* both rather than travelling a typo'd or invented number as NaN into a
|
|
263
|
+
* token_usage the schema then rejects for the wrong reason.
|
|
201
264
|
*
|
|
202
265
|
* D-210. `event tail --n` read its count with a bare Number.parseInt and
|
|
203
266
|
* checked nothing, which fails in both directions at once: `--n abc` is NaN,
|
|
@@ -435,6 +498,70 @@ async function issueInputs(flags) {
|
|
|
435
498
|
function planOptsFromFlags(flags) {
|
|
436
499
|
return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
|
|
437
500
|
}
|
|
501
|
+
/** `realpathSync`, falling back to the resolved lexical path when it fails. */
|
|
502
|
+
function realOrResolved(p) {
|
|
503
|
+
try {
|
|
504
|
+
return realpathSync(p);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return path.resolve(p);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* `plan amend`'s own specs-dir default (#219) -- deliberately not
|
|
512
|
+
* `planOptsFromFlags`, whose silent fallback to `SPECS_ACTIVE_DIR` is a
|
|
513
|
+
* documented contract several other commands rely on for a READ (see its
|
|
514
|
+
* own doc comment, and `issueInputs`'s). Amend is a WRITE: the version it
|
|
515
|
+
* cuts is filed at `<specsDir>/<epicId>/plan-vN.json`, so silently
|
|
516
|
+
* defaulting to this checkout's own specs tree when `--plan` already lives
|
|
517
|
+
* somewhere else does not merely read the wrong file, it plants the new
|
|
518
|
+
* version in the wrong one -- beside no other version of the plan it just
|
|
519
|
+
* amended.
|
|
520
|
+
*
|
|
521
|
+
* Derived from `--plan`'s own directory instead (`impliedSpecsDir`), when
|
|
522
|
+
* that path already sits in the shape a plan file lives in. An explicit
|
|
523
|
+
* `--specs-dir` still wins outright when it agrees with that; when it does
|
|
524
|
+
* not, the two flags are answering a question this command cannot silently
|
|
525
|
+
* referee, so it refuses rather than guessing which one the caller meant.
|
|
526
|
+
*/
|
|
527
|
+
function amendSpecsDirOpts(planPath, plan, flags) {
|
|
528
|
+
const explicit = flags['specs-dir'];
|
|
529
|
+
const implied = impliedSpecsDir(planPath, plan.epic_id);
|
|
530
|
+
if (explicit === undefined)
|
|
531
|
+
return implied === null ? {} : { specsDir: implied };
|
|
532
|
+
// Compared on disk, not lexically: the same tree reached through a
|
|
533
|
+
// symlink (macOS /tmp -> /private/tmp) is agreement, not a mismatch.
|
|
534
|
+
if (implied !== null && realOrResolved(explicit) !== realOrResolved(implied)) {
|
|
535
|
+
throw new SmithError('cli.specs-dir-mismatch', `--specs-dir "${explicit}" disagrees with the directory --plan "${planPath}" already lives in: that plan's own layout says its specs dir is "${implied}". Drop --specs-dir to amend in place, point it at "${implied}", or move --plan under the tree --specs-dir names.`, { plan: planPath, epicId: plan.epic_id, specsDir: explicit, impliedSpecsDir: implied });
|
|
536
|
+
}
|
|
537
|
+
return { specsDir: explicit };
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The tier `epic verdict`/`epic close` actually run at, resolved the same way
|
|
541
|
+
* `effort show` resolves it (security floor included) so the two commands
|
|
542
|
+
* can never disagree about what tier an epic is on. Read-only, best-effort on
|
|
543
|
+
* the plan the way `resolvePlanRoster` (epic.ts) is: a plan that fails to
|
|
544
|
+
* load is "unreadable", not a crash, and `resolveEffort` already treats an
|
|
545
|
+
* absent plan as "floor not evaluated" rather than "floor clean" (see
|
|
546
|
+
* `EffortResolution.securityFloorEvaluated`).
|
|
547
|
+
*/
|
|
548
|
+
function resolveEpicEffort(epicId, flags) {
|
|
549
|
+
const planOpts = planOptsFromFlags(flags);
|
|
550
|
+
const version = latestPlanVersion(epicId, planOpts);
|
|
551
|
+
let plan = null;
|
|
552
|
+
if (version !== null) {
|
|
553
|
+
try {
|
|
554
|
+
plan = loadPlan(epicId, version, planOpts);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
plan = null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const policy = loadEffortPolicy(flags.policy);
|
|
561
|
+
const securityPolicy = loadCrosscheckPolicy(flags.crosscheck).planQuorum;
|
|
562
|
+
const resolution = resolveEffort(policy, securityPolicy, plan ? { plan } : {});
|
|
563
|
+
return { tier: resolution.effective, closingSpecReview: resolution.profile.closingSpecReview };
|
|
564
|
+
}
|
|
438
565
|
/**
|
|
439
566
|
* The one reader for every flag usage.ts documents as `<iso>` (D-209).
|
|
440
567
|
*
|
|
@@ -491,10 +618,10 @@ function isoDateFlag(flags, name) {
|
|
|
491
618
|
* `event append` writes.
|
|
492
619
|
*
|
|
493
620
|
* `--roadmap-path` belongs here too: both verbs rebuild the whole milestones
|
|
494
|
-
* table from a roadmap file, and unset it falls back to
|
|
621
|
+
* table from a roadmap file, and unset it falls back to blacksmith's own
|
|
495
622
|
* factory/specs/roadmap.md. Dropping the flag therefore did not mean "keep the
|
|
496
623
|
* existing milestones" — it meant "replace this db's milestones with
|
|
497
|
-
*
|
|
624
|
+
* blacksmith's", which is a silent data swap for any db but this repo's.
|
|
498
625
|
*
|
|
499
626
|
* `--specs-dir` travels for the same reason and is the same flag `plan`
|
|
500
627
|
* already takes: the projector reads an epic's plan file to answer what
|
|
@@ -662,6 +789,25 @@ function mintFromEvidence(args, taskId, scope = {}) {
|
|
|
662
789
|
...scope,
|
|
663
790
|
}));
|
|
664
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* Mint a uiux visual pass's deviations under the `uiux` judge. `--uiux-visual`
|
|
794
|
+
* hands over the whole Result document (bs-uiux.md's output contract), not a
|
|
795
|
+
* findings-evidence array, so this reads `structured_output.deviations` the
|
|
796
|
+
* same way `readJudgeArtifact`'s uiux branch does (judges.ts) before handing
|
|
797
|
+
* the mapped evidence to the same `mintFindings` every other judge goes
|
|
798
|
+
* through.
|
|
799
|
+
*/
|
|
800
|
+
function mintFromUiuxVisual(filePath, taskId) {
|
|
801
|
+
// Reuse `readJudgeArtifact`'s uiux visual-branch validation (judges.ts)
|
|
802
|
+
// rather than re-checking the shape here: a document missing
|
|
803
|
+
// `structured_output.deviations`, or one where it is not an array, must
|
|
804
|
+
// raise `judges.artifact-invalid-evidence` instead of silently minting
|
|
805
|
+
// nothing and letting the gate pass on a wrong-shaped document (S2).
|
|
806
|
+
readJudgeArtifact(filePath, 'uiux', taskId, 'visual');
|
|
807
|
+
const document = readJsonFile(filePath);
|
|
808
|
+
const deviations = document.structured_output?.deviations ?? [];
|
|
809
|
+
return mintFindings(uiuxDeviationsToEvidence(deviations), { taskId, foundBy: 'uiux' });
|
|
810
|
+
}
|
|
665
811
|
/**
|
|
666
812
|
* The gated task's declared caps, read off the same `--plan` the gate already
|
|
667
813
|
* takes for ownership (D-41/P9-24) — no new flag, because a budget the gate can
|
|
@@ -686,9 +832,33 @@ function budgetFromFlags(flags, taskId) {
|
|
|
686
832
|
return undefined;
|
|
687
833
|
throw err;
|
|
688
834
|
}
|
|
689
|
-
const budget = plan
|
|
835
|
+
const budget = liveSpec(plan, resolved)?.budget;
|
|
690
836
|
return typeof budget === 'object' && budget !== null ? budget : undefined;
|
|
691
837
|
}
|
|
838
|
+
/**
|
|
839
|
+
* The task's `ui_affecting` flag off `--plan` (U2 D3/D6), for the gate's uiux
|
|
840
|
+
* stage: `undefined` means no `--plan` was given at all, so the stage cannot
|
|
841
|
+
* resolve the flag either way and records "unverifiable" rather than guess
|
|
842
|
+
* (D6). A `--plan` that does not name the task, same as `budgetFromFlags`,
|
|
843
|
+
* is read as `undefined` too — a follow-up task no plan version has been cut
|
|
844
|
+
* for yet is not grounds to force the uiux stage one way or the other.
|
|
845
|
+
*/
|
|
846
|
+
function uiAffectingFromFlags(flags, taskId) {
|
|
847
|
+
if (!flags.plan)
|
|
848
|
+
return undefined;
|
|
849
|
+
const plan = readJsonFile(flags.plan);
|
|
850
|
+
let resolved;
|
|
851
|
+
try {
|
|
852
|
+
resolved = resolveTaskId(plan, taskId);
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
if (err instanceof SmithError && err.code === 'plan.unknown-task')
|
|
856
|
+
return undefined;
|
|
857
|
+
throw err;
|
|
858
|
+
}
|
|
859
|
+
const spec = liveSpec(plan, resolved);
|
|
860
|
+
return spec ? isUiAffecting(spec) : undefined;
|
|
861
|
+
}
|
|
692
862
|
/**
|
|
693
863
|
* The claim globs one dispatch is scoped to, read off the plan (P9-2). Only
|
|
694
864
|
* claim-path-scoped lessons use them, so a role that declares no such scope
|
|
@@ -700,7 +870,21 @@ function claimsForDispatch(flags) {
|
|
|
700
870
|
if (!flags.plan)
|
|
701
871
|
return [];
|
|
702
872
|
const taskId = requireFlag(flags, 'task');
|
|
703
|
-
const
|
|
873
|
+
const plan = readJsonFile(flags.plan);
|
|
874
|
+
let resolved;
|
|
875
|
+
try {
|
|
876
|
+
resolved = resolveTaskId(plan, taskId);
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
if (err instanceof SmithError && err.code === 'plan.unknown-task') {
|
|
880
|
+
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
881
|
+
taskId,
|
|
882
|
+
plan: flags.plan,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
throw err;
|
|
886
|
+
}
|
|
887
|
+
const owner = ownershipFromPlan(plan).find((t) => t.task_id === resolved);
|
|
704
888
|
if (!owner) {
|
|
705
889
|
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
706
890
|
taskId,
|
|
@@ -724,7 +908,21 @@ function caseForDispatch(flags) {
|
|
|
724
908
|
if (!flags.plan)
|
|
725
909
|
return '';
|
|
726
910
|
const taskId = requireFlag(flags, 'task');
|
|
727
|
-
const
|
|
911
|
+
const plan = readJsonFile(flags.plan);
|
|
912
|
+
let resolved;
|
|
913
|
+
try {
|
|
914
|
+
resolved = resolveTaskId(plan, taskId);
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
if (err instanceof SmithError && err.code === 'plan.unknown-task') {
|
|
918
|
+
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
919
|
+
taskId,
|
|
920
|
+
plan: flags.plan,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
throw err;
|
|
924
|
+
}
|
|
925
|
+
const task = liveSpec(plan, resolved);
|
|
728
926
|
if (!task) {
|
|
729
927
|
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
730
928
|
taskId,
|
|
@@ -782,6 +980,47 @@ function splitNamespaceAction(argv) {
|
|
|
782
980
|
}
|
|
783
981
|
return { namespace, action: undefined, rest: restAll };
|
|
784
982
|
}
|
|
983
|
+
/**
|
|
984
|
+
* #250. `wave check --session` admits against the log, so before it admits
|
|
985
|
+
* it asks the log three things, in order: was this plan version ingested,
|
|
986
|
+
* does every id have a task row, and is every row still live. Each refusal is
|
|
987
|
+
* thrown before anything is written, so a refused wave leaves no
|
|
988
|
+
* `wave-admitted` behind. "Live" excludes only CLOSED_TO_FURTHER_WORK: a
|
|
989
|
+
* `failed` or `escalated` task is held by an operator and is re-admitted by
|
|
990
|
+
* the escalation ladder, so it passes.
|
|
991
|
+
*
|
|
992
|
+
* The no-row refusal is a defensive invariant, not a path the CLI reaches
|
|
993
|
+
* today: past an ingest every plan id has a row, and an id neither the plan
|
|
994
|
+
* nor the log knows is refused earlier, by the id resolution.
|
|
995
|
+
*
|
|
996
|
+
* Order matters for the answer the operator reads: an un-ingested plan makes
|
|
997
|
+
* every row question moot (the rows are missing or stale because the ingest
|
|
998
|
+
* is), so it is asked first and names the one command that fixes it.
|
|
999
|
+
*/
|
|
1000
|
+
async function refuseUnadmissibleTasks(plan, taskIds, flags) {
|
|
1001
|
+
const sessionId = flags.session;
|
|
1002
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
1003
|
+
const gaps = await planIngestGaps(plan, { sessionId }, eventOpts);
|
|
1004
|
+
if (gaps.length > 0) {
|
|
1005
|
+
const pending = [...new Set(gaps.map((g) => g.taskId))];
|
|
1006
|
+
throw new SmithError('cli.plan-not-ingested', `Plan "${plan.epic_id}" v${plan.version} has not been ingested into session "${sessionId}" (${pending.length} task(s) pending): run \`bs plan ingest\` on it first, then re-check.`, { epic: plan.epic_id, version: plan.version, session: sessionId, pending });
|
|
1007
|
+
}
|
|
1008
|
+
// Imported here for the reason nextWaveInputFrom gives: the db layer stays
|
|
1009
|
+
// out of the graph every `smith` invocation loads.
|
|
1010
|
+
const { foldTasks, taskIdCanonicalizer } = await import('./db/projector.js');
|
|
1011
|
+
const events = await readLineageEvents(sessionId, eventOpts);
|
|
1012
|
+
const statusOf = new Map();
|
|
1013
|
+
for (const row of foldTasks(events))
|
|
1014
|
+
statusOf.set(row.taskId, row.taskStatus);
|
|
1015
|
+
const canonical = taskIdCanonicalizer(events);
|
|
1016
|
+
const { missing, terminal } = unadmissibleTasks(taskIds.map(canonical), statusOf);
|
|
1017
|
+
if (missing.length > 0) {
|
|
1018
|
+
throw new SmithError('plan.unknown-task', `Session "${sessionId}" has no task row for ${missing.map((id) => `"${id}"`).join(', ')}: a wave is admitted against the log, and the log does not know ${missing.length === 1 ? 'this task' : 'these tasks'}.`, { session: sessionId, missing });
|
|
1019
|
+
}
|
|
1020
|
+
if (terminal.length > 0) {
|
|
1021
|
+
throw new SmithError('cli.task-not-live', `Refusing to admit ${terminal.map((t) => `"${t.taskId}" (${t.taskStatus})`).join(', ')}: the work is over in session "${sessionId}". Drop ${terminal.length === 1 ? 'it' : 'them'} from the wave.`, { session: sessionId, tasks: terminal });
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
785
1024
|
/**
|
|
786
1025
|
* Everything a wave computation reads about a plan: the plan itself with the
|
|
787
1026
|
* log's follow-ups merged in, the worktree policy, the import crossings
|
|
@@ -869,6 +1108,10 @@ async function nextWaveInputFrom(planFile, flags) {
|
|
|
869
1108
|
};
|
|
870
1109
|
}
|
|
871
1110
|
async function main() {
|
|
1111
|
+
// bs-rename, operator decision 3: a no-op unless argv[1]'s basename is the
|
|
1112
|
+
// deprecated `smith`, in which case it writes one line to stderr, never
|
|
1113
|
+
// stdout -- stdout is parsed as JSON by playbooks.
|
|
1114
|
+
warnIfLegacyName(process.argv[1], 'smith', 'bs');
|
|
872
1115
|
// Refuse an unsupported runtime before anything opens the database. The
|
|
873
1116
|
// native binding crashes lazily — `new Database()`, not import — so a check
|
|
874
1117
|
// here still runs, and a subcommand that happens to avoid SQLite must not
|
|
@@ -878,7 +1121,7 @@ async function main() {
|
|
|
878
1121
|
if (!runtime.supported) {
|
|
879
1122
|
// Prose on stderr so a human sees it even when stdout is piped into a
|
|
880
1123
|
// JSON parser; the structured form still goes to stdout for callers.
|
|
881
|
-
process.stderr.write(`
|
|
1124
|
+
process.stderr.write(`bs: unsupported runtime\n${runtime.reason ?? ''}\n`);
|
|
882
1125
|
printJson({ error: { code: 'unsupported-runtime', message: runtime.reason } });
|
|
883
1126
|
return 1;
|
|
884
1127
|
}
|
|
@@ -1023,7 +1266,25 @@ async function main() {
|
|
|
1023
1266
|
// requirePositionals is what makes it true, and it runs first.
|
|
1024
1267
|
const [planFile] = requirePositionals(positional, usageFor('plan validate'));
|
|
1025
1268
|
const plan = readJsonFile(planFile);
|
|
1026
|
-
|
|
1269
|
+
// Grandfathering (plan.ui-flag-missing, U2 S1 R2) needs the version this
|
|
1270
|
+
// one amends. It is readable here only when the file sits where
|
|
1271
|
+
// `impliedSpecsDir` expects a plan to sit (`<specsDir>/<epicId>/plan-vN.json`)
|
|
1272
|
+
// and the previous version is actually on disk beside it; a validate run
|
|
1273
|
+
// against a bare fixture or a file moved elsewhere gets no previous and
|
|
1274
|
+
// validates without the exemption, same as before this change.
|
|
1275
|
+
let previous;
|
|
1276
|
+
if (plan.version > 1) {
|
|
1277
|
+
const specsDir = impliedSpecsDir(planFile, plan.epic_id);
|
|
1278
|
+
if (specsDir !== null) {
|
|
1279
|
+
try {
|
|
1280
|
+
previous = loadPlan(plan.epic_id, plan.version - 1, { specsDir });
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
// No readable previous version alongside this file.
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
const result = validatePlan(plan, previous ? { previous } : {});
|
|
1027
1288
|
printJson(result);
|
|
1028
1289
|
return result.valid ? 0 : 1;
|
|
1029
1290
|
}
|
|
@@ -1085,6 +1346,13 @@ async function main() {
|
|
|
1085
1346
|
...(draft ? { plan: draft } : {}),
|
|
1086
1347
|
...(plannerConfidence !== undefined ? { plannerConfidence } : {}),
|
|
1087
1348
|
planOpts: planOptsFromFlags(flags),
|
|
1349
|
+
// Same reasoning as `epic verdict`/`epic close` (D-42/P9-26): an
|
|
1350
|
+
// optional --project hands the quorum a project's
|
|
1351
|
+
// .blacksmith/crosscheck.yml overlay (OpenRouter substitution/extra
|
|
1352
|
+
// judges) rather than only the shipped policy. No worktree
|
|
1353
|
+
// derivation here -- a plan critique has no task worktree to derive
|
|
1354
|
+
// one from -- so an absent --project reads exactly as before.
|
|
1355
|
+
crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir: flags.project }) },
|
|
1088
1356
|
}, ctx, eventOptsFromFlags(flags));
|
|
1089
1357
|
printJson(outcome);
|
|
1090
1358
|
// FD-49: the outcome is a few rationales of several kB each, and the
|
|
@@ -1107,7 +1375,12 @@ async function main() {
|
|
|
1107
1375
|
const plan = readJsonFile(planFile);
|
|
1108
1376
|
const ctx = eventContextFromFlags(flags);
|
|
1109
1377
|
const opts = eventOptsFromFlags(flags);
|
|
1110
|
-
|
|
1378
|
+
// --project: an epic already integrating on `smith/<epic>/integration`
|
|
1379
|
+
// (legacy continuity, worktree.ts's epicBranchPrefix) must have this
|
|
1380
|
+
// v(n+1) ingest declare the same prefix `worktree create` will actually
|
|
1381
|
+
// cut, not the plain `bs` default. Absent for a brand-new epic, which has
|
|
1382
|
+
// no such branch to ask git about yet.
|
|
1383
|
+
const written = await emitTasksAdded(plan, ctx, opts, flags.project);
|
|
1111
1384
|
// D-254: the arrows, after the nodes. The plan declares a DAG and the
|
|
1112
1385
|
// scheduler has always read it off the file, but nothing wrote it to the
|
|
1113
1386
|
// log -- so the db's `edges` table was empty and every operator-facing
|
|
@@ -1124,6 +1397,11 @@ async function main() {
|
|
|
1124
1397
|
// claims-and-edges only, so the input carries no crossings.
|
|
1125
1398
|
const { computePlanParallelism } = await import('./waveSchedule.js');
|
|
1126
1399
|
const parallelism = computePlanParallelism(plan, loadWorktreePolicy());
|
|
1400
|
+
// The last event this call appended -- the edges, after the nodes, since
|
|
1401
|
+
// emitEdgesRecorded runs after emitTasksAdded. A re-ingest that wrote
|
|
1402
|
+
// neither (everything already on the log) appends nothing, so there is
|
|
1403
|
+
// no id to print.
|
|
1404
|
+
const lastEvent = edges.length > 0 ? edges[edges.length - 1] : written[written.length - 1];
|
|
1127
1405
|
printJson({
|
|
1128
1406
|
epic: plan.epic_id,
|
|
1129
1407
|
version: plan.version,
|
|
@@ -1132,6 +1410,7 @@ async function main() {
|
|
|
1132
1410
|
skipped: plan.tasks.length - added,
|
|
1133
1411
|
edges: edges.length,
|
|
1134
1412
|
parallelism,
|
|
1413
|
+
...(lastEvent ? { event_id: lastEvent.event_id } : {}),
|
|
1135
1414
|
});
|
|
1136
1415
|
return 0;
|
|
1137
1416
|
}
|
|
@@ -1139,7 +1418,8 @@ async function main() {
|
|
|
1139
1418
|
// to cut a version that cites no spec finding, so "the plan changed" is
|
|
1140
1419
|
// always answerable with "which finding said it was wrong".
|
|
1141
1420
|
if (namespace === 'plan' && action === 'amend') {
|
|
1142
|
-
const
|
|
1421
|
+
const planPath = requireFlag(flags, 'plan');
|
|
1422
|
+
const plan = readJsonFile(planPath);
|
|
1143
1423
|
// Comma-split, not repeated: a finding id has no commas, and an amendment
|
|
1144
1424
|
// routinely cites several at once.
|
|
1145
1425
|
const findingIds = requireFlag(flags, 'findings')
|
|
@@ -1159,7 +1439,7 @@ async function main() {
|
|
|
1159
1439
|
rationale: requireFlag(flags, 'rationale'),
|
|
1160
1440
|
sites,
|
|
1161
1441
|
...(changes ? { changes } : {}),
|
|
1162
|
-
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...
|
|
1442
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
|
|
1163
1443
|
// No no-op warning here any more. A pure carry-forward used to be legal
|
|
1164
1444
|
// and merely suspicious — the shape a forgotten --changes takes — so the
|
|
1165
1445
|
// CLI printed a warning and cut the version anyway. Since D-127 an
|
|
@@ -1180,6 +1460,11 @@ async function main() {
|
|
|
1180
1460
|
// no task behind it is a deliberate call or a forgotten one.
|
|
1181
1461
|
sitesUnclaimed: result.sitesUnclaimed,
|
|
1182
1462
|
diff: result.diff,
|
|
1463
|
+
// #221: an open finding this amendment's own objective text names but
|
|
1464
|
+
// --findings does not cite -- surfaced the same way sitesUnclaimed is,
|
|
1465
|
+
// a fact for the operator to read off the result rather than a guard.
|
|
1466
|
+
warnings: result.warnings,
|
|
1467
|
+
event_id: result.event_id,
|
|
1183
1468
|
});
|
|
1184
1469
|
return 0;
|
|
1185
1470
|
}
|
|
@@ -1229,13 +1514,17 @@ async function main() {
|
|
|
1229
1514
|
// sites. Approval supplies them from what the worker already recorded.
|
|
1230
1515
|
if (namespace === 'plan' && action === 'approve') {
|
|
1231
1516
|
const [proposalId] = requirePositionals(positional, usageFor('plan approve'));
|
|
1232
|
-
const
|
|
1517
|
+
const planPath = requireFlag(flags, 'plan');
|
|
1518
|
+
const plan = readJsonFile(planPath);
|
|
1519
|
+
// #287: approval runs `plan amend` underneath, so it is the same write
|
|
1520
|
+
// `amendSpecsDirOpts` was written for (#219), not the read
|
|
1521
|
+
// `planOptsFromFlags` silently defaults for everyone else.
|
|
1233
1522
|
const result = await approveSpecChange({
|
|
1234
1523
|
proposalId,
|
|
1235
1524
|
plan,
|
|
1236
1525
|
decidedBy: requireFlag(flags, 'decided-by'),
|
|
1237
1526
|
...(flags.rationale ? { rationale: flags.rationale } : {}),
|
|
1238
|
-
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...
|
|
1527
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
|
|
1239
1528
|
printJson({
|
|
1240
1529
|
proposalId,
|
|
1241
1530
|
epic: result.plan.epic_id,
|
|
@@ -1245,6 +1534,7 @@ async function main() {
|
|
|
1245
1534
|
sites: result.proposal.sites,
|
|
1246
1535
|
sitesUnclaimed: result.sitesUnclaimed,
|
|
1247
1536
|
diff: result.diff,
|
|
1537
|
+
event_id: result.event_id,
|
|
1248
1538
|
});
|
|
1249
1539
|
return 0;
|
|
1250
1540
|
}
|
|
@@ -1274,7 +1564,7 @@ async function main() {
|
|
|
1274
1564
|
// at. resolveTaskId turns that silence into a named error.
|
|
1275
1565
|
const typedIds = positional.slice(1);
|
|
1276
1566
|
if (typedIds.length === 0) {
|
|
1277
|
-
throw new SmithError('cli.empty-wave', 'Usage:
|
|
1567
|
+
throw new SmithError('cli.empty-wave', 'Usage: bs wave check <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
|
|
1278
1568
|
}
|
|
1279
1569
|
// D-48/P9-31: a follow-up task exists only in the log, so the log is the
|
|
1280
1570
|
// second register an id may be found in — and the place its claims come
|
|
@@ -1284,6 +1574,8 @@ async function main() {
|
|
|
1284
1574
|
? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
|
|
1285
1575
|
: [];
|
|
1286
1576
|
const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged.map((t) => t.taskId)));
|
|
1577
|
+
if (flags.session)
|
|
1578
|
+
await refuseUnadmissibleTasks(plan, taskIds, flags);
|
|
1287
1579
|
// Neither register is narrowed on the way in. `?? []` used to stand here
|
|
1288
1580
|
// and on the line above, and it fires on a missing claims field but not on
|
|
1289
1581
|
// a claims field holding the wrong thing: a plan writing `"claims":
|
|
@@ -1351,7 +1643,9 @@ async function main() {
|
|
|
1351
1643
|
// the sum of every session that worked on it.
|
|
1352
1644
|
await readLineageEvents(flags.session, eventOptsFromFlags(flags))
|
|
1353
1645
|
: [];
|
|
1354
|
-
|
|
1646
|
+
// Sized for the tier this epic runs at: the plan's own `effort` after
|
|
1647
|
+
// effort.yml's security floor, else effort.yml's default_tier.
|
|
1648
|
+
const budgetPolicy = budgetPolicyForPlan(plan, { policyFile: flags['budget-policy'] });
|
|
1355
1649
|
const budget = checkWaveBudget(budgetEvents, budgetPolicy, declaredWaveBudgets(plan, taskIds, budgetPolicy), { sessionId: flags.session ?? '', epicId: plan.epic_id });
|
|
1356
1650
|
const overridden = rationale !== '' && blocksAdmission(budget.status);
|
|
1357
1651
|
const blocked = blocksAdmission(budget.status) && !overridden;
|
|
@@ -1365,7 +1659,7 @@ async function main() {
|
|
|
1365
1659
|
}
|
|
1366
1660
|
// Names only, and only when some differ from budgets.yml: which caps came
|
|
1367
1661
|
// from the box's env rather than the file.
|
|
1368
|
-
const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}));
|
|
1662
|
+
const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}, budgetPolicy.tier));
|
|
1369
1663
|
printJson({
|
|
1370
1664
|
...result,
|
|
1371
1665
|
symbolImpact,
|
|
@@ -1563,7 +1857,8 @@ async function main() {
|
|
|
1563
1857
|
// the whole safety property: an operator can read what the policy would
|
|
1564
1858
|
// have let run, and argue with it, before any of it moves. So this appends
|
|
1565
1859
|
// no event, writes no worktree and starts no agent -- exactly the daemon's
|
|
1566
|
-
// invariant, held by the command a person types too.
|
|
1860
|
+
// invariant, held by the command a person types too. No event_id in the
|
|
1861
|
+
// printed JSON either, for the same reason: there is never one to name.
|
|
1567
1862
|
if (namespace === 'scheduler' && action === 'admit') {
|
|
1568
1863
|
const { computeProposals, loadSchedulerPolicy } = await import('./scheduler.js');
|
|
1569
1864
|
const { admitProposals } = await import('./autonomy.js');
|
|
@@ -1650,11 +1945,12 @@ async function main() {
|
|
|
1650
1945
|
// plan on disk naming no project is this checkout's own epic, so it
|
|
1651
1946
|
// reads as FACTORY_PROJECT rather than staying an unresolved null the
|
|
1652
1947
|
// daemon would report as an unlabeled finding and the on/off switch
|
|
1653
|
-
// for '
|
|
1948
|
+
// for 'blacksmith' would never get asked about. Split into strict
|
|
1654
1949
|
// (resolveProjectForTaskRef) and self-only (selfFallbackForTaskRef)
|
|
1655
1950
|
// tiers so a session's own stamp can ride between them (S2-c).
|
|
1656
1951
|
resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
|
|
1657
1952
|
selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
|
|
1953
|
+
planOpts: planOptsFromFlags(flags),
|
|
1658
1954
|
...(flags.db ? { dbPath: flags.db } : {}),
|
|
1659
1955
|
...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
|
|
1660
1956
|
};
|
|
@@ -1710,7 +2006,7 @@ async function main() {
|
|
|
1710
2006
|
// Let acquireLock own the liveness question — one implementation of
|
|
1711
2007
|
// "is that pid still there" rather than a second opinion here.
|
|
1712
2008
|
throw new DaemonError('daemon.already-running', `A daemon (pid ${held.pid}, started ${held.startedAt}) already holds ${dir}. ` +
|
|
1713
|
-
'Run `
|
|
2009
|
+
'Run `bs daemon stop` first, or `bs daemon status` to see what it last found.', { pid: held.pid, dir });
|
|
1714
2010
|
}
|
|
1715
2011
|
mkdirSync(dir, { recursive: true });
|
|
1716
2012
|
// `ignore` would discard the one account of why a detached daemon died.
|
|
@@ -1801,11 +2097,32 @@ async function main() {
|
|
|
1801
2097
|
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1802
2098
|
const successors = taskSuccessors(events, epic);
|
|
1803
2099
|
const from = flags.from;
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2100
|
+
// Walks the successor chain hop by hop (spec.ts's
|
|
2101
|
+
// `successorChainPath`) rather than asking about the direct successor
|
|
2102
|
+
// alone: a plan amended twice before the intermediate successor is
|
|
2103
|
+
// ever cut still names `taskId` as `from`'s transitive successor, and
|
|
2104
|
+
// the log is the register this guard is answerable to.
|
|
2105
|
+
const chain = successorChainPath(epic, from, taskId, successors);
|
|
2106
|
+
if (chain === undefined) {
|
|
1807
2107
|
throw new SmithError('worktree.not-a-successor', `${epic}/${taskId} is not logged as the successor of ${epic}/${from}; run \`plan propose\`/\`plan approve\` with a supersede pairing them first.`, { epic, taskId, from });
|
|
1808
2108
|
}
|
|
2109
|
+
// The log naming a chain is not enough: `createTaskWorktree` branches
|
|
2110
|
+
// `taskId` directly off `from`'s own branch, so if some hop strictly
|
|
2111
|
+
// between them was already cut as its own branch, skipping straight
|
|
2112
|
+
// there would silently drop that hop's commits. `chain` is `from`'s
|
|
2113
|
+
// own hops through `taskId` inclusive (spec.ts's `successorChainPath`
|
|
2114
|
+
// doc comment); drop the last one (`taskId` itself, which never has a
|
|
2115
|
+
// branch yet) and refuse on the intermediate hop closest to `taskId`
|
|
2116
|
+
// if any of them do.
|
|
2117
|
+
const intermediates = chain.slice(0, -1);
|
|
2118
|
+
let cutIntermediate;
|
|
2119
|
+
for (const hop of intermediates) {
|
|
2120
|
+
if (taskBranchExists(projectDir, epic, hop))
|
|
2121
|
+
cutIntermediate = hop;
|
|
2122
|
+
}
|
|
2123
|
+
if (cutIntermediate !== undefined) {
|
|
2124
|
+
throw new SmithError('worktree.chain-intermediate-cut', `${epic}/${taskId} cannot be cut --from ${epic}/${from}: ${epic}/${cutIntermediate} sits between them and already has its own branch (${taskBranchName(epic, cutIntermediate, epicBranchPrefix(projectDir, epic))}), so its commits would be silently dropped; rerun with --from ${cutIntermediate} instead.`, { epic, taskId, from, intermediate: cutIntermediate });
|
|
2125
|
+
}
|
|
1809
2126
|
printJson(createTaskWorktree(projectDir, epic, taskId, { from }));
|
|
1810
2127
|
return 0;
|
|
1811
2128
|
}
|
|
@@ -1885,6 +2202,110 @@ async function main() {
|
|
|
1885
2202
|
printJson({ event_id: stored.event_id, record: stored.record });
|
|
1886
2203
|
return 0;
|
|
1887
2204
|
}
|
|
2205
|
+
// The hook-driven sibling of `prompt record`: reads one UserPromptSubmit
|
|
2206
|
+
// payload on stdin and files it where promptCapture.ts decides. The plugin
|
|
2207
|
+
// reaches the same function through the lean `bs-prompt-hook` bin; this verb
|
|
2208
|
+
// is the surface the CLI tests drive. It never fails a prompt, so every
|
|
2209
|
+
// outcome is exit 0.
|
|
2210
|
+
if (namespace === 'prompt' && action === 'capture') {
|
|
2211
|
+
try {
|
|
2212
|
+
const line = await capturePrompt(readFileSync(0, 'utf8'), captureContext(process.cwd(), process.env));
|
|
2213
|
+
if (line !== null)
|
|
2214
|
+
console.log(line);
|
|
2215
|
+
}
|
|
2216
|
+
catch {
|
|
2217
|
+
// A capture that fails loses one record; the prompt itself goes through.
|
|
2218
|
+
}
|
|
2219
|
+
return 0;
|
|
2220
|
+
}
|
|
2221
|
+
// Backfill: replays a Claude Code transcript into the home prompt log the
|
|
2222
|
+
// hook would have written (promptImport.ts). Unlike `capture` it is an
|
|
2223
|
+
// explicit act, so a refusal is exit 1. --dry-run prints each event first.
|
|
2224
|
+
if (namespace === 'prompt' && action === 'import') {
|
|
2225
|
+
const { events, summary } = await importTranscript(requireFlag(flags, 'transcript'), captureContext(process.cwd(), process.env), { dryRun: flags['dry-run'] === 'true' });
|
|
2226
|
+
if (flags['dry-run'] === 'true')
|
|
2227
|
+
for (const event of events)
|
|
2228
|
+
console.log(JSON.stringify(event));
|
|
2229
|
+
console.log(JSON.stringify(summary));
|
|
2230
|
+
return 0;
|
|
2231
|
+
}
|
|
2232
|
+
// The operator's mid-run comment on a task, from the dashboard or from
|
|
2233
|
+
// GitHub, and its two readers/writers below (feedback.ts). This PR is the
|
|
2234
|
+
// store and the CLI only; the dashboard form and the playbook that acts on
|
|
2235
|
+
// `smith feedback pending`'s suggestion are later work.
|
|
2236
|
+
if (namespace === 'feedback' && action === 'record') {
|
|
2237
|
+
const taskId = requireFlag(flags, 'task');
|
|
2238
|
+
const bodyFile = flags['body-file'];
|
|
2239
|
+
const body = bodyFile !== undefined ? readFileSync(bodyFile, 'utf8') : flags.body;
|
|
2240
|
+
if (body === undefined) {
|
|
2241
|
+
throw new SmithError('cli.missing-flag', 'Missing --body or --body-file: operator feedback needs a body.', { flag: 'body' });
|
|
2242
|
+
}
|
|
2243
|
+
if (flags.kind !== undefined && flags.kind !== 'must-fix' && flags.kind !== 'nice-to-have') {
|
|
2244
|
+
throw new SmithError('cli.invalid-flag', `--kind must be "must-fix" or "nice-to-have", got "${flags.kind}".`, { flag: 'kind', value: flags.kind });
|
|
2245
|
+
}
|
|
2246
|
+
if (flags.source !== undefined &&
|
|
2247
|
+
flags.source !== 'dashboard' &&
|
|
2248
|
+
flags.source !== 'github' &&
|
|
2249
|
+
flags.source !== 'cli') {
|
|
2250
|
+
throw new SmithError('cli.invalid-flag', `--source must be "dashboard", "github" or "cli", got "${flags.source}".`, { flag: 'source', value: flags.source });
|
|
2251
|
+
}
|
|
2252
|
+
const result = await recordFeedback({
|
|
2253
|
+
taskId,
|
|
2254
|
+
body,
|
|
2255
|
+
kind: flags.kind,
|
|
2256
|
+
source: flags.source,
|
|
2257
|
+
externalId: flags['external-id'],
|
|
2258
|
+
author: flags.author,
|
|
2259
|
+
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2260
|
+
printJson({
|
|
2261
|
+
event_id: result.event.event_id,
|
|
2262
|
+
feedback_id: result.feedbackId,
|
|
2263
|
+
deduped: result.deduped,
|
|
2264
|
+
});
|
|
2265
|
+
return 0;
|
|
2266
|
+
}
|
|
2267
|
+
if (namespace === 'feedback' && action === 'pending') {
|
|
2268
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2269
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2270
|
+
requireSession(sessionId, eventOpts);
|
|
2271
|
+
const taskId = flags.task;
|
|
2272
|
+
const epic = flags.epic;
|
|
2273
|
+
const pending = await listPendingFeedback(sessionId, { taskId }, eventOpts);
|
|
2274
|
+
const scoped = epic === undefined ? pending : pending.filter((row) => epicOfTaskId(row.taskId) === epic);
|
|
2275
|
+
// Kept out of the boot graph like every other db/projector.js call site
|
|
2276
|
+
// (P9-2, test/cliBoot.test.ts): the module pulls in drizzle-orm, and
|
|
2277
|
+
// `smith --help` should not pay for a database it never opens.
|
|
2278
|
+
const { foldTasks } = await import('./db/projector.js');
|
|
2279
|
+
const statuses = new Map(foldTasks(await readLineageEvents(sessionId, eventOpts)).map((t) => [t.taskId, t.taskStatus]));
|
|
2280
|
+
const withAction = scoped.map((row) => ({
|
|
2281
|
+
...row,
|
|
2282
|
+
// A task not yet closed to further work gets its comment bounced back
|
|
2283
|
+
// to the coder still on it; one that already merged (or was superseded
|
|
2284
|
+
// or waived) gets a follow-up task instead (taskStatus.ts's
|
|
2285
|
+
// CLOSED_TO_FURTHER_WORK, the same question claims.ts's
|
|
2286
|
+
// decideFindingAttribution answers for a finding).
|
|
2287
|
+
suggestedAction: CLOSED_TO_FURTHER_WORK.has(statuses.get(row.taskId) ?? '')
|
|
2288
|
+
? 'follow-up'
|
|
2289
|
+
: 'bounce',
|
|
2290
|
+
}));
|
|
2291
|
+
printJson({ sessionId, pending: withAction, count: withAction.length });
|
|
2292
|
+
return 0;
|
|
2293
|
+
}
|
|
2294
|
+
if (namespace === 'feedback' && action === 'resolve') {
|
|
2295
|
+
const feedbackId = requireFlag(flags, 'feedback');
|
|
2296
|
+
const resolution = requireFlag(flags, 'resolution');
|
|
2297
|
+
if (resolution !== 'bounced' && resolution !== 'follow-up' && resolution !== 'dismissed') {
|
|
2298
|
+
throw new SmithError('cli.invalid-flag', `--resolution must be "bounced", "follow-up" or "dismissed", got "${resolution}".`, { flag: 'resolution', value: resolution });
|
|
2299
|
+
}
|
|
2300
|
+
const stored = await resolveFeedback({
|
|
2301
|
+
feedbackId,
|
|
2302
|
+
resolution: resolution,
|
|
2303
|
+
note: flags.note,
|
|
2304
|
+
followUpTaskId: flags['follow-up-task'],
|
|
2305
|
+
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2306
|
+
printJson({ event_id: stored.event_id, record: stored.record });
|
|
2307
|
+
return 0;
|
|
2308
|
+
}
|
|
1888
2309
|
if (namespace === 'research' && action === 'check') {
|
|
1889
2310
|
const result = checkBrief(readJsonFile(requireFlag(flags, 'brief')));
|
|
1890
2311
|
printJson(result);
|
|
@@ -1909,20 +2330,23 @@ async function main() {
|
|
|
1909
2330
|
if (selectTestCmd !== undefined)
|
|
1910
2331
|
assertSelectableTestCmd(selectTestCmd);
|
|
1911
2332
|
const tasks = readJsonFile(tasksFile);
|
|
1912
|
-
// D-46/P9-29: the queue is the only component that knows a branch
|
|
1913
|
-
// and it used to say so to stdout and nowhere else — which is why
|
|
1914
|
-
// projector's `completed` column was unreachable by machine
|
|
1915
|
-
//
|
|
1916
|
-
//
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2333
|
+
// D-46/P9-29/#269: the queue is the only component that knows a branch
|
|
2334
|
+
// landed, and it used to say so to stdout and nowhere else — which is why
|
|
2335
|
+
// the projector's `completed` column was unreachable by machine, and why
|
|
2336
|
+
// a wave that followed the playbook literally still left `wave-merged`
|
|
2337
|
+
// unwritten. `--session` is what turns the run into a fact, so a run
|
|
2338
|
+
// with no session to write it into is refused before any git write
|
|
2339
|
+
// below, rather than left to merge and tell nobody.
|
|
2340
|
+
if (!flags.session) {
|
|
2341
|
+
throw new SmithError('cli.queue-run-needs-session', 'queue run merges task branches into the integration branch, so every merge must be logged: pass --session <id> --causal-parent <event-id> --plan <plan.json> (add --plan-version if the plan is not version 1).', { epic });
|
|
2342
|
+
}
|
|
2343
|
+
const events = { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) };
|
|
1920
2344
|
// `--tasks` is hand-written, so its ids are whatever was typed. Minting
|
|
1921
2345
|
// them from the plan before any git runs is the whole point of P9-29: a
|
|
1922
2346
|
// bare id here would put `wave-merged` in the log under a spelling the
|
|
1923
2347
|
// plan never used, which is how the dogfood epic folded one task instead
|
|
1924
2348
|
// of six. Refuse the run whole rather than merge some and mislabel them.
|
|
1925
|
-
if (
|
|
2349
|
+
if (!flags.plan) {
|
|
1926
2350
|
throw new SmithError('cli.missing-flag', 'queue run --session also needs --plan <plan.json>: a merge may only be logged under the task id the plan declares.', { epic });
|
|
1927
2351
|
}
|
|
1928
2352
|
// --batch (roadmap merge-lanes) groups tasks with groupForBatch before
|
|
@@ -1930,12 +2354,10 @@ async function main() {
|
|
|
1930
2354
|
// does not carry: the plan's `edges`, so a task never stacks with
|
|
1931
2355
|
// something it depends_on, and — for a tasks-file entry that omits its
|
|
1932
2356
|
// own `claims` — the plan's claim list, so a hand-typed batch run is not
|
|
1933
|
-
// blind to a disjointness `wave admit` already knows.
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
1936
|
-
|
|
1937
|
-
throw new SmithError('cli.missing-flag', 'queue run --batch also needs --plan <plan.json>: grouping reads its dependency edges and claim lists from there.', { epic });
|
|
1938
|
-
}
|
|
2357
|
+
// blind to a disjointness `wave admit` already knows. `--plan` is
|
|
2358
|
+
// unconditionally required above since #269, so a batch run with none is
|
|
2359
|
+
// already refused there.
|
|
2360
|
+
//
|
|
1939
2361
|
// `attemptCandidate` runs the epic's test command directly against the
|
|
1940
2362
|
// whole batch candidate — there is no per-task file set to narrow it to,
|
|
1941
2363
|
// so a `--select-test-cmd` template would either render nonsensically or
|
|
@@ -2073,7 +2495,10 @@ async function main() {
|
|
|
2073
2495
|
// the merge — and adopt any task with it, which is the forgery the whole
|
|
2074
2496
|
// verb exists to prevent. Deriving it from the plan-resolved id means the
|
|
2075
2497
|
// branch and the id it is logged under cannot disagree.
|
|
2076
|
-
{
|
|
2498
|
+
{
|
|
2499
|
+
taskId,
|
|
2500
|
+
branch: taskBranchName(plan.epic_id, taskId, epicBranchPrefix(projectDir, plan.epic_id)),
|
|
2501
|
+
}, {
|
|
2077
2502
|
projectDir,
|
|
2078
2503
|
epic: plan.epic_id,
|
|
2079
2504
|
mergeCommit,
|
|
@@ -2091,6 +2516,7 @@ async function main() {
|
|
|
2091
2516
|
...eventOptsFromFlags(flags),
|
|
2092
2517
|
...(flags.actor === undefined ? {} : { actor: flags.actor }),
|
|
2093
2518
|
...(flags.continues === undefined ? {} : { continues: flags.continues }),
|
|
2519
|
+
...(flags.prompt === undefined ? {} : { promptId: flags.prompt }),
|
|
2094
2520
|
}));
|
|
2095
2521
|
return 0;
|
|
2096
2522
|
}
|
|
@@ -2154,7 +2580,7 @@ async function main() {
|
|
|
2154
2580
|
process.stderr.write(`warning: this session-start is ${result.event_id}, not ${result.record.session_id}#0 — ` +
|
|
2155
2581
|
`session "${result.record.session_id}" was already open. The record is written and ` +
|
|
2156
2582
|
'durable, but a log has one root and readers take the first, so nothing will read this ' +
|
|
2157
|
-
'one. Use `
|
|
2583
|
+
'one. Use `bs session start`, which refuses instead of receipting, and chain off the ' +
|
|
2158
2584
|
'event that is already there.\n');
|
|
2159
2585
|
}
|
|
2160
2586
|
printJson({ ...result, on_timeline: onTimeline });
|
|
@@ -2394,7 +2820,7 @@ async function main() {
|
|
|
2394
2820
|
// Same refusal as `wave check`, for the same reason: validating the
|
|
2395
2821
|
// empty set answers "admissible" about a wave nobody described.
|
|
2396
2822
|
if (positional.length === 0) {
|
|
2397
|
-
throw new SmithError('cli.empty-wave', 'Usage:
|
|
2823
|
+
throw new SmithError('cli.empty-wave', 'Usage: bs claims impact --plan <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
|
|
2398
2824
|
}
|
|
2399
2825
|
const recordsById = new Map(plan.tasks.map((task) => [task.task_id, task]));
|
|
2400
2826
|
// Claims and keeps_exports are read through the one door validateWave
|
|
@@ -2562,7 +2988,7 @@ async function main() {
|
|
|
2562
2988
|
worktree: flags.worktree ?? null,
|
|
2563
2989
|
...(tier !== undefined ? { tier } : {}),
|
|
2564
2990
|
...(flags.schema !== undefined ? { schema: flags.schema } : {}),
|
|
2565
|
-
}, { policy: loadHarnessPolicy(flags.policy) });
|
|
2991
|
+
}, { policy: loadHarnessPolicy(flags.policy), planOpts: planOptsFromFlags(flags) });
|
|
2566
2992
|
printJson(invocation);
|
|
2567
2993
|
return 0;
|
|
2568
2994
|
}
|
|
@@ -2648,6 +3074,14 @@ async function main() {
|
|
|
2648
3074
|
const { runGate } = await import('./gate.js');
|
|
2649
3075
|
const [taskId] = requirePositionals(positional, usageFor('gate run'));
|
|
2650
3076
|
const worktreeDir = requireFlag(flags, 'worktree');
|
|
3077
|
+
// --project overrides; otherwise derived from --worktree, whose main
|
|
3078
|
+
// checkout IS the project (a task worktree is a git-worktree sibling,
|
|
3079
|
+
// `.wt/<project>/<task>`, not a child -- see projectDirFromFlags). Either
|
|
3080
|
+
// way this reaches the gate's own crosscheck load below, so a project's
|
|
3081
|
+
// .blacksmith/crosscheck.yml overlay (OpenRouter substitution/extra
|
|
3082
|
+
// judges) governs the quorum a gate run can trigger, not just the
|
|
3083
|
+
// shipped policy.
|
|
3084
|
+
const projectDir = projectDirFromFlags(flags);
|
|
2651
3085
|
const checks = readChecksFile(requireFlag(flags, 'checks'));
|
|
2652
3086
|
// The result file has the same two intake shapes as findings below, and for
|
|
2653
3087
|
// the same reason. With `--agent`, `--result` is the worker's half —
|
|
@@ -2656,6 +3090,13 @@ async function main() {
|
|
|
2656
3090
|
// meter, so a token count it writes is invented (D-18/P9-17). Without
|
|
2657
3091
|
// `--agent` the file is taken as a complete document, which is what a
|
|
2658
3092
|
// replay or a fixture hands over.
|
|
3093
|
+
//
|
|
3094
|
+
// `--input-tokens`/`--output-tokens` are optional, both-or-neither (#220):
|
|
3095
|
+
// a harness that ran the subprocess itself has real numbers, but a
|
|
3096
|
+
// dispatcher with no API onto a subagent's token spend — one running
|
|
3097
|
+
// inside Claude Code, say — has neither, and stampResultEnvelope turns
|
|
3098
|
+
// that absence into an honest `{measured: false}` rather than have the
|
|
3099
|
+
// caller invent a count to satisfy a mandatory flag.
|
|
2659
3100
|
const resultFile = readJsonFile(requireFlag(flags, 'result'));
|
|
2660
3101
|
const result = flags.agent
|
|
2661
3102
|
? stampResultEnvelope(resultFile, {
|
|
@@ -2663,8 +3104,8 @@ async function main() {
|
|
|
2663
3104
|
agent: flags.agent,
|
|
2664
3105
|
provider: requireFlag(flags, 'provider'),
|
|
2665
3106
|
modelTier: requireFlag(flags, 'model-tier'),
|
|
2666
|
-
inputTokens:
|
|
2667
|
-
outputTokens:
|
|
3107
|
+
inputTokens: boundedIntFlag(flags, 'input-tokens', { min: 0 }),
|
|
3108
|
+
outputTokens: boundedIntFlag(flags, 'output-tokens', { min: 0 }),
|
|
2668
3109
|
})
|
|
2669
3110
|
: resultFile;
|
|
2670
3111
|
// Two intake shapes. `--evidence` is what judges actually produce
|
|
@@ -2673,8 +3114,9 @@ async function main() {
|
|
|
2673
3114
|
// judge at this gate (D-32/P9-13). `--findings` stays for already-minted
|
|
2674
3115
|
// records (replays, fixtures, cross-check re-runs).
|
|
2675
3116
|
const findingsInput = [
|
|
2676
|
-
...(flags.findings ?
|
|
3117
|
+
...(flags.findings ? readFindingsInputFile(flags.findings) : []),
|
|
2677
3118
|
...mintFromEvidence(args, taskId),
|
|
3119
|
+
...(flags['uiux-visual'] ? mintFromUiuxVisual(flags['uiux-visual'], taskId) : []),
|
|
2678
3120
|
];
|
|
2679
3121
|
const lessons = flags.lessons ? parseLessons(readFileSync(flags.lessons, 'utf8')) : [];
|
|
2680
3122
|
const ctx = eventContextFromFlags(flags);
|
|
@@ -2709,18 +3151,24 @@ async function main() {
|
|
|
2709
3151
|
// the shape the grader's turn stayed open with its verdict on the command
|
|
2710
3152
|
// line, and the gate blocked on the judge it was about to read.
|
|
2711
3153
|
const evidenceGiven = evidenceSources(args);
|
|
2712
|
-
const graderGiven = flags.grader
|
|
2713
|
-
|
|
3154
|
+
const graderGiven = flags.grader
|
|
3155
|
+
? [{ foundBy: 'grader', file: flags.grader }]
|
|
3156
|
+
: [];
|
|
3157
|
+
const uiuxVisualGiven = flags['uiux-visual']
|
|
3158
|
+
? [{ foundBy: 'uiux', file: flags['uiux-visual'], kind: 'visual' }]
|
|
3159
|
+
: [];
|
|
3160
|
+
const handedIn = [...evidenceGiven, ...graderGiven, ...uiuxVisualGiven];
|
|
2714
3161
|
if (handedIn.length > 0) {
|
|
2715
3162
|
const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
|
|
2716
3163
|
const closed = new Set();
|
|
2717
|
-
for (const { foundBy, file } of handedIn) {
|
|
2718
|
-
|
|
3164
|
+
for (const { foundBy, file, kind } of handedIn) {
|
|
3165
|
+
const closeKey = kind ? `${foundBy}:${kind}` : foundBy;
|
|
3166
|
+
if (closed.has(closeKey))
|
|
2719
3167
|
continue;
|
|
2720
|
-
if (!turns.some((t) => t.role === foundBy && !t.reported))
|
|
3168
|
+
if (!turns.some((t) => t.role === foundBy && t.kind === (kind ?? null) && !t.reported))
|
|
2721
3169
|
continue;
|
|
2722
|
-
closed.add(
|
|
2723
|
-
await recordJudgeReport({ taskId, role: foundBy, artifactPath: file }, ctx, eventOptsFromFlags(flags));
|
|
3170
|
+
closed.add(closeKey);
|
|
3171
|
+
await recordJudgeReport({ taskId, role: foundBy, artifactPath: file, ...(kind ? { kind } : {}) }, ctx, eventOptsFromFlags(flags));
|
|
2724
3172
|
}
|
|
2725
3173
|
}
|
|
2726
3174
|
// P9-11: the genuinely clean case, said out loud. A judge that found
|
|
@@ -2733,7 +3181,7 @@ async function main() {
|
|
|
2733
3181
|
// role called "true" would close nothing (no such dispatch exists) and
|
|
2734
3182
|
// say so nowhere, so a bare --no-findings is a usage error instead.
|
|
2735
3183
|
if (role === 'true') {
|
|
2736
|
-
throw new SmithError('cli.no-findings-needs-role', '--no-findings names the judge role it attests for, e.g. --no-findings security-reviewer.', { usage: '
|
|
3184
|
+
throw new SmithError('cli.no-findings-needs-role', '--no-findings names the judge role it attests for, e.g. --no-findings security-reviewer.', { usage: 'bs gate run <task-id> --no-findings <role>' });
|
|
2737
3185
|
}
|
|
2738
3186
|
await recordJudgeReport({ taskId, role, noFindings: true }, ctx, eventOptsFromFlags(flags));
|
|
2739
3187
|
}
|
|
@@ -2762,10 +3210,75 @@ async function main() {
|
|
|
2762
3210
|
...(graderVerdict !== undefined ? { graderVerdict } : {}),
|
|
2763
3211
|
...(budget ? { budget } : {}),
|
|
2764
3212
|
...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
|
|
3213
|
+
uiAffecting: uiAffectingFromFlags(flags, taskId),
|
|
3214
|
+
crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
|
|
3215
|
+
}, ctx, eventOptsFromFlags(flags));
|
|
3216
|
+
printJson(outcome);
|
|
3217
|
+
return outcome.outcome === 'blocked' ? 1 : 0;
|
|
3218
|
+
}
|
|
3219
|
+
// The tester's screenshots reach the dashboard the moment the tester
|
|
3220
|
+
// finishes, not only at the next `gate run`. wave.md's tester has no
|
|
3221
|
+
// worktree to certify and no tests to run, so it was never handed to
|
|
3222
|
+
// `gate run` at all — and its declared artifacts never reached the
|
|
3223
|
+
// projector (`${event_id}#${index}` rows in db/projector.ts). This is the
|
|
3224
|
+
// schema-check/task-result-recorded/artifact-check third of `gate run`
|
|
3225
|
+
// exposed on its own, over the same `recordTaskResult` (gate.ts) rather
|
|
3226
|
+
// than a second copy of either check, and it never emits `gate-outcome`:
|
|
3227
|
+
// epic.ts's ungated-task check (D-138) treats that event as proof a full
|
|
3228
|
+
// gate ran, and a lean Result projection is not one.
|
|
3229
|
+
if (namespace === 'results' && action === 'record') {
|
|
3230
|
+
const { recordTaskResult } = await import('./gate.js');
|
|
3231
|
+
const taskId = requireFlag(flags, 'task');
|
|
3232
|
+
// The same two intake shapes as `gate run`'s `--result`, for the same
|
|
3233
|
+
// reason (D-18/P9-17): with `--agent` the file is the worker's half and
|
|
3234
|
+
// the dispatcher stamps token_usage, so a tester's own count never reaches
|
|
3235
|
+
// the budget reads.
|
|
3236
|
+
const resultFile = readJsonFile(requireFlag(flags, 'result'));
|
|
3237
|
+
const result = flags.agent
|
|
3238
|
+
? stampResultEnvelope(resultFile, {
|
|
3239
|
+
taskId,
|
|
3240
|
+
agent: flags.agent,
|
|
3241
|
+
provider: requireFlag(flags, 'provider'),
|
|
3242
|
+
modelTier: requireFlag(flags, 'model-tier'),
|
|
3243
|
+
inputTokens: boundedIntFlag(flags, 'input-tokens', { min: 0 }),
|
|
3244
|
+
outputTokens: boundedIntFlag(flags, 'output-tokens', { min: 0 }),
|
|
3245
|
+
})
|
|
3246
|
+
: resultFile;
|
|
3247
|
+
const ctx = eventContextFromFlags(flags);
|
|
3248
|
+
const outcome = await recordTaskResult({
|
|
3249
|
+
taskId,
|
|
3250
|
+
result,
|
|
3251
|
+
...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
|
|
3252
|
+
...(flags.worktree ? { worktreeDir: flags.worktree } : {}),
|
|
2765
3253
|
}, ctx, eventOptsFromFlags(flags));
|
|
2766
3254
|
printJson(outcome);
|
|
2767
3255
|
return outcome.outcome === 'blocked' ? 1 : 0;
|
|
2768
3256
|
}
|
|
3257
|
+
// U2 S3/D4: the preflight for the gate's uiux stage, over the same
|
|
3258
|
+
// checkUiux the stage itself calls — so a dispatcher can ask "is this task's
|
|
3259
|
+
// screenshot set fresh and reviewed yet" without staging a whole gate run,
|
|
3260
|
+
// and the two can never disagree about the same fixture.
|
|
3261
|
+
if (namespace === 'uiux' && action === 'check') {
|
|
3262
|
+
const { checkUiux } = await import('./uiuxGate.js');
|
|
3263
|
+
const taskId = requireFlag(flags, 'task');
|
|
3264
|
+
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
3265
|
+
const resolved = resolveTaskId(plan, taskId);
|
|
3266
|
+
const spec = liveSpec(plan, resolved);
|
|
3267
|
+
const ctx = eventContextFromFlags(flags);
|
|
3268
|
+
const opts = eventOptsFromFlags(flags);
|
|
3269
|
+
if (!spec || !isUiAffecting(spec)) {
|
|
3270
|
+
printJson({ outcome: 'pass' });
|
|
3271
|
+
return 0;
|
|
3272
|
+
}
|
|
3273
|
+
const head = flags.worktree ? runGit(flags.worktree, ['rev-parse', 'HEAD']) : undefined;
|
|
3274
|
+
const result = await checkUiux({
|
|
3275
|
+
taskId: resolved,
|
|
3276
|
+
...(head !== undefined ? { head } : {}),
|
|
3277
|
+
...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
|
|
3278
|
+
}, ctx, opts);
|
|
3279
|
+
printJson(result);
|
|
3280
|
+
return result.outcome === 'blocked' ? 1 : 0;
|
|
3281
|
+
}
|
|
2769
3282
|
// D-40/P9-25: the gate's coverage evidence, without staging a gate run.
|
|
2770
3283
|
// This is the verb the D-40 investigation wanted and did not have — it took
|
|
2771
3284
|
// a coverage re-run on the pre-task-4 integration branch to establish what
|
|
@@ -2812,11 +3325,21 @@ async function main() {
|
|
|
2812
3325
|
// session reports half an epic's cost as the whole of it, and reports it
|
|
2813
3326
|
// under an alarm threshold it may already have crossed.
|
|
2814
3327
|
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
3328
|
+
// Each epic is judged against budgets.yml sized for its own effort tier
|
|
3329
|
+
// (its latest plan's `effort`); the report's top-level cap is the default
|
|
3330
|
+
// tier's, and each epic line carries the cap it was judged against.
|
|
3331
|
+
const policyForEpic = epicBudgetPolicies({
|
|
3332
|
+
policyFile: flags.policy,
|
|
3333
|
+
planOpts: planOptsFromFlags(flags),
|
|
3334
|
+
});
|
|
2815
3335
|
const report = checkBudgetAlarm(events, loadBudgetPolicy(flags.policy), {
|
|
2816
3336
|
sessionId,
|
|
2817
3337
|
...(flags.epic ? { epicId: flags.epic } : {}),
|
|
3338
|
+
policyForEpic,
|
|
2818
3339
|
});
|
|
2819
|
-
const envOverrides =
|
|
3340
|
+
const envOverrides = [
|
|
3341
|
+
...new Set([undefined, ...report.epics.filter((e) => e.epicId !== '*').map((e) => e.epicId)].flatMap((epicId) => budgetEnvOverrides(loadBudgetPolicy(flags.policy, {}, epicId === undefined ? undefined : policyForEpic(epicId).tier)))),
|
|
3342
|
+
];
|
|
2820
3343
|
printJson(envOverrides.length > 0 ? { ...report, budgetEnvOverrides: envOverrides } : report);
|
|
2821
3344
|
return report.ok ? 0 : 1;
|
|
2822
3345
|
}
|
|
@@ -2869,12 +3392,26 @@ async function main() {
|
|
|
2869
3392
|
const outcome = await runEpicVerdict({
|
|
2870
3393
|
epicId,
|
|
2871
3394
|
integrationHeadSha: integrationHeadSha(projectDir, epicId),
|
|
3395
|
+
integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
|
|
2872
3396
|
mcp: mcpSurfaceFor(epicId, projectDir, flags),
|
|
2873
3397
|
goal: epicGoalFor(epicId, flags),
|
|
2874
3398
|
// D-126: the live plan is a voter. Without this the roster is the
|
|
2875
3399
|
// event-log fold alone, and a task an amendment added but nobody
|
|
2876
3400
|
// dispatched is invisible rather than unfinished.
|
|
2877
3401
|
planOpts: planOptsFromFlags(flags),
|
|
3402
|
+
// run.md step 13: tier `small` may waive an absent closing spec
|
|
3403
|
+
// review, but only `summarizeEpic` can tell an epic's tier apart
|
|
3404
|
+
// from a hole nobody filled -- resolved the same way `effort show`
|
|
3405
|
+
// resolves it, security floor included.
|
|
3406
|
+
effort: resolveEpicEffort(epicId, flags),
|
|
3407
|
+
// --project is already required above; hand it to the crosscheck
|
|
3408
|
+
// load too, so a project's .blacksmith/crosscheck.yml overlay
|
|
3409
|
+
// (OpenRouter substitution/extra judges) is what the verdict's
|
|
3410
|
+
// quorum actually reads, not just what preflight reports. No
|
|
3411
|
+
// --policy override for this command (unlike judge preflight):
|
|
3412
|
+
// the shipped/default path plus the project overlay is the only
|
|
3413
|
+
// combination epic verdict has ever read.
|
|
3414
|
+
crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
|
|
2878
3415
|
}, ctx, eventOptsFromFlags(flags));
|
|
2879
3416
|
printJson(outcome);
|
|
2880
3417
|
return outcome.outcome === 'hold' ? 1 : 0;
|
|
@@ -2891,9 +3428,17 @@ async function main() {
|
|
|
2891
3428
|
const record = await closeEpic({
|
|
2892
3429
|
epicId,
|
|
2893
3430
|
integrationHeadSha: integrationHeadSha(projectDir, epicId),
|
|
3431
|
+
integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
|
|
2894
3432
|
mcp: mcpSurfaceFor(epicId, projectDir, flags),
|
|
2895
3433
|
goal: epicGoalFor(epicId, flags),
|
|
2896
3434
|
planOpts: planOptsFromFlags(flags),
|
|
3435
|
+
// See `epic verdict` above: same tier resolution, so a close can
|
|
3436
|
+
// never waive a review its own verdict would have blocked on.
|
|
3437
|
+
effort: resolveEpicEffort(epicId, flags),
|
|
3438
|
+
// Same reasoning as `epic verdict` above: the project overlay must
|
|
3439
|
+
// reach the close's own crosscheck load too, not just the verdict
|
|
3440
|
+
// it is closing over.
|
|
3441
|
+
crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
|
|
2897
3442
|
...(flags['override-rationale'] !== undefined
|
|
2898
3443
|
? { overrideRationale: flags['override-rationale'] }
|
|
2899
3444
|
: {}),
|
|
@@ -2912,13 +3457,26 @@ async function main() {
|
|
|
2912
3457
|
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
2913
3458
|
// Evidence, not identity (interview N-2): the reviewer reports what it
|
|
2914
3459
|
// read; the plan version and the head it was read at come from here.
|
|
3460
|
+
//
|
|
3461
|
+
// Exactly one of --evidence or --no-findings (issue #249). A missing
|
|
3462
|
+
// --evidence used to read as "found nothing", so a reviewer that never
|
|
3463
|
+
// wrote its file closed the review clean. Same rule as `judge report`:
|
|
3464
|
+
// an empty review is the file saying so, or the operator attesting it.
|
|
3465
|
+
const noFindings = flags['no-findings'] === 'true';
|
|
3466
|
+
if (noFindings && flags.evidence) {
|
|
3467
|
+
throw new SmithError('cli.incompatible-flags', 'epic spec-review takes --evidence <file> or --no-findings, not both: the review either handed over its findings file or the operator attests it found nothing.', { epicId });
|
|
3468
|
+
}
|
|
3469
|
+
if (!noFindings && !flags.evidence) {
|
|
3470
|
+
throw new SmithError('cli.missing-flag', "epic spec-review needs --evidence <file> (the reviewer's findings list, `[]` when it found nothing) or --no-findings (the operator attests an empty review). A missing file is not an empty review.", { epicId, flag: 'evidence' });
|
|
3471
|
+
}
|
|
2915
3472
|
const evidence = flags.evidence ? readJsonFile(flags.evidence) : [];
|
|
2916
3473
|
// No branch, no review. The whole point of this dispatch is that it reads
|
|
2917
3474
|
// the code that now exists; recording one against a head that could not be
|
|
2918
3475
|
// read would produce a review nothing can be shown to cover.
|
|
2919
3476
|
const headSha = integrationHeadSha(projectDir, epicId);
|
|
2920
3477
|
if (headSha === null) {
|
|
2921
|
-
|
|
3478
|
+
const branch = integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId));
|
|
3479
|
+
throw new SmithError('cli.no-integration-branch', `Could not read the head of ${branch} in ${projectDir}. The closing spec review reads the assembled branch, so there is nothing to review yet.`, { epicId, projectDir });
|
|
2922
3480
|
}
|
|
2923
3481
|
const record = await recordSpecReview({
|
|
2924
3482
|
epicId,
|
|
@@ -2929,6 +3487,7 @@ async function main() {
|
|
|
2929
3487
|
? { reviewedByProvider: flags['reviewed-by-provider'] }
|
|
2930
3488
|
: {}),
|
|
2931
3489
|
evidence,
|
|
3490
|
+
...(noFindings ? { noFindings: true } : {}),
|
|
2932
3491
|
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2933
3492
|
printJson(record);
|
|
2934
3493
|
// Exit 0 even when it found something: the review ran, and a spec finding
|
|
@@ -3046,7 +3605,7 @@ async function main() {
|
|
|
3046
3605
|
// are indistinguishable to everything downstream.
|
|
3047
3606
|
const mintTaskId = specDispatch && plan ? `${plan.epic_id}/${RESERVED_TASK_ID}` : defaultTaskId;
|
|
3048
3607
|
const findingsInput = [
|
|
3049
|
-
...(flags.findings ?
|
|
3608
|
+
...(flags.findings ? readFindingsInputFile(flags.findings) : []),
|
|
3050
3609
|
// The mint id, not the default one: a spec finding belongs to the epic
|
|
3051
3610
|
// (D-33), and the dispatch's scope rides along with it so every finding
|
|
3052
3611
|
// in the batch is spec-scoped by construction.
|
|
@@ -3084,7 +3643,9 @@ async function main() {
|
|
|
3084
3643
|
// can hold the fix, because the fix is a plan amendment.
|
|
3085
3644
|
const isSpec = !result.suppressed && findingScope(result.finding) === SPEC_FINDING_SCOPE;
|
|
3086
3645
|
if (!result.suppressed && !isSpec) {
|
|
3087
|
-
|
|
3646
|
+
// The plan, when given, says which project a follow-up belongs to --
|
|
3647
|
+
// the same fallback the plan-driven task writers take (planScoped).
|
|
3648
|
+
await recordReattribution(routing, result.finding, plan?.project ? { ...ctx, project: plan.project } : ctx, opts);
|
|
3088
3649
|
}
|
|
3089
3650
|
raised.push({
|
|
3090
3651
|
findingId: result.suppressed ? null : result.finding.finding_id,
|
|
@@ -3093,6 +3654,8 @@ async function main() {
|
|
|
3093
3654
|
attribution: isSpec ? SPEC_FINDING_SCOPE : routing.attribution,
|
|
3094
3655
|
suppressed: result.suppressed,
|
|
3095
3656
|
reason: routing.reason,
|
|
3657
|
+
// A suppressed finding appended nothing, so it carries no event_id.
|
|
3658
|
+
event_id: result.suppressed ? undefined : result.event_id,
|
|
3096
3659
|
});
|
|
3097
3660
|
}
|
|
3098
3661
|
printJson(raised);
|
|
@@ -3132,11 +3695,11 @@ async function main() {
|
|
|
3132
3695
|
// Refusing here, on the status alone, keeps argument order as the reading
|
|
3133
3696
|
// order and lets the error name the verb that CAN take the edge.
|
|
3134
3697
|
const AMENDMENT_ROUTES = {
|
|
3135
|
-
[AMEND_PENDING_STATUS]: '`
|
|
3136
|
-
[AMENDED_STATUS]: '`
|
|
3698
|
+
[AMEND_PENDING_STATUS]: '`bs plan amend` puts every finding it cites into "amend-pending", with the task ids the new plan version added or superseded as the obligation',
|
|
3699
|
+
[AMENDED_STATUS]: '`bs epic close` computes which of those tasks actually landed and discharges the finding with that evidence',
|
|
3137
3700
|
};
|
|
3138
3701
|
if (newStatus in AMENDMENT_ROUTES) {
|
|
3139
|
-
throw new SmithError('cli.amendment-edge-unreachable', `"${newStatus}" is not reachable through "
|
|
3702
|
+
throw new SmithError('cli.amendment-edge-unreachable', `"${newStatus}" is not reachable through "bs findings transition": the amendment path is entered and closed by the commands that can compute its evidence, never by hand. ${AMENDMENT_ROUTES[newStatus]}.`, { status: newStatus, findingId });
|
|
3140
3703
|
}
|
|
3141
3704
|
const ctx = eventContextFromFlags(flags);
|
|
3142
3705
|
const finding = await transitionFinding(findingId, newStatus, ctx, eventOptsFromFlags(flags));
|
|
@@ -3145,20 +3708,25 @@ async function main() {
|
|
|
3145
3708
|
}
|
|
3146
3709
|
// The dispatch-time half of P9-15, shaped exactly like `lessons
|
|
3147
3710
|
// for-dispatch`: the caller composing a task prompt asks for the block and
|
|
3148
|
-
// splices it. `--plan`
|
|
3149
|
-
//
|
|
3150
|
-
// would answer "nothing is open in your files" when it
|
|
3711
|
+
// splices it. `--plan` and `--task` come as a pair: a `--task` without
|
|
3712
|
+
// `--plan` would have an empty claims list, every finding would fail the
|
|
3713
|
+
// join, and the command would answer "nothing is open in your files" when it
|
|
3714
|
+
// never looked. Neither is the epic-level shape (#248) — a role with no task
|
|
3715
|
+
// of its own (dispatch.md) gets every open finding in the epic instead.
|
|
3151
3716
|
if (namespace === 'findings' && action === 'for-dispatch') {
|
|
3152
|
-
const usage = '
|
|
3153
|
-
|
|
3154
|
-
|
|
3717
|
+
const usage = usageLine(usageFor('findings for-dispatch'));
|
|
3718
|
+
if (flags.task !== undefined)
|
|
3719
|
+
requireFlag(flags, 'plan');
|
|
3155
3720
|
const sessionId = requireFlag(flags, 'session');
|
|
3156
3721
|
const eventOpts = eventOptsFromFlags(flags);
|
|
3157
3722
|
requireSession(sessionId, eventOpts);
|
|
3158
3723
|
if (positional.length > 0) {
|
|
3159
3724
|
throw new SmithError('cli.usage', `Unexpected argument. Usage: ${usage}`, { positional });
|
|
3160
3725
|
}
|
|
3161
|
-
|
|
3726
|
+
// claimsForDispatch refuses a `--plan` with no `--task`, so half a
|
|
3727
|
+
// per-task call never falls through to the epic-wide answer.
|
|
3728
|
+
const claims = claimsForDispatch(flags);
|
|
3729
|
+
printJson(await findingsForDispatch({ sessionId, ...(flags.task === undefined ? {} : { taskId: flags.task }), claims }, eventOpts));
|
|
3162
3730
|
return 0;
|
|
3163
3731
|
}
|
|
3164
3732
|
// Re-dating a finding's evidence (P9-15). Deliberately its own verb and not
|
|
@@ -3170,8 +3738,8 @@ async function main() {
|
|
|
3170
3738
|
const ctx = eventContextFromFlags(flags);
|
|
3171
3739
|
const eventOpts = eventOptsFromFlags(flags);
|
|
3172
3740
|
requireSession(ctx.sessionId, eventOpts);
|
|
3173
|
-
await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
|
|
3174
|
-
printJson({ findingId, note: flags.note ?? '' });
|
|
3741
|
+
const reverified = await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
|
|
3742
|
+
printJson({ findingId, note: flags.note ?? '', event_id: reverified.event_id });
|
|
3175
3743
|
return 0;
|
|
3176
3744
|
}
|
|
3177
3745
|
// D-21 Part 4. Corrects a malformed amends_task_ids entry on a finding
|
|
@@ -3211,18 +3779,34 @@ async function main() {
|
|
|
3211
3779
|
printJson(pending);
|
|
3212
3780
|
return 0;
|
|
3213
3781
|
}
|
|
3782
|
+
if (namespace === 'waivers' && action === 'approve-task') {
|
|
3783
|
+
const [taskId] = requirePositionals(positional, usageFor('waivers approve-task'));
|
|
3784
|
+
const note = requireFlag(flags, 'note');
|
|
3785
|
+
const event = await approveTaskWaiver(taskId, note, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
3786
|
+
printJson(event);
|
|
3787
|
+
return 0;
|
|
3788
|
+
}
|
|
3214
3789
|
if (namespace === 'waivers' && action === 'apply') {
|
|
3215
3790
|
const [decisionsFile] = requirePositionals(positional, usageFor('waivers apply'));
|
|
3216
3791
|
const decisions = readJsonFile(decisionsFile);
|
|
3217
3792
|
const ctx = eventContextFromFlags(flags);
|
|
3218
|
-
const
|
|
3219
|
-
|
|
3793
|
+
const result = await applyBatch(decisions, ctx, eventOptsFromFlags(flags));
|
|
3794
|
+
// #221: a denial's output names the finding ids a later `plan amend
|
|
3795
|
+
// --findings` has to carry, or a denied finding stays open forever with
|
|
3796
|
+
// no discharge path left. See .claude/skills/bs/waivers.md.
|
|
3797
|
+
printJson(result);
|
|
3220
3798
|
return 0;
|
|
3221
3799
|
}
|
|
3222
3800
|
if (namespace === 'lessons' && action === 'candidates') {
|
|
3223
3801
|
const { openDb } = await import('./db/projector.js');
|
|
3224
3802
|
const { lessonsPage } = await import('./db/queries.js');
|
|
3225
3803
|
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
3804
|
+
// A named session must exist, same as `db rebuild`/`db apply` (P9-28):
|
|
3805
|
+
// without this, a typoed --session silently reads as "no rows for this
|
|
3806
|
+
// session" instead of "no such session", and --state-dir (accepted here
|
|
3807
|
+
// for exactly this) had no effect at all.
|
|
3808
|
+
if (flags.session)
|
|
3809
|
+
requireSession(flags.session, eventOptsFromFlags(flags));
|
|
3226
3810
|
const handle = openDb(dbPath);
|
|
3227
3811
|
try {
|
|
3228
3812
|
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
@@ -3297,7 +3881,7 @@ async function main() {
|
|
|
3297
3881
|
if (namespace === 'lessons' && action === 'compile') {
|
|
3298
3882
|
const { openDb } = await import('./db/projector.js');
|
|
3299
3883
|
const { lessonsPage } = await import('./db/queries.js');
|
|
3300
|
-
const { compileLessons } = await import('./lessons.js');
|
|
3884
|
+
const { compileLessons, lessonsToPreserve } = await import('./lessons.js');
|
|
3301
3885
|
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
3302
3886
|
// The operator's copy, not the package's: under an install the shipped one
|
|
3303
3887
|
// lives in node_modules, which the next `npm i` replaces wholesale. mkdir
|
|
@@ -3310,7 +3894,14 @@ async function main() {
|
|
|
3310
3894
|
try {
|
|
3311
3895
|
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
3312
3896
|
const approved = lessonsPage(handle.db, scope).approved;
|
|
3313
|
-
|
|
3897
|
+
// The store only ever tracks its OWN rows: an entry already in outPath
|
|
3898
|
+
// with no store row (hand-authored, or raised before the store existed)
|
|
3899
|
+
// is invisible to `approved` above, so compileLessons has to be handed
|
|
3900
|
+
// the file's own prior content or it has no way to tell "the store
|
|
3901
|
+
// doesn't track this" apart from "this was deleted" — see its own
|
|
3902
|
+
// docstring.
|
|
3903
|
+
const existingMarkdown = existsSync(outPath) ? readFileSync(outPath, 'utf8') : undefined;
|
|
3904
|
+
const compileInputs = approved.map((l) => ({
|
|
3314
3905
|
lessonId: l.lessonId,
|
|
3315
3906
|
lessonScope: l.lessonScope,
|
|
3316
3907
|
statement: l.statement,
|
|
@@ -3318,9 +3909,14 @@ async function main() {
|
|
|
3318
3909
|
claimPath: l.claimPath,
|
|
3319
3910
|
agentRole: l.agentRole,
|
|
3320
3911
|
caseType: l.caseType,
|
|
3321
|
-
}))
|
|
3912
|
+
}));
|
|
3913
|
+
const markdown = compileLessons(compileInputs, existingMarkdown);
|
|
3322
3914
|
writeFileSync(outPath, markdown, 'utf8');
|
|
3323
|
-
|
|
3915
|
+
// lessonsPreserved: pre-existing entries with no store row that this
|
|
3916
|
+
// compile kept verbatim rather than regenerated — the count an operator
|
|
3917
|
+
// needs to notice a hand-authored entry silently stopped surviving.
|
|
3918
|
+
const lessonsPreserved = lessonsToPreserve(compileInputs, existingMarkdown).length;
|
|
3919
|
+
printJson({ outPath, lessonsCompiled: approved.length, lessonsPreserved });
|
|
3324
3920
|
return 0;
|
|
3325
3921
|
}
|
|
3326
3922
|
finally {
|
|
@@ -3400,8 +3996,14 @@ async function main() {
|
|
|
3400
3996
|
model: requireFlag(flags, 'model'),
|
|
3401
3997
|
...(flags.provider ? { provider: flags.provider } : {}),
|
|
3402
3998
|
...(flags['model-tier'] ? { modelTier: flags['model-tier'] } : {}),
|
|
3999
|
+
// uiux only: which of its two turns this dispatch opens; recordJudgeDispatch
|
|
4000
|
+
// rejects it for any other role.
|
|
4001
|
+
...(flags.kind ? { kind: flags.kind } : {}),
|
|
3403
4002
|
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
3404
|
-
printJson(
|
|
4003
|
+
printJson({
|
|
4004
|
+
...stored,
|
|
4005
|
+
expected_line: formatDeclaredArtifactLine(requireFlag(flags, 'artifact')),
|
|
4006
|
+
});
|
|
3405
4007
|
return 0;
|
|
3406
4008
|
}
|
|
3407
4009
|
if (namespace === 'judge' && action === 'report') {
|
|
@@ -3419,16 +4021,27 @@ async function main() {
|
|
|
3419
4021
|
// Here the role is already named by --role, so the valueless spelling
|
|
3420
4022
|
// is the right one and 'true' means what it says.
|
|
3421
4023
|
...(flags['no-findings'] === 'true' ? { noFindings: true } : {}),
|
|
4024
|
+
// uiux only: which of its two open turns this closes; required when both are open.
|
|
4025
|
+
...(flags.kind ? { kind: flags.kind } : {}),
|
|
3422
4026
|
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
3423
4027
|
printJson(report);
|
|
3424
4028
|
return 0;
|
|
3425
4029
|
}
|
|
3426
4030
|
if (namespace === 'judge' && action === 'outstanding') {
|
|
3427
4031
|
const sessionId = requireFlag(flags, 'session');
|
|
4032
|
+
const taskId = requireFlag(flags, 'task');
|
|
3428
4033
|
const eventOpts = eventOptsFromFlags(flags);
|
|
3429
4034
|
requireSession(sessionId, eventOpts);
|
|
3430
|
-
|
|
3431
|
-
|
|
4035
|
+
// A typo such as `--kind visaul` must not read as "nothing outstanding":
|
|
4036
|
+
// filtering on an unrecognised kind would silently match zero turns and
|
|
4037
|
+
// exit 0 while a real one is still open (S2). Validate against the same
|
|
4038
|
+
// closed list `assertKindAllowed` checks before filtering.
|
|
4039
|
+
if (flags.kind !== undefined && !isJudgeKind(flags.kind)) {
|
|
4040
|
+
throw new JudgeError('judges.invalid-kind', `"--kind" must be "spec" or "visual"; got ${JSON.stringify(flags.kind)}.`, { kind: flags.kind });
|
|
4041
|
+
}
|
|
4042
|
+
const allOpen = outstandingJudges(await readJudgeTurns(taskId, { sessionId }, eventOpts));
|
|
4043
|
+
const open = flags.kind ? allOpen.filter((turn) => turn.kind === flags.kind) : allOpen;
|
|
4044
|
+
printJson({ taskId, sessionId, outstanding: open, count: open.length });
|
|
3432
4045
|
return open.length > 0 ? 1 : 0;
|
|
3433
4046
|
}
|
|
3434
4047
|
if (namespace === 'judge' && action === 'escalations') {
|
|
@@ -3453,7 +4066,7 @@ async function main() {
|
|
|
3453
4066
|
// Deliberately ahead of `judge run` in this file for the same reason it is
|
|
3454
4067
|
// ahead of it in the runbook: an operator reaching for a calibration call
|
|
3455
4068
|
// to find out why a provider keeps failing usually needed this instead.
|
|
3456
|
-
const report = judgePreflight(flags.policy);
|
|
4069
|
+
const report = judgePreflight(flags.policy, { projectDir: flags.project });
|
|
3457
4070
|
printJson(report);
|
|
3458
4071
|
return report.problems.length > 0 ? 1 : 0;
|
|
3459
4072
|
}
|
|
@@ -3473,7 +4086,15 @@ async function main() {
|
|
|
3473
4086
|
// the split between them is the operator mandate made operable: `request`
|
|
3474
4087
|
// shows exactly what would leave the machine WITHOUT sending it, `run`
|
|
3475
4088
|
// sends it, and `reconcile` needs no provider at all.
|
|
3476
|
-
|
|
4089
|
+
//
|
|
4090
|
+
// --project (or a --worktree to derive one from) reaches this load too,
|
|
4091
|
+
// same as `gate run`/`plan quorum`/`epic verdict`. Only `run` can ever
|
|
4092
|
+
// observe it: `independent_finder` -- the fragment `request`/`reconcile`
|
|
4093
|
+
// read -- is a repo-policy knob a project overlay is structurally
|
|
4094
|
+
// forbidden to touch (loadProjectProviderOverlay only ever merges
|
|
4095
|
+
// `providers`), and `run` is the one call that hands runIndependentFinder
|
|
4096
|
+
// the full policy, `providers` included.
|
|
4097
|
+
const policy = loadCrosscheckPolicy(flags.policy, { projectDir: projectDirFromFlags(flags) });
|
|
3477
4098
|
if (action === 'request') {
|
|
3478
4099
|
// Read-only and network-free by construction: it builds the JudgeRequest
|
|
3479
4100
|
// and prints it. Every refusal independentFinderRequest() can raise —
|
|
@@ -3580,7 +4201,7 @@ async function main() {
|
|
|
3580
4201
|
// missing feature but a silent data swap: the read path re-projects each
|
|
3581
4202
|
// session on the first request (app.ts's createRefresher), and apply()
|
|
3582
4203
|
// rebuilds the whole milestones table from a roadmap file while it is
|
|
3583
|
-
// there. Unset, that file falls back to
|
|
4204
|
+
// there. Unset, that file falls back to blacksmith's own
|
|
3584
4205
|
// factory/specs/roadmap.md — so serving another project's db showed this
|
|
3585
4206
|
// repo's roadmap from the first page load onward.
|
|
3586
4207
|
const roadmapPath = flags['roadmap-path'];
|
|
@@ -3588,13 +4209,30 @@ async function main() {
|
|
|
3588
4209
|
// request, and the plan files it consults to place an unstamped epic
|
|
3589
4210
|
// (D-246) must be the served db's, not this repo's.
|
|
3590
4211
|
const specsDir = flags['specs-dir'];
|
|
4212
|
+
// Where Claude Code keeps its live-session registry and transcripts, for
|
|
4213
|
+
// /api/cli-sessions: --claude-config-dir, then $CLAUDE_CONFIG_DIR, then
|
|
4214
|
+
// ~/.claude. Only this verb resolves a default; the server never does.
|
|
4215
|
+
const claudeFlag = flags['claude-config-dir'];
|
|
4216
|
+
const claudeEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
4217
|
+
const claude = claudeFlag
|
|
4218
|
+
? { dir: path.resolve(claudeFlag), source: 'flag' }
|
|
4219
|
+
: claudeEnv
|
|
4220
|
+
? { dir: path.resolve(claudeEnv), source: 'env' }
|
|
4221
|
+
: { dir: path.join(homedir(), '.claude'), source: 'default' };
|
|
4222
|
+
// Repeatable: extra state homes (`<dir>/state/events`) the dashboard reads
|
|
4223
|
+
// besides the live sessions' own. Read-only; see ui/server/src/stores.ts.
|
|
4224
|
+
const stores = (repeated.store ?? []).map((dir) => path.resolve(dir));
|
|
3591
4225
|
mod.serve({
|
|
3592
4226
|
port,
|
|
3593
4227
|
dbPath,
|
|
4228
|
+
...(stores.length > 0 ? { stores } : {}),
|
|
3594
4229
|
...(stateDir ? { stateDir } : {}),
|
|
3595
4230
|
...(roadmapPath ? { roadmapPath } : {}),
|
|
3596
4231
|
...(specsDir ? { specsDir } : {}),
|
|
3597
4232
|
...(nowIso !== undefined ? { nowIso } : {}),
|
|
4233
|
+
claudeConfigDir: claude.dir,
|
|
4234
|
+
claudeConfigSource: claude.source,
|
|
4235
|
+
knownRoots: [...new Set([REPO_ROOT, WORK_ROOT])],
|
|
3598
4236
|
});
|
|
3599
4237
|
return 0;
|
|
3600
4238
|
}
|
|
@@ -3699,11 +4337,63 @@ async function main() {
|
|
|
3699
4337
|
});
|
|
3700
4338
|
return 1;
|
|
3701
4339
|
}
|
|
4340
|
+
// `smith <verb> | head -c1` (or any reader that goes away before draining
|
|
4341
|
+
// stdout) closes the pipe out from under a write that has already committed
|
|
4342
|
+
// whatever the verb was reporting -- a `dispatch_decision` appended, an event
|
|
4343
|
+
// tailed, a report already computed. Node's default for an unheard stdout
|
|
4344
|
+
// `error` is to throw, which crashes the process AFTER that commit with an
|
|
4345
|
+
// uncaught `write EPIPE` stack; a caller that treats the resulting non-zero
|
|
4346
|
+
// exit as "did not happen" and retries then double-appends. `event tail
|
|
4347
|
+
// --follow` and `daemon run` already each register their own `process.stdout
|
|
4348
|
+
// .on('error', requestStop)` to stop their loop quietly (cli.ts's two other
|
|
4349
|
+
// stdout error listeners), but every one-shot verb -- everything that calls
|
|
4350
|
+
// `printJson` exactly once -- had no listener at all, so it was still one
|
|
4351
|
+
// early reader away from this crash. Registered here, at the top of the
|
|
4352
|
+
// file's only entry point, so it is in place before ANY write, including the
|
|
4353
|
+
// per-verb ones' own first `printJson` call.
|
|
4354
|
+
//
|
|
4355
|
+
// Multiple listeners on the same stream's `error` event all fire -- this one
|
|
4356
|
+
// does not replace `event tail --follow`'s or `daemon run`'s, it just also
|
|
4357
|
+
// catches everything they do not: whichever fires first, an EPIPE is still
|
|
4358
|
+
// quiet.
|
|
4359
|
+
//
|
|
4360
|
+
// A real write failure (ENOSPC, say) is not innocuous like EPIPE, but this
|
|
4361
|
+
// handler is registered before `event tail --follow`'s and `daemon run`'s
|
|
4362
|
+
// own `process.stdout.on('error', requestStop)` listeners (this file's other
|
|
4363
|
+
// two), at the top of the file's only entry point -- so if it rethrows
|
|
4364
|
+
// unconditionally, its throw fires first and those listeners never run at
|
|
4365
|
+
// all. For `daemon run` specifically, that throw becomes an uncaught
|
|
4366
|
+
// exception outside `runDaemon`'s own try/finally (daemon.ts's loop), which
|
|
4367
|
+
// skips the finally and strands the lock under a pid that is already gone --
|
|
4368
|
+
// exactly what the comment at this file's `daemon run` handler says the
|
|
4369
|
+
// listener there exists to prevent. `listenerCount` counts this listener
|
|
4370
|
+
// too, so `> 1` means one of those loop-owning listeners is also registered:
|
|
4371
|
+
// defer to it and let its own stop-the-loop handling run instead of
|
|
4372
|
+
// rethrowing here. Only when this is the sole listener -- a one-shot verb,
|
|
4373
|
+
// nothing else to stop -- does a real write failure still throw and crash,
|
|
4374
|
+
// same as before.
|
|
4375
|
+
let mainExitCode = null;
|
|
4376
|
+
process.stdout.on('error', (err) => {
|
|
4377
|
+
if (err.code === 'EPIPE') {
|
|
4378
|
+
// The command's own resolved status when we already have one (a verb
|
|
4379
|
+
// that computed its exit code before this write, the common case); 0 --
|
|
4380
|
+
// "wrote everything there was, the reader just stopped listening" --
|
|
4381
|
+
// when main() has not resolved yet, since an early EPIPE is the reader
|
|
4382
|
+
// saying "enough", not this process failing to do its job.
|
|
4383
|
+
process.exitCode = mainExitCode ?? 0;
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
if (process.stdout.listenerCount('error') > 1)
|
|
4387
|
+
return;
|
|
4388
|
+
throw err;
|
|
4389
|
+
});
|
|
3702
4390
|
main()
|
|
3703
4391
|
.then((code) => {
|
|
4392
|
+
mainExitCode = code;
|
|
3704
4393
|
process.exitCode = code;
|
|
3705
4394
|
})
|
|
3706
4395
|
.catch((err) => {
|
|
4396
|
+
mainExitCode = 1;
|
|
3707
4397
|
// A SmithError is a designed answer: its code names the failure and its
|
|
3708
4398
|
// details name the record, so a stack would only add noise. Anything else
|
|
3709
4399
|
// is a bug, and D-135 is what that costs — `{"message":"Cannot read
|