@juzser/blacksmith 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -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,19 +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';
|
|
35
|
+
import { lintDispatchPrompt } from './dispatchLint.js';
|
|
32
36
|
import { loadDotEnv } from './dotenv.js';
|
|
33
37
|
import { loadEffortPolicy, resolveEffort } from './effort.js';
|
|
38
|
+
import { budgetPolicyForPlan, epicBudgetPolicies } from './epicBudget.js';
|
|
34
39
|
import { ISSUE_CANDIDATE_EVENT_TYPES } from './errorIssues.js';
|
|
35
40
|
import { SmithError } from './errors.js';
|
|
36
41
|
import { checkEscalationLadder } from './escalation.js';
|
|
37
42
|
import { appendEvent, filterEvents, followEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
|
|
43
|
+
import { listPendingFeedback, recordFeedback, resolveFeedback, } from './feedback.js';
|
|
38
44
|
import { findingsForDispatch } from './findingContext.js';
|
|
39
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';
|
|
40
47
|
import { recordGoalCheck, resolveEpicGoal } from './goalCheck.js';
|
|
41
48
|
import { loadHarnessPolicy, planWorkerTurn, summarizeHarnesses, } from './harness.js';
|
|
42
49
|
import { decideHookPayload } from './hookDecision.js';
|
|
@@ -45,45 +52,104 @@ import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
|
|
|
45
52
|
import { integrationHeadSha, runIntegrationCheck } from './integration.js';
|
|
46
53
|
import { previewOutcomes, reportErrors } from './issueReporter.js';
|
|
47
54
|
import { judgePreflight } from './judgePreflight.js';
|
|
48
|
-
import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
|
|
55
|
+
import { isJudgeKind, JudgeError, outstandingJudges, readJudgeArtifact, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, uiuxDeviationsToEvidence, } from './judges.js';
|
|
49
56
|
import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
|
|
50
|
-
import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
|
|
51
|
-
import { diffPlans, livePlanTasks, resolveTaskId, validatePlan, } from './plan.js';
|
|
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';
|
|
52
59
|
import { runPlanQuorum } from './planQuorum.js';
|
|
53
60
|
import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
|
|
54
61
|
import { factoryProjects } from './projects.js';
|
|
62
|
+
import { captureContext, capturePrompt } from './promptCapture.js';
|
|
63
|
+
import { importTranscript } from './promptImport.js';
|
|
55
64
|
import { recordUserPrompt } from './prompts.js';
|
|
56
65
|
import { checkBrief, wrapIngested } from './provenance.js';
|
|
57
66
|
import { runJudge } from './providers/index.js';
|
|
58
|
-
import { admit, adopt, step } from './queue.js';
|
|
67
|
+
import { admit, adopt, batchStep, groupForBatch, step } from './queue.js';
|
|
59
68
|
import { stampResultEnvelope } from './results.js';
|
|
60
|
-
import { isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
|
|
69
|
+
import { FACTORY_PROJECT, isErrorTrackerWritable, loadRoadmap } from './roadmap.js';
|
|
61
70
|
import { checkRuntime } from './runtime.js';
|
|
62
71
|
import { checkSameMistakeKpi } from './sameMistakeKpi.js';
|
|
63
72
|
import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './sandbox.js';
|
|
64
73
|
import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
|
|
65
74
|
import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
|
|
66
75
|
import { parseLessons } from './severity.js';
|
|
67
|
-
import { amendPlan, recordSpecReview } from './spec.js';
|
|
76
|
+
import { amendPlan, recordSpecReview, successorChainPath, taskSuccessors } from './spec.js';
|
|
68
77
|
import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
|
|
69
78
|
import { checkStack, loadStackAnswers } from './stack.js';
|
|
70
79
|
import { buildSymbolGraph, collectSources } from './symbols.js';
|
|
71
|
-
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';
|
|
72
83
|
import { loadTaxonomy } from './taxonomy.js';
|
|
73
84
|
import { checkTesterIsolation } from './testerAudit.js';
|
|
74
85
|
import { assertSelectableTestCmd } from './testSelect.js';
|
|
75
86
|
import { COMMANDS, flagSpecFor, helpText, isDocumented, positionalNames, usageFor, usageLine, usageText, } from './usage.js';
|
|
76
|
-
import { applyBatch, pendingBatch } from './waivers.js';
|
|
87
|
+
import { applyBatch, approveTaskWaiver, pendingBatch, } from './waivers.js';
|
|
77
88
|
import { blocksAdmission, checkWaveBudget, } from './waveBudget.js';
|
|
78
89
|
import { computeNextWave, liveWaveTasks } from './waveNext.js';
|
|
79
90
|
import { initWorkRoot } from './workroot.js';
|
|
80
|
-
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';
|
|
81
92
|
function printJson(value) {
|
|
82
93
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
83
94
|
}
|
|
84
95
|
function readJsonFile(filePath) {
|
|
85
96
|
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
86
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* `--checks <file>`'s intake, both places it's read (`gate run`,
|
|
100
|
+
* `integration check`). Same fail-closed shape as `boundedIntFlag`, moved
|
|
101
|
+
* from a flag to a JSON field: a check's own `timeout_ms` goes straight into
|
|
102
|
+
* a `setTimeout` call in testgate.ts, and a non-positive or non-integer value
|
|
103
|
+
* there does not fail loudly — it fails as either an instant, unexplained
|
|
104
|
+
* timeout or one that silently never fires. Above 2^31-1 Node clamps the
|
|
105
|
+
* delay to 1ms, so an oversized value is the instant kind too. Refused here,
|
|
106
|
+
* at the point the file is read, rather than let a gate run act on either.
|
|
107
|
+
*/
|
|
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
|
+
}
|
|
141
|
+
function readChecksFile(filePath) {
|
|
142
|
+
const checks = readJsonFile(filePath);
|
|
143
|
+
for (const check of checks) {
|
|
144
|
+
const timeoutMs = check.timeout_ms;
|
|
145
|
+
if (timeoutMs === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_CHECK_TIMEOUT_MS) {
|
|
148
|
+
throw new SmithError('gate.invalid-check-timeout', `"${check.name}" in ${filePath} has timeout_ms ${JSON.stringify(timeoutMs)}; it must be a positive whole number of milliseconds, at most ${MAX_CHECK_TIMEOUT_MS}.`, { name: check.name, timeout_ms: timeoutMs, file: filePath });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return checks;
|
|
152
|
+
}
|
|
87
153
|
/** A task spec's `budget.tokens` when it is a usable positive number, else null. */
|
|
88
154
|
function declaredTokens(budget) {
|
|
89
155
|
if (typeof budget !== 'object' || budget === null)
|
|
@@ -142,6 +208,9 @@ function declaredWaveBudgets(plan, taskIds, policy) {
|
|
|
142
208
|
function admissionBudget(check, rationale) {
|
|
143
209
|
return {
|
|
144
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,
|
|
145
214
|
cap_tokens: check.capTokens,
|
|
146
215
|
projected_tokens: check.projectedTokens,
|
|
147
216
|
wave_tokens: check.waveTokens,
|
|
@@ -157,23 +226,41 @@ function requireFlag(flags, name) {
|
|
|
157
226
|
return value;
|
|
158
227
|
}
|
|
159
228
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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.
|
|
163
243
|
*/
|
|
164
|
-
function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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;
|
|
172
256
|
}
|
|
173
|
-
return value;
|
|
174
257
|
}
|
|
175
258
|
/**
|
|
176
|
-
*
|
|
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.
|
|
177
264
|
*
|
|
178
265
|
* D-210. `event tail --n` read its count with a bare Number.parseInt and
|
|
179
266
|
* checked nothing, which fails in both directions at once: `--n abc` is NaN,
|
|
@@ -333,20 +420,148 @@ function scopeIssueCandidates(events, flags) {
|
|
|
333
420
|
return since === undefined || record.ts >= since;
|
|
334
421
|
});
|
|
335
422
|
}
|
|
336
|
-
/**
|
|
423
|
+
/**
|
|
424
|
+
* Both `issues` actions read the same inputs; only the entry point differs.
|
|
425
|
+
*
|
|
426
|
+
* `resolveProject` and `selfFallback` together close the privacy-leak gap an
|
|
427
|
+
* unstamped row otherwise falls into: a session or plan driving a project
|
|
428
|
+
* other than this factory writes error/gate/task-added rows with no
|
|
429
|
+
* top-level `project` stamp. Both answer from the row's own task ref's
|
|
430
|
+
* epic's plan file (D-246's `db/projector.ts` precedent, restated here
|
|
431
|
+
* without a DB dependency `cli.ts`'s boot graph cannot carry) -- a plan on
|
|
432
|
+
* disk naming a project wins outright via `resolveProject`; a plan on disk
|
|
433
|
+
* naming none is this checkout's own epic, so `selfFallback` resolves it to
|
|
434
|
+
* `FACTORY_PROJECT`; no plan found at all (a foreign epic, or a bare ref)
|
|
435
|
+
* stays `null` from both, and `reportErrors`/`previewOutcomes` skip that row
|
|
436
|
+
* with `skipped-unresolved-project` rather than ever defaulting it into this
|
|
437
|
+
* factory's own repository.
|
|
438
|
+
*
|
|
439
|
+
* The two are kept as SEPARATE resolvers, not one combined
|
|
440
|
+
* `planProjectResolverForTaskRefOrSelf`, so `withSessionFallback`
|
|
441
|
+
* (`errorIssues.ts`) can try them as distinct tiers with a session's own
|
|
442
|
+
* stamp riding between them: `resolveProject` first, then any `project`
|
|
443
|
+
* stamp elsewhere in the same session (or a bare ref's `task-added`
|
|
444
|
+
* `epic_id` re-asked of `resolveProject`), and only then `selfFallback`
|
|
445
|
+
* (S2-c) -- a session-mate's real stamp naming a different project must
|
|
446
|
+
* outrank this weakest, locally-guessed signal, which a single combined
|
|
447
|
+
* resolver tried as one first tier could not guarantee.
|
|
448
|
+
*
|
|
449
|
+
* `--specs-dir` (via `planOptsFromFlags`) and `--state-dir` (via
|
|
450
|
+
* `eventOptsFromFlags`) are two independent flags on purpose: `--state-dir`
|
|
451
|
+
* says where THIS session's own events live, `--specs-dir` says where the
|
|
452
|
+
* epics IT references keep their plans, and a session's events and the
|
|
453
|
+
* plans its tasks belong to are not always colocated (the daemon's own
|
|
454
|
+
* event log and a target checkout's specs tree, for one). Requiring them to
|
|
455
|
+
* travel together would make `issues report` unable to point one flag at a
|
|
456
|
+
* remote-events state dir while resolving projects from this checkout's own
|
|
457
|
+
* specs/active, which is `smith`'s ordinary daemon posture. Callers who do
|
|
458
|
+
* mean the same tree for both pass the same path to both flags.
|
|
459
|
+
*
|
|
460
|
+
* `lineageEvents` is the session's UNSCOPED lineage, read before
|
|
461
|
+
* `scopeIssueCandidates` narrows `events` to `--epic`/`--since` (S3):
|
|
462
|
+
* `ORIGIN_STAMP_EVENT_TYPES` overlaps the scoped `ISSUE_CANDIDATE_EVENT_TYPES`
|
|
463
|
+
* set, so `sessionProjectStamps` reading straight off the narrowed `events`
|
|
464
|
+
* could lose a session-mate's stamp purely because `--epic`/`--since`
|
|
465
|
+
* excluded that row, changing an unrelated in-scope row's resolved project
|
|
466
|
+
* -- and therefore its fingerprint -- based only on which scoping flags were
|
|
467
|
+
* passed. `reportErrors`/`previewOutcomes` fold `events` for candidates but
|
|
468
|
+
* consult `lineageEvents` for the session-stamp tier, so `--epic`/`--since`
|
|
469
|
+
* narrow what gets reported without perturbing how a row's project is
|
|
470
|
+
* resolved.
|
|
471
|
+
*/
|
|
337
472
|
async function issueInputs(flags) {
|
|
338
473
|
const sessionId = requireFlag(flags, 'session');
|
|
339
474
|
const eventOpts = eventOptsFromFlags(flags);
|
|
340
475
|
requireSession(sessionId, eventOpts);
|
|
341
|
-
const
|
|
476
|
+
const lineageEvents = await readLineageEvents(sessionId, eventOpts);
|
|
477
|
+
const events = scopeIssueCandidates(lineageEvents, flags);
|
|
342
478
|
const milestones = loadRoadmap(flags['roadmap-path']);
|
|
343
479
|
const isEnabled = (project) => isErrorTrackerWritable(milestones, project);
|
|
344
|
-
|
|
480
|
+
const planOpts = planOptsFromFlags(flags);
|
|
481
|
+
const resolveProject = planProjectResolverForTaskRef(planOpts);
|
|
482
|
+
const selfFallback = planSelfFallbackForTaskRef(FACTORY_PROJECT, planOpts);
|
|
483
|
+
return {
|
|
484
|
+
events,
|
|
485
|
+
isEnabled,
|
|
486
|
+
register: factoryProjects(),
|
|
487
|
+
eventOpts,
|
|
488
|
+
resolveProject,
|
|
489
|
+
selfFallback,
|
|
490
|
+
lineageEvents,
|
|
491
|
+
};
|
|
345
492
|
}
|
|
346
|
-
/**
|
|
493
|
+
/**
|
|
494
|
+
* Where plan version files are read from and written to; defaults to
|
|
495
|
+
* factory/specs/active. See `issueInputs`'s doc comment for why this
|
|
496
|
+
* deliberately does not travel with `--state-dir`.
|
|
497
|
+
*/
|
|
347
498
|
function planOptsFromFlags(flags) {
|
|
348
499
|
return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
|
|
349
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
|
+
}
|
|
350
565
|
/**
|
|
351
566
|
* The one reader for every flag usage.ts documents as `<iso>` (D-209).
|
|
352
567
|
*
|
|
@@ -403,10 +618,10 @@ function isoDateFlag(flags, name) {
|
|
|
403
618
|
* `event append` writes.
|
|
404
619
|
*
|
|
405
620
|
* `--roadmap-path` belongs here too: both verbs rebuild the whole milestones
|
|
406
|
-
* 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
|
|
407
622
|
* factory/specs/roadmap.md. Dropping the flag therefore did not mean "keep the
|
|
408
623
|
* existing milestones" — it meant "replace this db's milestones with
|
|
409
|
-
*
|
|
624
|
+
* blacksmith's", which is a silent data swap for any db but this repo's.
|
|
410
625
|
*
|
|
411
626
|
* `--specs-dir` travels for the same reason and is the same flag `plan`
|
|
412
627
|
* already takes: the projector reads an epic's plan file to answer what
|
|
@@ -574,6 +789,25 @@ function mintFromEvidence(args, taskId, scope = {}) {
|
|
|
574
789
|
...scope,
|
|
575
790
|
}));
|
|
576
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
|
+
}
|
|
577
811
|
/**
|
|
578
812
|
* The gated task's declared caps, read off the same `--plan` the gate already
|
|
579
813
|
* takes for ownership (D-41/P9-24) — no new flag, because a budget the gate can
|
|
@@ -598,9 +832,33 @@ function budgetFromFlags(flags, taskId) {
|
|
|
598
832
|
return undefined;
|
|
599
833
|
throw err;
|
|
600
834
|
}
|
|
601
|
-
const budget = plan
|
|
835
|
+
const budget = liveSpec(plan, resolved)?.budget;
|
|
602
836
|
return typeof budget === 'object' && budget !== null ? budget : undefined;
|
|
603
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
|
+
}
|
|
604
862
|
/**
|
|
605
863
|
* The claim globs one dispatch is scoped to, read off the plan (P9-2). Only
|
|
606
864
|
* claim-path-scoped lessons use them, so a role that declares no such scope
|
|
@@ -612,7 +870,21 @@ function claimsForDispatch(flags) {
|
|
|
612
870
|
if (!flags.plan)
|
|
613
871
|
return [];
|
|
614
872
|
const taskId = requireFlag(flags, 'task');
|
|
615
|
-
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);
|
|
616
888
|
if (!owner) {
|
|
617
889
|
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
618
890
|
taskId,
|
|
@@ -636,7 +908,21 @@ function caseForDispatch(flags) {
|
|
|
636
908
|
if (!flags.plan)
|
|
637
909
|
return '';
|
|
638
910
|
const taskId = requireFlag(flags, 'task');
|
|
639
|
-
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);
|
|
640
926
|
if (!task) {
|
|
641
927
|
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
642
928
|
taskId,
|
|
@@ -694,6 +980,47 @@ function splitNamespaceAction(argv) {
|
|
|
694
980
|
}
|
|
695
981
|
return { namespace, action: undefined, rest: restAll };
|
|
696
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
|
+
}
|
|
697
1024
|
/**
|
|
698
1025
|
* Everything a wave computation reads about a plan: the plan itself with the
|
|
699
1026
|
* log's follow-ups merged in, the worktree policy, the import crossings
|
|
@@ -732,8 +1059,19 @@ async function nextWaveInputFrom(planFile, flags) {
|
|
|
732
1059
|
? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
|
|
733
1060
|
: [];
|
|
734
1061
|
const planIds = new Set(planOnDisk.tasks.map((t) => t.task_id));
|
|
1062
|
+
// A lineage is not one epic. `readAddedTasks` reads the WHOLE lineage
|
|
1063
|
+
// (D-119), so a resumed session that ever ran a different, earlier epic
|
|
1064
|
+
// carries that epic's `task-added` rows too — and a row an operator
|
|
1065
|
+
// abandoned non-terminal (never merged, waived, or superseded) reads back
|
|
1066
|
+
// here exactly like a genuine follow-up of THIS plan. Unfiltered, it was
|
|
1067
|
+
// spliced into `plan.tasks` below and reached `computeNextWave` as a
|
|
1068
|
+
// candidate or an occupied claim-holder: proposed as this epic's own work,
|
|
1069
|
+
// and able to defer this epic's tasks on a claim overlap that has nothing
|
|
1070
|
+
// to do with them. `wave check` never has this problem because it only
|
|
1071
|
+
// ever looks at the ids an operator names — matching that, a follow-up
|
|
1072
|
+
// only counts here when its own `task-added` named this plan's epic.
|
|
735
1073
|
const followUps = logged
|
|
736
|
-
.filter((t) => !planIds.has(t.taskId))
|
|
1074
|
+
.filter((t) => !planIds.has(t.taskId) && t.epicId === planOnDisk.epic_id)
|
|
737
1075
|
.map((t) => ({
|
|
738
1076
|
task_id: t.taskId,
|
|
739
1077
|
// The log is the only register that holds this task's status, so a
|
|
@@ -770,6 +1108,10 @@ async function nextWaveInputFrom(planFile, flags) {
|
|
|
770
1108
|
};
|
|
771
1109
|
}
|
|
772
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');
|
|
773
1115
|
// Refuse an unsupported runtime before anything opens the database. The
|
|
774
1116
|
// native binding crashes lazily — `new Database()`, not import — so a check
|
|
775
1117
|
// here still runs, and a subcommand that happens to avoid SQLite must not
|
|
@@ -779,7 +1121,7 @@ async function main() {
|
|
|
779
1121
|
if (!runtime.supported) {
|
|
780
1122
|
// Prose on stderr so a human sees it even when stdout is piped into a
|
|
781
1123
|
// JSON parser; the structured form still goes to stdout for callers.
|
|
782
|
-
process.stderr.write(`
|
|
1124
|
+
process.stderr.write(`bs: unsupported runtime\n${runtime.reason ?? ''}\n`);
|
|
783
1125
|
printJson({ error: { code: 'unsupported-runtime', message: runtime.reason } });
|
|
784
1126
|
return 1;
|
|
785
1127
|
}
|
|
@@ -898,7 +1240,20 @@ async function main() {
|
|
|
898
1240
|
}
|
|
899
1241
|
if (namespace === 'audit' && action === 'resolve') {
|
|
900
1242
|
const [projectDir] = requirePositionals(positional, usageFor('audit resolve'));
|
|
901
|
-
|
|
1243
|
+
// Comma-split, not repeated: a fingerprint has no commas, and deferring
|
|
1244
|
+
// more than one at a time is the common case (D-33's precedent, `plan
|
|
1245
|
+
// amend --findings`).
|
|
1246
|
+
const except = flags.except
|
|
1247
|
+
? flags.except
|
|
1248
|
+
.split(',')
|
|
1249
|
+
.map((fp) => fp.trim())
|
|
1250
|
+
.filter((fp) => fp !== '')
|
|
1251
|
+
: undefined;
|
|
1252
|
+
printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags), {
|
|
1253
|
+
...(except ? { except } : {}),
|
|
1254
|
+
...(flags.plan ? { plan: readJsonFile(flags.plan) } : {}),
|
|
1255
|
+
planOpts: planOptsFromFlags(flags),
|
|
1256
|
+
}));
|
|
902
1257
|
return 0;
|
|
903
1258
|
}
|
|
904
1259
|
if (namespace === 'audit' && action === 'close') {
|
|
@@ -911,7 +1266,25 @@ async function main() {
|
|
|
911
1266
|
// requirePositionals is what makes it true, and it runs first.
|
|
912
1267
|
const [planFile] = requirePositionals(positional, usageFor('plan validate'));
|
|
913
1268
|
const plan = readJsonFile(planFile);
|
|
914
|
-
|
|
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 } : {});
|
|
915
1288
|
printJson(result);
|
|
916
1289
|
return result.valid ? 0 : 1;
|
|
917
1290
|
}
|
|
@@ -973,6 +1346,13 @@ async function main() {
|
|
|
973
1346
|
...(draft ? { plan: draft } : {}),
|
|
974
1347
|
...(plannerConfidence !== undefined ? { plannerConfidence } : {}),
|
|
975
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 }) },
|
|
976
1356
|
}, ctx, eventOptsFromFlags(flags));
|
|
977
1357
|
printJson(outcome);
|
|
978
1358
|
// FD-49: the outcome is a few rationales of several kB each, and the
|
|
@@ -995,7 +1375,12 @@ async function main() {
|
|
|
995
1375
|
const plan = readJsonFile(planFile);
|
|
996
1376
|
const ctx = eventContextFromFlags(flags);
|
|
997
1377
|
const opts = eventOptsFromFlags(flags);
|
|
998
|
-
|
|
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);
|
|
999
1384
|
// D-254: the arrows, after the nodes. The plan declares a DAG and the
|
|
1000
1385
|
// scheduler has always read it off the file, but nothing wrote it to the
|
|
1001
1386
|
// log -- so the db's `edges` table was empty and every operator-facing
|
|
@@ -1004,6 +1389,19 @@ async function main() {
|
|
|
1004
1389
|
// what went unnoticed.
|
|
1005
1390
|
const edges = await emitEdgesRecorded(plan, ctx, opts);
|
|
1006
1391
|
const added = written.filter((e) => e.record.event_type === 'task-added').length;
|
|
1392
|
+
// Criterion 7: a plan that serializes by claim geometry alone used to be
|
|
1393
|
+
// invisible until `/bs run` actually hit the ceiling. Dynamic for the
|
|
1394
|
+
// same reason `wave schedule` imports it dynamically -- nothing an
|
|
1395
|
+
// ingest that never schedules needs should be on the boot path every
|
|
1396
|
+
// `smith --help` walks. No repo scan here: `parallel_with` is
|
|
1397
|
+
// claims-and-edges only, so the input carries no crossings.
|
|
1398
|
+
const { computePlanParallelism } = await import('./waveSchedule.js');
|
|
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];
|
|
1007
1405
|
printJson({
|
|
1008
1406
|
epic: plan.epic_id,
|
|
1009
1407
|
version: plan.version,
|
|
@@ -1011,6 +1409,8 @@ async function main() {
|
|
|
1011
1409
|
superseded: written.length - added,
|
|
1012
1410
|
skipped: plan.tasks.length - added,
|
|
1013
1411
|
edges: edges.length,
|
|
1412
|
+
parallelism,
|
|
1413
|
+
...(lastEvent ? { event_id: lastEvent.event_id } : {}),
|
|
1014
1414
|
});
|
|
1015
1415
|
return 0;
|
|
1016
1416
|
}
|
|
@@ -1018,7 +1418,8 @@ async function main() {
|
|
|
1018
1418
|
// to cut a version that cites no spec finding, so "the plan changed" is
|
|
1019
1419
|
// always answerable with "which finding said it was wrong".
|
|
1020
1420
|
if (namespace === 'plan' && action === 'amend') {
|
|
1021
|
-
const
|
|
1421
|
+
const planPath = requireFlag(flags, 'plan');
|
|
1422
|
+
const plan = readJsonFile(planPath);
|
|
1022
1423
|
// Comma-split, not repeated: a finding id has no commas, and an amendment
|
|
1023
1424
|
// routinely cites several at once.
|
|
1024
1425
|
const findingIds = requireFlag(flags, 'findings')
|
|
@@ -1038,7 +1439,7 @@ async function main() {
|
|
|
1038
1439
|
rationale: requireFlag(flags, 'rationale'),
|
|
1039
1440
|
sites,
|
|
1040
1441
|
...(changes ? { changes } : {}),
|
|
1041
|
-
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...
|
|
1442
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
|
|
1042
1443
|
// No no-op warning here any more. A pure carry-forward used to be legal
|
|
1043
1444
|
// and merely suspicious — the shape a forgotten --changes takes — so the
|
|
1044
1445
|
// CLI printed a warning and cut the version anyway. Since D-127 an
|
|
@@ -1059,6 +1460,11 @@ async function main() {
|
|
|
1059
1460
|
// no task behind it is a deliberate call or a forgotten one.
|
|
1060
1461
|
sitesUnclaimed: result.sitesUnclaimed,
|
|
1061
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,
|
|
1062
1468
|
});
|
|
1063
1469
|
return 0;
|
|
1064
1470
|
}
|
|
@@ -1108,13 +1514,17 @@ async function main() {
|
|
|
1108
1514
|
// sites. Approval supplies them from what the worker already recorded.
|
|
1109
1515
|
if (namespace === 'plan' && action === 'approve') {
|
|
1110
1516
|
const [proposalId] = requirePositionals(positional, usageFor('plan approve'));
|
|
1111
|
-
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.
|
|
1112
1522
|
const result = await approveSpecChange({
|
|
1113
1523
|
proposalId,
|
|
1114
1524
|
plan,
|
|
1115
1525
|
decidedBy: requireFlag(flags, 'decided-by'),
|
|
1116
1526
|
...(flags.rationale ? { rationale: flags.rationale } : {}),
|
|
1117
|
-
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...
|
|
1527
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...amendSpecsDirOpts(planPath, plan, flags) });
|
|
1118
1528
|
printJson({
|
|
1119
1529
|
proposalId,
|
|
1120
1530
|
epic: result.plan.epic_id,
|
|
@@ -1124,6 +1534,7 @@ async function main() {
|
|
|
1124
1534
|
sites: result.proposal.sites,
|
|
1125
1535
|
sitesUnclaimed: result.sitesUnclaimed,
|
|
1126
1536
|
diff: result.diff,
|
|
1537
|
+
event_id: result.event_id,
|
|
1127
1538
|
});
|
|
1128
1539
|
return 0;
|
|
1129
1540
|
}
|
|
@@ -1153,7 +1564,7 @@ async function main() {
|
|
|
1153
1564
|
// at. resolveTaskId turns that silence into a named error.
|
|
1154
1565
|
const typedIds = positional.slice(1);
|
|
1155
1566
|
if (typedIds.length === 0) {
|
|
1156
|
-
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 });
|
|
1157
1568
|
}
|
|
1158
1569
|
// D-48/P9-31: a follow-up task exists only in the log, so the log is the
|
|
1159
1570
|
// second register an id may be found in — and the place its claims come
|
|
@@ -1163,6 +1574,8 @@ async function main() {
|
|
|
1163
1574
|
? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
|
|
1164
1575
|
: [];
|
|
1165
1576
|
const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged.map((t) => t.taskId)));
|
|
1577
|
+
if (flags.session)
|
|
1578
|
+
await refuseUnadmissibleTasks(plan, taskIds, flags);
|
|
1166
1579
|
// Neither register is narrowed on the way in. `?? []` used to stand here
|
|
1167
1580
|
// and on the line above, and it fires on a missing claims field but not on
|
|
1168
1581
|
// a claims field holding the wrong thing: a plan writing `"claims":
|
|
@@ -1230,7 +1643,9 @@ async function main() {
|
|
|
1230
1643
|
// the sum of every session that worked on it.
|
|
1231
1644
|
await readLineageEvents(flags.session, eventOptsFromFlags(flags))
|
|
1232
1645
|
: [];
|
|
1233
|
-
|
|
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'] });
|
|
1234
1649
|
const budget = checkWaveBudget(budgetEvents, budgetPolicy, declaredWaveBudgets(plan, taskIds, budgetPolicy), { sessionId: flags.session ?? '', epicId: plan.epic_id });
|
|
1235
1650
|
const overridden = rationale !== '' && blocksAdmission(budget.status);
|
|
1236
1651
|
const blocked = blocksAdmission(budget.status) && !overridden;
|
|
@@ -1244,7 +1659,7 @@ async function main() {
|
|
|
1244
1659
|
}
|
|
1245
1660
|
// Names only, and only when some differ from budgets.yml: which caps came
|
|
1246
1661
|
// from the box's env rather than the file.
|
|
1247
|
-
const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}));
|
|
1662
|
+
const envOverrides = budgetEnvOverrides(loadBudgetPolicy(flags['budget-policy'], {}, budgetPolicy.tier));
|
|
1248
1663
|
printJson({
|
|
1249
1664
|
...result,
|
|
1250
1665
|
symbolImpact,
|
|
@@ -1280,9 +1695,13 @@ async function main() {
|
|
|
1280
1695
|
// Static would be fine — waveSchedule.js reaches nothing waveNext.js has
|
|
1281
1696
|
// not already put on the boot path — but the input assembly it is handed
|
|
1282
1697
|
// may dynamically import `db/projector.js`, so the await is here anyway.
|
|
1283
|
-
const { scheduleWaves } = await import('./waveSchedule.js');
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1698
|
+
const { computePlanParallelism, scheduleWaves } = await import('./waveSchedule.js');
|
|
1699
|
+
const input = await nextWaveInputFrom(planFile, flags);
|
|
1700
|
+
const schedule = scheduleWaves(input);
|
|
1701
|
+
// Same `size` block `plan ingest` prints under `parallelism`, so the
|
|
1702
|
+
// operator sees the small-epic bound from either command.
|
|
1703
|
+
const { size } = computePlanParallelism(input.plan, input.policy);
|
|
1704
|
+
printJson({ ...schedule, size });
|
|
1286
1705
|
// Writes nothing, for the reason `wave next` writes nothing and one more:
|
|
1287
1706
|
// every round after the first is a simulation. The tasks it marks complete
|
|
1288
1707
|
// were completed by nobody, and a log that recorded them would be claiming
|
|
@@ -1401,7 +1820,21 @@ async function main() {
|
|
|
1401
1820
|
const projectDirs = resolveProjectDirs(repeated.project, {
|
|
1402
1821
|
self: flags['no-self'] !== 'true',
|
|
1403
1822
|
});
|
|
1404
|
-
|
|
1823
|
+
// Same resolver `issues report`/`preview` and the daemon use (D-246): an
|
|
1824
|
+
// error-report proposal's fingerprint is hashed over its `project`
|
|
1825
|
+
// (computeFingerprint), so this verb has to resolve a row's project the
|
|
1826
|
+
// same way the reporter that later clears the proposal does. A divergent
|
|
1827
|
+
// resolver here -- or none, which folds every row to `project: null` --
|
|
1828
|
+
// hashes a different fingerprint than the reporter's `issue-reported`
|
|
1829
|
+
// event names, so the proposal `scheduler run` keeps proposing never
|
|
1830
|
+
// matches the one already answered and is re-appended forever (S1-b).
|
|
1831
|
+
const input = {
|
|
1832
|
+
events,
|
|
1833
|
+
now,
|
|
1834
|
+
projectDirs,
|
|
1835
|
+
resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
|
|
1836
|
+
selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
|
|
1837
|
+
};
|
|
1405
1838
|
if (dry) {
|
|
1406
1839
|
printJson({ proposals: computeProposals(input) });
|
|
1407
1840
|
return 0;
|
|
@@ -1424,7 +1857,8 @@ async function main() {
|
|
|
1424
1857
|
// the whole safety property: an operator can read what the policy would
|
|
1425
1858
|
// have let run, and argue with it, before any of it moves. So this appends
|
|
1426
1859
|
// no event, writes no worktree and starts no agent -- exactly the daemon's
|
|
1427
|
-
// 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.
|
|
1428
1862
|
if (namespace === 'scheduler' && action === 'admit') {
|
|
1429
1863
|
const { computeProposals, loadSchedulerPolicy } = await import('./scheduler.js');
|
|
1430
1864
|
const { admitProposals } = await import('./autonomy.js');
|
|
@@ -1448,11 +1882,17 @@ async function main() {
|
|
|
1448
1882
|
// This clone joins the pass by default (contract clause 1); `--no-self`
|
|
1449
1883
|
// is the refusal path.
|
|
1450
1884
|
const { resolveProjectDirs } = await import('./projects.js');
|
|
1885
|
+
// Same resolver as `scheduler run` above, for the same reason (S1-b): a
|
|
1886
|
+
// divergent or absent resolver hashes a different fingerprint than the
|
|
1887
|
+
// reporter's `issue-reported` event names, so an already-answered
|
|
1888
|
+
// error-report proposal never clears.
|
|
1451
1889
|
const proposals = computeProposals({
|
|
1452
1890
|
events,
|
|
1453
1891
|
now,
|
|
1454
1892
|
policy,
|
|
1455
1893
|
projectDirs: resolveProjectDirs(repeated.project, { self: flags['no-self'] !== 'true' }),
|
|
1894
|
+
resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
|
|
1895
|
+
selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
|
|
1456
1896
|
});
|
|
1457
1897
|
// A RecheckProposal names a task and no paths, so without this the
|
|
1458
1898
|
// security match would see an opaque id and clear a recheck of
|
|
@@ -1501,6 +1941,16 @@ async function main() {
|
|
|
1501
1941
|
...eventOptsFromFlags(flags),
|
|
1502
1942
|
projectDirs: resolveProjectDirs(repeated.project, { self }),
|
|
1503
1943
|
readProjects: () => factoryProjects().filter((ref) => self || !ref.self),
|
|
1944
|
+
// Same D-246 self-fallback as issueInputs (see its doc comment): a
|
|
1945
|
+
// plan on disk naming no project is this checkout's own epic, so it
|
|
1946
|
+
// reads as FACTORY_PROJECT rather than staying an unresolved null the
|
|
1947
|
+
// daemon would report as an unlabeled finding and the on/off switch
|
|
1948
|
+
// for 'blacksmith' would never get asked about. Split into strict
|
|
1949
|
+
// (resolveProjectForTaskRef) and self-only (selfFallbackForTaskRef)
|
|
1950
|
+
// tiers so a session's own stamp can ride between them (S2-c).
|
|
1951
|
+
resolveProjectForTaskRef: planProjectResolverForTaskRef(planOptsFromFlags(flags)),
|
|
1952
|
+
selfFallbackForTaskRef: planSelfFallbackForTaskRef(FACTORY_PROJECT, planOptsFromFlags(flags)),
|
|
1953
|
+
planOpts: planOptsFromFlags(flags),
|
|
1504
1954
|
...(flags.db ? { dbPath: flags.db } : {}),
|
|
1505
1955
|
...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
|
|
1506
1956
|
};
|
|
@@ -1556,7 +2006,7 @@ async function main() {
|
|
|
1556
2006
|
// Let acquireLock own the liveness question — one implementation of
|
|
1557
2007
|
// "is that pid still there" rather than a second opinion here.
|
|
1558
2008
|
throw new DaemonError('daemon.already-running', `A daemon (pid ${held.pid}, started ${held.startedAt}) already holds ${dir}. ` +
|
|
1559
|
-
'Run `
|
|
2009
|
+
'Run `bs daemon stop` first, or `bs daemon status` to see what it last found.', { pid: held.pid, dir });
|
|
1560
2010
|
}
|
|
1561
2011
|
mkdirSync(dir, { recursive: true });
|
|
1562
2012
|
// `ignore` would discard the one account of why a detached daemon died.
|
|
@@ -1636,6 +2086,46 @@ async function main() {
|
|
|
1636
2086
|
}
|
|
1637
2087
|
if (namespace === 'worktree' && action === 'create') {
|
|
1638
2088
|
const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree create'));
|
|
2089
|
+
if (flags.from !== undefined) {
|
|
2090
|
+
// `--from` reuses a predecessor's commits, so it may only be spent on a
|
|
2091
|
+
// task the log actually names as that predecessor's successor — never
|
|
2092
|
+
// on a bare "I want the same branch history" request. `--session` is
|
|
2093
|
+
// what supplies the log to check against; without it there is nothing
|
|
2094
|
+
// to refuse against, so demand it explicitly rather than silently
|
|
2095
|
+
// trusting an unauthenticated `--from`.
|
|
2096
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2097
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2098
|
+
const successors = taskSuccessors(events, epic);
|
|
2099
|
+
const from = flags.from;
|
|
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) {
|
|
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 });
|
|
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
|
+
}
|
|
2126
|
+
printJson(createTaskWorktree(projectDir, epic, taskId, { from }));
|
|
2127
|
+
return 0;
|
|
2128
|
+
}
|
|
1639
2129
|
printJson(createTaskWorktree(projectDir, epic, taskId));
|
|
1640
2130
|
return 0;
|
|
1641
2131
|
}
|
|
@@ -1712,6 +2202,110 @@ async function main() {
|
|
|
1712
2202
|
printJson({ event_id: stored.event_id, record: stored.record });
|
|
1713
2203
|
return 0;
|
|
1714
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
|
+
}
|
|
1715
2309
|
if (namespace === 'research' && action === 'check') {
|
|
1716
2310
|
const result = checkBrief(readJsonFile(requireFlag(flags, 'brief')));
|
|
1717
2311
|
printJson(result);
|
|
@@ -1736,22 +2330,45 @@ async function main() {
|
|
|
1736
2330
|
if (selectTestCmd !== undefined)
|
|
1737
2331
|
assertSelectableTestCmd(selectTestCmd);
|
|
1738
2332
|
const tasks = readJsonFile(tasksFile);
|
|
1739
|
-
// D-46/P9-29: the queue is the only component that knows a branch
|
|
1740
|
-
// and it used to say so to stdout and nowhere else — which is why
|
|
1741
|
-
// projector's `completed` column was unreachable by machine
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
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) };
|
|
1747
2344
|
// `--tasks` is hand-written, so its ids are whatever was typed. Minting
|
|
1748
2345
|
// them from the plan before any git runs is the whole point of P9-29: a
|
|
1749
2346
|
// bare id here would put `wave-merged` in the log under a spelling the
|
|
1750
2347
|
// plan never used, which is how the dogfood epic folded one task instead
|
|
1751
2348
|
// of six. Refuse the run whole rather than merge some and mislabel them.
|
|
1752
|
-
if (
|
|
2349
|
+
if (!flags.plan) {
|
|
1753
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 });
|
|
1754
2351
|
}
|
|
2352
|
+
// --batch (roadmap merge-lanes) groups tasks with groupForBatch before
|
|
2353
|
+
// any of them run, and that grouping needs two things `--tasks` alone
|
|
2354
|
+
// does not carry: the plan's `edges`, so a task never stacks with
|
|
2355
|
+
// something it depends_on, and — for a tasks-file entry that omits its
|
|
2356
|
+
// own `claims` — the plan's claim list, so a hand-typed batch run is not
|
|
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
|
+
//
|
|
2361
|
+
// `attemptCandidate` runs the epic's test command directly against the
|
|
2362
|
+
// whole batch candidate — there is no per-task file set to narrow it to,
|
|
2363
|
+
// so a `--select-test-cmd` template would either render nonsensically or
|
|
2364
|
+
// silently run the full suite while the outcome claimed it was selected
|
|
2365
|
+
// (the same lie D-260 refuses above). Refuse the combination instead of
|
|
2366
|
+
// ignoring the flag.
|
|
2367
|
+
if (flags.batch === 'true' && selectTestCmd !== undefined) {
|
|
2368
|
+
throw new SmithError('cli.incompatible-flags', 'queue run --batch does not support --select-test-cmd: a batch candidate has no single task to narrow the test command to.', { epic });
|
|
2369
|
+
}
|
|
2370
|
+
let edges = [];
|
|
2371
|
+
let claimsById = new Map();
|
|
1755
2372
|
if (flags.plan) {
|
|
1756
2373
|
const plan = readJsonFile(flags.plan);
|
|
1757
2374
|
// …and the plan is not the only register: a follow-up minted by
|
|
@@ -1763,6 +2380,11 @@ async function main() {
|
|
|
1763
2380
|
const loggedIds = logged.map((t) => t.taskId);
|
|
1764
2381
|
for (const task of tasks)
|
|
1765
2382
|
task.taskId = resolveTaskId(plan, task.taskId, loggedIds);
|
|
2383
|
+
edges = plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn }));
|
|
2384
|
+
claimsById = new Map(plan.tasks.map((t) => [
|
|
2385
|
+
t.task_id,
|
|
2386
|
+
Array.isArray(t.claims) ? t.claims.filter((c) => typeof c === 'string') : [],
|
|
2387
|
+
]));
|
|
1766
2388
|
// D-186: the ids are the plan's now, so the order can be too. `--tasks`
|
|
1767
2389
|
// is hand-written, and merging in the order someone typed lets a task
|
|
1768
2390
|
// land before the task it declares `depends_on` — the epic's cumulative
|
|
@@ -1770,21 +2392,81 @@ async function main() {
|
|
|
1770
2392
|
// and blames the wrong branch when it goes red. `admit()` exists for
|
|
1771
2393
|
// exactly this and had no caller until here; a follow-up the plan never
|
|
1772
2394
|
// declared carries no edges, so it just sorts by id among its peers.
|
|
1773
|
-
const order = admit(tasks.map((t) => ({ task_id: t.taskId })),
|
|
2395
|
+
const order = admit(tasks.map((t) => ({ task_id: t.taskId })), edges);
|
|
1774
2396
|
// Stable: two tasks the plan does not order keep the order admit gave
|
|
1775
2397
|
// them, and a duplicated id is still run twice rather than dropped.
|
|
1776
2398
|
tasks.sort((a, b) => order.indexOf(a.taskId) - order.indexOf(b.taskId));
|
|
1777
2399
|
}
|
|
2400
|
+
const stepOpts = {
|
|
2401
|
+
projectDir,
|
|
2402
|
+
epic,
|
|
2403
|
+
testCmd,
|
|
2404
|
+
...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
|
|
2405
|
+
...(events ? { events } : {}),
|
|
2406
|
+
};
|
|
1778
2407
|
const outcomes = [];
|
|
1779
2408
|
let allMerged = true;
|
|
2409
|
+
if (flags.batch === 'true') {
|
|
2410
|
+
const byId = new Map(tasks.map((t) => [t.taskId, t]));
|
|
2411
|
+
// A tasks-file entry's own `claims` wins over the plan's — the plan
|
|
2412
|
+
// describes what the task was authored to touch, the tasks file is
|
|
2413
|
+
// what actually ran, and the two can disagree the same way a diff can
|
|
2414
|
+
// outgrow the claim it was cut from.
|
|
2415
|
+
const groupable = tasks.map((t) => ({
|
|
2416
|
+
taskId: t.taskId,
|
|
2417
|
+
claims: t.claims ?? claimsById.get(t.taskId),
|
|
2418
|
+
}));
|
|
2419
|
+
const groups = groupForBatch(groupable, edges, loadWorktreePolicy().serializeAlwaysGlobs);
|
|
2420
|
+
const batches = [];
|
|
2421
|
+
for (const group of groups) {
|
|
2422
|
+
const groupTasks = group.map((id) => {
|
|
2423
|
+
const t = byId.get(id);
|
|
2424
|
+
if (!t) {
|
|
2425
|
+
// groupForBatch only ever returns ids it was handed, so this
|
|
2426
|
+
// would mean the group and --tasks have drifted apart — a wrong
|
|
2427
|
+
// candidate is worse than a refusal.
|
|
2428
|
+
throw new SmithError('queue.batch-unknown-task', `--batch grouped task "${id}" but no task by that id was in --tasks.`, { epic, taskId: id });
|
|
2429
|
+
}
|
|
2430
|
+
return t;
|
|
2431
|
+
});
|
|
2432
|
+
// groupForBatch's own contract (see its doc comment): a group of one
|
|
2433
|
+
// is not a batch, it's a task that closed the open group before it —
|
|
2434
|
+
// no candidate to stack, so it runs through the plain step it would
|
|
2435
|
+
// have taken without --batch at all, rather than through batchStep's
|
|
2436
|
+
// fold/candidate-worktree machinery for a "stack" of one.
|
|
2437
|
+
if (groupTasks.length === 1) {
|
|
2438
|
+
const [only] = groupTasks;
|
|
2439
|
+
const outcome = await step(only, stepOpts);
|
|
2440
|
+
outcomes.push(outcome);
|
|
2441
|
+
const landed = outcome.outcome === 'merged';
|
|
2442
|
+
// `step` refuses before any test runs on `nothing-to-merge` (D-30's
|
|
2443
|
+
// uncommitted-work guard) and `rebase-conflict` — 0 suites ran, not
|
|
2444
|
+
// 1, for either. Every other outcome it can return (`merged`,
|
|
2445
|
+
// `tests-failed`, `integration-dirty`) only happens after
|
|
2446
|
+
// `runTestCmd`.
|
|
2447
|
+
const suiteRuns = outcome.outcome === 'nothing-to-merge' || outcome.outcome === 'rebase-conflict' ? 0 : 1;
|
|
2448
|
+
batches.push({ task_ids: group, suite_runs: suiteRuns, landed });
|
|
2449
|
+
if (!landed) {
|
|
2450
|
+
allMerged = false;
|
|
2451
|
+
break;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
else {
|
|
2455
|
+
const result = await batchStep(groupTasks, stepOpts);
|
|
2456
|
+
outcomes.push(...result.outcomes);
|
|
2457
|
+
const landed = result.outcomes.every((o) => o.outcome === 'merged');
|
|
2458
|
+
batches.push({ task_ids: group, suite_runs: result.suiteRuns, landed });
|
|
2459
|
+
if (!landed) {
|
|
2460
|
+
allMerged = false;
|
|
2461
|
+
break;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
printJson({ outcomes, batches });
|
|
2466
|
+
return allMerged ? 0 : 1;
|
|
2467
|
+
}
|
|
1780
2468
|
for (const task of tasks) {
|
|
1781
|
-
const outcome = await step(task,
|
|
1782
|
-
projectDir,
|
|
1783
|
-
epic,
|
|
1784
|
-
testCmd,
|
|
1785
|
-
...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
|
|
1786
|
-
...(events ? { events } : {}),
|
|
1787
|
-
});
|
|
2469
|
+
const outcome = await step(task, stepOpts);
|
|
1788
2470
|
outcomes.push(outcome);
|
|
1789
2471
|
if (outcome.outcome !== 'merged') {
|
|
1790
2472
|
allMerged = false;
|
|
@@ -1813,7 +2495,10 @@ async function main() {
|
|
|
1813
2495
|
// the merge — and adopt any task with it, which is the forgery the whole
|
|
1814
2496
|
// verb exists to prevent. Deriving it from the plan-resolved id means the
|
|
1815
2497
|
// branch and the id it is logged under cannot disagree.
|
|
1816
|
-
{
|
|
2498
|
+
{
|
|
2499
|
+
taskId,
|
|
2500
|
+
branch: taskBranchName(plan.epic_id, taskId, epicBranchPrefix(projectDir, plan.epic_id)),
|
|
2501
|
+
}, {
|
|
1817
2502
|
projectDir,
|
|
1818
2503
|
epic: plan.epic_id,
|
|
1819
2504
|
mergeCommit,
|
|
@@ -1831,6 +2516,7 @@ async function main() {
|
|
|
1831
2516
|
...eventOptsFromFlags(flags),
|
|
1832
2517
|
...(flags.actor === undefined ? {} : { actor: flags.actor }),
|
|
1833
2518
|
...(flags.continues === undefined ? {} : { continues: flags.continues }),
|
|
2519
|
+
...(flags.prompt === undefined ? {} : { promptId: flags.prompt }),
|
|
1834
2520
|
}));
|
|
1835
2521
|
return 0;
|
|
1836
2522
|
}
|
|
@@ -1894,7 +2580,7 @@ async function main() {
|
|
|
1894
2580
|
process.stderr.write(`warning: this session-start is ${result.event_id}, not ${result.record.session_id}#0 — ` +
|
|
1895
2581
|
`session "${result.record.session_id}" was already open. The record is written and ` +
|
|
1896
2582
|
'durable, but a log has one root and readers take the first, so nothing will read this ' +
|
|
1897
|
-
'one. Use `
|
|
2583
|
+
'one. Use `bs session start`, which refuses instead of receipting, and chain off the ' +
|
|
1898
2584
|
'event that is already there.\n');
|
|
1899
2585
|
}
|
|
1900
2586
|
printJson({ ...result, on_timeline: onTimeline });
|
|
@@ -2013,6 +2699,27 @@ async function main() {
|
|
|
2013
2699
|
printJson(report);
|
|
2014
2700
|
return report.ok ? 0 : 1;
|
|
2015
2701
|
}
|
|
2702
|
+
if (namespace === 'dispatch' && action === 'lint') {
|
|
2703
|
+
// dispatch.md "Carry into the prompt" / "Declare each judge's artifact":
|
|
2704
|
+
// catches a stated turn budget that disagrees with the template's
|
|
2705
|
+
// `maxTurns` in either direction, a template it cannot read at all, and a
|
|
2706
|
+
// judge prompt missing (or mismatching) its declared-artifact line,
|
|
2707
|
+
// before the agent ever runs. Fails closed: any applicable status but
|
|
2708
|
+
// `ok` exits 1. See dispatchLint.ts for why each status means what it
|
|
2709
|
+
// means. `-` reads stdin, same convention as `prompt wrap`/`prompt record`.
|
|
2710
|
+
const [file] = requirePositionals(positional, usageFor('dispatch lint'), 1);
|
|
2711
|
+
const prompt = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
|
|
2712
|
+
const report = await lintDispatchPrompt({
|
|
2713
|
+
prompt,
|
|
2714
|
+
role: requireFlag(flags, 'role'),
|
|
2715
|
+
taskId: requireFlag(flags, 'task'),
|
|
2716
|
+
sessionId: requireFlag(flags, 'session'),
|
|
2717
|
+
...(flags['agents-dir'] ? { agentsDir: flags['agents-dir'] } : {}),
|
|
2718
|
+
eventOpts: eventOptsFromFlags(flags),
|
|
2719
|
+
});
|
|
2720
|
+
printJson(report);
|
|
2721
|
+
return report.exitCode;
|
|
2722
|
+
}
|
|
2016
2723
|
if (namespace === 'tester' && action === 'check') {
|
|
2017
2724
|
// The other half of `dispatch check`: that one asks whether the critic ran
|
|
2018
2725
|
// on the finder's model, this one asks whether a tester ran at all, in a
|
|
@@ -2113,7 +2820,7 @@ async function main() {
|
|
|
2113
2820
|
// Same refusal as `wave check`, for the same reason: validating the
|
|
2114
2821
|
// empty set answers "admissible" about a wave nobody described.
|
|
2115
2822
|
if (positional.length === 0) {
|
|
2116
|
-
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 });
|
|
2117
2824
|
}
|
|
2118
2825
|
const recordsById = new Map(plan.tasks.map((task) => [task.task_id, task]));
|
|
2119
2826
|
// Claims and keeps_exports are read through the one door validateWave
|
|
@@ -2179,20 +2886,20 @@ async function main() {
|
|
|
2179
2886
|
}
|
|
2180
2887
|
if (namespace === 'issues' && action === 'report') {
|
|
2181
2888
|
// The one path in this epic that runs `gh` for real.
|
|
2182
|
-
const { events, isEnabled, register, eventOpts } = await issueInputs(flags);
|
|
2889
|
+
const { events, isEnabled, register, eventOpts, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
|
|
2183
2890
|
const clock = () => new Date().toISOString();
|
|
2184
|
-
printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts));
|
|
2891
|
+
printJson(await reportErrors(events, isEnabled, register, issueCommandRunner, clock, eventOpts, resolveProject, selfFallback, lineageEvents));
|
|
2185
2892
|
return 0;
|
|
2186
2893
|
}
|
|
2187
2894
|
if (namespace === 'issues' && action === 'preview') {
|
|
2188
2895
|
// Same inputs, no `gh` and no event: the runner throws if anything
|
|
2189
2896
|
// reaches it, so a preview that spawned would fail loudly, not quietly.
|
|
2190
|
-
const { events, isEnabled, register } = await issueInputs(flags);
|
|
2897
|
+
const { events, isEnabled, register, resolveProject, selfFallback, lineageEvents } = await issueInputs(flags);
|
|
2191
2898
|
const neverRun = (cmd) => {
|
|
2192
2899
|
throw new Error(`issues preview must never run a command, asked for ${cmd}`);
|
|
2193
2900
|
};
|
|
2194
2901
|
const clock = () => new Date().toISOString();
|
|
2195
|
-
printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock));
|
|
2902
|
+
printJson(await previewOutcomes(events, isEnabled, register, neverRun, clock, resolveProject, selfFallback, lineageEvents));
|
|
2196
2903
|
return 0;
|
|
2197
2904
|
}
|
|
2198
2905
|
// The judge sandbox (A3). `worktree fingerprint`/`verify` above detect a
|
|
@@ -2281,7 +2988,7 @@ async function main() {
|
|
|
2281
2988
|
worktree: flags.worktree ?? null,
|
|
2282
2989
|
...(tier !== undefined ? { tier } : {}),
|
|
2283
2990
|
...(flags.schema !== undefined ? { schema: flags.schema } : {}),
|
|
2284
|
-
}, { policy: loadHarnessPolicy(flags.policy) });
|
|
2991
|
+
}, { policy: loadHarnessPolicy(flags.policy), planOpts: planOptsFromFlags(flags) });
|
|
2285
2992
|
printJson(invocation);
|
|
2286
2993
|
return 0;
|
|
2287
2994
|
}
|
|
@@ -2367,7 +3074,15 @@ async function main() {
|
|
|
2367
3074
|
const { runGate } = await import('./gate.js');
|
|
2368
3075
|
const [taskId] = requirePositionals(positional, usageFor('gate run'));
|
|
2369
3076
|
const worktreeDir = requireFlag(flags, 'worktree');
|
|
2370
|
-
|
|
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);
|
|
3085
|
+
const checks = readChecksFile(requireFlag(flags, 'checks'));
|
|
2371
3086
|
// The result file has the same two intake shapes as findings below, and for
|
|
2372
3087
|
// the same reason. With `--agent`, `--result` is the worker's half —
|
|
2373
3088
|
// run_status/structured_output/artifacts — and the dispatcher stamps the
|
|
@@ -2375,6 +3090,13 @@ async function main() {
|
|
|
2375
3090
|
// meter, so a token count it writes is invented (D-18/P9-17). Without
|
|
2376
3091
|
// `--agent` the file is taken as a complete document, which is what a
|
|
2377
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.
|
|
2378
3100
|
const resultFile = readJsonFile(requireFlag(flags, 'result'));
|
|
2379
3101
|
const result = flags.agent
|
|
2380
3102
|
? stampResultEnvelope(resultFile, {
|
|
@@ -2382,8 +3104,8 @@ async function main() {
|
|
|
2382
3104
|
agent: flags.agent,
|
|
2383
3105
|
provider: requireFlag(flags, 'provider'),
|
|
2384
3106
|
modelTier: requireFlag(flags, 'model-tier'),
|
|
2385
|
-
inputTokens:
|
|
2386
|
-
outputTokens:
|
|
3107
|
+
inputTokens: boundedIntFlag(flags, 'input-tokens', { min: 0 }),
|
|
3108
|
+
outputTokens: boundedIntFlag(flags, 'output-tokens', { min: 0 }),
|
|
2387
3109
|
})
|
|
2388
3110
|
: resultFile;
|
|
2389
3111
|
// Two intake shapes. `--evidence` is what judges actually produce
|
|
@@ -2392,8 +3114,9 @@ async function main() {
|
|
|
2392
3114
|
// judge at this gate (D-32/P9-13). `--findings` stays for already-minted
|
|
2393
3115
|
// records (replays, fixtures, cross-check re-runs).
|
|
2394
3116
|
const findingsInput = [
|
|
2395
|
-
...(flags.findings ?
|
|
3117
|
+
...(flags.findings ? readFindingsInputFile(flags.findings) : []),
|
|
2396
3118
|
...mintFromEvidence(args, taskId),
|
|
3119
|
+
...(flags['uiux-visual'] ? mintFromUiuxVisual(flags['uiux-visual'], taskId) : []),
|
|
2397
3120
|
];
|
|
2398
3121
|
const lessons = flags.lessons ? parseLessons(readFileSync(flags.lessons, 'utf8')) : [];
|
|
2399
3122
|
const ctx = eventContextFromFlags(flags);
|
|
@@ -2428,18 +3151,24 @@ async function main() {
|
|
|
2428
3151
|
// the shape the grader's turn stayed open with its verdict on the command
|
|
2429
3152
|
// line, and the gate blocked on the judge it was about to read.
|
|
2430
3153
|
const evidenceGiven = evidenceSources(args);
|
|
2431
|
-
const graderGiven = flags.grader
|
|
2432
|
-
|
|
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];
|
|
2433
3161
|
if (handedIn.length > 0) {
|
|
2434
3162
|
const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
|
|
2435
3163
|
const closed = new Set();
|
|
2436
|
-
for (const { foundBy, file } of handedIn) {
|
|
2437
|
-
|
|
3164
|
+
for (const { foundBy, file, kind } of handedIn) {
|
|
3165
|
+
const closeKey = kind ? `${foundBy}:${kind}` : foundBy;
|
|
3166
|
+
if (closed.has(closeKey))
|
|
2438
3167
|
continue;
|
|
2439
|
-
if (!turns.some((t) => t.role === foundBy && !t.reported))
|
|
3168
|
+
if (!turns.some((t) => t.role === foundBy && t.kind === (kind ?? null) && !t.reported))
|
|
2440
3169
|
continue;
|
|
2441
|
-
closed.add(
|
|
2442
|
-
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));
|
|
2443
3172
|
}
|
|
2444
3173
|
}
|
|
2445
3174
|
// P9-11: the genuinely clean case, said out loud. A judge that found
|
|
@@ -2452,7 +3181,7 @@ async function main() {
|
|
|
2452
3181
|
// role called "true" would close nothing (no such dispatch exists) and
|
|
2453
3182
|
// say so nowhere, so a bare --no-findings is a usage error instead.
|
|
2454
3183
|
if (role === 'true') {
|
|
2455
|
-
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>' });
|
|
2456
3185
|
}
|
|
2457
3186
|
await recordJudgeReport({ taskId, role, noFindings: true }, ctx, eventOptsFromFlags(flags));
|
|
2458
3187
|
}
|
|
@@ -2481,10 +3210,75 @@ async function main() {
|
|
|
2481
3210
|
...(graderVerdict !== undefined ? { graderVerdict } : {}),
|
|
2482
3211
|
...(budget ? { budget } : {}),
|
|
2483
3212
|
...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
|
|
3213
|
+
uiAffecting: uiAffectingFromFlags(flags, taskId),
|
|
3214
|
+
crosscheck: { policy: loadCrosscheckPolicy(undefined, { projectDir }) },
|
|
2484
3215
|
}, ctx, eventOptsFromFlags(flags));
|
|
2485
3216
|
printJson(outcome);
|
|
2486
3217
|
return outcome.outcome === 'blocked' ? 1 : 0;
|
|
2487
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 } : {}),
|
|
3253
|
+
}, ctx, eventOptsFromFlags(flags));
|
|
3254
|
+
printJson(outcome);
|
|
3255
|
+
return outcome.outcome === 'blocked' ? 1 : 0;
|
|
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
|
+
}
|
|
2488
3282
|
// D-40/P9-25: the gate's coverage evidence, without staging a gate run.
|
|
2489
3283
|
// This is the verb the D-40 investigation wanted and did not have — it took
|
|
2490
3284
|
// a coverage re-run on the pre-task-4 integration branch to establish what
|
|
@@ -2531,11 +3325,21 @@ async function main() {
|
|
|
2531
3325
|
// session reports half an epic's cost as the whole of it, and reports it
|
|
2532
3326
|
// under an alarm threshold it may already have crossed.
|
|
2533
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
|
+
});
|
|
2534
3335
|
const report = checkBudgetAlarm(events, loadBudgetPolicy(flags.policy), {
|
|
2535
3336
|
sessionId,
|
|
2536
3337
|
...(flags.epic ? { epicId: flags.epic } : {}),
|
|
3338
|
+
policyForEpic,
|
|
2537
3339
|
});
|
|
2538
|
-
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
|
+
];
|
|
2539
3343
|
printJson(envOverrides.length > 0 ? { ...report, budgetEnvOverrides: envOverrides } : report);
|
|
2540
3344
|
return report.ok ? 0 : 1;
|
|
2541
3345
|
}
|
|
@@ -2564,7 +3368,7 @@ async function main() {
|
|
|
2564
3368
|
if (namespace === 'integration' && action === 'check') {
|
|
2565
3369
|
const epicId = requireFlag(flags, 'epic');
|
|
2566
3370
|
const projectDir = requireFlag(flags, 'project');
|
|
2567
|
-
const checks =
|
|
3371
|
+
const checks = readChecksFile(requireFlag(flags, 'checks'));
|
|
2568
3372
|
const ctx = eventContextFromFlags(flags);
|
|
2569
3373
|
const record = await runIntegrationCheck({
|
|
2570
3374
|
epicId,
|
|
@@ -2588,12 +3392,26 @@ async function main() {
|
|
|
2588
3392
|
const outcome = await runEpicVerdict({
|
|
2589
3393
|
epicId,
|
|
2590
3394
|
integrationHeadSha: integrationHeadSha(projectDir, epicId),
|
|
3395
|
+
integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
|
|
2591
3396
|
mcp: mcpSurfaceFor(epicId, projectDir, flags),
|
|
2592
3397
|
goal: epicGoalFor(epicId, flags),
|
|
2593
3398
|
// D-126: the live plan is a voter. Without this the roster is the
|
|
2594
3399
|
// event-log fold alone, and a task an amendment added but nobody
|
|
2595
3400
|
// dispatched is invisible rather than unfinished.
|
|
2596
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 }) },
|
|
2597
3415
|
}, ctx, eventOptsFromFlags(flags));
|
|
2598
3416
|
printJson(outcome);
|
|
2599
3417
|
return outcome.outcome === 'hold' ? 1 : 0;
|
|
@@ -2610,9 +3428,17 @@ async function main() {
|
|
|
2610
3428
|
const record = await closeEpic({
|
|
2611
3429
|
epicId,
|
|
2612
3430
|
integrationHeadSha: integrationHeadSha(projectDir, epicId),
|
|
3431
|
+
integrationBranch: integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)),
|
|
2613
3432
|
mcp: mcpSurfaceFor(epicId, projectDir, flags),
|
|
2614
3433
|
goal: epicGoalFor(epicId, flags),
|
|
2615
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 }) },
|
|
2616
3442
|
...(flags['override-rationale'] !== undefined
|
|
2617
3443
|
? { overrideRationale: flags['override-rationale'] }
|
|
2618
3444
|
: {}),
|
|
@@ -2631,13 +3457,26 @@ async function main() {
|
|
|
2631
3457
|
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
2632
3458
|
// Evidence, not identity (interview N-2): the reviewer reports what it
|
|
2633
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
|
+
}
|
|
2634
3472
|
const evidence = flags.evidence ? readJsonFile(flags.evidence) : [];
|
|
2635
3473
|
// No branch, no review. The whole point of this dispatch is that it reads
|
|
2636
3474
|
// the code that now exists; recording one against a head that could not be
|
|
2637
3475
|
// read would produce a review nothing can be shown to cover.
|
|
2638
3476
|
const headSha = integrationHeadSha(projectDir, epicId);
|
|
2639
3477
|
if (headSha === null) {
|
|
2640
|
-
|
|
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 });
|
|
2641
3480
|
}
|
|
2642
3481
|
const record = await recordSpecReview({
|
|
2643
3482
|
epicId,
|
|
@@ -2648,6 +3487,7 @@ async function main() {
|
|
|
2648
3487
|
? { reviewedByProvider: flags['reviewed-by-provider'] }
|
|
2649
3488
|
: {}),
|
|
2650
3489
|
evidence,
|
|
3490
|
+
...(noFindings ? { noFindings: true } : {}),
|
|
2651
3491
|
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2652
3492
|
printJson(record);
|
|
2653
3493
|
// Exit 0 even when it found something: the review ran, and a spec finding
|
|
@@ -2765,7 +3605,7 @@ async function main() {
|
|
|
2765
3605
|
// are indistinguishable to everything downstream.
|
|
2766
3606
|
const mintTaskId = specDispatch && plan ? `${plan.epic_id}/${RESERVED_TASK_ID}` : defaultTaskId;
|
|
2767
3607
|
const findingsInput = [
|
|
2768
|
-
...(flags.findings ?
|
|
3608
|
+
...(flags.findings ? readFindingsInputFile(flags.findings) : []),
|
|
2769
3609
|
// The mint id, not the default one: a spec finding belongs to the epic
|
|
2770
3610
|
// (D-33), and the dispatch's scope rides along with it so every finding
|
|
2771
3611
|
// in the batch is spec-scoped by construction.
|
|
@@ -2803,7 +3643,9 @@ async function main() {
|
|
|
2803
3643
|
// can hold the fix, because the fix is a plan amendment.
|
|
2804
3644
|
const isSpec = !result.suppressed && findingScope(result.finding) === SPEC_FINDING_SCOPE;
|
|
2805
3645
|
if (!result.suppressed && !isSpec) {
|
|
2806
|
-
|
|
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);
|
|
2807
3649
|
}
|
|
2808
3650
|
raised.push({
|
|
2809
3651
|
findingId: result.suppressed ? null : result.finding.finding_id,
|
|
@@ -2812,6 +3654,8 @@ async function main() {
|
|
|
2812
3654
|
attribution: isSpec ? SPEC_FINDING_SCOPE : routing.attribution,
|
|
2813
3655
|
suppressed: result.suppressed,
|
|
2814
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,
|
|
2815
3659
|
});
|
|
2816
3660
|
}
|
|
2817
3661
|
printJson(raised);
|
|
@@ -2851,11 +3695,11 @@ async function main() {
|
|
|
2851
3695
|
// Refusing here, on the status alone, keeps argument order as the reading
|
|
2852
3696
|
// order and lets the error name the verb that CAN take the edge.
|
|
2853
3697
|
const AMENDMENT_ROUTES = {
|
|
2854
|
-
[AMEND_PENDING_STATUS]: '`
|
|
2855
|
-
[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',
|
|
2856
3700
|
};
|
|
2857
3701
|
if (newStatus in AMENDMENT_ROUTES) {
|
|
2858
|
-
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 });
|
|
2859
3703
|
}
|
|
2860
3704
|
const ctx = eventContextFromFlags(flags);
|
|
2861
3705
|
const finding = await transitionFinding(findingId, newStatus, ctx, eventOptsFromFlags(flags));
|
|
@@ -2864,20 +3708,25 @@ async function main() {
|
|
|
2864
3708
|
}
|
|
2865
3709
|
// The dispatch-time half of P9-15, shaped exactly like `lessons
|
|
2866
3710
|
// for-dispatch`: the caller composing a task prompt asks for the block and
|
|
2867
|
-
// splices it. `--plan`
|
|
2868
|
-
//
|
|
2869
|
-
// 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.
|
|
2870
3716
|
if (namespace === 'findings' && action === 'for-dispatch') {
|
|
2871
|
-
const usage = '
|
|
2872
|
-
|
|
2873
|
-
|
|
3717
|
+
const usage = usageLine(usageFor('findings for-dispatch'));
|
|
3718
|
+
if (flags.task !== undefined)
|
|
3719
|
+
requireFlag(flags, 'plan');
|
|
2874
3720
|
const sessionId = requireFlag(flags, 'session');
|
|
2875
3721
|
const eventOpts = eventOptsFromFlags(flags);
|
|
2876
3722
|
requireSession(sessionId, eventOpts);
|
|
2877
3723
|
if (positional.length > 0) {
|
|
2878
3724
|
throw new SmithError('cli.usage', `Unexpected argument. Usage: ${usage}`, { positional });
|
|
2879
3725
|
}
|
|
2880
|
-
|
|
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));
|
|
2881
3730
|
return 0;
|
|
2882
3731
|
}
|
|
2883
3732
|
// Re-dating a finding's evidence (P9-15). Deliberately its own verb and not
|
|
@@ -2889,8 +3738,8 @@ async function main() {
|
|
|
2889
3738
|
const ctx = eventContextFromFlags(flags);
|
|
2890
3739
|
const eventOpts = eventOptsFromFlags(flags);
|
|
2891
3740
|
requireSession(ctx.sessionId, eventOpts);
|
|
2892
|
-
await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
|
|
2893
|
-
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 });
|
|
2894
3743
|
return 0;
|
|
2895
3744
|
}
|
|
2896
3745
|
// D-21 Part 4. Corrects a malformed amends_task_ids entry on a finding
|
|
@@ -2930,18 +3779,34 @@ async function main() {
|
|
|
2930
3779
|
printJson(pending);
|
|
2931
3780
|
return 0;
|
|
2932
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
|
+
}
|
|
2933
3789
|
if (namespace === 'waivers' && action === 'apply') {
|
|
2934
3790
|
const [decisionsFile] = requirePositionals(positional, usageFor('waivers apply'));
|
|
2935
3791
|
const decisions = readJsonFile(decisionsFile);
|
|
2936
3792
|
const ctx = eventContextFromFlags(flags);
|
|
2937
|
-
const
|
|
2938
|
-
|
|
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);
|
|
2939
3798
|
return 0;
|
|
2940
3799
|
}
|
|
2941
3800
|
if (namespace === 'lessons' && action === 'candidates') {
|
|
2942
3801
|
const { openDb } = await import('./db/projector.js');
|
|
2943
3802
|
const { lessonsPage } = await import('./db/queries.js');
|
|
2944
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));
|
|
2945
3810
|
const handle = openDb(dbPath);
|
|
2946
3811
|
try {
|
|
2947
3812
|
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
@@ -3016,7 +3881,7 @@ async function main() {
|
|
|
3016
3881
|
if (namespace === 'lessons' && action === 'compile') {
|
|
3017
3882
|
const { openDb } = await import('./db/projector.js');
|
|
3018
3883
|
const { lessonsPage } = await import('./db/queries.js');
|
|
3019
|
-
const { compileLessons } = await import('./lessons.js');
|
|
3884
|
+
const { compileLessons, lessonsToPreserve } = await import('./lessons.js');
|
|
3020
3885
|
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
3021
3886
|
// The operator's copy, not the package's: under an install the shipped one
|
|
3022
3887
|
// lives in node_modules, which the next `npm i` replaces wholesale. mkdir
|
|
@@ -3029,7 +3894,14 @@ async function main() {
|
|
|
3029
3894
|
try {
|
|
3030
3895
|
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
3031
3896
|
const approved = lessonsPage(handle.db, scope).approved;
|
|
3032
|
-
|
|
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) => ({
|
|
3033
3905
|
lessonId: l.lessonId,
|
|
3034
3906
|
lessonScope: l.lessonScope,
|
|
3035
3907
|
statement: l.statement,
|
|
@@ -3037,9 +3909,14 @@ async function main() {
|
|
|
3037
3909
|
claimPath: l.claimPath,
|
|
3038
3910
|
agentRole: l.agentRole,
|
|
3039
3911
|
caseType: l.caseType,
|
|
3040
|
-
}))
|
|
3912
|
+
}));
|
|
3913
|
+
const markdown = compileLessons(compileInputs, existingMarkdown);
|
|
3041
3914
|
writeFileSync(outPath, markdown, 'utf8');
|
|
3042
|
-
|
|
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 });
|
|
3043
3920
|
return 0;
|
|
3044
3921
|
}
|
|
3045
3922
|
finally {
|
|
@@ -3119,8 +3996,14 @@ async function main() {
|
|
|
3119
3996
|
model: requireFlag(flags, 'model'),
|
|
3120
3997
|
...(flags.provider ? { provider: flags.provider } : {}),
|
|
3121
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 } : {}),
|
|
3122
4002
|
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
3123
|
-
printJson(
|
|
4003
|
+
printJson({
|
|
4004
|
+
...stored,
|
|
4005
|
+
expected_line: formatDeclaredArtifactLine(requireFlag(flags, 'artifact')),
|
|
4006
|
+
});
|
|
3124
4007
|
return 0;
|
|
3125
4008
|
}
|
|
3126
4009
|
if (namespace === 'judge' && action === 'report') {
|
|
@@ -3138,16 +4021,27 @@ async function main() {
|
|
|
3138
4021
|
// Here the role is already named by --role, so the valueless spelling
|
|
3139
4022
|
// is the right one and 'true' means what it says.
|
|
3140
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 } : {}),
|
|
3141
4026
|
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
3142
4027
|
printJson(report);
|
|
3143
4028
|
return 0;
|
|
3144
4029
|
}
|
|
3145
4030
|
if (namespace === 'judge' && action === 'outstanding') {
|
|
3146
4031
|
const sessionId = requireFlag(flags, 'session');
|
|
4032
|
+
const taskId = requireFlag(flags, 'task');
|
|
3147
4033
|
const eventOpts = eventOptsFromFlags(flags);
|
|
3148
4034
|
requireSession(sessionId, eventOpts);
|
|
3149
|
-
|
|
3150
|
-
|
|
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 });
|
|
3151
4045
|
return open.length > 0 ? 1 : 0;
|
|
3152
4046
|
}
|
|
3153
4047
|
if (namespace === 'judge' && action === 'escalations') {
|
|
@@ -3172,7 +4066,7 @@ async function main() {
|
|
|
3172
4066
|
// Deliberately ahead of `judge run` in this file for the same reason it is
|
|
3173
4067
|
// ahead of it in the runbook: an operator reaching for a calibration call
|
|
3174
4068
|
// to find out why a provider keeps failing usually needed this instead.
|
|
3175
|
-
const report = judgePreflight(flags.policy);
|
|
4069
|
+
const report = judgePreflight(flags.policy, { projectDir: flags.project });
|
|
3176
4070
|
printJson(report);
|
|
3177
4071
|
return report.problems.length > 0 ? 1 : 0;
|
|
3178
4072
|
}
|
|
@@ -3192,7 +4086,15 @@ async function main() {
|
|
|
3192
4086
|
// the split between them is the operator mandate made operable: `request`
|
|
3193
4087
|
// shows exactly what would leave the machine WITHOUT sending it, `run`
|
|
3194
4088
|
// sends it, and `reconcile` needs no provider at all.
|
|
3195
|
-
|
|
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) });
|
|
3196
4098
|
if (action === 'request') {
|
|
3197
4099
|
// Read-only and network-free by construction: it builds the JudgeRequest
|
|
3198
4100
|
// and prints it. Every refusal independentFinderRequest() can raise —
|
|
@@ -3299,7 +4201,7 @@ async function main() {
|
|
|
3299
4201
|
// missing feature but a silent data swap: the read path re-projects each
|
|
3300
4202
|
// session on the first request (app.ts's createRefresher), and apply()
|
|
3301
4203
|
// rebuilds the whole milestones table from a roadmap file while it is
|
|
3302
|
-
// there. Unset, that file falls back to
|
|
4204
|
+
// there. Unset, that file falls back to blacksmith's own
|
|
3303
4205
|
// factory/specs/roadmap.md — so serving another project's db showed this
|
|
3304
4206
|
// repo's roadmap from the first page load onward.
|
|
3305
4207
|
const roadmapPath = flags['roadmap-path'];
|
|
@@ -3307,13 +4209,30 @@ async function main() {
|
|
|
3307
4209
|
// request, and the plan files it consults to place an unstamped epic
|
|
3308
4210
|
// (D-246) must be the served db's, not this repo's.
|
|
3309
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));
|
|
3310
4225
|
mod.serve({
|
|
3311
4226
|
port,
|
|
3312
4227
|
dbPath,
|
|
4228
|
+
...(stores.length > 0 ? { stores } : {}),
|
|
3313
4229
|
...(stateDir ? { stateDir } : {}),
|
|
3314
4230
|
...(roadmapPath ? { roadmapPath } : {}),
|
|
3315
4231
|
...(specsDir ? { specsDir } : {}),
|
|
3316
4232
|
...(nowIso !== undefined ? { nowIso } : {}),
|
|
4233
|
+
claudeConfigDir: claude.dir,
|
|
4234
|
+
claudeConfigSource: claude.source,
|
|
4235
|
+
knownRoots: [...new Set([REPO_ROOT, WORK_ROOT])],
|
|
3317
4236
|
});
|
|
3318
4237
|
return 0;
|
|
3319
4238
|
}
|
|
@@ -3418,11 +4337,63 @@ async function main() {
|
|
|
3418
4337
|
});
|
|
3419
4338
|
return 1;
|
|
3420
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
|
+
});
|
|
3421
4390
|
main()
|
|
3422
4391
|
.then((code) => {
|
|
4392
|
+
mainExitCode = code;
|
|
3423
4393
|
process.exitCode = code;
|
|
3424
4394
|
})
|
|
3425
4395
|
.catch((err) => {
|
|
4396
|
+
mainExitCode = 1;
|
|
3426
4397
|
// A SmithError is a designed answer: its code names the failure and its
|
|
3427
4398
|
// details name the record, so a stack would only add noise. Anything else
|
|
3428
4399
|
// is a bug, and D-135 is what that costs — `{"message":"Cannot read
|