@juzser/blacksmith 0.1.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 +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,3185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin CLI router: one subcommand per orchestrator module operation. No
|
|
3
|
+
// framework — plain argv parsing. Logic lives in the modules; this file only
|
|
4
|
+
// wires stdin/argv to them and prints JSON.
|
|
5
|
+
//
|
|
6
|
+
// One rule about the import list below: **nothing that reaches the database
|
|
7
|
+
// layer is imported at module scope.** `db/schema.js` pulls in drizzle-orm,
|
|
8
|
+
// which roughly triples what booting this file costs, and a static import here
|
|
9
|
+
// charges that to every invocation of the binary — `smith --help` and `smith
|
|
10
|
+
// policy check` as much as `smith stats overview`. The nine modules that reach
|
|
11
|
+
// it (`attribution`, `daemon`, `db/projector`, `db/queries`, `epic`, `gate`,
|
|
12
|
+
// `lessonAudit`, `lessons`, `scheduler`) are `await import()`ed inside the
|
|
13
|
+
// branches that use them, which is why `main()` is async. Type-only imports of
|
|
14
|
+
// the same modules are fine and stay up here: tsc erases them, so they cost
|
|
15
|
+
// nothing at runtime. `test/cliBoot.test.ts` reads the built graph and fails if
|
|
16
|
+
// the database layer creeps back into it.
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
import { parseArgs } from './args.js';
|
|
22
|
+
import { closeAudit, consolidateAudit, cutAudit, decideAudit, openAudit, recordAudit, resolveAudit, } from './audit.js';
|
|
23
|
+
import { checkBudgetAlarm } from './budgetAlarm.js';
|
|
24
|
+
import { loadBudgetPolicy } from './budgets.js';
|
|
25
|
+
import { collectCommittedChanges, loadWorktreePolicy, postRunCheck, validateWave, writeRootCheck, } from './claims.js';
|
|
26
|
+
import { collectCoverageEvidence } from './coverage.js';
|
|
27
|
+
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
28
|
+
import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
|
|
29
|
+
import { checkDelegationGrants, checkDelegationLog, loadDelegationPolicy } from './delegation.js';
|
|
30
|
+
import { checkDispatchAsymmetry } from './dispatchAudit.js';
|
|
31
|
+
import { loadDotEnv } from './dotenv.js';
|
|
32
|
+
import { loadEffortPolicy, resolveEffort } from './effort.js';
|
|
33
|
+
import { SmithError } from './errors.js';
|
|
34
|
+
import { checkEscalationLadder } from './escalation.js';
|
|
35
|
+
import { appendEvent, filterEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
|
|
36
|
+
import { findingsForDispatch } from './findingContext.js';
|
|
37
|
+
import { AMEND_PENDING_STATUS, AMENDED_STATUS, findingScope, listFindings, mintFindings, OPEN_FINDING_STATUSES, raiseFinding, repairObligation, reverifyFinding, SPEC_FINDING_SCOPE, transition as transitionFinding, } from './findings.js';
|
|
38
|
+
import { recordGoalCheck, resolveEpicGoal } from './goalCheck.js';
|
|
39
|
+
import { decideHookPayload } from './hookDecision.js';
|
|
40
|
+
import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
|
|
41
|
+
import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
|
|
42
|
+
import { integrationHeadSha, runIntegrationCheck } from './integration.js';
|
|
43
|
+
import { judgePreflight } from './judgePreflight.js';
|
|
44
|
+
import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
|
|
45
|
+
import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
|
|
46
|
+
import { DOTENV_PATH, LESSONS_MD_PATH, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
|
|
47
|
+
import { diffPlans, livePlanTasks, resolveTaskId, validatePlan, } from './plan.js';
|
|
48
|
+
import { runPlanQuorum } from './planQuorum.js';
|
|
49
|
+
import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
|
|
50
|
+
import { recordUserPrompt } from './prompts.js';
|
|
51
|
+
import { checkBrief, wrapIngested } from './provenance.js';
|
|
52
|
+
import { runJudge } from './providers/index.js';
|
|
53
|
+
import { admit, adopt, step } from './queue.js';
|
|
54
|
+
import { stampResultEnvelope } from './results.js';
|
|
55
|
+
import { checkRuntime } from './runtime.js';
|
|
56
|
+
import { checkSameMistakeKpi } from './sameMistakeKpi.js';
|
|
57
|
+
import { activeSandboxFor, closeSandbox, listSandboxes, openSandbox, } from './sandbox.js';
|
|
58
|
+
import { registerProjectInRoadmap, scaffoldProject } from './scaffold.js';
|
|
59
|
+
import { loadSensitivePathsPolicy, securityTriggers, } from './security.js';
|
|
60
|
+
import { parseLessons } from './severity.js';
|
|
61
|
+
import { amendPlan, recordSpecReview } from './spec.js';
|
|
62
|
+
import { approveSpecChange, listSpecChanges, proposeSpecChange, rejectSpecChange, } from './specChange.js';
|
|
63
|
+
import { checkStack, loadStackAnswers } from './stack.js';
|
|
64
|
+
import { buildSymbolGraph, collectSources } from './symbols.js';
|
|
65
|
+
import { emitEdgesRecorded, emitTasksAdded, emitWaveAdmitted, readAddedTasks, } from './taskEvents.js';
|
|
66
|
+
import { loadTaxonomy } from './taxonomy.js';
|
|
67
|
+
import { checkTesterIsolation } from './testerAudit.js';
|
|
68
|
+
import { assertSelectableTestCmd } from './testSelect.js';
|
|
69
|
+
import { COMMANDS, flagSpecFor, helpText, isDocumented, positionalNames, usageFor, usageLine, usageText, } from './usage.js';
|
|
70
|
+
import { applyBatch, pendingBatch } from './waivers.js';
|
|
71
|
+
import { blocksAdmission, checkWaveBudget, } from './waveBudget.js';
|
|
72
|
+
import { computeNextWave, liveWaveTasks } from './waveNext.js';
|
|
73
|
+
import { createTaskWorktree, listStale, RESERVED_TASK_ID, removeTaskWorktree, taskBranchName, } from './worktree.js';
|
|
74
|
+
function printJson(value) {
|
|
75
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
76
|
+
}
|
|
77
|
+
function readJsonFile(filePath) {
|
|
78
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
79
|
+
}
|
|
80
|
+
/** A task spec's `budget.tokens` when it is a usable positive number, else null. */
|
|
81
|
+
function declaredTokens(budget) {
|
|
82
|
+
if (typeof budget !== 'object' || budget === null)
|
|
83
|
+
return null;
|
|
84
|
+
const tokens = budget.tokens;
|
|
85
|
+
return typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0 ? tokens : null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* What this wave will cost, task by task, priced from what declared it.
|
|
89
|
+
*
|
|
90
|
+
* Plan-declared tasks are priced from livePlanTasks, never `plan.tasks`
|
|
91
|
+
* (D-126/D-184): a superseded spec lives on in the array under the same
|
|
92
|
+
* `task_id`, so pricing off the raw list would read whichever copy came first
|
|
93
|
+
* rather than the one that is in force.
|
|
94
|
+
*
|
|
95
|
+
* Two different silences, and they get two different answers.
|
|
96
|
+
*
|
|
97
|
+
* A task the plan declares but leaves unpriced comes back `null` — not 0,
|
|
98
|
+
* because those are opposite claims (0 says "this costs nothing", null says
|
|
99
|
+
* "nobody said"), and only null makes the wave unverifiable. That refusal is
|
|
100
|
+
* safe to make because it is also cheap to fix: `task-spec.schema.json`
|
|
101
|
+
* REQUIRES `budget.tokens`, so a plan reaching here without one never passed
|
|
102
|
+
* `smith plan validate`, and `wave check` reads its plan with a bare cast
|
|
103
|
+
* (see claims.ts's `ProposedWaveTask`) rather than re-validating it. The
|
|
104
|
+
* unpriced spec is the finding.
|
|
105
|
+
*
|
|
106
|
+
* A task NO plan declares is the other silence. A follow-up minted by
|
|
107
|
+
* `findings raise` exists only in the log, which records its claims but never
|
|
108
|
+
* its budget (D-48/P9-31) — there was never a field for anyone to fill in.
|
|
109
|
+
* Refusing it would close the factory's own repair path: the follow-up would
|
|
110
|
+
* be admissible only by hand-editing a plan it was invented precisely to
|
|
111
|
+
* avoid, which is the same shape of failure as guard.sh's `deny`-on-
|
|
112
|
+
* unavailable — not safe, only stuck. So the policy prices it instead, at the
|
|
113
|
+
* coder task cap, which is the most such a task is permitted to spend before
|
|
114
|
+
* D-29's overrun record fires. That is a real number checked against a real
|
|
115
|
+
* cap, not a waiver: a wave of ten follow-ups is ten coder caps, and it is
|
|
116
|
+
* refused when ten of them will not fit.
|
|
117
|
+
*/
|
|
118
|
+
function declaredWaveBudgets(plan, taskIds, policy) {
|
|
119
|
+
const priced = new Map(livePlanTasks(plan).map((spec) => [spec.task_id, declaredTokens(spec.budget)]));
|
|
120
|
+
// `has` rather than `??`: an id the plan declares at null is unpriced, an id
|
|
121
|
+
// the plan never mentions at all is log-only. Collapsing them would make the
|
|
122
|
+
// plan defect above unreportable.
|
|
123
|
+
return taskIds.map((taskId) => ({
|
|
124
|
+
taskId,
|
|
125
|
+
tokens: priced.has(taskId) ? (priced.get(taskId) ?? null) : policy.task.coder.capTokens,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The gate's verdict in the log's snake_case shape, written on every admission.
|
|
130
|
+
*
|
|
131
|
+
* `override_rationale` is passed only when a human actually overrode a
|
|
132
|
+
* blocking status, so an admission that simply fit is never recorded as a
|
|
133
|
+
* decision somebody had to make.
|
|
134
|
+
*/
|
|
135
|
+
function admissionBudget(check, rationale) {
|
|
136
|
+
return {
|
|
137
|
+
status: check.status,
|
|
138
|
+
cap_tokens: check.capTokens,
|
|
139
|
+
projected_tokens: check.projectedTokens,
|
|
140
|
+
wave_tokens: check.waveTokens,
|
|
141
|
+
headroom_tokens: check.headroomTokens,
|
|
142
|
+
...(rationale !== undefined ? { override_rationale: rationale } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function requireFlag(flags, name) {
|
|
146
|
+
const value = flags[name];
|
|
147
|
+
if (value === undefined) {
|
|
148
|
+
throw new SmithError('cli.missing-flag', `Missing required flag --${name}.`, { flag: name });
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* requireFlag for a flag that must parse as a number, so `--input-tokens abc`
|
|
154
|
+
* is named here rather than travelling as NaN into a token_usage the schema
|
|
155
|
+
* then rejects for the wrong reason.
|
|
156
|
+
*/
|
|
157
|
+
function requireIntFlag(flags, name) {
|
|
158
|
+
const raw = requireFlag(flags, name);
|
|
159
|
+
const value = Number(raw);
|
|
160
|
+
if (!Number.isInteger(value)) {
|
|
161
|
+
throw new SmithError('cli.non-numeric-flag', `--${name} must be an integer, got "${raw}".`, {
|
|
162
|
+
flag: name,
|
|
163
|
+
value: raw,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* requireIntFlag's optional twin, for a count that also has to be in range.
|
|
170
|
+
*
|
|
171
|
+
* D-210. `event tail --n` read its count with a bare Number.parseInt and
|
|
172
|
+
* checked nothing, which fails in both directions at once: `--n abc` is NaN,
|
|
173
|
+
* and `all.slice(Math.max(0, len - NaN))` is `slice(0)` -- the WHOLE log, from
|
|
174
|
+
* the verb whose usage line promises "the last n records" -- while `--n 0` and
|
|
175
|
+
* `--n -5` make the offset larger and print nothing, which is exactly the
|
|
176
|
+
* "your session is empty" answer P9-28 built requireSession to prevent.
|
|
177
|
+
*
|
|
178
|
+
* Number(), not parseInt(), for the reason `plan quorum --confidence` already
|
|
179
|
+
* documents for parseFloat: parseInt stops at the first character it cannot
|
|
180
|
+
* use and returns the prefix, so `--n 1e2` is 1 rather than 100, `--n 0x10` is
|
|
181
|
+
* 0, and `--port 8080abc` is 8080. Every one of those is a typo answered
|
|
182
|
+
* confidently with the wrong number instead of being named.
|
|
183
|
+
*/
|
|
184
|
+
function boundedIntFlag(flags, name, range) {
|
|
185
|
+
const raw = flags[name];
|
|
186
|
+
if (raw === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
const value = Number(raw.trim() === '' ? Number.NaN : raw);
|
|
189
|
+
const max = range.max;
|
|
190
|
+
if (!Number.isInteger(value) || value < range.min || (max !== undefined && value > max)) {
|
|
191
|
+
const bound = max === undefined ? `at least ${range.min}` : `${range.min}-${max}`;
|
|
192
|
+
throw new SmithError('cli.invalid-flag', `--${name} must be a whole number ${bound}, got "${raw}".`, { flag: name, value: raw });
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The same two numbers gate.ts, epic.ts and planQuorum.ts each declare for
|
|
198
|
+
* themselves, and for their reason: a judge call is a network round-trip, not
|
|
199
|
+
* a test. Declared here rather than imported because none of those three
|
|
200
|
+
* exports it, and cli.ts depending on a gate module for a default would make
|
|
201
|
+
* the CLI a fourth caller of the gate rather than a peer of it.
|
|
202
|
+
*/
|
|
203
|
+
const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
|
|
204
|
+
/**
|
|
205
|
+
* A judge budget the operator may narrow from the command line.
|
|
206
|
+
*
|
|
207
|
+
* Both bounds are floors, not ceilings, on purpose: a `--timeout-ms 0` would
|
|
208
|
+
* make every call fail as a timeout and read as a provider that refuses to
|
|
209
|
+
* answer, and a `--max-output-bytes 0` would truncate every verdict to nothing
|
|
210
|
+
* and read as a provider that answered with silence. Those are the two
|
|
211
|
+
* failures this repo works hardest to keep distinguishable, so neither is
|
|
212
|
+
* reachable by typo.
|
|
213
|
+
*/
|
|
214
|
+
function judgeBudgetFromFlags(flags) {
|
|
215
|
+
return {
|
|
216
|
+
timeout_ms: boundedIntFlag(flags, 'timeout-ms', { min: 1 }) ?? DEFAULT_JUDGE_BUDGET.timeout_ms,
|
|
217
|
+
max_output_bytes: boundedIntFlag(flags, 'max-output-bytes', { min: 1 }) ??
|
|
218
|
+
DEFAULT_JUDGE_BUDGET.max_output_bytes,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* requireFlag's twin for positional arguments (P9-28).
|
|
223
|
+
*
|
|
224
|
+
* Every verb below used to take its positionals as `positional[0] as string` —
|
|
225
|
+
* a cast, which checks nothing. The `undefined` then travelled: into
|
|
226
|
+
* readFileSync, which blames a path argument rather than the argument you
|
|
227
|
+
* forgot; into git, run in an undefined directory; and worst, into a log read
|
|
228
|
+
* that returned `[]` and exited 0, so `smith event tail` with no session id
|
|
229
|
+
* reported success and an empty session. Flags have been checked since the
|
|
230
|
+
* first commit; positionals were not checked at all.
|
|
231
|
+
*
|
|
232
|
+
* The names come out of the command's documented positionals, so the error can
|
|
233
|
+
* name the argument that is missing AND print the line that would have worked,
|
|
234
|
+
* with no second list to drift out of sync. Since P9-21 that line comes from
|
|
235
|
+
* usage.ts rather than a literal typed here, which is what lets `--help` print
|
|
236
|
+
* the same text a mistake earns you.
|
|
237
|
+
*
|
|
238
|
+
* `required` is passed only where a verb needs fewer positionals than it
|
|
239
|
+
* documents — after P9-21 that is `wave check <plan.json> <task-id>...` alone,
|
|
240
|
+
* which checks the plan path here and keeps its own, better `cli.empty-wave`
|
|
241
|
+
* message for the variadic tail. The two other overrides died with the split:
|
|
242
|
+
* `worktree verify --before <fingerprint.json>` needed one because its flag's
|
|
243
|
+
* placeholder used to sit in the counted string.
|
|
244
|
+
*
|
|
245
|
+
* Surplus positionals stay accepted on purpose: `wave check` is variadic and
|
|
246
|
+
* `smith new` spends its first token on the project name, so an arity check
|
|
247
|
+
* would reject valid lines. Rejecting *unexpected* arguments is a different
|
|
248
|
+
* question from this one, and this is not it.
|
|
249
|
+
*
|
|
250
|
+
* An empty-string argument counts as missing: `smith gate run ""` is the same
|
|
251
|
+
* mistake as `smith gate run`, usually an unset shell variable, and the empty
|
|
252
|
+
* id would otherwise travel exactly as far as `undefined` did.
|
|
253
|
+
*/
|
|
254
|
+
function requirePositionals(positional, doc, required) {
|
|
255
|
+
const usage = usageLine(doc);
|
|
256
|
+
const names = positionalNames(doc);
|
|
257
|
+
const missing = names.slice(0, required ?? names.length).filter((_, i) => !positional[i]);
|
|
258
|
+
if (missing.length > 0) {
|
|
259
|
+
const which = missing.map((name) => `<${name}>`).join(' ');
|
|
260
|
+
throw new SmithError('cli.missing-positional', `Missing required argument${missing.length > 1 ? 's' : ''} ${which}. Usage: ${usage}`, { missing, usage });
|
|
261
|
+
}
|
|
262
|
+
return positional;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Every gate/findings/waivers command writes to the same session log, so they
|
|
266
|
+
* share this shape.
|
|
267
|
+
*
|
|
268
|
+
* `--plan-version` goes through boundedIntFlag (D-210's class): the bare
|
|
269
|
+
* `Number.parseInt` this replaced stamped `--plan-version 2.9` into the
|
|
270
|
+
* persisted envelope as plan 2, `9e9` as 9 and `3x` as 3, and the truthiness
|
|
271
|
+
* test in front of it turned an explicitly empty `--plan-version ""` into the
|
|
272
|
+
* default 1. Only `abc` ever reached the event schema, which then blamed the
|
|
273
|
+
* record for a malformed field rather than the flag that malformed it. This is
|
|
274
|
+
* the shared envelope for twenty-odd verbs and the number outlives the typo, so
|
|
275
|
+
* a fence read back tomorrow is the first place anyone would notice.
|
|
276
|
+
*/
|
|
277
|
+
function eventContextFromFlags(flags) {
|
|
278
|
+
return {
|
|
279
|
+
sessionId: requireFlag(flags, 'session'),
|
|
280
|
+
planVersion: boundedIntFlag(flags, 'plan-version', { min: 1 }) ?? 1,
|
|
281
|
+
causalParent: requireFlag(flags, 'causal-parent'),
|
|
282
|
+
actor: flags.actor,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* `--state-dir` overrides the events log directory (defaults to the real
|
|
287
|
+
* state/events/ dir, unchanged, when the flag is absent) — lets tests
|
|
288
|
+
* exercise `event append`/`event tail` without littering the real state/
|
|
289
|
+
* dir across runs.
|
|
290
|
+
*/
|
|
291
|
+
function eventOptsFromFlags(flags) {
|
|
292
|
+
return flags['state-dir'] ? { stateDir: flags['state-dir'] } : {};
|
|
293
|
+
}
|
|
294
|
+
/** Where plan version files are read from and written to; defaults to factory/specs/active. */
|
|
295
|
+
function planOptsFromFlags(flags) {
|
|
296
|
+
return flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The one reader for every flag usage.ts documents as `<iso>` (D-209).
|
|
300
|
+
*
|
|
301
|
+
* usage.ts states of its flag column: "Documentation only -- never parsed."
|
|
302
|
+
* That was true of the value as well. `scheduler run --now`, `dream --since`
|
|
303
|
+
* and `stats providers --since` each took the operator's string and handed it
|
|
304
|
+
* straight to a Date or to a SQL comparison, so a typo did not fail -- it
|
|
305
|
+
* changed the answer, in a different direction at each of the three:
|
|
306
|
+
*
|
|
307
|
+
* --now: `new Date('now')` is Invalid Date, and every comparison against
|
|
308
|
+
* the NaN behind it is false. proposeRechecks never pushes `time-elapsed`
|
|
309
|
+
* (fails CLOSED -- the operator asks what is due and a typo answers
|
|
310
|
+
* "nothing"), while proposeGrowthReview never returns null (fails OPEN --
|
|
311
|
+
* the review fires on every run regardless of cadence). Worse, the recheck
|
|
312
|
+
* proposal object IS the payload runScheduler persists, and Math.floor(NaN)
|
|
313
|
+
* serialises to null, so `daysElapsed: null` outlives the typo in an
|
|
314
|
+
* append-only log that event.schema.json accepts as-is (`payload` is an
|
|
315
|
+
* unconstrained object).
|
|
316
|
+
*
|
|
317
|
+
* dream --since: `Date.parse(ts) < NaN` is false, so no event is ever
|
|
318
|
+
* skipped and the whole log is distilled instead of the window asked for.
|
|
319
|
+
*
|
|
320
|
+
* stats providers --since: the value goes into `gte(eventsRaw.ts, since)`,
|
|
321
|
+
* a LEXICAL comparison. Every stored ts starts with a digit, so any word
|
|
322
|
+
* sorts above all of them and the report is silently empty.
|
|
323
|
+
*
|
|
324
|
+
* Strict ISO 8601, not "whatever Date accepts", because the parseable inputs
|
|
325
|
+
* are the dangerous ones: V8 reads '01/10/2026' as a US M/D/Y date, so the
|
|
326
|
+
* dd/mm/yyyy operator gets January 10 with no error and no way to see it. A
|
|
327
|
+
* shape check is the only thing that separates that from the date they meant.
|
|
328
|
+
*
|
|
329
|
+
* Returns a Date so the one caller needing an instant gets one; the two
|
|
330
|
+
* comparing against stored timestamps take .toISOString(), which also
|
|
331
|
+
* normalises an offset like +07:00 to the Z form those timestamps are in --
|
|
332
|
+
* the lexical comparison above is only meaningful between like forms.
|
|
333
|
+
*/
|
|
334
|
+
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
335
|
+
function isoDateFlag(flags, name) {
|
|
336
|
+
const raw = flags[name];
|
|
337
|
+
if (raw === undefined)
|
|
338
|
+
return undefined;
|
|
339
|
+
const trimmed = raw.trim();
|
|
340
|
+
const parsed = ISO_INSTANT.test(trimmed) ? new Date(trimmed) : new Date(Number.NaN);
|
|
341
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
342
|
+
throw new SmithError('cli.invalid-flag', `--${name} must be an ISO 8601 instant, got "${raw}". The form is ` +
|
|
343
|
+
'2026-08-20 or 2026-08-20T14:30:00Z. Words like "now" are not dates, and ' +
|
|
344
|
+
'a slashed date is read as US month/day/year, so 01/10/2026 is January ' +
|
|
345
|
+
'10 -- both change the result silently instead of failing.', { flag: name, value: raw });
|
|
346
|
+
}
|
|
347
|
+
return parsed;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Same override, for `db rebuild`/`db apply` — they read the same events log
|
|
351
|
+
* `event append` writes.
|
|
352
|
+
*
|
|
353
|
+
* `--roadmap-path` belongs here too: both verbs rebuild the whole milestones
|
|
354
|
+
* table from a roadmap file, and unset it falls back to black-smith's own
|
|
355
|
+
* factory/specs/roadmap.md. Dropping the flag therefore did not mean "keep the
|
|
356
|
+
* existing milestones" — it meant "replace this db's milestones with
|
|
357
|
+
* black-smith's", which is a silent data swap for any db but this repo's.
|
|
358
|
+
*
|
|
359
|
+
* `--specs-dir` travels for the same reason and is the same flag `plan`
|
|
360
|
+
* already takes: the projector reads an epic's plan file to answer what
|
|
361
|
+
* project its pre-D-232 events never stamped (D-246), and unset it reads this
|
|
362
|
+
* repo's factory/specs/active — another tree's epics, and no answer.
|
|
363
|
+
*/
|
|
364
|
+
function dbOptsFromFlags(flags) {
|
|
365
|
+
return {
|
|
366
|
+
...(flags['state-dir'] ? { stateDir: flags['state-dir'] } : {}),
|
|
367
|
+
...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
|
|
368
|
+
...(flags['specs-dir'] ? { specsDir: flags['specs-dir'] } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The novelty gate's two numbers (D-159). architecture §9.3 documents them as
|
|
373
|
+
* living in factory/policies/scheduler.yml, and `LessonsSchedulerPolicy` calls
|
|
374
|
+
* that file the single source of truth — but every path into the gate used to
|
|
375
|
+
* fall back to lessons.ts's own constants, so the file was a knob wired to
|
|
376
|
+
* nothing. `--policy` overrides the path the same way it does on `dispatch
|
|
377
|
+
* check` and friends; unguarded like `computeProposals`, because a missing or
|
|
378
|
+
* malformed policy is a loud `scheduler.invalid-policy`, not a default.
|
|
379
|
+
*
|
|
380
|
+
* `--novelty-threshold` still wins where it is accepted: a one-run override of
|
|
381
|
+
* the standing policy is exactly what an operator reaches for mid-review. It is
|
|
382
|
+
* folded in here rather than spread over the result at each call site (D-208),
|
|
383
|
+
* so one place answers "what threshold is in effect" and no later caller can
|
|
384
|
+
* take the override without its check. `dream` accepts no such flag, and D-132's
|
|
385
|
+
* unknown-flag guard throws in main() before any handler runs, so reading it
|
|
386
|
+
* here cannot smuggle the flag into a command that does not advertise it.
|
|
387
|
+
*/
|
|
388
|
+
async function noveltyOptsFromFlags(flags) {
|
|
389
|
+
const override = noveltyThresholdOverride(flags);
|
|
390
|
+
// Async only to keep scheduler.ts out of the boot graph — see the header.
|
|
391
|
+
// Every caller is a lessons/dream branch that is already awaiting something.
|
|
392
|
+
const { loadSchedulerPolicy } = await import('./scheduler.js');
|
|
393
|
+
const { noveltyJaccardThreshold, shingleSize, noveltyLengthAware } = loadSchedulerPolicy(flags.policy).lessons;
|
|
394
|
+
// No flag for the length correction, deliberately: it decides HOW the
|
|
395
|
+
// threshold is read, and an operator who wants a different bar for one run
|
|
396
|
+
// already has --novelty-threshold to say so in the units they are thinking
|
|
397
|
+
// in. A second flag that silently re-scales the first is a worse override.
|
|
398
|
+
return { noveltyThreshold: override ?? noveltyJaccardThreshold, shingleSize, noveltyLengthAware };
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The override held to the range its own source of truth is held to (D-208).
|
|
402
|
+
* parseSchedulerPolicy refuses a novelty_jaccard_threshold outside (0, 1] and
|
|
403
|
+
* says why -- "the novelty gate reads this directly and a degenerate value
|
|
404
|
+
* voids it silently" -- but `--novelty-threshold`, which replaces that exact
|
|
405
|
+
* number, went through a bare Number.parseFloat and was checked by nothing, so
|
|
406
|
+
* the flag could express precisely the values the file may not hold.
|
|
407
|
+
*
|
|
408
|
+
* Both ends are silent. No Jaccard score can reach 80, so the percent-for-
|
|
409
|
+
* fraction typo fails OPEN: the duplicate gate never fires, a verbatim
|
|
410
|
+
* re-statement is logged as a `candidate` with exit 0 and no novelty-rejected
|
|
411
|
+
* event, and once approved it joins the corpus every later check scores
|
|
412
|
+
* against. A 0 -- what parseFloat('0,7') silently returns -- fails closed and
|
|
413
|
+
* rejects unrelated lessons as duplicates.
|
|
414
|
+
*
|
|
415
|
+
* Number(), not parseFloat(), for the reason `plan quorum --confidence`
|
|
416
|
+
* already documents: parseFloat stops at the first bad character, so '80%'
|
|
417
|
+
* and '0,7' are accepted as numbers instead of being named as typos.
|
|
418
|
+
*/
|
|
419
|
+
function noveltyThresholdOverride(flags) {
|
|
420
|
+
const raw = flags['novelty-threshold'];
|
|
421
|
+
if (raw === undefined)
|
|
422
|
+
return undefined;
|
|
423
|
+
const value = Number(raw.trim());
|
|
424
|
+
if (!Number.isFinite(value) || value <= 0 || value > 1) {
|
|
425
|
+
throw new SmithError('cli.invalid-flag', `--novelty-threshold must be a number in (0, 1], got "${raw}". It overrides ` +
|
|
426
|
+
'scheduler.yml lessons.novelty_jaccard_threshold, which is held to the same ' +
|
|
427
|
+
'range: a Jaccard score never exceeds 1, so a larger value passes every ' +
|
|
428
|
+
'duplicate and a zero rejects every candidate.', { flag: 'novelty-threshold', value: raw });
|
|
429
|
+
}
|
|
430
|
+
return value;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The plan's claims map, as the ownership input routeFindings takes (D-41/P9-24).
|
|
434
|
+
* `TaskSpecRecord.claims` is `unknown` — the plan's own schema is what
|
|
435
|
+
* guarantees it is a string array, so validating it a second time here would
|
|
436
|
+
* only add a second opinion. A task without claims owns no file, and is
|
|
437
|
+
* dropped rather than defaulted to owning everything.
|
|
438
|
+
*/
|
|
439
|
+
function ownershipFromPlan(plan) {
|
|
440
|
+
return plan.tasks
|
|
441
|
+
.filter((task) => Array.isArray(task.claims))
|
|
442
|
+
.map((task) => ({ task_id: task.task_id, claims: task.claims }));
|
|
443
|
+
}
|
|
444
|
+
/** `--plan <file>` is optional everywhere findings are raised; without it, ownership is unknown. */
|
|
445
|
+
function ownershipFromFlags(flags) {
|
|
446
|
+
return flags.plan ? ownershipFromPlan(readJsonFile(flags.plan)) : undefined;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Pair every `--evidence <file>` with the `--found-by`/`--found-by-provider`
|
|
450
|
+
* written after it (D-32/P9-13).
|
|
451
|
+
*
|
|
452
|
+
* A task normally has several judges. `task-3-validate` came back with a test
|
|
453
|
+
* gap from the reviewer and a totality violation from the security-reviewer,
|
|
454
|
+
* and the old single-valued read (`flags.evidence` + `requireFlag(flags,
|
|
455
|
+
* 'found-by')`) could only file both under one name — so one of the two
|
|
456
|
+
* attributions was false. That is not a label: `found_by` feeds the
|
|
457
|
+
* same-mistake quorum trigger and every "which role catches what" question the
|
|
458
|
+
* factory asks of its own log.
|
|
459
|
+
*
|
|
460
|
+
* Positional pairing, not a parallel-arrays convention: `--evidence a --evidence
|
|
461
|
+
* b --found-by x --found-by y` reads as two lists that happen to be the same
|
|
462
|
+
* length until the day one of them is not, and then it silently swaps two
|
|
463
|
+
* judges' names.
|
|
464
|
+
*
|
|
465
|
+
* A `--found-by` seen BEFORE any `--evidence` is the default for the sources
|
|
466
|
+
* that name no judge of their own. That is exactly the pre-P9-13 line
|
|
467
|
+
* (`--found-by reviewer --evidence file.json`), which the skill, the operator
|
|
468
|
+
* guide and every existing caller write, and flags were order-independent when
|
|
469
|
+
* they wrote it — a strict "role must follow evidence" rule would turn all of
|
|
470
|
+
* them into errors overnight.
|
|
471
|
+
*
|
|
472
|
+
* An evidence file with neither is refused, naming the file. Falling back to
|
|
473
|
+
* the nearest judge is the misattribution this whole item exists to remove, and
|
|
474
|
+
* it would be silent.
|
|
475
|
+
*/
|
|
476
|
+
function evidenceSources(args) {
|
|
477
|
+
const drafts = [];
|
|
478
|
+
let defaultFoundBy;
|
|
479
|
+
let defaultProvider;
|
|
480
|
+
for (const { key, value } of args.ordered) {
|
|
481
|
+
if (key === 'evidence') {
|
|
482
|
+
drafts.push({ file: value });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (key !== 'found-by' && key !== 'found-by-provider')
|
|
486
|
+
continue;
|
|
487
|
+
// Attaches to the evidence file it follows; before the first one, it is the
|
|
488
|
+
// default. Last occurrence wins within a source, as it does everywhere else
|
|
489
|
+
// in this parser.
|
|
490
|
+
const open = drafts.at(-1);
|
|
491
|
+
if (key === 'found-by') {
|
|
492
|
+
if (open)
|
|
493
|
+
open.foundBy = value;
|
|
494
|
+
else
|
|
495
|
+
defaultFoundBy = value;
|
|
496
|
+
}
|
|
497
|
+
else if (open)
|
|
498
|
+
open.foundByProvider = value;
|
|
499
|
+
else
|
|
500
|
+
defaultProvider = value;
|
|
501
|
+
}
|
|
502
|
+
return drafts.map((draft) => {
|
|
503
|
+
const foundBy = draft.foundBy ?? defaultFoundBy;
|
|
504
|
+
if (foundBy === undefined) {
|
|
505
|
+
throw new SmithError('cli.missing-flag', `--evidence ${draft.file} has no --found-by. Each evidence file needs the judge role that produced it, written after it: --evidence <file> --found-by <role>.`, { flag: 'found-by', evidence: draft.file });
|
|
506
|
+
}
|
|
507
|
+
const foundByProvider = draft.foundByProvider ?? defaultProvider;
|
|
508
|
+
return { file: draft.file, foundBy, ...(foundByProvider ? { foundByProvider } : {}) };
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Mint every `--evidence` file's findings under its own judge. Shared by `gate
|
|
513
|
+
* run` and `findings raise` because they have the same judges — fixing one and
|
|
514
|
+
* not the other would leave the misattribution wherever the operator happened
|
|
515
|
+
* to be standing when they recorded the finding.
|
|
516
|
+
*/
|
|
517
|
+
function mintFromEvidence(args, taskId, scope = {}) {
|
|
518
|
+
return evidenceSources(args).flatMap((source) => mintFindings(readJsonFile(source.file), {
|
|
519
|
+
taskId,
|
|
520
|
+
foundBy: source.foundBy,
|
|
521
|
+
...(source.foundByProvider ? { foundByProvider: source.foundByProvider } : {}),
|
|
522
|
+
...scope,
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* The gated task's declared caps, read off the same `--plan` the gate already
|
|
527
|
+
* takes for ownership (D-41/P9-24) — no new flag, because a budget the gate can
|
|
528
|
+
* check is by definition one the plan already stated.
|
|
529
|
+
*
|
|
530
|
+
* A task the plan does not name gets `undefined` rather than an error: a
|
|
531
|
+
* follow-up minted by `findings raise` (D-48/P9-31) is real, gateable work that
|
|
532
|
+
* no plan version has been cut for yet, and refusing to gate it would be a
|
|
533
|
+
* worse answer than reporting its budget as not-declared. Ambiguity still
|
|
534
|
+
* throws — two tasks the id could equally mean is a question, not an absence.
|
|
535
|
+
*/
|
|
536
|
+
function budgetFromFlags(flags, taskId) {
|
|
537
|
+
if (!flags.plan)
|
|
538
|
+
return undefined;
|
|
539
|
+
const plan = readJsonFile(flags.plan);
|
|
540
|
+
let resolved;
|
|
541
|
+
try {
|
|
542
|
+
resolved = resolveTaskId(plan, taskId);
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
if (err instanceof SmithError && err.code === 'plan.unknown-task')
|
|
546
|
+
return undefined;
|
|
547
|
+
throw err;
|
|
548
|
+
}
|
|
549
|
+
const budget = plan.tasks.find((t) => t.task_id === resolved)?.budget;
|
|
550
|
+
return typeof budget === 'object' && budget !== null ? budget : undefined;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The claim globs one dispatch is scoped to, read off the plan (P9-2). Only
|
|
554
|
+
* claim-path-scoped lessons use them, so a role that declares no such scope
|
|
555
|
+
* may omit `--plan` entirely; a `--plan` that names a task it does not contain
|
|
556
|
+
* is an error rather than an empty claims list, which would silently drop
|
|
557
|
+
* every claim-path lesson the task was supposed to see.
|
|
558
|
+
*/
|
|
559
|
+
function claimsForDispatch(flags) {
|
|
560
|
+
if (!flags.plan)
|
|
561
|
+
return [];
|
|
562
|
+
const taskId = requireFlag(flags, 'task');
|
|
563
|
+
const owner = ownershipFromPlan(readJsonFile(flags.plan)).find((t) => t.task_id === taskId);
|
|
564
|
+
if (!owner) {
|
|
565
|
+
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
566
|
+
taskId,
|
|
567
|
+
plan: flags.plan,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return owner.claims;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* The taxonomy `case` one dispatch is scoped to (D-129) — the selector a
|
|
574
|
+
* `case-type` lesson is filtered by, read off the same immutable plan the
|
|
575
|
+
* claims come from so the two cannot disagree. `--case-type` overrides it for
|
|
576
|
+
* a dispatch that has no plan file to point at.
|
|
577
|
+
*
|
|
578
|
+
* Absent both, this returns '' and `lessonsForDispatch` warns rather than
|
|
579
|
+
* injecting every case's lessons — an unknown case is not a wildcard.
|
|
580
|
+
*/
|
|
581
|
+
function caseForDispatch(flags) {
|
|
582
|
+
if (flags['case-type'])
|
|
583
|
+
return flags['case-type'];
|
|
584
|
+
if (!flags.plan)
|
|
585
|
+
return '';
|
|
586
|
+
const taskId = requireFlag(flags, 'task');
|
|
587
|
+
const task = readJsonFile(flags.plan).tasks.find((t) => t.task_id === taskId);
|
|
588
|
+
if (!task) {
|
|
589
|
+
throw new SmithError('cli.task-not-in-plan', `No task ${taskId} in ${flags.plan}.`, {
|
|
590
|
+
taskId,
|
|
591
|
+
plan: flags.plan,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return typeof task.case === 'string' ? task.case : '';
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The MCP half of the epic gate (docs/standards/mcp.md step 4). Kept here
|
|
598
|
+
* rather than inside epic.ts for the same reason integrationHeadSha is: epic.ts
|
|
599
|
+
* is a fold over events and stays filesystem-free, so the caller reads the
|
|
600
|
+
* roadmap and the manifest and states the result.
|
|
601
|
+
*/
|
|
602
|
+
function mcpSurfaceFor(epicId, projectDir, flags) {
|
|
603
|
+
return resolveMcpSurface({
|
|
604
|
+
epicId,
|
|
605
|
+
projectDir,
|
|
606
|
+
...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The goal half of the epic gate. Same division of labour as mcpSurfaceFor:
|
|
611
|
+
* the roadmap read happens out here so epic.ts stays a fold over values it was
|
|
612
|
+
* handed, and `--roadmap-path` points both halves at the same file.
|
|
613
|
+
*/
|
|
614
|
+
function epicGoalFor(epicId, flags) {
|
|
615
|
+
return resolveEpicGoal({
|
|
616
|
+
epicId,
|
|
617
|
+
...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Most commands are `smith <namespace> <action> ...` (`plan validate`, `wave
|
|
622
|
+
* check`, ...). A few Phase-7 commands are `smith <namespace> <positional>
|
|
623
|
+
* [--flags]` with no action word at all (`smith new <project> [--ui]`,
|
|
624
|
+
* `smith dream [--since ...]`) — telling the two apart on a flag-shaped
|
|
625
|
+
* second token (starts with `--`) keeps every existing two-word command's
|
|
626
|
+
* parsing byte-identical while letting the one-word commands' first
|
|
627
|
+
* positional/flag land in `rest` instead of being swallowed as `action`.
|
|
628
|
+
*/
|
|
629
|
+
/**
|
|
630
|
+
* The spellings that mean "tell me what this does" rather than "do it". `help`
|
|
631
|
+
* has to be in the namespace slot's vocabulary because `smith --help` parses as
|
|
632
|
+
* a namespace: splitNamespaceAction treats the first token as the namespace
|
|
633
|
+
* whatever it looks like, and asking it to special-case flags would change how
|
|
634
|
+
* every other command parses.
|
|
635
|
+
*/
|
|
636
|
+
const HELP_WORDS = new Set(['help', '--help', '-h']);
|
|
637
|
+
function splitNamespaceAction(argv) {
|
|
638
|
+
const [namespace, ...restAll] = argv;
|
|
639
|
+
const second = restAll[0];
|
|
640
|
+
if (second !== undefined && !second.startsWith('--')) {
|
|
641
|
+
return { namespace, action: second, rest: restAll.slice(1) };
|
|
642
|
+
}
|
|
643
|
+
return { namespace, action: undefined, rest: restAll };
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Everything a wave computation reads about a plan: the plan itself with the
|
|
647
|
+
* log's follow-ups merged in, the worktree policy, the import crossings
|
|
648
|
+
* between its tasks, and the live status register.
|
|
649
|
+
*
|
|
650
|
+
* Assembled in one place because `wave next` and `wave schedule` have to read
|
|
651
|
+
* the same plan. Two copies of this would be two definitions of what "the
|
|
652
|
+
* plan" is, and the day they drifted the two commands would answer truthfully
|
|
653
|
+
* about different plans -- the failure that is hardest to see, because both
|
|
654
|
+
* outputs stay internally consistent.
|
|
655
|
+
*/
|
|
656
|
+
async function nextWaveInputFrom(planFile, flags) {
|
|
657
|
+
const planOnDisk = readJsonFile(planFile);
|
|
658
|
+
// D-48/P9-31, the rule `wave check` already follows: a follow-up minted by
|
|
659
|
+
// `findings raise` lives in the log and in no plan file. A scheduler that
|
|
660
|
+
// read only the plan would never propose it — the task would exist, be
|
|
661
|
+
// admissible by id, and be offered to nobody. The plan wins for an id both
|
|
662
|
+
// registers know, since a re-cut plan is the newer statement of its claims.
|
|
663
|
+
const events = flags.session
|
|
664
|
+
? // Lineage-wide (D-119) for the same reason the budget check is: an
|
|
665
|
+
// epic's tasks are not confined to the session that happens to ask.
|
|
666
|
+
await readLineageEvents(flags.session, eventOptsFromFlags(flags))
|
|
667
|
+
: [];
|
|
668
|
+
// Imported here rather than at module scope: `db/projector.js` reaches
|
|
669
|
+
// `db/schema.js` and drizzle behind it, and cliBoot.test.ts holds that out
|
|
670
|
+
// of the graph every `smith` invocation pays — `smith --help` included.
|
|
671
|
+
// Without a session there are no events to fold, so the cost is not even
|
|
672
|
+
// paid by every wave read.
|
|
673
|
+
const statusById = new Map();
|
|
674
|
+
if (events.length > 0) {
|
|
675
|
+
const { foldTasks } = await import('./db/projector.js');
|
|
676
|
+
for (const row of foldTasks(events))
|
|
677
|
+
statusById.set(row.taskId, row.taskStatus);
|
|
678
|
+
}
|
|
679
|
+
const logged = flags.session
|
|
680
|
+
? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
|
|
681
|
+
: [];
|
|
682
|
+
const planIds = new Set(planOnDisk.tasks.map((t) => t.task_id));
|
|
683
|
+
const followUps = logged
|
|
684
|
+
.filter((t) => !planIds.has(t.taskId))
|
|
685
|
+
.map((t) => ({
|
|
686
|
+
task_id: t.taskId,
|
|
687
|
+
// The log is the only register that holds this task's status, so a
|
|
688
|
+
// missing row means nobody has moved it — which is `todo`, not done.
|
|
689
|
+
task_status: statusById.get(t.taskId) ?? 'todo',
|
|
690
|
+
plan_version: planOnDisk.version,
|
|
691
|
+
// As written, never `?? []` (D-48): substituting an empty list for a
|
|
692
|
+
// claims value nothing can read hands the comparison the one claim set
|
|
693
|
+
// that overlaps nobody, and admits the unreadable task as a disjoint one.
|
|
694
|
+
claims: t.claims,
|
|
695
|
+
}));
|
|
696
|
+
const plan = followUps.length > 0
|
|
697
|
+
? { ...planOnDisk, tasks: [...planOnDisk.tasks, ...followUps] }
|
|
698
|
+
: planOnDisk;
|
|
699
|
+
// P9-3, the same second question `wave check` asks: two tasks whose claims
|
|
700
|
+
// are disjoint can still be coupled by an import edge between the files
|
|
701
|
+
// those claims match. `wave check` uses the answer to REFUSE a wave someone
|
|
702
|
+
// proposed; the callers here order one instead — the producer is admitted
|
|
703
|
+
// and the consumer deferred with the crossing named, which is the remedy an
|
|
704
|
+
// operator would have applied by hand after the refusal.
|
|
705
|
+
//
|
|
706
|
+
// Through `liveWaveTasks` so the graph is handed claim sets read by the
|
|
707
|
+
// same function that refuses an unreadable one.
|
|
708
|
+
const graph = buildSymbolGraph(collectSources(flags.repo ?? REPO_ROOT));
|
|
709
|
+
const crossings = waveImpact(graph, liveWaveTasks(plan)).crossings;
|
|
710
|
+
return {
|
|
711
|
+
plan,
|
|
712
|
+
policy: loadWorktreePolicy(),
|
|
713
|
+
crossings,
|
|
714
|
+
// The log outranks the plan file on status (D-18b): `task_status` in a
|
|
715
|
+
// plan is the task's INITIAL status, and a plan read from disk hours
|
|
716
|
+
// into a run still says `todo` about work that finished.
|
|
717
|
+
statusById,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
async function main() {
|
|
721
|
+
// Refuse an unsupported runtime before anything opens the database. The
|
|
722
|
+
// native binding crashes lazily — `new Database()`, not import — so a check
|
|
723
|
+
// here still runs, and a subcommand that happens to avoid SQLite must not
|
|
724
|
+
// be allowed to "work" on a runtime where the rest of the CLI segfaults:
|
|
725
|
+
// partial support is exactly how D-47 stayed hidden.
|
|
726
|
+
const runtime = checkRuntime();
|
|
727
|
+
if (!runtime.supported) {
|
|
728
|
+
// Prose on stderr so a human sees it even when stdout is piped into a
|
|
729
|
+
// JSON parser; the structured form still goes to stdout for callers.
|
|
730
|
+
process.stderr.write(`smith: unsupported runtime\n${runtime.reason ?? ''}\n`);
|
|
731
|
+
printJson({ error: { code: 'unsupported-runtime', message: runtime.reason } });
|
|
732
|
+
return 1;
|
|
733
|
+
}
|
|
734
|
+
// Before any command resolves a provider. `enabled: auto` is answered by
|
|
735
|
+
// `apiKeyPresent`, which reads `process.env` -- so a key the operator put
|
|
736
|
+
// where `docs/runbooks/providers.md` told them to has to arrive here first,
|
|
737
|
+
// or the policy resolves off and preflight reports a key the operator can
|
|
738
|
+
// see in the file (D-270). Sets only names the environment does not already
|
|
739
|
+
// hold, and returns names, never values.
|
|
740
|
+
loadDotEnv(DOTENV_PATH);
|
|
741
|
+
const { namespace, action, rest } = splitNamespaceAction(process.argv.slice(2));
|
|
742
|
+
// Parse against what this command declares, not against "anything starting
|
|
743
|
+
// with --" (D-131/D-132). A documented command gets a spec; an undocumented
|
|
744
|
+
// one gets `undefined` and the old permissive parse, because its argv has to
|
|
745
|
+
// survive long enough to print `Unknown command` rather than a flag error
|
|
746
|
+
// about a command that does not exist.
|
|
747
|
+
const args = parseArgs(rest, flagSpecFor(namespace, action));
|
|
748
|
+
const { positional, flags, repeated } = args;
|
|
749
|
+
// `smith help gate run` and `smith gate run --help` ask the same question, so
|
|
750
|
+
// they get the same answer: `help` in the namespace slot shifts the topic one
|
|
751
|
+
// token right, and every other form is the `--help` flag parseArgs already saw.
|
|
752
|
+
const helpWord = namespace !== undefined && HELP_WORDS.has(namespace);
|
|
753
|
+
const topic = helpText(helpWord ? action : namespace, helpWord ? (action === undefined ? undefined : rest[0]) : action);
|
|
754
|
+
if ((helpWord || flags.help === 'true') && topic !== undefined) {
|
|
755
|
+
process.stdout.write(topic);
|
|
756
|
+
return 0;
|
|
757
|
+
}
|
|
758
|
+
// Refuse before dispatch what usage.ts does not describe. This is the half of
|
|
759
|
+
// "the table and the dispatcher agree" that a test cannot enforce — a command
|
|
760
|
+
// added below without a line above cannot run at all, so the help can never
|
|
761
|
+
// be silently incomplete. (test/usage.test.ts enforces the other half.)
|
|
762
|
+
if (!isDocumented(namespace, action)) {
|
|
763
|
+
// Prose on stderr, structured error on stdout — same split as the runtime
|
|
764
|
+
// check above, so a `| jq` pipeline still parses while a human still reads.
|
|
765
|
+
process.stderr.write(topic ?? usageText());
|
|
766
|
+
const given = [namespace, action].filter(Boolean).join(' ');
|
|
767
|
+
printJson({
|
|
768
|
+
error: { message: given === '' ? 'No command given.' : `Unknown command: ${given}` },
|
|
769
|
+
});
|
|
770
|
+
return 1;
|
|
771
|
+
}
|
|
772
|
+
// Same two-step resolution as flagSpecFor, so the usage line printed is the
|
|
773
|
+
// one whose flags were actually enforced. Shared by both refusals below.
|
|
774
|
+
const doc = COMMANDS.find((d) => d.command === `${namespace} ${action}`) ??
|
|
775
|
+
COMMANDS.find((d) => d.command === namespace);
|
|
776
|
+
// D-132. Before this, every `--`-prefixed token went into a bag and each call
|
|
777
|
+
// site reached in for the keys it knew; nothing asked whether a key had been
|
|
778
|
+
// understood by anyone. A typo, a flag borrowed from another subcommand and a
|
|
779
|
+
// correct flag were the same observable event — exit 0, and the default
|
|
780
|
+
// quietly used. It is also what made D-131 dangerous rather than annoying:
|
|
781
|
+
// `--target-dir=<path>` degraded into an unknown flag named
|
|
782
|
+
// `target-dir=<path>`, and this is the check that was not there to catch it.
|
|
783
|
+
if (args.unknown.length > 0) {
|
|
784
|
+
const named = args.unknown.map((f) => `--${f}`).join(', ');
|
|
785
|
+
throw new SmithError('cli.unknown-flag', `Unknown flag${args.unknown.length > 1 ? 's' : ''} for "${[namespace, action].filter(Boolean).join(' ')}": ${named}.`, {
|
|
786
|
+
flag: args.unknown[0],
|
|
787
|
+
flags: args.unknown,
|
|
788
|
+
...(doc ? { usage: usageLine(doc) } : {}),
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
// The other half of D-132. That check asks whether a flag was declared; this
|
|
792
|
+
// one asks whether the declaration was honoured. A flag documented `--task
|
|
793
|
+
// <task-id>` and written bare parsed as the string 'true', and 'true' is a
|
|
794
|
+
// legal value for every flag whose value is a bare string — so the failure
|
|
795
|
+
// was not an error but an answer: an actor named "true" in the append-only
|
|
796
|
+
// log, a session filed under `true.jsonl`, an empty timeline that read as
|
|
797
|
+
// "this task did nothing". cli.ts already carried one hand-written guard
|
|
798
|
+
// against this shape, for `--no-findings`; eighty value-taking flags across
|
|
799
|
+
// sixty-seven commands had none.
|
|
800
|
+
if (args.missingValue.length > 0) {
|
|
801
|
+
const named = args.missingValue.map((f) => `--${f}`).join(', ');
|
|
802
|
+
throw new SmithError('cli.missing-flag-value', `${named} ${args.missingValue.length > 1 ? 'each take a value' : 'takes a value'} and ` +
|
|
803
|
+
`${args.missingValue.length > 1 ? 'were' : 'was'} given none.`, {
|
|
804
|
+
flag: args.missingValue[0],
|
|
805
|
+
flags: args.missingValue,
|
|
806
|
+
...(doc ? { usage: usageLine(doc) } : {}),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
// -- audit --------------------------------------------------------------
|
|
810
|
+
// Seven verbs, one positional: the project directory (docs/specs/
|
|
811
|
+
// audit-command-scope.md §6). Nothing here reads a cwd, and the only state
|
|
812
|
+
// between verbs is the manifest `audit open` writes under `.blacksmith/`.
|
|
813
|
+
// Axis and decision are validated in audit.ts, so the casts below are the
|
|
814
|
+
// same non-load-bearing shape as `plan validate`'s.
|
|
815
|
+
if (namespace === 'audit' && action === 'open') {
|
|
816
|
+
const [projectDir] = requirePositionals(positional, usageFor('audit open'));
|
|
817
|
+
printJson(await openAudit(projectDir, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
|
|
818
|
+
return 0;
|
|
819
|
+
}
|
|
820
|
+
if (namespace === 'audit' && action === 'record') {
|
|
821
|
+
const [projectDir] = requirePositionals(positional, usageFor('audit record'));
|
|
822
|
+
const axis = requireFlag(flags, 'axis');
|
|
823
|
+
const evidence = readJsonFile(requireFlag(flags, 'evidence'));
|
|
824
|
+
printJson(await recordAudit(projectDir, axis, evidence, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
|
|
825
|
+
return 0;
|
|
826
|
+
}
|
|
827
|
+
if (namespace === 'audit' && action === 'consolidate') {
|
|
828
|
+
const [projectDir] = requirePositionals(positional, usageFor('audit consolidate'));
|
|
829
|
+
printJson(consolidateAudit(projectDir));
|
|
830
|
+
return 0;
|
|
831
|
+
}
|
|
832
|
+
if (namespace === 'audit' && action === 'decide') {
|
|
833
|
+
const [projectDir] = requirePositionals(positional, usageFor('audit decide'));
|
|
834
|
+
printJson(await decideAudit(projectDir, {
|
|
835
|
+
fingerprint: requireFlag(flags, 'fingerprint'),
|
|
836
|
+
decision: requireFlag(flags, 'decision'),
|
|
837
|
+
...(flags['same-as'] === undefined ? {} : { sameAs: flags['same-as'] }),
|
|
838
|
+
...(flags.note === undefined ? {} : { note: flags.note }),
|
|
839
|
+
}, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
if (namespace === 'audit' && action === 'cut') {
|
|
843
|
+
const [projectDir] = requirePositionals(positional, usageFor('audit cut'));
|
|
844
|
+
printJson(await cutAudit(projectDir, { epicId: requireFlag(flags, 'epic'), title: requireFlag(flags, 'title') }, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
|
|
845
|
+
return 0;
|
|
846
|
+
}
|
|
847
|
+
if (namespace === 'audit' && action === 'resolve') {
|
|
848
|
+
const [projectDir] = requirePositionals(positional, usageFor('audit resolve'));
|
|
849
|
+
printJson(await resolveAudit(projectDir, requireFlag(flags, 'epic'), eventContextFromFlags(flags), eventOptsFromFlags(flags)));
|
|
850
|
+
return 0;
|
|
851
|
+
}
|
|
852
|
+
if (namespace === 'audit' && action === 'close') {
|
|
853
|
+
const [projectDir] = requirePositionals(positional, usageFor('audit close'));
|
|
854
|
+
printJson(await closeAudit(projectDir, { force: flags.force === 'true' }, eventContextFromFlags(flags), eventOptsFromFlags(flags)));
|
|
855
|
+
return 0;
|
|
856
|
+
}
|
|
857
|
+
if (namespace === 'plan' && action === 'validate') {
|
|
858
|
+
// The cast is unchanged everywhere below, but it is no longer load-bearing:
|
|
859
|
+
// requirePositionals is what makes it true, and it runs first.
|
|
860
|
+
const [planFile] = requirePositionals(positional, usageFor('plan validate'));
|
|
861
|
+
const plan = readJsonFile(planFile);
|
|
862
|
+
const result = validatePlan(plan);
|
|
863
|
+
printJson(result);
|
|
864
|
+
return result.valid ? 0 : 1;
|
|
865
|
+
}
|
|
866
|
+
if (namespace === 'plan' && action === 'diff') {
|
|
867
|
+
const [fileA, fileB] = requirePositionals(positional, usageFor('plan diff'));
|
|
868
|
+
const vA = readJsonFile(fileA);
|
|
869
|
+
const vB = readJsonFile(fileB);
|
|
870
|
+
printJson(diffPlans(vA, vB));
|
|
871
|
+
return 0;
|
|
872
|
+
}
|
|
873
|
+
if (namespace === 'plan' && action === 'quorum') {
|
|
874
|
+
// Critique-only (planQuorum.ts module header): exit 0 means nothing
|
|
875
|
+
// needs the operator (no trigger fired, or endorsed); exit 1 means the
|
|
876
|
+
// operator must look (critiqued or escalated) before approving the plan.
|
|
877
|
+
const epicId = requireFlag(flags, 'epic');
|
|
878
|
+
// Required for this verb, unlike the shared envelope where it defaults to
|
|
879
|
+
// 1: a quorum is a critique of one specific plan version.
|
|
880
|
+
requireFlag(flags, 'plan-version');
|
|
881
|
+
// Number(), not parseFloat(): both are validated, but parseFloat() would
|
|
882
|
+
// silently accept a garbage suffix — parseFloat('0,7') is 0, not NaN, so a
|
|
883
|
+
// European-decimal typo would read as "zero confidence" and fire trigger 3
|
|
884
|
+
// on every plan. Number('0,7') is NaN and is rejected below. A bare NaN
|
|
885
|
+
// must never reach runPlanQuorum() either: `NaN < threshold` is false, so
|
|
886
|
+
// an unparseable value would fail OPEN — trigger 3's planner arm just never
|
|
887
|
+
// fires and the plan sails through uncritiqued. A gate trigger must be
|
|
888
|
+
// neither disabled nor pinned on by a typo.
|
|
889
|
+
const rawConfidence = flags.confidence?.trim();
|
|
890
|
+
const plannerConfidence = rawConfidence ? Number(rawConfidence) : undefined;
|
|
891
|
+
if ((flags.confidence !== undefined && !rawConfidence) ||
|
|
892
|
+
(plannerConfidence !== undefined &&
|
|
893
|
+
(!Number.isFinite(plannerConfidence) || plannerConfidence < 0 || plannerConfidence > 1))) {
|
|
894
|
+
throw new SmithError('cli.invalid-flag', `--confidence must be a number in [0, 1], got "${flags.confidence}".`, { flag: 'confidence', value: flags.confidence });
|
|
895
|
+
}
|
|
896
|
+
const ctx = eventContextFromFlags(flags);
|
|
897
|
+
// D-211: the version comes off `ctx`, not off a second read of the flag.
|
|
898
|
+
// This verb used to parse `--plan-version` twice with two parsers that
|
|
899
|
+
// disagree — a bare `Number.parseInt` chose the plan FILE to critique,
|
|
900
|
+
// while boundedIntFlag inside eventContextFromFlags chose the plan_version
|
|
901
|
+
// stamped on every event the run appends. D-210 settled the notation:
|
|
902
|
+
// `1e2` is an unambiguous numeric literal for 100, and answering with the
|
|
903
|
+
// 1 of its first character is the defect. So `--plan-version 1e2`
|
|
904
|
+
// critiqued v1 and logged v100, with no error anywhere — and
|
|
905
|
+
// planQuorum.ts's `inputRefs.plan_version`, which reads the version out of
|
|
906
|
+
// the loaded FILE, then disagreed with the envelope around it. A record
|
|
907
|
+
// that names two different plans is worse than one that fails: reading it
|
|
908
|
+
// back, neither number can be trusted. One read, one number.
|
|
909
|
+
const version = ctx.planVersion;
|
|
910
|
+
const outcome = await runPlanQuorum({ epicId, version, ...(plannerConfidence !== undefined ? { plannerConfidence } : {}) }, ctx, eventOptsFromFlags(flags));
|
|
911
|
+
printJson(outcome);
|
|
912
|
+
return outcome.outcome === 'endorsed' ? 0 : 1;
|
|
913
|
+
}
|
|
914
|
+
if (namespace === 'plan' && action === 'ingest') {
|
|
915
|
+
// D-46/P9-29: where a task starts existing as far as the log — and so
|
|
916
|
+
// the DB, the kanban and every dashboard number — is concerned. Before
|
|
917
|
+
// this, a task row only sprang into being as a side effect of whatever
|
|
918
|
+
// wave or gate event first happened to name an id, with no epic, no
|
|
919
|
+
// claims and no budget. Idempotent, because ingesting the plan again
|
|
920
|
+
// (a resumed session, a v(n+1) that carries tasks forward) is normal.
|
|
921
|
+
const [planFile] = requirePositionals(positional, usageFor('plan ingest'));
|
|
922
|
+
const plan = readJsonFile(planFile);
|
|
923
|
+
const ctx = eventContextFromFlags(flags);
|
|
924
|
+
const opts = eventOptsFromFlags(flags);
|
|
925
|
+
const written = await emitTasksAdded(plan, ctx, opts);
|
|
926
|
+
// D-254: the arrows, after the nodes. The plan declares a DAG and the
|
|
927
|
+
// scheduler has always read it off the file, but nothing wrote it to the
|
|
928
|
+
// log -- so the db's `edges` table was empty and every operator-facing
|
|
929
|
+
// view of the graph showed a flat list. Counted in the output for the
|
|
930
|
+
// same reason `added` is: an ingest that silently wrote none is exactly
|
|
931
|
+
// what went unnoticed.
|
|
932
|
+
const edges = await emitEdgesRecorded(plan, ctx, opts);
|
|
933
|
+
const added = written.filter((e) => e.record.event_type === 'task-added').length;
|
|
934
|
+
printJson({
|
|
935
|
+
epic: plan.epic_id,
|
|
936
|
+
version: plan.version,
|
|
937
|
+
added,
|
|
938
|
+
superseded: written.length - added,
|
|
939
|
+
skipped: plan.tasks.length - added,
|
|
940
|
+
edges: edges.length,
|
|
941
|
+
});
|
|
942
|
+
return 0;
|
|
943
|
+
}
|
|
944
|
+
// P9-9/D-33: the one legitimate way to change an immutable plan. It refuses
|
|
945
|
+
// to cut a version that cites no spec finding, so "the plan changed" is
|
|
946
|
+
// always answerable with "which finding said it was wrong".
|
|
947
|
+
if (namespace === 'plan' && action === 'amend') {
|
|
948
|
+
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
949
|
+
// Comma-split, not repeated: a finding id has no commas, and an amendment
|
|
950
|
+
// routinely cites several at once.
|
|
951
|
+
const findingIds = requireFlag(flags, 'findings')
|
|
952
|
+
.split(',')
|
|
953
|
+
.map((id) => id.trim())
|
|
954
|
+
.filter((id) => id !== '');
|
|
955
|
+
// Same comma split, for the same reason: a path has no commas either, and
|
|
956
|
+
// the whole point of D-123 is that this list is routinely longer than one.
|
|
957
|
+
const sites = requireFlag(flags, 'sites')
|
|
958
|
+
.split(',')
|
|
959
|
+
.map((s) => s.trim())
|
|
960
|
+
.filter((s) => s !== '');
|
|
961
|
+
const changes = flags.changes ? readJsonFile(flags.changes) : undefined;
|
|
962
|
+
const result = await amendPlan({
|
|
963
|
+
plan,
|
|
964
|
+
findingIds,
|
|
965
|
+
rationale: requireFlag(flags, 'rationale'),
|
|
966
|
+
sites,
|
|
967
|
+
...(changes ? { changes } : {}),
|
|
968
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
|
|
969
|
+
// No no-op warning here any more. A pure carry-forward used to be legal
|
|
970
|
+
// and merely suspicious — the shape a forgotten --changes takes — so the
|
|
971
|
+
// CLI printed a warning and cut the version anyway. Since D-127 an
|
|
972
|
+
// amendment that adds and supersedes nothing is refused by `amendPlan`
|
|
973
|
+
// itself, before the version exists: the cited finding would have had
|
|
974
|
+
// nothing to wait on. The check belongs there and not here, because the
|
|
975
|
+
// library is where the finding is transitioned; a guard living only in
|
|
976
|
+
// the CLI would let any other caller close an unwaivable finding with an
|
|
977
|
+
// identical plan version.
|
|
978
|
+
printJson({
|
|
979
|
+
epic: result.plan.epic_id,
|
|
980
|
+
version: result.plan.version,
|
|
981
|
+
previousVersion: plan.version,
|
|
982
|
+
findingIds,
|
|
983
|
+
sites,
|
|
984
|
+
// Printed at authorship, not only recorded for the close: the operator
|
|
985
|
+
// who just named these is the one best placed to say whether a site with
|
|
986
|
+
// no task behind it is a deliberate call or a forgotten one.
|
|
987
|
+
sitesUnclaimed: result.sitesUnclaimed,
|
|
988
|
+
diff: result.diff,
|
|
989
|
+
});
|
|
990
|
+
return 0;
|
|
991
|
+
}
|
|
992
|
+
// The worker's half of the same wall. A coder cannot emit an event and
|
|
993
|
+
// cannot mint a spec-scoped finding, so it returns a spec_change_request in
|
|
994
|
+
// its structured_output and the node that dispatched it runs this. Nothing
|
|
995
|
+
// here writes a plan file: `plan amend` above stays the only path to a
|
|
996
|
+
// version, and this only records that someone asked for one.
|
|
997
|
+
if (namespace === 'plan' && action === 'propose') {
|
|
998
|
+
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
999
|
+
// A file, not a flag soup: `changes` alone is a nested object, and the
|
|
1000
|
+
// worker already returned the whole request as JSON. Asking the dispatcher
|
|
1001
|
+
// to re-type it into flags would be asking it to paraphrase the worker.
|
|
1002
|
+
const request = readJsonFile(requireFlag(flags, 'request'));
|
|
1003
|
+
const proposal = await proposeSpecChange({
|
|
1004
|
+
plan,
|
|
1005
|
+
taskId: requireFlag(flags, 'task'),
|
|
1006
|
+
proposedBy: requireFlag(flags, 'proposed-by'),
|
|
1007
|
+
...(flags['proposed-by-provider']
|
|
1008
|
+
? { proposedByProvider: flags['proposed-by-provider'] }
|
|
1009
|
+
: {}),
|
|
1010
|
+
request,
|
|
1011
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
|
|
1012
|
+
printJson(proposal);
|
|
1013
|
+
return 0;
|
|
1014
|
+
}
|
|
1015
|
+
if (namespace === 'plan' && action === 'proposals') {
|
|
1016
|
+
const sessionId = requireFlag(flags, 'session');
|
|
1017
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
1018
|
+
// Same P9-28 rule the findings verbs follow: an empty list is an answer
|
|
1019
|
+
// about the proposals, and it must not double as the answer about a
|
|
1020
|
+
// session that was never opened.
|
|
1021
|
+
requireSession(sessionId, eventOpts);
|
|
1022
|
+
const proposals = await listSpecChanges(sessionId, {
|
|
1023
|
+
epicId: flags.epic,
|
|
1024
|
+
taskId: flags.task,
|
|
1025
|
+
status: flags.status,
|
|
1026
|
+
}, { ...eventOpts, ...planOptsFromFlags(flags) });
|
|
1027
|
+
// Printed whole, diff included. The operator's next move is a yes or a no
|
|
1028
|
+
// on a plan diff, and a listing that made them go and read the proposal
|
|
1029
|
+
// event by hand to see it would have answered the wrong question.
|
|
1030
|
+
printJson(proposals);
|
|
1031
|
+
return 0;
|
|
1032
|
+
}
|
|
1033
|
+
// "Approve it fast" is one command, and it is one command without any guard
|
|
1034
|
+
// being relaxed: `amendPlan` still demands a rationale, findings, and
|
|
1035
|
+
// sites. Approval supplies them from what the worker already recorded.
|
|
1036
|
+
if (namespace === 'plan' && action === 'approve') {
|
|
1037
|
+
const [proposalId] = requirePositionals(positional, usageFor('plan approve'));
|
|
1038
|
+
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
1039
|
+
const result = await approveSpecChange({
|
|
1040
|
+
proposalId,
|
|
1041
|
+
plan,
|
|
1042
|
+
decidedBy: requireFlag(flags, 'decided-by'),
|
|
1043
|
+
...(flags.rationale ? { rationale: flags.rationale } : {}),
|
|
1044
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
|
|
1045
|
+
printJson({
|
|
1046
|
+
proposalId,
|
|
1047
|
+
epic: result.plan.epic_id,
|
|
1048
|
+
version: result.plan.version,
|
|
1049
|
+
previousVersion: plan.version,
|
|
1050
|
+
findingIds: [result.proposal.findingId],
|
|
1051
|
+
sites: result.proposal.sites,
|
|
1052
|
+
sitesUnclaimed: result.sitesUnclaimed,
|
|
1053
|
+
diff: result.diff,
|
|
1054
|
+
});
|
|
1055
|
+
return 0;
|
|
1056
|
+
}
|
|
1057
|
+
if (namespace === 'plan' && action === 'reject') {
|
|
1058
|
+
const [proposalId] = requirePositionals(positional, usageFor('plan reject'));
|
|
1059
|
+
// --rationale is required here and optional on approve, which is not an
|
|
1060
|
+
// inconsistency: approval can fall back to the worker's own argument
|
|
1061
|
+
// because approval agrees with it. A rejection is the operator saying
|
|
1062
|
+
// something the log does not already contain.
|
|
1063
|
+
const proposal = await rejectSpecChange({
|
|
1064
|
+
proposalId,
|
|
1065
|
+
decidedBy: requireFlag(flags, 'decided-by'),
|
|
1066
|
+
rationale: requireFlag(flags, 'rationale'),
|
|
1067
|
+
}, eventContextFromFlags(flags), { ...eventOptsFromFlags(flags), ...planOptsFromFlags(flags) });
|
|
1068
|
+
printJson(proposal);
|
|
1069
|
+
return 0;
|
|
1070
|
+
}
|
|
1071
|
+
if (namespace === 'wave' && action === 'check') {
|
|
1072
|
+
// `required: 1` — the variadic tail has its own, better message below.
|
|
1073
|
+
const [planFile] = requirePositionals(positional, usageFor('wave check'), 1);
|
|
1074
|
+
const plan = readJsonFile(planFile);
|
|
1075
|
+
// D-46/P9-29: every id is minted from the plan, not taken as typed. The
|
|
1076
|
+
// old code filtered the plan's tasks by `taskIds.includes(t.task_id)`,
|
|
1077
|
+
// so a wave named in the other id convention matched NOTHING and
|
|
1078
|
+
// validateWave([]) pronounced the empty set admissible — a wave could
|
|
1079
|
+
// pass this check without a single one of its tasks having been looked
|
|
1080
|
+
// at. resolveTaskId turns that silence into a named error.
|
|
1081
|
+
const typedIds = positional.slice(1);
|
|
1082
|
+
if (typedIds.length === 0) {
|
|
1083
|
+
throw new SmithError('cli.empty-wave', 'Usage: smith wave check <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
|
|
1084
|
+
}
|
|
1085
|
+
// D-48/P9-31: a follow-up task exists only in the log, so the log is the
|
|
1086
|
+
// second register an id may be found in — and the place its claims come
|
|
1087
|
+
// from. Read only when there is a session to read; the plan wins for an
|
|
1088
|
+
// id both know, since a re-cut plan is the newer statement of its claims.
|
|
1089
|
+
const logged = flags.session
|
|
1090
|
+
? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
|
|
1091
|
+
: [];
|
|
1092
|
+
const taskIds = typedIds.map((typed) => resolveTaskId(plan, typed, logged.map((t) => t.taskId)));
|
|
1093
|
+
// Neither register is narrowed on the way in. `?? []` used to stand here
|
|
1094
|
+
// and on the line above, and it fires on a missing claims field but not on
|
|
1095
|
+
// a claims field holding the wrong thing: a plan writing `"claims":
|
|
1096
|
+
// "src/api/**"` reached the comparison as a string and was iterated by
|
|
1097
|
+
// character. Both defaults said "claims nothing", which validateWave read
|
|
1098
|
+
// as "disjoint from everyone" — the answer that admits the wave. The shape
|
|
1099
|
+
// is that function's to judge, once, where the comparison happens.
|
|
1100
|
+
const claimsById = new Map(logged.map((t) => [t.taskId, t.claims]));
|
|
1101
|
+
for (const t of plan.tasks) {
|
|
1102
|
+
claimsById.set(t.task_id, t.claims);
|
|
1103
|
+
}
|
|
1104
|
+
const tasks = taskIds.map((id) => ({
|
|
1105
|
+
task_id: id,
|
|
1106
|
+
claims: claimsById.get(id),
|
|
1107
|
+
}));
|
|
1108
|
+
const policy = loadWorktreePolicy();
|
|
1109
|
+
// D-212: the plan has been in hand since the top of this verb, and it is
|
|
1110
|
+
// the register that says which of these tasks may not run beside which.
|
|
1111
|
+
const result = validateWave(tasks, policy, plan.edges);
|
|
1112
|
+
// P9-3: the claim check compares two lists of globs. This compares the
|
|
1113
|
+
// edges between the files those globs match, which is where the conflict
|
|
1114
|
+
// a disjoint claim list cannot see actually lives — task A changes
|
|
1115
|
+
// `parse()`, task B calls it, neither writes the other's file. A crossing
|
|
1116
|
+
// between two tasks the plan declared no edge between is a dependency the
|
|
1117
|
+
// planner missed, so the wave is refused and the remedy is to run them in
|
|
1118
|
+
// order. (A crossing the plan DID declare never reaches here: validateWave
|
|
1119
|
+
// above already refuses a wave holding both ends of a declared edge.)
|
|
1120
|
+
//
|
|
1121
|
+
// Only asked of a wave that survived the claim check: validateWave is what
|
|
1122
|
+
// proves the claims are readable at all, and a wave already refused is
|
|
1123
|
+
// better named by the failure an operator can act on first.
|
|
1124
|
+
const symbolImpact = result.valid
|
|
1125
|
+
? waveImpact(buildSymbolGraph(collectSources(flags.repo ?? REPO_ROOT)),
|
|
1126
|
+
// Readable by construction — validateWave threw otherwise.
|
|
1127
|
+
tasks)
|
|
1128
|
+
: null;
|
|
1129
|
+
const coupled = symbolImpact !== null && !symbolImpact.ok;
|
|
1130
|
+
// The epic budget gate, checked after the claim check so a wave that fails
|
|
1131
|
+
// both is still named by the failure an operator can act on first — and so
|
|
1132
|
+
// the claim errors above keep their exact wording. Two separate questions:
|
|
1133
|
+
// `valid` answers "may these run beside each other", `budget` answers "can
|
|
1134
|
+
// this epic afford to start them", and the JSON carries both because a
|
|
1135
|
+
// wave refused for cost is not an invalid wave.
|
|
1136
|
+
//
|
|
1137
|
+
// Upstream of every dispatch on purpose. D-29 rules that the *task* cap
|
|
1138
|
+
// must only record an overrun, because a self-policed cap on work already
|
|
1139
|
+
// running becomes pressure on the work being measured. Wave admission is
|
|
1140
|
+
// the other side of that line: nothing in the wave has been dispatched
|
|
1141
|
+
// yet, so refusing here distorts no work — it decides whether the work
|
|
1142
|
+
// begins.
|
|
1143
|
+
const rationaleFlag = flags['override-rationale'];
|
|
1144
|
+
const rationale = rationaleFlag?.trim() ?? '';
|
|
1145
|
+
if (rationaleFlag !== undefined && rationale === '') {
|
|
1146
|
+
// Mirrors epic.ts's close-refused check (D-43/P9-27): the reason is the
|
|
1147
|
+
// whole point of recording an override, and a blank one would leave the
|
|
1148
|
+
// log saying a human decided this with nothing to say what they decided.
|
|
1149
|
+
throw new SmithError('cli.blank-override-rationale', 'Refusing to admit this wave with a blank --override-rationale: the reason is the whole point of recording the override.', { plan: plan.epic_id });
|
|
1150
|
+
}
|
|
1151
|
+
const budgetEvents = flags.session
|
|
1152
|
+
? // D-119: lineage-wide, not this session's own log — an epic's spend is
|
|
1153
|
+
// the sum of every session that worked on it.
|
|
1154
|
+
await readLineageEvents(flags.session, eventOptsFromFlags(flags))
|
|
1155
|
+
: [];
|
|
1156
|
+
const budgetPolicy = loadBudgetPolicy(flags['budget-policy']);
|
|
1157
|
+
const budget = checkWaveBudget(budgetEvents, budgetPolicy, declaredWaveBudgets(plan, taskIds, budgetPolicy), { sessionId: flags.session ?? '', epicId: plan.epic_id });
|
|
1158
|
+
const overridden = rationale !== '' && blocksAdmission(budget.status);
|
|
1159
|
+
const blocked = blocksAdmission(budget.status) && !overridden;
|
|
1160
|
+
// Log only an admissible wave, and only after it is known to be one:
|
|
1161
|
+
// `wave-admitted` is what moves a task to `ready`, so writing it for a
|
|
1162
|
+
// wave that just failed its claim-disjointness check would record an
|
|
1163
|
+
// admission that never happened. `--dry` asks the question without
|
|
1164
|
+
// answering it in the log.
|
|
1165
|
+
if (result.valid && !coupled && !blocked && flags.dry !== 'true') {
|
|
1166
|
+
await emitWaveAdmitted(plan, taskIds, eventContextFromFlags(flags), eventOptsFromFlags(flags), admissionBudget(budget, overridden ? rationale : undefined));
|
|
1167
|
+
}
|
|
1168
|
+
printJson({ ...result, symbolImpact, budget });
|
|
1169
|
+
return result.valid && !coupled && !blocked ? 0 : 1;
|
|
1170
|
+
}
|
|
1171
|
+
if (namespace === 'wave' && action === 'next') {
|
|
1172
|
+
const [planFile] = requirePositionals(positional, usageFor('wave next'));
|
|
1173
|
+
const result = computeNextWave(await nextWaveInputFrom(planFile, flags));
|
|
1174
|
+
printJson(result);
|
|
1175
|
+
// Writes nothing on purpose. `wave-admitted` is what moves a task to
|
|
1176
|
+
// `ready`, and this command proposes rather than admits — recording an
|
|
1177
|
+
// admission here would move tasks nobody had yet agreed to start, and
|
|
1178
|
+
// leave `wave check` re-admitting what the log already said was admitted.
|
|
1179
|
+
//
|
|
1180
|
+
// The cost question is deliberately not asked here. `wave check` refuses a
|
|
1181
|
+
// wave the epic cannot afford, and it stays the only place that verdict is
|
|
1182
|
+
// rendered — a proposer that also priced the wave would be a second opinion
|
|
1183
|
+
// about the same fact, and would have to decide which task to drop, which
|
|
1184
|
+
// is an operator's call and not a graph's. So a proposed wave can still be
|
|
1185
|
+
// refused for cost on the next line of the playbook; that is the gate
|
|
1186
|
+
// working, not the proposal being wrong.
|
|
1187
|
+
//
|
|
1188
|
+
// Exit 1 names a stall, not an empty answer: work is left, and none of it
|
|
1189
|
+
// can begin. A loop driving waves needs that distinguished from the epic
|
|
1190
|
+
// simply being finished, which is an empty wave with nothing remaining.
|
|
1191
|
+
return result.wave.length > 0 || result.remaining === 0 ? 0 : 1;
|
|
1192
|
+
}
|
|
1193
|
+
if (namespace === 'wave' && action === 'schedule') {
|
|
1194
|
+
const [planFile] = requirePositionals(positional, usageFor('wave schedule'));
|
|
1195
|
+
// Static would be fine — waveSchedule.js reaches nothing waveNext.js has
|
|
1196
|
+
// not already put on the boot path — but the input assembly it is handed
|
|
1197
|
+
// may dynamically import `db/projector.js`, so the await is here anyway.
|
|
1198
|
+
const { scheduleWaves } = await import('./waveSchedule.js');
|
|
1199
|
+
const schedule = scheduleWaves(await nextWaveInputFrom(planFile, flags));
|
|
1200
|
+
printJson(schedule);
|
|
1201
|
+
// Writes nothing, for the reason `wave next` writes nothing and one more:
|
|
1202
|
+
// every round after the first is a simulation. The tasks it marks complete
|
|
1203
|
+
// were completed by nobody, and a log that recorded them would be claiming
|
|
1204
|
+
// work that has not happened.
|
|
1205
|
+
return schedule.exitCode;
|
|
1206
|
+
}
|
|
1207
|
+
if (namespace === 'wave' && action === 'audit') {
|
|
1208
|
+
// Dynamic for P9-2 (test/cliBoot.test.ts): the module reaches
|
|
1209
|
+
// agents-registry.ts, and nothing that only `wave audit` needs should be
|
|
1210
|
+
// on the path every `smith --help` walks.
|
|
1211
|
+
const { auditWaveConcurrency, summariseWaveConcurrency } = await import('./waveConcurrency.js');
|
|
1212
|
+
const sessionId = requireFlag(flags, 'session');
|
|
1213
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
1214
|
+
requireSession(sessionId, eventOpts);
|
|
1215
|
+
// Lineage-wide, for the reason `wave next` and the budget check already
|
|
1216
|
+
// are (D-119): an epic's waves are not confined to the session that
|
|
1217
|
+
// happens to ask, and a wave admitted in session 1 whose agents ran in
|
|
1218
|
+
// session 2 would read as `unobserved` -- the factory reported broken on
|
|
1219
|
+
// nothing but where the operator stood.
|
|
1220
|
+
const summary = summariseWaveConcurrency(auditWaveConcurrency(await readLineageEvents(sessionId, eventOpts), {
|
|
1221
|
+
epicId: typeof flags.epic === 'string' ? flags.epic : undefined,
|
|
1222
|
+
}));
|
|
1223
|
+
printJson(summary);
|
|
1224
|
+
return summary.exitCode;
|
|
1225
|
+
}
|
|
1226
|
+
if (namespace === 'new') {
|
|
1227
|
+
// `smith new <project> [--ui]` — action doubles as the positional
|
|
1228
|
+
// project name here (splitNamespaceAction), never a subcommand word.
|
|
1229
|
+
// `--target-dir`/`--roadmap-path` override the real projects root and
|
|
1230
|
+
// factory/specs/roadmap.md paths (same override pattern as
|
|
1231
|
+
// `--state-dir` elsewhere in this file) so tests never touch either.
|
|
1232
|
+
if (!action) {
|
|
1233
|
+
// Checked all along, but filed under the flag code; one kind of mistake,
|
|
1234
|
+
// one code (P9-28). The project name cannot go through
|
|
1235
|
+
// requirePositionals because splitNamespaceAction spends it on `action`.
|
|
1236
|
+
const usage = usageLine(usageFor('new'));
|
|
1237
|
+
throw new SmithError('cli.missing-positional', `Missing required argument <project>. Usage: ${usage}`, { missing: ['project'], usage });
|
|
1238
|
+
}
|
|
1239
|
+
const result = scaffoldProject({
|
|
1240
|
+
projectName: action,
|
|
1241
|
+
ui: flags.ui === 'true',
|
|
1242
|
+
...(flags['target-dir'] ? { targetDir: flags['target-dir'] } : {}),
|
|
1243
|
+
// P9-19: `smith new` installs the toolchain and runs the project's own
|
|
1244
|
+
// gates before it commits, so the first epic planned here starts against
|
|
1245
|
+
// a repo whose checks are known to pass. --skip-toolchain is for an
|
|
1246
|
+
// offline operator; the report then says `skipped`, never a green.
|
|
1247
|
+
...(flags['skip-toolchain'] === 'true' ? { skipToolchain: true } : {}),
|
|
1248
|
+
});
|
|
1249
|
+
registerProjectInRoadmap(action, flags['roadmap-path']);
|
|
1250
|
+
printJson(result);
|
|
1251
|
+
// Red gates are reported, not hidden: the tree stays for the operator to
|
|
1252
|
+
// fix, and the exit code says the toolchain was not proven.
|
|
1253
|
+
return result.toolchain.status === 'failed' ? 1 : 0;
|
|
1254
|
+
}
|
|
1255
|
+
if (namespace === 'mcp' && action === 'init') {
|
|
1256
|
+
// Deliberately not folded into `smith new`: the MCP surface is due at the
|
|
1257
|
+
// END of a project, when the tools it should expose are known, and the
|
|
1258
|
+
// milestone this registers is what makes that due date real. Scaffolding it
|
|
1259
|
+
// on day one would ship a manifest declaring nothing, which is exactly the
|
|
1260
|
+
// rubber-stamp the standard exists to prevent.
|
|
1261
|
+
const [projectName] = requirePositionals(positional, usageFor('mcp init'));
|
|
1262
|
+
const result = addMcpSurface({
|
|
1263
|
+
projectName,
|
|
1264
|
+
...(flags['target-dir'] ? { targetDir: flags['target-dir'] } : {}),
|
|
1265
|
+
...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
|
|
1266
|
+
});
|
|
1267
|
+
printJson(result);
|
|
1268
|
+
return 0;
|
|
1269
|
+
}
|
|
1270
|
+
// The verdict `smith epic close` gates on, rendered on its own so it can be
|
|
1271
|
+
// read before the gate refuses. Exit 1 on red — same convention as `plan
|
|
1272
|
+
// validate` and `gate run`, so CI needs no output parsing.
|
|
1273
|
+
if (namespace === 'mcp' && action === 'check') {
|
|
1274
|
+
const [projectName] = requirePositionals(positional, usageFor('mcp check'));
|
|
1275
|
+
const report = runMcpCheck({
|
|
1276
|
+
projectName,
|
|
1277
|
+
...(flags['target-dir'] ? { targetDir: flags['target-dir'] } : {}),
|
|
1278
|
+
...(flags['roadmap-path'] ? { roadmapPath: flags['roadmap-path'] } : {}),
|
|
1279
|
+
});
|
|
1280
|
+
printJson(report);
|
|
1281
|
+
return report.ok ? 0 : 1;
|
|
1282
|
+
}
|
|
1283
|
+
if (namespace === 'scheduler' && action === 'run') {
|
|
1284
|
+
const { computeProposals, runScheduler } = await import('./scheduler.js');
|
|
1285
|
+
const sessionId = requireFlag(flags, 'session');
|
|
1286
|
+
const dry = flags.dry === 'true';
|
|
1287
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
1288
|
+
// The lineage (D-119). The scheduler's idempotency is positional — a
|
|
1289
|
+
// proposal counts as resolved when the events resolving it appear LATER in
|
|
1290
|
+
// the log — so a session-scoped read from a continuation finds none of the
|
|
1291
|
+
// parent's proposals and re-proposes every recheck the epic already ran.
|
|
1292
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1293
|
+
const now = isoDateFlag(flags, 'now') ?? new Date();
|
|
1294
|
+
// `repeated`, not `flags`: --project may name several repos, and the
|
|
1295
|
+
// last-occurrence-wins read would silently watch only the final one.
|
|
1296
|
+
// This clone joins the pass by default (contract clause 1); `--no-self`
|
|
1297
|
+
// is the refusal path.
|
|
1298
|
+
const { resolveProjectDirs } = await import('./projects.js');
|
|
1299
|
+
const projectDirs = resolveProjectDirs(repeated.project, {
|
|
1300
|
+
self: flags['no-self'] !== 'true',
|
|
1301
|
+
});
|
|
1302
|
+
const input = { events, now, projectDirs };
|
|
1303
|
+
if (dry) {
|
|
1304
|
+
printJson({ proposals: computeProposals(input) });
|
|
1305
|
+
return 0;
|
|
1306
|
+
}
|
|
1307
|
+
const ctx = eventContextFromFlags(flags);
|
|
1308
|
+
const result = await runScheduler(input, {
|
|
1309
|
+
sessionId: ctx.sessionId,
|
|
1310
|
+
planVersion: ctx.planVersion,
|
|
1311
|
+
causalParent: ctx.causalParent,
|
|
1312
|
+
actor: ctx.actor,
|
|
1313
|
+
}, eventOptsFromFlags(flags), false);
|
|
1314
|
+
printJson(result);
|
|
1315
|
+
return 0;
|
|
1316
|
+
}
|
|
1317
|
+
// The other half of a scheduler tick. `run --dry` answers "what is due";
|
|
1318
|
+
// this answers "which of that may proceed without me", and answers it out
|
|
1319
|
+
// loud, in one place, from scheduler.yml's `autonomy:` block alone.
|
|
1320
|
+
//
|
|
1321
|
+
// It enacts nothing. Splitting the classification out of the dispatch is
|
|
1322
|
+
// the whole safety property: an operator can read what the policy would
|
|
1323
|
+
// have let run, and argue with it, before any of it moves. So this appends
|
|
1324
|
+
// no event, writes no worktree and starts no agent -- exactly the daemon's
|
|
1325
|
+
// invariant, held by the command a person types too.
|
|
1326
|
+
if (namespace === 'scheduler' && action === 'admit') {
|
|
1327
|
+
const { computeProposals, loadSchedulerPolicy } = await import('./scheduler.js');
|
|
1328
|
+
const { admitProposals } = await import('./autonomy.js');
|
|
1329
|
+
// Kept out of the boot graph on purpose: db/projector.js reaches
|
|
1330
|
+
// db/schema.js, and test/cliBoot.test.ts pins that `smith --help` never
|
|
1331
|
+
// pays for it. A top-level import here would be invisible until that test
|
|
1332
|
+
// failed (P9-2).
|
|
1333
|
+
const { foldTasks } = await import('./db/projector.js');
|
|
1334
|
+
const sessionId = requireFlag(flags, 'session');
|
|
1335
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
1336
|
+
// Lineage, for the same reason `run` reads it: proposal idempotency is
|
|
1337
|
+
// positional, so a session-scoped read from a continuation would classify
|
|
1338
|
+
// proposals the parent already resolved.
|
|
1339
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1340
|
+
const now = isoDateFlag(flags, 'now') ?? new Date();
|
|
1341
|
+
// Read once and passed down both halves. `--policy` has to govern which
|
|
1342
|
+
// proposals exist as well as who may say yes to them: a file that retuned
|
|
1343
|
+
// `recheck.days_elapsed` but was consulted only for `autonomy:` would
|
|
1344
|
+
// classify proposals the operator's own policy never made.
|
|
1345
|
+
const policy = loadSchedulerPolicy(flags.policy);
|
|
1346
|
+
// This clone joins the pass by default (contract clause 1); `--no-self`
|
|
1347
|
+
// is the refusal path.
|
|
1348
|
+
const { resolveProjectDirs } = await import('./projects.js');
|
|
1349
|
+
const proposals = computeProposals({
|
|
1350
|
+
events,
|
|
1351
|
+
now,
|
|
1352
|
+
policy,
|
|
1353
|
+
projectDirs: resolveProjectDirs(repeated.project, { self: flags['no-self'] !== 'true' }),
|
|
1354
|
+
});
|
|
1355
|
+
// A RecheckProposal names a task and no paths, so without this the
|
|
1356
|
+
// security match would see an opaque id and clear a recheck of
|
|
1357
|
+
// src/auth/session.ts. The claims come from the log, through the same
|
|
1358
|
+
// fold every other reader uses.
|
|
1359
|
+
const claimsByTask = new Map();
|
|
1360
|
+
for (const task of foldTasks(events)) {
|
|
1361
|
+
if (task.claims && task.claims.length > 0)
|
|
1362
|
+
claimsByTask.set(task.taskId, task.claims);
|
|
1363
|
+
}
|
|
1364
|
+
const admissions = admitProposals(proposals, policy.autonomy, {
|
|
1365
|
+
// One list, read from crosscheck.yml rather than copied into
|
|
1366
|
+
// scheduler.yml: promoting a word has to move the cross-check trigger
|
|
1367
|
+
// and this gate together, or a keyword added in one place quietly
|
|
1368
|
+
// stops meaning anything in the other.
|
|
1369
|
+
securityKeywords: loadCrosscheckPolicy(flags.crosscheck).planQuorum.securityKeywords,
|
|
1370
|
+
claimsByTask,
|
|
1371
|
+
});
|
|
1372
|
+
printJson({ admissions });
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
// The background watcher (Phase 10). Four verbs and one invariant: none of
|
|
1376
|
+
// them dispatches an agent, merges a branch or writes to a worktree. A
|
|
1377
|
+
// process that outlives the operator's terminal is the last place to relax
|
|
1378
|
+
// the rule that a human admits work.
|
|
1379
|
+
if (namespace === 'daemon' && action === 'run') {
|
|
1380
|
+
const { DEFAULT_DAEMON_DIR, DEFAULT_INTERVAL_SECONDS, runDaemon } = await import('./daemon.js');
|
|
1381
|
+
const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
|
|
1382
|
+
const interval = flags.interval === undefined ? DEFAULT_INTERVAL_SECONDS : Number(flags.interval);
|
|
1383
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
1384
|
+
throw new SmithError('cli.invalid-flag', `--interval must be a whole number of seconds greater than zero, got "${flags.interval}".`, { flag: 'interval', value: flags.interval ?? null });
|
|
1385
|
+
}
|
|
1386
|
+
// The register that judges `--project`, supplied here rather than defaulted
|
|
1387
|
+
// inside the fold: this is where the flags are read, so this is where the
|
|
1388
|
+
// list they should have covered belongs. A tick handed no register raises
|
|
1389
|
+
// no `unwatched-project`, which keeps inspectFactory's answer a function of
|
|
1390
|
+
// its arguments.
|
|
1391
|
+
const { factoryProjects, resolveProjectDirs } = await import('./projects.js');
|
|
1392
|
+
// This clone joins the pass by default (contract clause 1); `--no-self`
|
|
1393
|
+
// is the refusal path. It has to drop this clone from BOTH sides of the
|
|
1394
|
+
// fold: leaving it in the register while pulling it from `projectDirs`
|
|
1395
|
+
// would still fold to an `unwatched-project` finding for it, which is
|
|
1396
|
+
// exactly the alarm `--no-self` exists to silence.
|
|
1397
|
+
const self = flags['no-self'] !== 'true';
|
|
1398
|
+
const tickOpts = {
|
|
1399
|
+
...eventOptsFromFlags(flags),
|
|
1400
|
+
projectDirs: resolveProjectDirs(repeated.project, { self }),
|
|
1401
|
+
readProjects: () => factoryProjects().filter((ref) => self || !ref.self),
|
|
1402
|
+
...(flags.db ? { dbPath: flags.db } : {}),
|
|
1403
|
+
...(flags['no-db'] === 'true' ? { projectDb: false } : {}),
|
|
1404
|
+
};
|
|
1405
|
+
// SIGTERM has to reach the sleep, not just the flag: a daemon woken only
|
|
1406
|
+
// by the next tick would ignore `smith daemon stop` for up to an interval,
|
|
1407
|
+
// and an operator who waits that long reaches for `kill -9` — which is
|
|
1408
|
+
// exactly the exit that strands the lock.
|
|
1409
|
+
let stopping = false;
|
|
1410
|
+
let wake = null;
|
|
1411
|
+
const requestStop = () => {
|
|
1412
|
+
stopping = true;
|
|
1413
|
+
wake?.();
|
|
1414
|
+
};
|
|
1415
|
+
process.once('SIGTERM', requestStop);
|
|
1416
|
+
process.once('SIGINT', requestStop);
|
|
1417
|
+
const reports = await runDaemon({
|
|
1418
|
+
dir,
|
|
1419
|
+
intervalSeconds: interval,
|
|
1420
|
+
...tickOpts,
|
|
1421
|
+
...(flags.once === 'true' ? { once: true } : {}),
|
|
1422
|
+
shouldContinue: () => !stopping,
|
|
1423
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
1424
|
+
const timer = setTimeout(() => {
|
|
1425
|
+
wake = null;
|
|
1426
|
+
resolve();
|
|
1427
|
+
}, ms);
|
|
1428
|
+
wake = () => {
|
|
1429
|
+
clearTimeout(timer);
|
|
1430
|
+
wake = null;
|
|
1431
|
+
resolve();
|
|
1432
|
+
};
|
|
1433
|
+
}),
|
|
1434
|
+
});
|
|
1435
|
+
const last = reports[reports.length - 1];
|
|
1436
|
+
printJson({ ticks: reports.length, dir, ...(last === undefined ? {} : { last }) });
|
|
1437
|
+
return 0;
|
|
1438
|
+
}
|
|
1439
|
+
if (namespace === 'daemon' && action === 'start') {
|
|
1440
|
+
const { DaemonError, DEFAULT_DAEMON_DIR, readLock } = await import('./daemon.js');
|
|
1441
|
+
const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
|
|
1442
|
+
const held = readLock(dir);
|
|
1443
|
+
if (held !== null) {
|
|
1444
|
+
// Let acquireLock own the liveness question — one implementation of
|
|
1445
|
+
// "is that pid still there" rather than a second opinion here.
|
|
1446
|
+
throw new DaemonError('daemon.already-running', `A daemon (pid ${held.pid}, started ${held.startedAt}) already holds ${dir}. ` +
|
|
1447
|
+
'Run `smith daemon stop` first, or `smith daemon status` to see what it last found.', { pid: held.pid, dir });
|
|
1448
|
+
}
|
|
1449
|
+
mkdirSync(dir, { recursive: true });
|
|
1450
|
+
// `ignore` would discard the one account of why a detached daemon died.
|
|
1451
|
+
const logFd = openSync(path.join(dir, 'daemon.log'), 'a');
|
|
1452
|
+
const argv = [
|
|
1453
|
+
'daemon',
|
|
1454
|
+
'run',
|
|
1455
|
+
'--dir',
|
|
1456
|
+
dir,
|
|
1457
|
+
...(flags.interval === undefined ? [] : ['--interval', flags.interval]),
|
|
1458
|
+
// One `--project` per repo, so the detached child watches every repo the
|
|
1459
|
+
// operator named rather than the last one they typed.
|
|
1460
|
+
...(repeated.project ?? []).flatMap((dir) => ['--project', dir]),
|
|
1461
|
+
...(flags.db === undefined ? [] : ['--db', flags.db]),
|
|
1462
|
+
...(flags['no-db'] === 'true' ? ['--no-db'] : []),
|
|
1463
|
+
...(flags['no-self'] === 'true' ? ['--no-self'] : []),
|
|
1464
|
+
...(flags['state-dir'] === undefined ? [] : ['--state-dir', flags['state-dir']]),
|
|
1465
|
+
];
|
|
1466
|
+
const child = spawn(process.execPath, [process.argv[1], ...argv], {
|
|
1467
|
+
detached: true,
|
|
1468
|
+
stdio: ['ignore', logFd, logFd],
|
|
1469
|
+
});
|
|
1470
|
+
child.unref();
|
|
1471
|
+
// The child writes the lock under its OWN pid; this one is the spawn's
|
|
1472
|
+
// answer, and `smith daemon status` is what confirms the lock exists.
|
|
1473
|
+
printJson({ started: true, pid: child.pid ?? null, dir, log: path.join(dir, 'daemon.log') });
|
|
1474
|
+
return 0;
|
|
1475
|
+
}
|
|
1476
|
+
if (namespace === 'daemon' && action === 'status') {
|
|
1477
|
+
const { DEFAULT_DAEMON_DIR, daemonStatus } = await import('./daemon.js');
|
|
1478
|
+
const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
|
|
1479
|
+
const report = daemonStatus(dir);
|
|
1480
|
+
printJson(report);
|
|
1481
|
+
// Exit 1 when nothing is watching, so a health check is `smith daemon
|
|
1482
|
+
// status >/dev/null` rather than a JSON parse in a shell script. A stale
|
|
1483
|
+
// daemon fails it too: one wedged mid-tick still holds its lock and still
|
|
1484
|
+
// answers `kill -0`, so a probe that only asked `running` reported the
|
|
1485
|
+
// silence as fine -- which is the exact condition a watcher exists to
|
|
1486
|
+
// break. The JSON still separates the two, for a reader that cares which.
|
|
1487
|
+
return report.running && !report.stale ? 0 : 1;
|
|
1488
|
+
}
|
|
1489
|
+
if (namespace === 'daemon' && action === 'stop') {
|
|
1490
|
+
const { DEFAULT_DAEMON_DIR, stopDaemon } = await import('./daemon.js');
|
|
1491
|
+
const dir = flags.dir ?? DEFAULT_DAEMON_DIR;
|
|
1492
|
+
printJson(stopDaemon(dir));
|
|
1493
|
+
return 0;
|
|
1494
|
+
}
|
|
1495
|
+
// What the factory is answerable for, read off the register it already keeps:
|
|
1496
|
+
// `registerProjectInRoadmap` writes a `- project:` bullet for every project
|
|
1497
|
+
// it scaffolds. Without this the `unwatched-project` finding would name a
|
|
1498
|
+
// repo and leave the operator to reconstruct the rest of the list by hand.
|
|
1499
|
+
if (namespace === 'projects' && action === 'list') {
|
|
1500
|
+
const { factoryProjects, missingProjects } = await import('./projects.js');
|
|
1501
|
+
const roadmapOpts = flags.roadmap === undefined ? {} : { roadmapPath: flags.roadmap };
|
|
1502
|
+
const refs = factoryProjects(roadmapOpts);
|
|
1503
|
+
const missing = missingProjects(roadmapOpts);
|
|
1504
|
+
// The flag line is built from `refs` alone, so a declared-and-missing
|
|
1505
|
+
// project can never reach it -- there is no directory to paste.
|
|
1506
|
+
const flagLine = refs.map((ref) => `--project ${ref.dir}`).join(' ');
|
|
1507
|
+
if (flags.json) {
|
|
1508
|
+
printJson({ projects: refs, missing, flags: flagLine });
|
|
1509
|
+
}
|
|
1510
|
+
else {
|
|
1511
|
+
// The flag line last, because it is the line the operator copies, and a
|
|
1512
|
+
// thing to copy is easier to find at the bottom than in the middle.
|
|
1513
|
+
// `?` marks a declared-and-missing project -- distinct from `*` (self)
|
|
1514
|
+
// and the space that marks a resolved project.
|
|
1515
|
+
for (const ref of refs) {
|
|
1516
|
+
process.stdout.write(`${ref.self ? '*' : ' '} ${ref.name}\t${ref.dir}\n`);
|
|
1517
|
+
}
|
|
1518
|
+
for (const m of missing) {
|
|
1519
|
+
process.stdout.write(`? ${m.name}\tnot found under ${m.roots.join(', ')}\n`);
|
|
1520
|
+
}
|
|
1521
|
+
process.stdout.write(`\n${flagLine}\n`);
|
|
1522
|
+
}
|
|
1523
|
+
return 0;
|
|
1524
|
+
}
|
|
1525
|
+
if (namespace === 'worktree' && action === 'create') {
|
|
1526
|
+
const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree create'));
|
|
1527
|
+
printJson(createTaskWorktree(projectDir, epic, taskId));
|
|
1528
|
+
return 0;
|
|
1529
|
+
}
|
|
1530
|
+
if (namespace === 'worktree' && action === 'rm') {
|
|
1531
|
+
const [projectDir, epic, taskId] = requirePositionals(positional, usageFor('worktree rm'));
|
|
1532
|
+
removeTaskWorktree(projectDir, epic, taskId);
|
|
1533
|
+
printJson({ removed: true, epic, taskId });
|
|
1534
|
+
return 0;
|
|
1535
|
+
}
|
|
1536
|
+
// The judge-immutability guard (P9-5): fingerprint before the judge runs,
|
|
1537
|
+
// verify after. Six judge roles are read-only in prose and hold `Bash` in
|
|
1538
|
+
// fact (agent-interviews.md N-10) — this turns the sentence into a check.
|
|
1539
|
+
if (namespace === 'worktree' && action === 'fingerprint') {
|
|
1540
|
+
const [worktreeDir] = requirePositionals(positional, usageFor('worktree fingerprint'));
|
|
1541
|
+
printJson(fingerprintWorktree(worktreeDir));
|
|
1542
|
+
return 0;
|
|
1543
|
+
}
|
|
1544
|
+
if (namespace === 'worktree' && action === 'verify') {
|
|
1545
|
+
// No `required` override any more: `--before <fingerprint.json>` is a flag,
|
|
1546
|
+
// and since P9-21 flags live outside the counted string.
|
|
1547
|
+
const [worktreeDir] = requirePositionals(positional, usageFor('worktree verify'));
|
|
1548
|
+
const before = readJsonFile(requireFlag(flags, 'before'));
|
|
1549
|
+
const result = checkWorktreeImmutable(worktreeDir, before);
|
|
1550
|
+
printJson(result);
|
|
1551
|
+
// Unlike `security triggers`, drift is a violation, not an instruction:
|
|
1552
|
+
// the judge's result is not trustworthy once it moved what it judged.
|
|
1553
|
+
return result.unchanged ? 0 : 1;
|
|
1554
|
+
}
|
|
1555
|
+
if (namespace === 'worktree' && action === 'stale') {
|
|
1556
|
+
const [projectDir, epic] = requirePositionals(positional, usageFor('worktree stale'));
|
|
1557
|
+
printJson(listStale(projectDir, epic));
|
|
1558
|
+
return 0;
|
|
1559
|
+
}
|
|
1560
|
+
// P9-6: the two ends of ingested text. `prompt wrap` fences a payload before
|
|
1561
|
+
// it enters a prompt; `research check` keeps a brief's citations separable
|
|
1562
|
+
// from its recommendation on the way back out.
|
|
1563
|
+
if (namespace === 'prompt' && action === 'wrap') {
|
|
1564
|
+
const [file] = requirePositionals(positional, usageFor('prompt wrap'), 1);
|
|
1565
|
+
const block = wrapIngested({
|
|
1566
|
+
// `-` reads stdin's usual role here: the payload is often a fetch that
|
|
1567
|
+
// was never a file. Kept explicit rather than implicit so a file named
|
|
1568
|
+
// `-` is a caller error, not a silent stdin read.
|
|
1569
|
+
text: file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8'),
|
|
1570
|
+
kind: requireFlag(flags, 'kind'),
|
|
1571
|
+
source: requireFlag(flags, 'source'),
|
|
1572
|
+
});
|
|
1573
|
+
if (flags.json) {
|
|
1574
|
+
printJson(block);
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
// The default output is the block itself, because the caller is composing
|
|
1578
|
+
// a prompt: piping JSON into one would fence the escaping in quotes.
|
|
1579
|
+
process.stdout.write(`${block.text}\n`);
|
|
1580
|
+
}
|
|
1581
|
+
return 0;
|
|
1582
|
+
}
|
|
1583
|
+
// D-142: the writer `user_prompt` never had. Five readers folded the type —
|
|
1584
|
+
// the prompts table, the Decisions lens, the escalation window, the UI's
|
|
1585
|
+
// Prompts filter — and the only way one reached a log was a person hand-
|
|
1586
|
+
// writing `smith event append`, which nobody did. It sits beside `prompt
|
|
1587
|
+
// wrap` because both are about what enters a prompt honestly: wrap labels
|
|
1588
|
+
// borrowed text, record puts the operator's own on the record.
|
|
1589
|
+
if (namespace === 'prompt' && action === 'record') {
|
|
1590
|
+
const [file] = requirePositionals(positional, usageFor('prompt record'), 1);
|
|
1591
|
+
// Same `-` convention as `prompt wrap`, and for a stronger reason: an
|
|
1592
|
+
// operator turn is typed, not filed, so stdin is the ordinary case here
|
|
1593
|
+
// and a heredoc is how it arrives.
|
|
1594
|
+
const text = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
|
|
1595
|
+
const stored = await recordUserPrompt(text, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
1596
|
+
// The event id, not the record: what the caller does next is pass it as
|
|
1597
|
+
// `--causal-parent` to the dispatch this prompt caused, which is the edge
|
|
1598
|
+
// that makes the interleaved timeline (architecture §7) real rather than
|
|
1599
|
+
// inferred from timestamps.
|
|
1600
|
+
printJson({ event_id: stored.event_id, record: stored.record });
|
|
1601
|
+
return 0;
|
|
1602
|
+
}
|
|
1603
|
+
if (namespace === 'research' && action === 'check') {
|
|
1604
|
+
const result = checkBrief(readJsonFile(requireFlag(flags, 'brief')));
|
|
1605
|
+
printJson(result);
|
|
1606
|
+
// A brief whose claims are uncited, or whose recommendation rests on
|
|
1607
|
+
// nothing in it, is a contract violation like a failed claims check —
|
|
1608
|
+
// not a dispatch instruction, so the exit code is the verdict.
|
|
1609
|
+
return result.ok ? 0 : 1;
|
|
1610
|
+
}
|
|
1611
|
+
if (namespace === 'queue' && action === 'run') {
|
|
1612
|
+
const [epic] = requirePositionals(positional, usageFor('queue run'));
|
|
1613
|
+
// Same disease as the positionals, on the flag side, in the one verb that
|
|
1614
|
+
// runs git: `flags.project as string` put an undefined cwd into every
|
|
1615
|
+
// command step() shells out to.
|
|
1616
|
+
const projectDir = requireFlag(flags, 'project');
|
|
1617
|
+
const testCmd = requireFlag(flags, 'test-cmd');
|
|
1618
|
+
const tasksFile = requireFlag(flags, 'tasks');
|
|
1619
|
+
// D-260: the narrowed command is validated here, once, before any git
|
|
1620
|
+
// runs. A template with no `{files}` would otherwise render as the full
|
|
1621
|
+
// suite on every task while the outcome said `selected` — a gate that
|
|
1622
|
+
// lies about its own coverage is worse than a slow one.
|
|
1623
|
+
const selectTestCmd = flags['select-test-cmd'];
|
|
1624
|
+
if (selectTestCmd !== undefined)
|
|
1625
|
+
assertSelectableTestCmd(selectTestCmd);
|
|
1626
|
+
const tasks = readJsonFile(tasksFile);
|
|
1627
|
+
// D-46/P9-29: the queue is the only component that knows a branch landed,
|
|
1628
|
+
// and it used to say so to stdout and nowhere else — which is why the
|
|
1629
|
+
// projector's `completed` column was unreachable by machine. `--session`
|
|
1630
|
+
// is what turns the run into a fact; without it the queue still runs, and
|
|
1631
|
+
// still tells nobody.
|
|
1632
|
+
const events = flags.session
|
|
1633
|
+
? { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) }
|
|
1634
|
+
: undefined;
|
|
1635
|
+
// `--tasks` is hand-written, so its ids are whatever was typed. Minting
|
|
1636
|
+
// them from the plan before any git runs is the whole point of P9-29: a
|
|
1637
|
+
// bare id here would put `wave-merged` in the log under a spelling the
|
|
1638
|
+
// plan never used, which is how the dogfood epic folded one task instead
|
|
1639
|
+
// of six. Refuse the run whole rather than merge some and mislabel them.
|
|
1640
|
+
if (events && !flags.plan) {
|
|
1641
|
+
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 });
|
|
1642
|
+
}
|
|
1643
|
+
if (flags.plan) {
|
|
1644
|
+
const plan = readJsonFile(flags.plan);
|
|
1645
|
+
// …and the plan is not the only register: a follow-up minted by
|
|
1646
|
+
// `findings raise` is in the log alone, and refusing it here is what
|
|
1647
|
+
// left D-41's own follow-up unmergeable (D-48/P9-31).
|
|
1648
|
+
const logged = flags.session
|
|
1649
|
+
? await readAddedTasks({ sessionId: flags.session }, eventOptsFromFlags(flags))
|
|
1650
|
+
: [];
|
|
1651
|
+
const loggedIds = logged.map((t) => t.taskId);
|
|
1652
|
+
for (const task of tasks)
|
|
1653
|
+
task.taskId = resolveTaskId(plan, task.taskId, loggedIds);
|
|
1654
|
+
// D-186: the ids are the plan's now, so the order can be too. `--tasks`
|
|
1655
|
+
// is hand-written, and merging in the order someone typed lets a task
|
|
1656
|
+
// land before the task it declares `depends_on` — the epic's cumulative
|
|
1657
|
+
// gate then runs against an integration branch missing the prerequisite,
|
|
1658
|
+
// and blames the wrong branch when it goes red. `admit()` exists for
|
|
1659
|
+
// exactly this and had no caller until here; a follow-up the plan never
|
|
1660
|
+
// declared carries no edges, so it just sorts by id among its peers.
|
|
1661
|
+
const order = admit(tasks.map((t) => ({ task_id: t.taskId })), plan.edges.map((e) => ({ task: e.task, dependsOn: e.dependsOn })));
|
|
1662
|
+
// Stable: two tasks the plan does not order keep the order admit gave
|
|
1663
|
+
// them, and a duplicated id is still run twice rather than dropped.
|
|
1664
|
+
tasks.sort((a, b) => order.indexOf(a.taskId) - order.indexOf(b.taskId));
|
|
1665
|
+
}
|
|
1666
|
+
const outcomes = [];
|
|
1667
|
+
let allMerged = true;
|
|
1668
|
+
for (const task of tasks) {
|
|
1669
|
+
const outcome = await step(task, {
|
|
1670
|
+
projectDir,
|
|
1671
|
+
epic,
|
|
1672
|
+
testCmd,
|
|
1673
|
+
...(selectTestCmd !== undefined ? { selectTestCmd } : {}),
|
|
1674
|
+
...(events ? { events } : {}),
|
|
1675
|
+
});
|
|
1676
|
+
outcomes.push(outcome);
|
|
1677
|
+
if (outcome.outcome !== 'merged') {
|
|
1678
|
+
allMerged = false;
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
printJson(outcomes);
|
|
1683
|
+
return allMerged ? 0 : 1;
|
|
1684
|
+
}
|
|
1685
|
+
if (namespace === 'queue' && action === 'adopt') {
|
|
1686
|
+
const [typedTaskId] = requirePositionals(positional, usageFor('queue adopt'));
|
|
1687
|
+
const projectDir = requireFlag(flags, 'project');
|
|
1688
|
+
const mergeCommit = requireFlag(flags, 'merge-commit');
|
|
1689
|
+
// Every flag here is required, unlike `queue run`'s optional session: this
|
|
1690
|
+
// verb's entire output is one `wave-merged`, so a run without a session to
|
|
1691
|
+
// write it into has done nothing, and a run without a plan would write it
|
|
1692
|
+
// under whatever id was typed — the D-46/P9-29 mislabelling, reintroduced
|
|
1693
|
+
// by the command that exists to repair its aftermath.
|
|
1694
|
+
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
1695
|
+
const sessionId = requireFlag(flags, 'session');
|
|
1696
|
+
const logged = await readAddedTasks({ sessionId }, eventOptsFromFlags(flags));
|
|
1697
|
+
const taskId = resolveTaskId(plan, typedTaskId, logged.map((t) => t.taskId));
|
|
1698
|
+
const outcome = await adopt(
|
|
1699
|
+
// The branch is derived, never typed. A `--branch` override would let the
|
|
1700
|
+
// operator hand this the OTHER task's branch — which really is a parent of
|
|
1701
|
+
// the merge — and adopt any task with it, which is the forgery the whole
|
|
1702
|
+
// verb exists to prevent. Deriving it from the plan-resolved id means the
|
|
1703
|
+
// branch and the id it is logged under cannot disagree.
|
|
1704
|
+
{ taskId, branch: taskBranchName(plan.epic_id, taskId) }, {
|
|
1705
|
+
projectDir,
|
|
1706
|
+
epic: plan.epic_id,
|
|
1707
|
+
mergeCommit,
|
|
1708
|
+
events: { ctx: eventContextFromFlags(flags), ...eventOptsFromFlags(flags) },
|
|
1709
|
+
});
|
|
1710
|
+
printJson(outcome);
|
|
1711
|
+
return 0;
|
|
1712
|
+
}
|
|
1713
|
+
if (namespace === 'session' && action === 'start') {
|
|
1714
|
+
const [sessionId] = requirePositionals(positional, usageFor('session start'));
|
|
1715
|
+
// Every other write verb takes `--session` and `--causal-parent` and can
|
|
1716
|
+
// therefore assume a log. This is the one that cannot, so it is the one
|
|
1717
|
+
// command in the CLI whose whole argument list is what it is about.
|
|
1718
|
+
printJson(await startSession(sessionId, {
|
|
1719
|
+
...eventOptsFromFlags(flags),
|
|
1720
|
+
...(flags.actor === undefined ? {} : { actor: flags.actor }),
|
|
1721
|
+
...(flags.continues === undefined ? {} : { continues: flags.continues }),
|
|
1722
|
+
}));
|
|
1723
|
+
return 0;
|
|
1724
|
+
}
|
|
1725
|
+
if (namespace === 'event' && action === 'append') {
|
|
1726
|
+
const [json] = requirePositionals(positional, usageFor('event append'));
|
|
1727
|
+
const input = JSON.parse(json);
|
|
1728
|
+
const result = await appendEvent(input, eventOptsFromFlags(flags));
|
|
1729
|
+
// D-163. `event_type` is open here and closed in timeline(): the schema
|
|
1730
|
+
// keeps the write side free so an unknown type is never rejected and lost,
|
|
1731
|
+
// and the read side filters to timelineEventTypes() so the operator's
|
|
1732
|
+
// screen stays a timeline rather than a firehose. Both are deliberate.
|
|
1733
|
+
// What was missing is any word to the writer that they had landed on the
|
|
1734
|
+
// far side of that line — this command accepted nineteen improvised types
|
|
1735
|
+
// from the factory's own operator skill, receipted every one as success,
|
|
1736
|
+
// and none of the 25 records ever reached the screen. Exit stays 0: the
|
|
1737
|
+
// write worked, and refusing it is precisely what the open side prevents.
|
|
1738
|
+
// Loaded here rather than at the top of the branch, for the reason `ui
|
|
1739
|
+
// serve` states below: the append is the command, and a malformed payload
|
|
1740
|
+
// should cost a message rather than the module loading it never needed.
|
|
1741
|
+
// `timelineEventTypes()` reads the taxonomy and no database, but it lives
|
|
1742
|
+
// beside the queries that do.
|
|
1743
|
+
const { timelineEventTypes } = await import('./db/queries.js');
|
|
1744
|
+
const onTimeline = timelineEventTypes().includes(result.record.event_type);
|
|
1745
|
+
if (!onTimeline) {
|
|
1746
|
+
process.stderr.write(`warning: event_type "${result.record.event_type}" is not read by the operator timeline. ` +
|
|
1747
|
+
`${result.event_id} is written and durable, but timeline() filters it out under every ` +
|
|
1748
|
+
'filter. Use a gate_event/graph_event value from factory/policies/taxonomy.yml, or add ' +
|
|
1749
|
+
'the type to FREE_TIMELINE_EVENT_TYPES in factory/orchestrator/src/db/queries.ts.\n');
|
|
1750
|
+
}
|
|
1751
|
+
// D-245. The same shape of receipt, for the other half of the record the
|
|
1752
|
+
// writer can get silently wrong. `task_id` is read at the top level, beside
|
|
1753
|
+
// `session_id`; SKILL.md used to list the dispatch payload's fields and say
|
|
1754
|
+
// nothing about where the task id went, so 29 hand-written dispatches
|
|
1755
|
+
// across two dogfood sessions put it inside `payload` instead. Every
|
|
1756
|
+
// reader now takes it from either level, but only the top-level field is a
|
|
1757
|
+
// column, so a payload-only id answers no task-scoped query typed against
|
|
1758
|
+
// the DB. Exit stays 0: the write is valid and durable, and the id is not
|
|
1759
|
+
// lost -- it is just in the slower place.
|
|
1760
|
+
const payloadTaskId = result.record.payload?.task_id;
|
|
1761
|
+
const envelopeTaskId = result.record.task_id;
|
|
1762
|
+
if (typeof payloadTaskId === 'string' &&
|
|
1763
|
+
payloadTaskId.length > 0 &&
|
|
1764
|
+
(typeof envelopeTaskId !== 'string' || envelopeTaskId.length === 0)) {
|
|
1765
|
+
process.stderr.write(`warning: "${payloadTaskId}" names a task from inside the payload, and the event's own ` +
|
|
1766
|
+
`task_id is empty. ${result.event_id} is written and durable, and the folds read both ` +
|
|
1767
|
+
'levels, but only the top-level field is indexed. Put task_id beside session_id, at the ' +
|
|
1768
|
+
'top level of the JSON.\n');
|
|
1769
|
+
}
|
|
1770
|
+
// D-261. The third of these receipts, for the field the two above do not
|
|
1771
|
+
// cover: a log has one root, and this command is structurally unable to be
|
|
1772
|
+
// what enforces that. `causal_parent: null` on a `session-start` is exactly
|
|
1773
|
+
// what the writer's rule permits, so a second one into a log that already
|
|
1774
|
+
// has a root passes every check and comes back as a success -- and no
|
|
1775
|
+
// reader ever looks at it. `sessionLineage` takes the FIRST root, and the
|
|
1776
|
+
// tree-of-sessions reading the cross-session edge rests on assumes one
|
|
1777
|
+
// entry point per session. Exit stays 0 for the same reason it does above:
|
|
1778
|
+
// the record is valid and durable, and refusing it is not this side's job.
|
|
1779
|
+
// `smith session start` is the side that can refuse.
|
|
1780
|
+
if (result.record.event_type === 'session-start' &&
|
|
1781
|
+
result.event_id !== `${result.record.session_id}#0`) {
|
|
1782
|
+
process.stderr.write(`warning: this session-start is ${result.event_id}, not ${result.record.session_id}#0 — ` +
|
|
1783
|
+
`session "${result.record.session_id}" was already open. The record is written and ` +
|
|
1784
|
+
'durable, but a log has one root and readers take the first, so nothing will read this ' +
|
|
1785
|
+
'one. Use `smith session start`, which refuses instead of receipting, and chain off the ' +
|
|
1786
|
+
'event that is already there.\n');
|
|
1787
|
+
}
|
|
1788
|
+
printJson({ ...result, on_timeline: onTimeline });
|
|
1789
|
+
return 0;
|
|
1790
|
+
}
|
|
1791
|
+
if (namespace === 'event' && action === 'tail') {
|
|
1792
|
+
const [sessionId] = requirePositionals(positional, usageFor('event tail'));
|
|
1793
|
+
const n = boundedIntFlag(flags, 'n', { min: 1 }) ?? 20;
|
|
1794
|
+
const opts = eventOptsFromFlags(flags);
|
|
1795
|
+
// P9-28: and the id has to name a session that exists. This is the verb an
|
|
1796
|
+
// operator reaches for when they are not sure what the log holds, so "your
|
|
1797
|
+
// session is empty" was the one wrong answer it could give to a typo.
|
|
1798
|
+
requireSession(sessionId, opts);
|
|
1799
|
+
// P9-7: --lineage reads the epic rather than the session. An epic split
|
|
1800
|
+
// across operator sessions has its recent history in whichever session ran
|
|
1801
|
+
// last, and the plain tail would show only that one.
|
|
1802
|
+
//
|
|
1803
|
+
// Through readLineageEvents since D-119, which merges the logs by `ts`
|
|
1804
|
+
// rather than concatenating them root-first. On a tail that matters: the
|
|
1805
|
+
// last n events of a concatenation are the last n of the LAST session, so
|
|
1806
|
+
// an operator resuming an epic saw its newest events padded with nothing
|
|
1807
|
+
// from before the split. Now `--lineage` tails the epic in time order.
|
|
1808
|
+
let events = flags.lineage
|
|
1809
|
+
? (await readLineageEvents(sessionId, opts)).slice(-n)
|
|
1810
|
+
: await tailEvents(sessionId, n, opts);
|
|
1811
|
+
if (flags.task)
|
|
1812
|
+
events = filterEvents(events, { taskId: flags.task });
|
|
1813
|
+
printJson(events);
|
|
1814
|
+
return 0;
|
|
1815
|
+
}
|
|
1816
|
+
if (namespace === 'event' && action === 'lineage') {
|
|
1817
|
+
const [sessionId] = requirePositionals(positional, usageFor('event lineage'));
|
|
1818
|
+
const tree = await sessionTree(sessionId, eventOptsFromFlags(flags));
|
|
1819
|
+
// D-266. `lineage` is every session a lineage-wide fold reads, because a
|
|
1820
|
+
// display verb that draws a narrower scope than the gates decide on is the
|
|
1821
|
+
// reason the operator trusts the wrong picture. `depth` stays the length of
|
|
1822
|
+
// the ancestry alone -- it has always meant "how many windows back does
|
|
1823
|
+
// this go", and a fan-out is not depth. `continued_by` is the half that is
|
|
1824
|
+
// new, and printing it as its own field is what makes the two readable
|
|
1825
|
+
// apart: lineage === [...ancestry, ...continued_by].
|
|
1826
|
+
printJson({
|
|
1827
|
+
session: sessionId,
|
|
1828
|
+
lineage: tree.sessions,
|
|
1829
|
+
depth: tree.ancestry.length,
|
|
1830
|
+
root: tree.ancestry[0],
|
|
1831
|
+
continued_by: tree.continuations,
|
|
1832
|
+
});
|
|
1833
|
+
return 0;
|
|
1834
|
+
}
|
|
1835
|
+
if (namespace === 'dispatch' && action === 'check') {
|
|
1836
|
+
// P9-23: crosscheck.yml's finder_ne_critic, asserted against the log
|
|
1837
|
+
// instead of trusted. Fail-closed — `unverifiable` exits 1 exactly like a
|
|
1838
|
+
// violation, because a check that cannot answer must not read as a pass;
|
|
1839
|
+
// see dispatchAudit.ts for why each status means what it means.
|
|
1840
|
+
// required: 1 — `<id>` and `<path>` in the usage line are flag values,
|
|
1841
|
+
// and requirePositionals reads every `<placeholder>` positionally.
|
|
1842
|
+
const [sessionId] = requirePositionals(positional, usageFor('dispatch check'), 1);
|
|
1843
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
1844
|
+
// Lineage-wide (D-119): a finder dispatched before the session split and
|
|
1845
|
+
// its critic after it is precisely the pairing this check exists to catch,
|
|
1846
|
+
// and one session's log holds half of it.
|
|
1847
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1848
|
+
const pairs = loadCrosscheckPolicy(flags.policy).asymmetricRoles.pairs;
|
|
1849
|
+
const report = checkDispatchAsymmetry(events, pairs, {
|
|
1850
|
+
sessionId,
|
|
1851
|
+
...(flags.task ? { taskId: flags.task } : {}),
|
|
1852
|
+
});
|
|
1853
|
+
printJson(report);
|
|
1854
|
+
return report.ok ? 0 : 1;
|
|
1855
|
+
}
|
|
1856
|
+
if (namespace === 'tester' && action === 'check') {
|
|
1857
|
+
// The other half of `dispatch check`: that one asks whether the critic ran
|
|
1858
|
+
// on the finder's model, this one asks whether a tester ran at all, in a
|
|
1859
|
+
// turn of its own, before the gate that graded its tests. Same fail-closed
|
|
1860
|
+
// contract — `unverifiable` exits 1 — and testerAudit.ts says why absence
|
|
1861
|
+
// is a violation here where it is `not-applicable` there.
|
|
1862
|
+
// required: 1 — `<id>` and `<path>` in the usage line are flag values.
|
|
1863
|
+
const [sessionId] = requirePositionals(positional, usageFor('tester check'), 1);
|
|
1864
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
1865
|
+
// Lineage-wide (D-119), for `dispatch check`'s reason: a coder dispatched
|
|
1866
|
+
// before a session split and its tester after it is exactly the pairing
|
|
1867
|
+
// this check reads, and one session's log holds half of it.
|
|
1868
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1869
|
+
const pairs = loadCrosscheckPolicy(flags.policy).roleIsolation.pairs;
|
|
1870
|
+
const report = checkTesterIsolation(events, pairs, {
|
|
1871
|
+
sessionId,
|
|
1872
|
+
...(flags.task ? { taskId: flags.task } : {}),
|
|
1873
|
+
});
|
|
1874
|
+
printJson(report);
|
|
1875
|
+
return report.ok ? 0 : 1;
|
|
1876
|
+
}
|
|
1877
|
+
if (namespace === 'delegation' && action === 'check') {
|
|
1878
|
+
// D13 step 3: the other two checks read a dispatch as evidence that a
|
|
1879
|
+
// second turn happened, and that reading only holds while the dispatching
|
|
1880
|
+
// node owns the log it writes into. This verb asserts the condition. It is
|
|
1881
|
+
// two questions in one report because they fail apart: the topology can be
|
|
1882
|
+
// unsound while the run obeyed it, and a sound topology can be disobeyed.
|
|
1883
|
+
// required: 1 -- `<id>` and `<path>` in the usage line are flag values.
|
|
1884
|
+
const [sessionId] = requirePositionals(positional, usageFor('delegation check'), 1);
|
|
1885
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
1886
|
+
// Lineage-wide (D-119), and here it is the whole point: a wave-runner's
|
|
1887
|
+
// own session is where its dispatches land, so a check that read only the
|
|
1888
|
+
// epic's session would see the delegation it exists to audit as absent.
|
|
1889
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1890
|
+
const policy = loadDelegationPolicy(flags.policy);
|
|
1891
|
+
const crosscheck = loadCrosscheckPolicy(flags.crosscheck);
|
|
1892
|
+
const grants = checkDelegationGrants(policy, {
|
|
1893
|
+
agentRoles: loadTaxonomy().dimensions.agent ?? [],
|
|
1894
|
+
isolationPairs: crosscheck.roleIsolation.pairs,
|
|
1895
|
+
asymmetricPairs: crosscheck.asymmetricRoles.pairs,
|
|
1896
|
+
});
|
|
1897
|
+
const log = checkDelegationLog(events, policy, {
|
|
1898
|
+
sessionId,
|
|
1899
|
+
...(flags.task ? { taskId: flags.task } : {}),
|
|
1900
|
+
});
|
|
1901
|
+
printJson({ session: sessionId, grants, log, ok: grants.ok && log.ok });
|
|
1902
|
+
// Same fail-closed contract as `dispatch check`: `unverifiable` exits 1,
|
|
1903
|
+
// because a wave-runner that has not opened its log yet is not a pass.
|
|
1904
|
+
return grants.ok && log.ok ? 0 : 1;
|
|
1905
|
+
}
|
|
1906
|
+
if (namespace === 'escalation' && action === 'check') {
|
|
1907
|
+
// P9-32: budgets.yml's escalation_ladder, asserted against the log instead
|
|
1908
|
+
// of trusted. Same fail-closed contract as `dispatch check` — see
|
|
1909
|
+
// escalation.ts, including what the rung-3 check does and does not claim.
|
|
1910
|
+
// required: 1 — `<id>` and `<path>` in the usage line are flag values.
|
|
1911
|
+
const [sessionId] = requirePositionals(positional, usageFor('escalation check'), 1);
|
|
1912
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
1913
|
+
// Lineage-wide (D-119), for `dispatch check`'s reason: a ladder is climbed
|
|
1914
|
+
// rung by rung over an epic, and reading one session shows a run that
|
|
1915
|
+
// started at rung 2 as one that skipped rung 1.
|
|
1916
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
1917
|
+
const ladder = loadBudgetPolicy(flags.policy).escalationLadder;
|
|
1918
|
+
const report = checkEscalationLadder(events, ladder, {
|
|
1919
|
+
sessionId,
|
|
1920
|
+
...(flags.task ? { taskId: flags.task } : {}),
|
|
1921
|
+
});
|
|
1922
|
+
printJson(report);
|
|
1923
|
+
return report.ok ? 0 : 1;
|
|
1924
|
+
}
|
|
1925
|
+
if (namespace === 'claims' && action === 'check') {
|
|
1926
|
+
// Two populations, one classifier (P9-3). `--roots` is the write-root
|
|
1927
|
+
// mode: a role that works outside a worktree — the planner
|
|
1928
|
+
// (factory/specs/active/<epic-id>/**) and the scribe (state/lessons/**) —
|
|
1929
|
+
// has no claims to read from a spec, no task branch to diff, and hands its
|
|
1930
|
+
// output back uncommitted. `--since <ref>` widens the window to what the
|
|
1931
|
+
// role committed itself; the planner holds Bash, so it can.
|
|
1932
|
+
const roots = repeated.roots;
|
|
1933
|
+
if (roots !== undefined) {
|
|
1934
|
+
const [rootDir] = requirePositionals(positional, usageFor('claims check --roots'));
|
|
1935
|
+
const result = writeRootCheck(rootDir, roots, flags.since ? { since: flags.since } : {});
|
|
1936
|
+
printJson(result);
|
|
1937
|
+
return result.violation ? 1 : 0;
|
|
1938
|
+
}
|
|
1939
|
+
const [worktreeDir, specFile] = requirePositionals(positional, usageFor('claims check spec'));
|
|
1940
|
+
const spec = readJsonFile(specFile);
|
|
1941
|
+
const result = postRunCheck(worktreeDir, spec.claims);
|
|
1942
|
+
printJson(result);
|
|
1943
|
+
return result.violation ? 1 : 0;
|
|
1944
|
+
}
|
|
1945
|
+
if (namespace === 'claims' && action === 'impact') {
|
|
1946
|
+
// The blind spot a path claim has by construction (P9-3). `claims check`
|
|
1947
|
+
// compares two lists of globs; this compares the edges between the files
|
|
1948
|
+
// those globs match, which is where "task A changed parse(), task B calls
|
|
1949
|
+
// it" actually lives. Two forms, and the difference is what they can
|
|
1950
|
+
// prove — see impact.ts's header.
|
|
1951
|
+
if (flags.plan !== undefined) {
|
|
1952
|
+
const plan = readJsonFile(flags.plan);
|
|
1953
|
+
// Same refusal as `wave check`, for the same reason: validating the
|
|
1954
|
+
// empty set answers "admissible" about a wave nobody described.
|
|
1955
|
+
if (positional.length === 0) {
|
|
1956
|
+
throw new SmithError('cli.empty-wave', 'Usage: smith claims impact --plan <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
|
|
1957
|
+
}
|
|
1958
|
+
const claimsById = new Map(plan.tasks.map((task) => [task.task_id, task.claims]));
|
|
1959
|
+
const tasks = positional.map((typed) => {
|
|
1960
|
+
const id = resolveTaskId(plan, typed);
|
|
1961
|
+
const claims = claimsById.get(id);
|
|
1962
|
+
if (!Array.isArray(claims) || claims.some((claim) => typeof claim !== 'string')) {
|
|
1963
|
+
throw new SmithError('claims.unreadable-claims', `Task "${id}" does not declare its claims as a list of globs.`, { task_id: id, received: claims === undefined ? 'undefined' : typeof claims });
|
|
1964
|
+
}
|
|
1965
|
+
return { task_id: id, claims };
|
|
1966
|
+
});
|
|
1967
|
+
// The declarations are read off the checkout, not off the plan: a claim
|
|
1968
|
+
// says which files a task may write, and only the tree says what those
|
|
1969
|
+
// files import today.
|
|
1970
|
+
const graph = buildSymbolGraph(collectSources(flags.repo ?? REPO_ROOT));
|
|
1971
|
+
const report = waveImpact(graph, tasks);
|
|
1972
|
+
printJson(report);
|
|
1973
|
+
return report.ok ? 0 : 1;
|
|
1974
|
+
}
|
|
1975
|
+
const [worktreeDir, specFile] = requirePositionals(positional, usageFor('claims impact spec'));
|
|
1976
|
+
const spec = readJsonFile(specFile);
|
|
1977
|
+
// The worktree is a full checkout, so it is both halves of the question:
|
|
1978
|
+
// the diff this task committed, and everyone in the repo who imports it.
|
|
1979
|
+
const diffs = collectExportDiffs(worktreeDir, collectCommittedChanges(worktreeDir));
|
|
1980
|
+
const graph = buildSymbolGraph(collectSources(worktreeDir));
|
|
1981
|
+
const report = exportImpact(graph, diffs, spec.claims);
|
|
1982
|
+
printJson(report);
|
|
1983
|
+
return report.ok ? 0 : 1;
|
|
1984
|
+
}
|
|
1985
|
+
if (namespace === 'effort' && action === 'show') {
|
|
1986
|
+
// How much judgment this epic buys, computed instead of remembered — the
|
|
1987
|
+
// same "ask, do not recall" contract `security triggers` above has. Always
|
|
1988
|
+
// exits 0 when it can answer: a tier is a plan for the run, not a verdict
|
|
1989
|
+
// on it. `--effort` answers for a tier the plan does not carry yet, which
|
|
1990
|
+
// is the case `/bs plan` is in when it picks one.
|
|
1991
|
+
const policy = loadEffortPolicy(flags.policy);
|
|
1992
|
+
const securityPolicy = loadCrosscheckPolicy(flags.crosscheck).planQuorum;
|
|
1993
|
+
const planFile = flags.plan;
|
|
1994
|
+
printJson(resolveEffort(policy, securityPolicy, {
|
|
1995
|
+
...(planFile ? { plan: readJsonFile(planFile) } : {}),
|
|
1996
|
+
...(flags.effort !== undefined ? { override: flags.effort } : {}),
|
|
1997
|
+
}));
|
|
1998
|
+
return 0;
|
|
1999
|
+
}
|
|
2000
|
+
if (namespace === 'security' && action === 'triggers') {
|
|
2001
|
+
// The security-reviewer's dispatch condition, computed instead of
|
|
2002
|
+
// remembered (P9-4). Always exits 0 when it can answer: a fired trigger is
|
|
2003
|
+
// a dispatch instruction, not a violation — read `dispatchSecurityReviewer`.
|
|
2004
|
+
const spec = readJsonFile(requireFlag(flags, 'task'));
|
|
2005
|
+
const result = securityTriggers(spec, loadSensitivePathsPolicy(flags.policy), {
|
|
2006
|
+
case: flags.case,
|
|
2007
|
+
epicTags: repeated['epic-tag'],
|
|
2008
|
+
scheduledRecheck: flags.recheck === 'true',
|
|
2009
|
+
});
|
|
2010
|
+
printJson(result);
|
|
2011
|
+
return 0;
|
|
2012
|
+
}
|
|
2013
|
+
// The judge sandbox (A3). `worktree fingerprint`/`verify` above detect a
|
|
2014
|
+
// judge that wrote; these three refuse it up front. The orchestrator opens a
|
|
2015
|
+
// lease before handing a worktree to a judge and closes it when the verdict
|
|
2016
|
+
// is in; while one is open, `policy hook` adds guardrails.yml's three
|
|
2017
|
+
// `judge-*` rules to the six that always apply. The judge itself is refused
|
|
2018
|
+
// all three of these verbs by `judge-sandbox-escape` — they are the
|
|
2019
|
+
// orchestrator's, which is the only reason the lease means anything.
|
|
2020
|
+
if (namespace === 'sandbox' && action === 'open') {
|
|
2021
|
+
const [worktreeDir] = requirePositionals(positional, usageFor('sandbox open'));
|
|
2022
|
+
const lease = openSandbox({
|
|
2023
|
+
worktreeDir,
|
|
2024
|
+
role: requireFlag(flags, 'role'),
|
|
2025
|
+
taskId: requireFlag(flags, 'task'),
|
|
2026
|
+
sessionId: requireFlag(flags, 'session'),
|
|
2027
|
+
// The clock is read here rather than inside sandbox.ts so the module
|
|
2028
|
+
// stays pinnable by a test — the same reason `openedAt` is an input.
|
|
2029
|
+
openedAt: flags.at ?? new Date().toISOString(),
|
|
2030
|
+
}, flags['lease-dir'] ?? SANDBOX_LEASE_DIR);
|
|
2031
|
+
printJson(lease);
|
|
2032
|
+
return 0;
|
|
2033
|
+
}
|
|
2034
|
+
if (namespace === 'sandbox' && action === 'close') {
|
|
2035
|
+
// Exit 0 whether or not a lease was there. Closing is the orchestrator's
|
|
2036
|
+
// cleanup path, and it runs after a judge that crashed just as much as
|
|
2037
|
+
// after one that finished; making "already closed" an error would turn
|
|
2038
|
+
// tidying up into a second failure to handle.
|
|
2039
|
+
const [worktreeDir] = requirePositionals(positional, usageFor('sandbox close'));
|
|
2040
|
+
const closed = closeSandbox(worktreeDir, flags['lease-dir'] ?? SANDBOX_LEASE_DIR);
|
|
2041
|
+
printJson({ closed, worktreeDir });
|
|
2042
|
+
return 0;
|
|
2043
|
+
}
|
|
2044
|
+
if (namespace === 'sandbox' && action === 'status') {
|
|
2045
|
+
// With --worktree: the lease that would bind a command run there, which is
|
|
2046
|
+
// the question the hook asks. Without: every open lease, which is the
|
|
2047
|
+
// question an operator asks when a judge is stuck or a wave died holding
|
|
2048
|
+
// one.
|
|
2049
|
+
const leaseDir = flags['lease-dir'] ?? SANDBOX_LEASE_DIR;
|
|
2050
|
+
if (flags.worktree) {
|
|
2051
|
+
const lease = activeSandboxFor(flags.worktree, leaseDir);
|
|
2052
|
+
printJson({ worktree: flags.worktree, sandbox: lease });
|
|
2053
|
+
return 0;
|
|
2054
|
+
}
|
|
2055
|
+
printJson({ sandboxes: listSandboxes(leaseDir) });
|
|
2056
|
+
return 0;
|
|
2057
|
+
}
|
|
2058
|
+
if (namespace === 'stack' && (action === 'show' || action === 'check')) {
|
|
2059
|
+
// The install interview's answers, read back (`show`) and priced against
|
|
2060
|
+
// what factory/scaffold/ can actually build (`check`).
|
|
2061
|
+
//
|
|
2062
|
+
// `check` exits 1 only on a `refused` answer — one `smith new` will stop
|
|
2063
|
+
// on. A `recorded` mismatch is deliberately green: an operator whose stack
|
|
2064
|
+
// is wider than the template tree has not misconfigured anything, and a
|
|
2065
|
+
// check that went red for them would be a check they learned to ignore.
|
|
2066
|
+
const answers = loadStackAnswers(flags.policy);
|
|
2067
|
+
if (action === 'show') {
|
|
2068
|
+
printJson(answers);
|
|
2069
|
+
return 0;
|
|
2070
|
+
}
|
|
2071
|
+
const report = checkStack(answers);
|
|
2072
|
+
printJson(report);
|
|
2073
|
+
return report.ok ? 0 : 1;
|
|
2074
|
+
}
|
|
2075
|
+
if (namespace === 'policy' && action === 'check') {
|
|
2076
|
+
// Dry run of the guard hook (`policy hook`, below) against one command
|
|
2077
|
+
// line instead of a live PreToolUse payload — "would this be blocked?"
|
|
2078
|
+
// before an agent or operator actually runs it. Branch/repoRoot default
|
|
2079
|
+
// to what the checkout actually is, same as the hook itself resolves
|
|
2080
|
+
// them, so a caller only needs --branch/--tool to ask a hypothetical
|
|
2081
|
+
// ("what if I were on main") rather than the real one.
|
|
2082
|
+
// --command stays required even for a file tool, because the answer to
|
|
2083
|
+
// "what would the hook say" depends on both halves of the payload and a
|
|
2084
|
+
// caller who omits one is usually asking the wrong question. `--command
|
|
2085
|
+
// ''` is the file-tool form: nothing to run, a path to check.
|
|
2086
|
+
const command = requireFlag(flags, 'command');
|
|
2087
|
+
const toolName = flags.tool ?? 'Bash';
|
|
2088
|
+
// The other half of a PreToolUse payload: `tool_input.file_path`, which
|
|
2089
|
+
// is what a `Write`/`Edit` call carries instead of a command. Only the
|
|
2090
|
+
// role write scopes read it today — see policy.ts's PolicyContext.
|
|
2091
|
+
const filePath = flags.file ?? null;
|
|
2092
|
+
// Resolved from the caller's cwd, not from the checkout this binary was
|
|
2093
|
+
// built in: an operator asking "would this be denied?" means from where
|
|
2094
|
+
// they are standing, and in this repo that is routinely a worktree on a
|
|
2095
|
+
// different branch than the main clone. Same reason as `policy hook`
|
|
2096
|
+
// below, which has the stricter version of the problem.
|
|
2097
|
+
const repoRoot = detectRepoRoot(process.cwd());
|
|
2098
|
+
const branch = flags.branch ?? detectCurrentBranch(process.cwd());
|
|
2099
|
+
// Same shape as --branch: without it, the real lease over the caller's cwd,
|
|
2100
|
+
// so `policy check` answers what the hook would actually answer from here.
|
|
2101
|
+
// With it, a hypothetical — "what would a reviewer session be refused?" —
|
|
2102
|
+
// which is how an operator reads the judge rules without having to stage a
|
|
2103
|
+
// judge to read them.
|
|
2104
|
+
const sandbox = flags.sandbox
|
|
2105
|
+
? {
|
|
2106
|
+
worktreeDir: repoRoot ?? process.cwd(),
|
|
2107
|
+
role: flags.sandbox,
|
|
2108
|
+
taskId: '(hypothetical)',
|
|
2109
|
+
sessionId: '(hypothetical)',
|
|
2110
|
+
openedAt: new Date().toISOString(),
|
|
2111
|
+
}
|
|
2112
|
+
: activeSandboxFor(process.cwd(), flags['lease-dir'] ?? SANDBOX_LEASE_DIR);
|
|
2113
|
+
const decision = evaluateCommand({ toolName, command, branch, repoRoot, sandbox, filePath }, loadGuardrailPolicy());
|
|
2114
|
+
printJson(decision);
|
|
2115
|
+
return decision.allowed ? 0 : 1;
|
|
2116
|
+
}
|
|
2117
|
+
if (namespace === 'policy' && action === 'hook') {
|
|
2118
|
+
// The PreToolUse hook body, kept as a command because guard.sh's header,
|
|
2119
|
+
// the docs and cli.test.ts all name it — but the shim no longer execs it.
|
|
2120
|
+
// It execs `dist/policyHook.js`, an entry point over the same function
|
|
2121
|
+
// whose import graph is the decision's alone; this router carries 64
|
|
2122
|
+
// top-level imports, ~1.3s of them the database layer, in front of ~39ms
|
|
2123
|
+
// of policy work, and the hook pays that on every guarded tool call. Both
|
|
2124
|
+
// paths call decideHookPayload, so a second copy cannot drift out of
|
|
2125
|
+
// agreement with the one actually guarding the repo.
|
|
2126
|
+
//
|
|
2127
|
+
// The contract, which policyHook.ts mirrors exactly: exit 0 with the deny
|
|
2128
|
+
// envelope on stdout, exit 0 in silence for an allow, and let a malformed
|
|
2129
|
+
// payload throw out through main()'s catch for a non-zero exit. See
|
|
2130
|
+
// hookDecision.ts for why each of those three is what it is.
|
|
2131
|
+
const output = decideHookPayload(readFileSync(0, 'utf8'), process.cwd());
|
|
2132
|
+
if (output)
|
|
2133
|
+
printJson(output);
|
|
2134
|
+
return 0;
|
|
2135
|
+
}
|
|
2136
|
+
if (namespace === 'gate' && action === 'run') {
|
|
2137
|
+
const { runGate } = await import('./gate.js');
|
|
2138
|
+
const [taskId] = requirePositionals(positional, usageFor('gate run'));
|
|
2139
|
+
const worktreeDir = requireFlag(flags, 'worktree');
|
|
2140
|
+
const checks = readJsonFile(requireFlag(flags, 'checks'));
|
|
2141
|
+
// The result file has the same two intake shapes as findings below, and for
|
|
2142
|
+
// the same reason. With `--agent`, `--result` is the worker's half —
|
|
2143
|
+
// run_status/structured_output/artifacts — and the dispatcher stamps the
|
|
2144
|
+
// five fields it owns, token_usage included: an agent cannot read its own
|
|
2145
|
+
// meter, so a token count it writes is invented (D-18/P9-17). Without
|
|
2146
|
+
// `--agent` the file is taken as a complete document, which is what a
|
|
2147
|
+
// replay or a fixture hands over.
|
|
2148
|
+
const resultFile = readJsonFile(requireFlag(flags, 'result'));
|
|
2149
|
+
const result = flags.agent
|
|
2150
|
+
? stampResultEnvelope(resultFile, {
|
|
2151
|
+
taskId,
|
|
2152
|
+
agent: flags.agent,
|
|
2153
|
+
provider: requireFlag(flags, 'provider'),
|
|
2154
|
+
modelTier: requireFlag(flags, 'model-tier'),
|
|
2155
|
+
inputTokens: requireIntFlag(flags, 'input-tokens'),
|
|
2156
|
+
outputTokens: requireIntFlag(flags, 'output-tokens'),
|
|
2157
|
+
})
|
|
2158
|
+
: resultFile;
|
|
2159
|
+
// Two intake shapes. `--evidence` is what judges actually produce
|
|
2160
|
+
// (interview N-2): evidence only, with the orchestrator minting
|
|
2161
|
+
// finding_id/task_id/found_by/finding_status here. It repeats, once per
|
|
2162
|
+
// judge at this gate (D-32/P9-13). `--findings` stays for already-minted
|
|
2163
|
+
// records (replays, fixtures, cross-check re-runs).
|
|
2164
|
+
const findingsInput = [
|
|
2165
|
+
...(flags.findings ? readJsonFile(flags.findings) : []),
|
|
2166
|
+
...mintFromEvidence(args, taskId),
|
|
2167
|
+
];
|
|
2168
|
+
const lessons = flags.lessons ? parseLessons(readFileSync(flags.lessons, 'utf8')) : [];
|
|
2169
|
+
const ctx = eventContextFromFlags(flags);
|
|
2170
|
+
// --plan is what lets the gate answer "whose finding is this" from the
|
|
2171
|
+
// file rather than from who happened to be at the gate (D-41/P9-24).
|
|
2172
|
+
// Optional: a gate run without a plan keeps every finding on --task, which
|
|
2173
|
+
// is the pre-P9-24 behaviour, not a silent misattribution.
|
|
2174
|
+
const ownership = ownershipFromFlags(flags);
|
|
2175
|
+
// P9-18: the same file also carries what the task said it would cost, so
|
|
2176
|
+
// the gate can compare the declaration against the measurement. `--base`
|
|
2177
|
+
// is for callers that know the exact base they branched from (the merge
|
|
2178
|
+
// queue does); without it the diff is measured against the integration
|
|
2179
|
+
// branch the task-branch name implies.
|
|
2180
|
+
const budget = budgetFromFlags(flags, taskId);
|
|
2181
|
+
// P9-11: handing the gate a judge's evidence IS that judge reporting.
|
|
2182
|
+
// Doing it here means the common path — dispatch, judge writes its file,
|
|
2183
|
+
// `gate run --evidence` — closes the turn in one command instead of two,
|
|
2184
|
+
// and forgetting the second one can no longer block a task whose judge
|
|
2185
|
+
// did everything right. A `--found-by` role with no dispatch behind it
|
|
2186
|
+
// has no turn to close and takes the pre-P9-11 path untouched.
|
|
2187
|
+
//
|
|
2188
|
+
// D-158: the turns to close are the paired sources, not `flags.evidence`
|
|
2189
|
+
// and `flags['found-by']`. Those are last-occurrence-wins, so a gate run
|
|
2190
|
+
// carrying two judges' files — the shape D-32/P9-13 taught the minting
|
|
2191
|
+
// path — closed the second judge's turn and left the first outstanding,
|
|
2192
|
+
// blocking the gate on a judge that had just handed in its evidence. One
|
|
2193
|
+
// close per role: a judge that splits its findings across two files still
|
|
2194
|
+
// owes one turn, and a second report against it would be a duplicate.
|
|
2195
|
+
const evidenceGiven = evidenceSources(args);
|
|
2196
|
+
if (evidenceGiven.length > 0) {
|
|
2197
|
+
const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
|
|
2198
|
+
const closed = new Set();
|
|
2199
|
+
for (const { foundBy, file } of evidenceGiven) {
|
|
2200
|
+
if (closed.has(foundBy))
|
|
2201
|
+
continue;
|
|
2202
|
+
if (!turns.some((t) => t.role === foundBy && !t.reported))
|
|
2203
|
+
continue;
|
|
2204
|
+
closed.add(foundBy);
|
|
2205
|
+
await recordJudgeReport({ taskId, role: foundBy, artifactPath: file }, ctx, eventOptsFromFlags(flags));
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
// P9-11: the genuinely clean case, said out loud. A judge that found
|
|
2209
|
+
// nothing writes `[]` and reports through `smith judge report`; this flag
|
|
2210
|
+
// is for the operator who ran one outside the factory, and it records an
|
|
2211
|
+
// attestation as an attestation — artifact_path null, attested_by
|
|
2212
|
+
// operator — rather than dressing it up as a file that was never written.
|
|
2213
|
+
for (const role of repeated['no-findings'] ?? []) {
|
|
2214
|
+
// `parseArgs` renders a valueless flag as the string 'true'. Attesting a
|
|
2215
|
+
// role called "true" would close nothing (no such dispatch exists) and
|
|
2216
|
+
// say so nowhere, so a bare --no-findings is a usage error instead.
|
|
2217
|
+
if (role === 'true') {
|
|
2218
|
+
throw new SmithError('cli.no-findings-needs-role', '--no-findings names the judge role it attests for, e.g. --no-findings security-reviewer.', { usage: 'smith gate run <task-id> --no-findings <role>' });
|
|
2219
|
+
}
|
|
2220
|
+
await recordJudgeReport({ taskId, role, noFindings: true }, ctx, eventOptsFromFlags(flags));
|
|
2221
|
+
}
|
|
2222
|
+
// --grader is the grader's own result file (state/results/<task-id>
|
|
2223
|
+
// .grader-r<round>.json). Optional for the same reason as --plan: an ad-hoc
|
|
2224
|
+
// gate run has no rubric result to hand over, and inventing one would be
|
|
2225
|
+
// worse than skipping the stage (D-34/P9-14).
|
|
2226
|
+
const graderVerdict = flags.grader ? readJsonFile(flags.grader) : undefined;
|
|
2227
|
+
const outcome = await runGate({
|
|
2228
|
+
taskId,
|
|
2229
|
+
result,
|
|
2230
|
+
worktreeDir,
|
|
2231
|
+
checks,
|
|
2232
|
+
findingsInput,
|
|
2233
|
+
lessons,
|
|
2234
|
+
runAll: flags['run-all'] === 'true',
|
|
2235
|
+
...(ownership ? { ownership } : {}),
|
|
2236
|
+
// --base is the ref the queue would merge into, normally
|
|
2237
|
+
// smith/<epic>/integration. One flag, two readers: the commit
|
|
2238
|
+
// certificate asks whether the branch carries commits the base does
|
|
2239
|
+
// not (D-30/P9-8), the budget check measures the diff against it
|
|
2240
|
+
// (P9-18). Optional like --plan — without it the gate still refuses an
|
|
2241
|
+
// uncommitted worktree and still measures the diff, it just falls back
|
|
2242
|
+
// to the base the task-branch name implies.
|
|
2243
|
+
...(flags.base ? { baseRef: flags.base } : {}),
|
|
2244
|
+
...(graderVerdict !== undefined ? { graderVerdict } : {}),
|
|
2245
|
+
...(budget ? { budget } : {}),
|
|
2246
|
+
...(flags['artifacts-dir'] ? { artifactsDir: flags['artifacts-dir'] } : {}),
|
|
2247
|
+
}, ctx, eventOptsFromFlags(flags));
|
|
2248
|
+
printJson(outcome);
|
|
2249
|
+
return outcome.outcome === 'blocked' ? 1 : 0;
|
|
2250
|
+
}
|
|
2251
|
+
// D-40/P9-25: the gate's coverage evidence, without staging a gate run.
|
|
2252
|
+
// This is the verb the D-40 investigation wanted and did not have — it took
|
|
2253
|
+
// a coverage re-run on the pre-task-4 integration branch to establish what
|
|
2254
|
+
// one lookup in coverage-summary.json says outright.
|
|
2255
|
+
if (namespace === 'coverage' && action === 'check') {
|
|
2256
|
+
const [worktreeDir] = requirePositionals(positional, usageFor('coverage check'));
|
|
2257
|
+
// Whose claims to judge is a question with no safe default: judging the
|
|
2258
|
+
// whole plan's claims at one task's gate is D-41 again, and judging none
|
|
2259
|
+
// silently would report `complete: true` on no evidence at all. So --plan
|
|
2260
|
+
// demands --task, and neither means "the total, and no subjects".
|
|
2261
|
+
const claims = flags.plan ? claimsForDispatch(flags) : [];
|
|
2262
|
+
const evidence = await collectCoverageEvidence({
|
|
2263
|
+
worktreeDir,
|
|
2264
|
+
claims,
|
|
2265
|
+
...(flags.summary ? { summaryPath: flags.summary } : {}),
|
|
2266
|
+
});
|
|
2267
|
+
printJson({
|
|
2268
|
+
summary_path: evidence.summaryPath,
|
|
2269
|
+
present: evidence.present,
|
|
2270
|
+
complete: evidence.complete,
|
|
2271
|
+
files_measured: evidence.filesMeasured,
|
|
2272
|
+
total: evidence.total,
|
|
2273
|
+
subjects: evidence.subjects.map((s) => ({
|
|
2274
|
+
path: s.path,
|
|
2275
|
+
status: s.status,
|
|
2276
|
+
lines_pct: s.coverage?.lines.pct ?? null,
|
|
2277
|
+
statements_pct: s.coverage?.statements.pct ?? null,
|
|
2278
|
+
functions_pct: s.coverage?.functions.pct ?? null,
|
|
2279
|
+
branches_pct: s.coverage?.branches.pct ?? null,
|
|
2280
|
+
})),
|
|
2281
|
+
detail: evidence.detail,
|
|
2282
|
+
});
|
|
2283
|
+
return evidence.complete ? 0 : 1;
|
|
2284
|
+
}
|
|
2285
|
+
// P9-33: the consumer `epic.alarm_ratio` never had. Read-only over the log —
|
|
2286
|
+
// it reports where spend stands, it does not stop anything. Exit 1 on a
|
|
2287
|
+
// crossing OR on a record too holey to prove there wasn't one.
|
|
2288
|
+
if (namespace === 'budget' && action === 'alarm') {
|
|
2289
|
+
// required: 1 — `<id>` and `<path>` in the usage line are flag values, and
|
|
2290
|
+
// requirePositionals reads every `<placeholder>` positionally.
|
|
2291
|
+
const [sessionId] = requirePositionals(positional, usageFor('budget alarm'), 1);
|
|
2292
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
2293
|
+
// Lineage-wide (D-119). Spend is an epic's, not a session's: reading one
|
|
2294
|
+
// session reports half an epic's cost as the whole of it, and reports it
|
|
2295
|
+
// under an alarm threshold it may already have crossed.
|
|
2296
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2297
|
+
const report = checkBudgetAlarm(events, loadBudgetPolicy(flags.policy), {
|
|
2298
|
+
sessionId,
|
|
2299
|
+
...(flags.epic ? { epicId: flags.epic } : {}),
|
|
2300
|
+
});
|
|
2301
|
+
printJson(report);
|
|
2302
|
+
return report.ok ? 0 : 1;
|
|
2303
|
+
}
|
|
2304
|
+
// The consumer architecture §9.7's "monotonically decreasing same-mistake
|
|
2305
|
+
// rate" never had. Read-only over the log, like `budget alarm` — and like it,
|
|
2306
|
+
// exit 1 both on a rate that rose and on a record that cannot show it didn't.
|
|
2307
|
+
// `--lessons` defaults to the committed corpus because the corpus IS half the
|
|
2308
|
+
// measurement: a rate of zero against lessons that can escalate nothing is a
|
|
2309
|
+
// fact about the corpus, not about the work.
|
|
2310
|
+
if (namespace === 'kpi' && action === 'same-mistake') {
|
|
2311
|
+
const [sessionId] = requirePositionals(positional, usageFor('kpi same-mistake'), 1);
|
|
2312
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
2313
|
+
// Lineage-wide (D-119): a same-mistake RATE measured over half the work is
|
|
2314
|
+
// a different number, and the half it drops is the earlier one — the half
|
|
2315
|
+
// that holds the first occurrence every repeat is counted against.
|
|
2316
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2317
|
+
const lessons = parseLessons(readFileSync(flags.lessons ?? LESSONS_MD_PATH, 'utf8'));
|
|
2318
|
+
const report = checkSameMistakeKpi(events, lessons, { sessionId });
|
|
2319
|
+
printJson(report);
|
|
2320
|
+
return report.ok ? 0 : 1;
|
|
2321
|
+
}
|
|
2322
|
+
// D-42/P9-26: the one command that runs against the ASSEMBLED branch rather
|
|
2323
|
+
// than a task worktree. Every other gate in this CLI runs inside a worktree,
|
|
2324
|
+
// so without this every quality claim the factory makes is a claim about a
|
|
2325
|
+
// worktree — and the epic gate below now refuses to ship until this has run.
|
|
2326
|
+
if (namespace === 'integration' && action === 'check') {
|
|
2327
|
+
const epicId = requireFlag(flags, 'epic');
|
|
2328
|
+
const projectDir = requireFlag(flags, 'project');
|
|
2329
|
+
const checks = readJsonFile(requireFlag(flags, 'checks'));
|
|
2330
|
+
const ctx = eventContextFromFlags(flags);
|
|
2331
|
+
const record = await runIntegrationCheck({
|
|
2332
|
+
epicId,
|
|
2333
|
+
projectDir,
|
|
2334
|
+
checks,
|
|
2335
|
+
// Unlike the per-task gate, the default here is the whole picture:
|
|
2336
|
+
// --run-all false opts back into short-circuiting.
|
|
2337
|
+
runAll: flags['run-all'] !== 'false',
|
|
2338
|
+
}, ctx, eventOptsFromFlags(flags));
|
|
2339
|
+
printJson(record);
|
|
2340
|
+
return record.pass ? 0 : 1;
|
|
2341
|
+
}
|
|
2342
|
+
if (namespace === 'epic' && action === 'verdict') {
|
|
2343
|
+
const { runEpicVerdict } = await import('./epic.js');
|
|
2344
|
+
const epicId = requireFlag(flags, 'epic');
|
|
2345
|
+
// --project is required (D-42/P9-26): the verdict cannot be rendered
|
|
2346
|
+
// without knowing where the integration branch actually is, and an
|
|
2347
|
+
// optional flag would silently mean "no check needed".
|
|
2348
|
+
const projectDir = requireFlag(flags, 'project');
|
|
2349
|
+
const ctx = eventContextFromFlags(flags);
|
|
2350
|
+
const outcome = await runEpicVerdict({
|
|
2351
|
+
epicId,
|
|
2352
|
+
integrationHeadSha: integrationHeadSha(projectDir, epicId),
|
|
2353
|
+
mcp: mcpSurfaceFor(epicId, projectDir, flags),
|
|
2354
|
+
goal: epicGoalFor(epicId, flags),
|
|
2355
|
+
// D-126: the live plan is a voter. Without this the roster is the
|
|
2356
|
+
// event-log fold alone, and a task an amendment added but nobody
|
|
2357
|
+
// dispatched is invisible rather than unfinished.
|
|
2358
|
+
planOpts: planOptsFromFlags(flags),
|
|
2359
|
+
}, ctx, eventOptsFromFlags(flags));
|
|
2360
|
+
printJson(outcome);
|
|
2361
|
+
return outcome.outcome === 'hold' ? 1 : 0;
|
|
2362
|
+
}
|
|
2363
|
+
// D-43/P9-27: `epic verdict` above stays the free read-only probe; this is
|
|
2364
|
+
// the verb that writes the close down. A hold closes only with
|
|
2365
|
+
// --override-rationale, and then the log carries the machine's verdict, the
|
|
2366
|
+
// blockers overridden, and the human's reason.
|
|
2367
|
+
if (namespace === 'epic' && action === 'close') {
|
|
2368
|
+
const { closeEpic } = await import('./epic.js');
|
|
2369
|
+
const epicId = requireFlag(flags, 'epic');
|
|
2370
|
+
const projectDir = requireFlag(flags, 'project');
|
|
2371
|
+
const ctx = eventContextFromFlags(flags);
|
|
2372
|
+
const record = await closeEpic({
|
|
2373
|
+
epicId,
|
|
2374
|
+
integrationHeadSha: integrationHeadSha(projectDir, epicId),
|
|
2375
|
+
mcp: mcpSurfaceFor(epicId, projectDir, flags),
|
|
2376
|
+
goal: epicGoalFor(epicId, flags),
|
|
2377
|
+
planOpts: planOptsFromFlags(flags),
|
|
2378
|
+
...(flags['override-rationale'] !== undefined
|
|
2379
|
+
? { overrideRationale: flags['override-rationale'] }
|
|
2380
|
+
: {}),
|
|
2381
|
+
}, ctx, eventOptsFromFlags(flags));
|
|
2382
|
+
printJson(record);
|
|
2383
|
+
return 0;
|
|
2384
|
+
}
|
|
2385
|
+
// P9-9/D-33: the closing spec review. The pre-code review reads a plan
|
|
2386
|
+
// against nothing; this one reads it against the code that now exists, which
|
|
2387
|
+
// is the only reading that can see the defects the code reveals. It is
|
|
2388
|
+
// pinned to the integration head so `epic verdict` can tell a review of this
|
|
2389
|
+
// branch from a review of an older one.
|
|
2390
|
+
if (namespace === 'epic' && action === 'spec-review') {
|
|
2391
|
+
const epicId = requireFlag(flags, 'epic');
|
|
2392
|
+
const projectDir = requireFlag(flags, 'project');
|
|
2393
|
+
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
2394
|
+
// Evidence, not identity (interview N-2): the reviewer reports what it
|
|
2395
|
+
// read; the plan version and the head it was read at come from here.
|
|
2396
|
+
const evidence = flags.evidence ? readJsonFile(flags.evidence) : [];
|
|
2397
|
+
// No branch, no review. The whole point of this dispatch is that it reads
|
|
2398
|
+
// the code that now exists; recording one against a head that could not be
|
|
2399
|
+
// read would produce a review nothing can be shown to cover.
|
|
2400
|
+
const headSha = integrationHeadSha(projectDir, epicId);
|
|
2401
|
+
if (headSha === null) {
|
|
2402
|
+
throw new SmithError('cli.no-integration-branch', `Could not read the head of smith/${epicId}/integration in ${projectDir}. The closing spec review reads the assembled branch, so there is nothing to review yet.`, { epicId, projectDir });
|
|
2403
|
+
}
|
|
2404
|
+
const record = await recordSpecReview({
|
|
2405
|
+
epicId,
|
|
2406
|
+
planVersion: plan.version,
|
|
2407
|
+
headSha,
|
|
2408
|
+
reviewedBy: requireFlag(flags, 'reviewed-by'),
|
|
2409
|
+
...(flags['reviewed-by-provider']
|
|
2410
|
+
? { reviewedByProvider: flags['reviewed-by-provider'] }
|
|
2411
|
+
: {}),
|
|
2412
|
+
evidence,
|
|
2413
|
+
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2414
|
+
printJson(record);
|
|
2415
|
+
// Exit 0 even when it found something: the review ran, and a spec finding
|
|
2416
|
+
// blocks the plan, not this command. `plan amend` is what answers it.
|
|
2417
|
+
return 0;
|
|
2418
|
+
}
|
|
2419
|
+
// The spec-vs-goal check. Every gate before it reads text the planner wrote;
|
|
2420
|
+
// this one reads the roadmap goal the operator wrote before planning began,
|
|
2421
|
+
// so a plan that decomposes the wrong problem stops being invisible.
|
|
2422
|
+
if (namespace === 'epic' && action === 'goal-check') {
|
|
2423
|
+
const epicId = requireFlag(flags, 'epic');
|
|
2424
|
+
const plan = readJsonFile(requireFlag(flags, 'plan'));
|
|
2425
|
+
const goal = epicGoalFor(epicId, flags);
|
|
2426
|
+
// Refuse rather than record a check against nothing. The blocker for an
|
|
2427
|
+
// undeclared goal is already in the epic gate; producing an event here
|
|
2428
|
+
// would be a record of a check that had no reference text.
|
|
2429
|
+
if (goal.goal === null || goal.milestoneId === null) {
|
|
2430
|
+
throw new SmithError('cli.no-epic-goal', `No roadmap milestone states a goal for "${epicId}", so there is nothing to check its plan against. Give the milestone that owns it a \`- goal:\` line in factory/specs/roadmap.md, or add the epic to an existing milestone's \`- epics:\` list.`, { epicId, milestoneId: goal.milestoneId });
|
|
2431
|
+
}
|
|
2432
|
+
const coverage = readJsonFile(requireFlag(flags, 'coverage'));
|
|
2433
|
+
const record = await recordGoalCheck({
|
|
2434
|
+
epicId,
|
|
2435
|
+
milestoneId: goal.milestoneId,
|
|
2436
|
+
goal: goal.goal,
|
|
2437
|
+
planVersion: plan.version,
|
|
2438
|
+
livePlanTaskIds: livePlanTasks(plan).map((spec) => spec.task_id),
|
|
2439
|
+
checkedBy: requireFlag(flags, 'checked-by'),
|
|
2440
|
+
...(flags['checked-by-provider']
|
|
2441
|
+
? { checkedByProvider: flags['checked-by-provider'] }
|
|
2442
|
+
: {}),
|
|
2443
|
+
coverage,
|
|
2444
|
+
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2445
|
+
printJson(record);
|
|
2446
|
+
// Exit 0 for the reason `epic spec-review` does: the check ran, and an
|
|
2447
|
+
// uncovered clause blocks the plan rather than this command. `plan amend`
|
|
2448
|
+
// is what answers it.
|
|
2449
|
+
return 0;
|
|
2450
|
+
}
|
|
2451
|
+
// The clause list a coverage map has to answer, printed so a judge dispatch
|
|
2452
|
+
// (or an operator writing one by hand) does not have to guess how
|
|
2453
|
+
// goalClauses() splits the goal. Read-only: no event, no finding.
|
|
2454
|
+
if (namespace === 'epic' && action === 'goal') {
|
|
2455
|
+
const epicId = requireFlag(flags, 'epic');
|
|
2456
|
+
printJson(epicGoalFor(epicId, flags));
|
|
2457
|
+
return 0;
|
|
2458
|
+
}
|
|
2459
|
+
if (namespace === 'epic' && action === 'width') {
|
|
2460
|
+
// Dynamic for P9-2 (test/cliBoot.test.ts): epicWidth.js reaches
|
|
2461
|
+
// waveConcurrency.js and through it agents-registry.js, and nothing only
|
|
2462
|
+
// this command needs belongs on the path every `smith --help` walks.
|
|
2463
|
+
const { summariseEpicWidth } = await import('./epicWidth.js');
|
|
2464
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2465
|
+
const sessionId = typeof flags.session === 'string' ? flags.session : null;
|
|
2466
|
+
// The default is every session, which no other read command here does, and
|
|
2467
|
+
// it is the whole point: `wave audit` answers "did the waves in the log I
|
|
2468
|
+
// am standing in run wide", and this answers "does this factory build in
|
|
2469
|
+
// parallel". A close is written wherever the epic finished, so a
|
|
2470
|
+
// lineage-scoped default would answer the factory question with whatever
|
|
2471
|
+
// subset of its own history the operator happened to be inside — and
|
|
2472
|
+
// report a workshop of one narrow epic and forty parallel ones as narrow.
|
|
2473
|
+
// --session narrows back to one lineage for anyone who wants that instead.
|
|
2474
|
+
let events;
|
|
2475
|
+
if (sessionId === null) {
|
|
2476
|
+
const ids = listSessionIds(eventOpts.stateDir);
|
|
2477
|
+
events = mergeSessionLogs(await Promise.all(ids.map(async (id) => ({ sessionId: id, events: await readEvents(id, eventOpts) }))));
|
|
2478
|
+
}
|
|
2479
|
+
else {
|
|
2480
|
+
requireSession(sessionId, eventOpts);
|
|
2481
|
+
events = await readLineageEvents(sessionId, eventOpts);
|
|
2482
|
+
}
|
|
2483
|
+
const summary = summariseEpicWidth(events);
|
|
2484
|
+
printJson(summary);
|
|
2485
|
+
return summary.exitCode;
|
|
2486
|
+
}
|
|
2487
|
+
// D-41/P9-24: a finding can exist without a gate run. The wave-4 security
|
|
2488
|
+
// reviewer's S2 was about a file nobody at that gate could touch, and the
|
|
2489
|
+
// only verb that could record it was `gate run` — so recording it at all
|
|
2490
|
+
// meant blocking a diff that could not contain the fix. This raises the
|
|
2491
|
+
// finding on its own, routed to whoever claims the file.
|
|
2492
|
+
if (namespace === 'findings' && action === 'raise') {
|
|
2493
|
+
const { recordReattribution, routeFindings } = await import('./attribution.js');
|
|
2494
|
+
const plan = flags.plan ? readJsonFile(flags.plan) : undefined;
|
|
2495
|
+
// Ownership has to come from somewhere. --task names it outright; --plan
|
|
2496
|
+
// makes the epic the fallback owner, which resolveFindingOwner can only
|
|
2497
|
+
// improve on. With neither, there is no honest task_id to stamp.
|
|
2498
|
+
// The epic is a fallback, never a real owner: decideFindingAttribution
|
|
2499
|
+
// returns `gated` only when the owner equals this id, and an epic id never
|
|
2500
|
+
// equals a task id — so a plan-only raise always resolves or escalates.
|
|
2501
|
+
const defaultTaskId = flags.task ?? plan?.epic_id;
|
|
2502
|
+
if (defaultTaskId === undefined) {
|
|
2503
|
+
throw new SmithError('cli.missing-flag', 'findings raise needs --task, --plan, or both to attribute the finding.', { flag: 'task' });
|
|
2504
|
+
}
|
|
2505
|
+
const ownership = plan ? ownershipFromPlan(plan) : undefined;
|
|
2506
|
+
const ctx = eventContextFromFlags(flags);
|
|
2507
|
+
const opts = eventOptsFromFlags(flags);
|
|
2508
|
+
// P9-9/D-33. Scope is a property of the dispatch, not of each item: one
|
|
2509
|
+
// review reads one thing. --plan is required for a spec raise because the
|
|
2510
|
+
// version reviewed is read from the plan file rather than typed — a spec
|
|
2511
|
+
// finding against the wrong version points at a criterion that never moved.
|
|
2512
|
+
if (flags.scope !== undefined && flags.scope !== 'spec' && flags.scope !== 'diff') {
|
|
2513
|
+
throw new SmithError('cli.invalid-flag', `--scope must be "diff" or "spec", got "${flags.scope}".`, { flag: 'scope', value: flags.scope });
|
|
2514
|
+
}
|
|
2515
|
+
const specDispatch = flags.scope === 'spec';
|
|
2516
|
+
if (specDispatch && plan === undefined) {
|
|
2517
|
+
throw new SmithError('cli.missing-flag', 'findings raise --scope spec needs --plan: a spec finding names the plan version whose criterion it read.', { flag: 'plan' });
|
|
2518
|
+
}
|
|
2519
|
+
// Scope is decided by the dispatch, and a pre-built draft was minted by
|
|
2520
|
+
// some other dispatch. Accepting one here would put a diff finding into a
|
|
2521
|
+
// spec batch and report it as spec-scoped when it is not.
|
|
2522
|
+
if (specDispatch && flags.findings) {
|
|
2523
|
+
throw new SmithError('cli.invalid-flag', 'findings raise --scope spec takes --evidence, not --findings: scope belongs to the dispatch, and a pre-built draft was minted under a different one.', { flag: 'findings' });
|
|
2524
|
+
}
|
|
2525
|
+
// A spec finding is owned by the epic, not by whoever claims the file it
|
|
2526
|
+
// cites — same id recordSpecReview stamps, so the two routes into the log
|
|
2527
|
+
// are indistinguishable to everything downstream.
|
|
2528
|
+
const mintTaskId = specDispatch && plan ? `${plan.epic_id}/${RESERVED_TASK_ID}` : defaultTaskId;
|
|
2529
|
+
const findingsInput = [
|
|
2530
|
+
...(flags.findings ? readJsonFile(flags.findings) : []),
|
|
2531
|
+
// The mint id, not the default one: a spec finding belongs to the epic
|
|
2532
|
+
// (D-33), and the dispatch's scope rides along with it so every finding
|
|
2533
|
+
// in the batch is spec-scoped by construction.
|
|
2534
|
+
...mintFromEvidence(args, mintTaskId, specDispatch && plan ? { spec: { planVersion: plan.version } } : {}),
|
|
2535
|
+
];
|
|
2536
|
+
// A spec dispatch never enters the routing. routeFindings would rewrite
|
|
2537
|
+
// task_id to whoever claims the file the finding happens to cite — and a
|
|
2538
|
+
// plan defect attributed to a task is exactly the deadlock D-33 exists to
|
|
2539
|
+
// end. gate.ts diverts for the same reason; this diverts one step earlier,
|
|
2540
|
+
// before attribution rather than after it.
|
|
2541
|
+
const routings = specDispatch
|
|
2542
|
+
? findingsInput.map((input) => ({
|
|
2543
|
+
input,
|
|
2544
|
+
attribution: 'gated',
|
|
2545
|
+
taskId: mintTaskId,
|
|
2546
|
+
epicId: plan?.epic_id ?? mintTaskId,
|
|
2547
|
+
fromTaskId: mintTaskId,
|
|
2548
|
+
reason: '',
|
|
2549
|
+
claims: [],
|
|
2550
|
+
}))
|
|
2551
|
+
: await routeFindings(findingsInput, {
|
|
2552
|
+
defaultTaskId,
|
|
2553
|
+
// The plan states the epic outright, so nothing has to read it back
|
|
2554
|
+
// out of `defaultTaskId` — which on a plan-only raise IS the epic id
|
|
2555
|
+
// and names no epic of its own (D-49/P9-10).
|
|
2556
|
+
...(plan?.epic_id ? { epicId: plan.epic_id } : {}),
|
|
2557
|
+
...(ownership ? { ownership } : {}),
|
|
2558
|
+
}, ctx, opts);
|
|
2559
|
+
const raised = [];
|
|
2560
|
+
for (const routing of routings) {
|
|
2561
|
+
const result = await raiseFinding(routing.input, ctx, opts);
|
|
2562
|
+
// A waived fingerprint is already settled, so it mints no follow-up
|
|
2563
|
+
// task — same rule the gate applies, for the same reason. A spec finding
|
|
2564
|
+
// is skipped for the other half of that rule (gate.ts's divert): no task
|
|
2565
|
+
// can hold the fix, because the fix is a plan amendment.
|
|
2566
|
+
const isSpec = !result.suppressed && findingScope(result.finding) === SPEC_FINDING_SCOPE;
|
|
2567
|
+
if (!result.suppressed && !isSpec) {
|
|
2568
|
+
await recordReattribution(routing, result.finding, ctx, opts);
|
|
2569
|
+
}
|
|
2570
|
+
raised.push({
|
|
2571
|
+
findingId: result.suppressed ? null : result.finding.finding_id,
|
|
2572
|
+
filePath: routing.input.filePath,
|
|
2573
|
+
taskId: routing.taskId,
|
|
2574
|
+
attribution: isSpec ? SPEC_FINDING_SCOPE : routing.attribution,
|
|
2575
|
+
suppressed: result.suppressed,
|
|
2576
|
+
reason: routing.reason,
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
printJson(raised);
|
|
2580
|
+
return 0;
|
|
2581
|
+
}
|
|
2582
|
+
// `--state-dir` is threaded through every findings/waivers verb below.
|
|
2583
|
+
// It used to be parsed and dropped here, so `findings list --state-dir X`
|
|
2584
|
+
// answered about the real state/events/ log instead — an answer about a
|
|
2585
|
+
// different session, which is worse than an error (P9-15).
|
|
2586
|
+
if (namespace === 'findings' && action === 'list') {
|
|
2587
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2588
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2589
|
+
// An empty list is an answer about the findings; it must not also be the
|
|
2590
|
+
// answer about the session (P9-28).
|
|
2591
|
+
requireSession(sessionId, eventOpts);
|
|
2592
|
+
const findings = await listFindings(sessionId, {
|
|
2593
|
+
taskId: flags.task,
|
|
2594
|
+
epic: flags.epic,
|
|
2595
|
+
status: flags.status,
|
|
2596
|
+
severity: flags.severity,
|
|
2597
|
+
category: flags.category,
|
|
2598
|
+
}, eventOpts);
|
|
2599
|
+
printJson(findings);
|
|
2600
|
+
return 0;
|
|
2601
|
+
}
|
|
2602
|
+
if (namespace === 'findings' && action === 'transition') {
|
|
2603
|
+
// Positionals first, then flags: argument order is the reading order, and
|
|
2604
|
+
// a usage error should name the first thing wrong on the line.
|
|
2605
|
+
const [findingId, newStatus] = requirePositionals(positional, usageFor('findings transition'));
|
|
2606
|
+
// D-136. Both amendment edges are gated on evidence this command line has
|
|
2607
|
+
// no way to carry: `amendsTaskIds` comes off a plan diff and
|
|
2608
|
+
// `amendsSatisfiedBy` off the task fold, and neither has a flag — by
|
|
2609
|
+
// design, since a hand-typed obligation is exactly the unchecked claim
|
|
2610
|
+
// D-127 closed. So every invocation naming them already fails; what it
|
|
2611
|
+
// failed with was a message about task ids the operator was never offered,
|
|
2612
|
+
// five guards into a log fold, which reads as a bug in their command line.
|
|
2613
|
+
// Refusing here, on the status alone, keeps argument order as the reading
|
|
2614
|
+
// order and lets the error name the verb that CAN take the edge.
|
|
2615
|
+
const AMENDMENT_ROUTES = {
|
|
2616
|
+
[AMEND_PENDING_STATUS]: '`smith plan amend` puts every finding it cites into "amend-pending", with the task ids the new plan version added or superseded as the obligation',
|
|
2617
|
+
[AMENDED_STATUS]: '`smith epic close` computes which of those tasks actually landed and discharges the finding with that evidence',
|
|
2618
|
+
};
|
|
2619
|
+
if (newStatus in AMENDMENT_ROUTES) {
|
|
2620
|
+
throw new SmithError('cli.amendment-edge-unreachable', `"${newStatus}" is not reachable through "smith 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 });
|
|
2621
|
+
}
|
|
2622
|
+
const ctx = eventContextFromFlags(flags);
|
|
2623
|
+
const finding = await transitionFinding(findingId, newStatus, ctx, eventOptsFromFlags(flags));
|
|
2624
|
+
printJson(finding);
|
|
2625
|
+
return 0;
|
|
2626
|
+
}
|
|
2627
|
+
// The dispatch-time half of P9-15, shaped exactly like `lessons
|
|
2628
|
+
// for-dispatch`: the caller composing a task prompt asks for the block and
|
|
2629
|
+
// splices it. `--plan` is required rather than optional here — without it
|
|
2630
|
+
// the claims list is empty, every finding fails the join, and the command
|
|
2631
|
+
// would answer "nothing is open in your files" when it never looked.
|
|
2632
|
+
if (namespace === 'findings' && action === 'for-dispatch') {
|
|
2633
|
+
const usage = 'smith findings for-dispatch --session ... --plan plan.json --task task-id [--state-dir dir]';
|
|
2634
|
+
requireFlag(flags, 'plan');
|
|
2635
|
+
const taskId = requireFlag(flags, 'task');
|
|
2636
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2637
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2638
|
+
requireSession(sessionId, eventOpts);
|
|
2639
|
+
if (positional.length > 0) {
|
|
2640
|
+
throw new SmithError('cli.usage', `Unexpected argument. Usage: ${usage}`, { positional });
|
|
2641
|
+
}
|
|
2642
|
+
printJson(await findingsForDispatch({ sessionId, taskId, claims: claimsForDispatch(flags) }, eventOpts));
|
|
2643
|
+
return 0;
|
|
2644
|
+
}
|
|
2645
|
+
// Re-dating a finding's evidence (P9-15). Deliberately its own verb and not
|
|
2646
|
+
// a `transition`: re-verification does not change finding_status, and the
|
|
2647
|
+
// two statuses it could be confused with mean something else entirely
|
|
2648
|
+
// (`confirmed` cannot be re-entered, `refuted` says the finding was wrong).
|
|
2649
|
+
if (namespace === 'findings' && action === 'reverify') {
|
|
2650
|
+
const [findingId] = requirePositionals(positional, usageFor('findings reverify'));
|
|
2651
|
+
const ctx = eventContextFromFlags(flags);
|
|
2652
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2653
|
+
requireSession(ctx.sessionId, eventOpts);
|
|
2654
|
+
await reverifyFinding(findingId, flags.note ?? '', ctx, eventOpts);
|
|
2655
|
+
printJson({ findingId, note: flags.note ?? '' });
|
|
2656
|
+
return 0;
|
|
2657
|
+
}
|
|
2658
|
+
// D-21 Part 4. Corrects a malformed amends_task_ids entry on a finding
|
|
2659
|
+
// parked at amend-pending -- the shape a malformed "plan amend" --changes
|
|
2660
|
+
// file can write (parts 1-3 of D-21 now refuse that at the source, but the
|
|
2661
|
+
// log is append-only). Never a hand-typed obligation: repairObligation's own
|
|
2662
|
+
// guards refuse anything this verb is not meant to do.
|
|
2663
|
+
if (namespace === 'findings' && action === 'repair-obligation') {
|
|
2664
|
+
const [findingId] = requirePositionals(positional, usageFor('findings repair-obligation'));
|
|
2665
|
+
// Comma-split, not repeated: a task id has no commas, and a repaired
|
|
2666
|
+
// obligation routinely names more than one (D-136's --findings precedent).
|
|
2667
|
+
//
|
|
2668
|
+
// Unlike --findings/--sites, a stray empty SEGMENT is not filtered out
|
|
2669
|
+
// here (D-21 Part 4 review, S4 behavioral-drift): "a,,b" used to filter
|
|
2670
|
+
// down to ['a', 'b'], so repairObligation's guard 6 -- every replacement
|
|
2671
|
+
// id must be a non-empty string -- was implemented and unit-tested but
|
|
2672
|
+
// unreachable through this command line; an operator's typo'd double
|
|
2673
|
+
// comma was silently corrected instead of refused. Only the flag's own
|
|
2674
|
+
// value being entirely blank (nothing typed at all, the one shape a
|
|
2675
|
+
// string flag can use to mean "zero entries") maps to an empty list, so
|
|
2676
|
+
// guard 3 (cannot empty) stays reachable too.
|
|
2677
|
+
const replaceWithRaw = requireFlag(flags, 'replace-with');
|
|
2678
|
+
const replaceWith = replaceWithRaw.trim() === '' ? [] : replaceWithRaw.split(',').map((id) => id.trim());
|
|
2679
|
+
const ctx = eventContextFromFlags(flags);
|
|
2680
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2681
|
+
requireSession(ctx.sessionId, eventOpts);
|
|
2682
|
+
const finding = await repairObligation({ findingId, replaceWith, reason: requireFlag(flags, 'reason') }, ctx, eventOpts);
|
|
2683
|
+
printJson(finding);
|
|
2684
|
+
return 0;
|
|
2685
|
+
}
|
|
2686
|
+
if (namespace === 'waivers' && action === 'pending') {
|
|
2687
|
+
const [epic] = requirePositionals(positional, usageFor('waivers pending'));
|
|
2688
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2689
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2690
|
+
requireSession(sessionId, eventOpts);
|
|
2691
|
+
const pending = await pendingBatch(epic, { sessionId }, eventOpts);
|
|
2692
|
+
printJson(pending);
|
|
2693
|
+
return 0;
|
|
2694
|
+
}
|
|
2695
|
+
if (namespace === 'waivers' && action === 'apply') {
|
|
2696
|
+
const [decisionsFile] = requirePositionals(positional, usageFor('waivers apply'));
|
|
2697
|
+
const decisions = readJsonFile(decisionsFile);
|
|
2698
|
+
const ctx = eventContextFromFlags(flags);
|
|
2699
|
+
const results = await applyBatch(decisions, ctx, eventOptsFromFlags(flags));
|
|
2700
|
+
printJson(results);
|
|
2701
|
+
return 0;
|
|
2702
|
+
}
|
|
2703
|
+
if (namespace === 'lessons' && action === 'candidates') {
|
|
2704
|
+
const { openDb } = await import('./db/projector.js');
|
|
2705
|
+
const { lessonsPage } = await import('./db/queries.js');
|
|
2706
|
+
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
2707
|
+
const handle = openDb(dbPath);
|
|
2708
|
+
try {
|
|
2709
|
+
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
2710
|
+
printJson(lessonsPage(handle.db, scope).pending);
|
|
2711
|
+
return 0;
|
|
2712
|
+
}
|
|
2713
|
+
finally {
|
|
2714
|
+
handle.sqlite.close();
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
// The hand-authored entrance to the pipeline (P9-34). `smith dream` only
|
|
2718
|
+
// ever sees four checkpoint shapes in a log, so a rule derived by reading a
|
|
2719
|
+
// whole run had no way in except `smith event append`, which applies no
|
|
2720
|
+
// novelty check at all. Exit 1 on a novelty rejection: the candidate is
|
|
2721
|
+
// logged either way, but the operator must look before assuming it landed.
|
|
2722
|
+
if (namespace === 'lessons' && action === 'raise') {
|
|
2723
|
+
const { raiseLessonCandidate } = await import('./lessons.js');
|
|
2724
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2725
|
+
const ctx = eventContextFromFlags(flags);
|
|
2726
|
+
requireSession(ctx.sessionId, eventOpts);
|
|
2727
|
+
const result = await raiseLessonCandidate({
|
|
2728
|
+
statement: requireFlag(flags, 'statement'),
|
|
2729
|
+
lessonType: requireFlag(flags, 'lesson-type'),
|
|
2730
|
+
lessonScope: requireFlag(flags, 'lesson-scope'),
|
|
2731
|
+
provenanceEventIds: requireFlag(flags, 'provenance')
|
|
2732
|
+
.split(',')
|
|
2733
|
+
.map((id) => id.trim())
|
|
2734
|
+
.filter(Boolean),
|
|
2735
|
+
provenanceSessionId: flags['provenance-session'],
|
|
2736
|
+
evidence: flags.evidence,
|
|
2737
|
+
findingCategory: flags['finding-category'],
|
|
2738
|
+
claimPath: flags['claim-path'],
|
|
2739
|
+
agentRole: flags['agent-role'],
|
|
2740
|
+
caseType: flags['case-type'],
|
|
2741
|
+
lessonId: flags['lesson-id'],
|
|
2742
|
+
}, ctx, eventOpts, await noveltyOptsFromFlags(flags));
|
|
2743
|
+
printJson(result);
|
|
2744
|
+
return result.novel ? 0 : 1;
|
|
2745
|
+
}
|
|
2746
|
+
// `approve` and `reject` are the same verb with the destination fixed
|
|
2747
|
+
// (P9-1): reject maps to `invalidated`, the status ui/server's reject route
|
|
2748
|
+
// already writes. Both refuse a transition LEGAL_LESSON_TRANSITIONS does
|
|
2749
|
+
// not allow rather than letting a hand-typed envelope poison the memory.
|
|
2750
|
+
//
|
|
2751
|
+
// P9-34/P9-35: an approval also carries the novelty review — `--statement`
|
|
2752
|
+
// is scored by the same gate `raise` uses, and even an unedited approval
|
|
2753
|
+
// reports what the text most resembles, because the gate only catches a
|
|
2754
|
+
// near-verbatim re-statement and the operator is the rest of the check.
|
|
2755
|
+
// Exit 1 when the text that just landed in memory is not novel: it landed
|
|
2756
|
+
// either way, but that is a "look at this", not a clean run.
|
|
2757
|
+
if (namespace === 'lessons' && (action === 'approve' || action === 'reject')) {
|
|
2758
|
+
const { transitionLesson } = await import('./lessons.js');
|
|
2759
|
+
const [lessonId] = requirePositionals(positional, usageFor(`lessons ${action}`));
|
|
2760
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2761
|
+
const ctx = eventContextFromFlags(flags);
|
|
2762
|
+
requireSession(ctx.sessionId, eventOpts);
|
|
2763
|
+
const row = await transitionLesson(lessonId, action === 'approve' ? 'approved' : 'invalidated', ctx, eventOpts, {
|
|
2764
|
+
note: flags.note,
|
|
2765
|
+
edit: {
|
|
2766
|
+
statement: flags.statement,
|
|
2767
|
+
lessonType: flags['lesson-type'],
|
|
2768
|
+
lessonScope: flags['lesson-scope'],
|
|
2769
|
+
agentRole: flags['agent-role'],
|
|
2770
|
+
caseType: flags['case-type'],
|
|
2771
|
+
},
|
|
2772
|
+
acceptDuplicate: flags['accept-duplicate'] === 'true',
|
|
2773
|
+
...(await noveltyOptsFromFlags(flags)),
|
|
2774
|
+
});
|
|
2775
|
+
printJson(row);
|
|
2776
|
+
return row.novelty && !row.novelty.novel ? 1 : 0;
|
|
2777
|
+
}
|
|
2778
|
+
if (namespace === 'lessons' && action === 'compile') {
|
|
2779
|
+
const { openDb } = await import('./db/projector.js');
|
|
2780
|
+
const { lessonsPage } = await import('./db/queries.js');
|
|
2781
|
+
const { compileLessons } = await import('./lessons.js');
|
|
2782
|
+
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
2783
|
+
const outPath = flags.out ?? LESSONS_MD_PATH;
|
|
2784
|
+
const handle = openDb(dbPath);
|
|
2785
|
+
try {
|
|
2786
|
+
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
2787
|
+
const approved = lessonsPage(handle.db, scope).approved;
|
|
2788
|
+
const markdown = compileLessons(approved.map((l) => ({
|
|
2789
|
+
lessonId: l.lessonId,
|
|
2790
|
+
lessonScope: l.lessonScope,
|
|
2791
|
+
statement: l.statement,
|
|
2792
|
+
findingCategory: l.findingCategory,
|
|
2793
|
+
claimPath: l.claimPath,
|
|
2794
|
+
agentRole: l.agentRole,
|
|
2795
|
+
caseType: l.caseType,
|
|
2796
|
+
})));
|
|
2797
|
+
writeFileSync(outPath, markdown, 'utf8');
|
|
2798
|
+
printJson({ outPath, lessonsCompiled: approved.length });
|
|
2799
|
+
return 0;
|
|
2800
|
+
}
|
|
2801
|
+
finally {
|
|
2802
|
+
handle.sqlite.close();
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
// The dispatch-time half of the lessons loop (P9-2): the caller composing a
|
|
2806
|
+
// role prompt asks for the block and splices it. Claims come from the
|
|
2807
|
+
// immutable plan rather than a repeated `--claim` flag — parseArgs keeps one
|
|
2808
|
+
// value per flag, and a claim glob may itself contain a comma (`src/{a,b}/**`),
|
|
2809
|
+
// so neither repetition nor splitting can carry a claims list faithfully.
|
|
2810
|
+
if (namespace === 'lessons' && action === 'for-dispatch') {
|
|
2811
|
+
const { lessonsForDispatch } = await import('./lessons.js');
|
|
2812
|
+
const [role] = requirePositionals(positional, usageFor('lessons for-dispatch'));
|
|
2813
|
+
printJson(lessonsForDispatch(role, claimsForDispatch(flags), {
|
|
2814
|
+
...(flags['agents-dir'] ? { agentsDir: flags['agents-dir'] } : {}),
|
|
2815
|
+
...(flags.lessons ? { lessonsPath: flags.lessons } : {}),
|
|
2816
|
+
caseType: caseForDispatch(flags),
|
|
2817
|
+
}));
|
|
2818
|
+
return 0;
|
|
2819
|
+
}
|
|
2820
|
+
// The other half of the lessons loop, and the one it never had: `lessons.md`
|
|
2821
|
+
// only ever grows, and nothing read an entry back to ask whether it still
|
|
2822
|
+
// does anything. Read-only over both the log and the corpus — it recommends
|
|
2823
|
+
// `retire`, it does not retire, because a lesson is a standing instruction a
|
|
2824
|
+
// human wrote and §9.6 reserves supersession for a human's call. Exit 1 on
|
|
2825
|
+
// anything but `clean`, for the same reason `kpi same-mistake` does: a corpus
|
|
2826
|
+
// that cannot be read is not a corpus that is fine.
|
|
2827
|
+
if (namespace === 'lessons' && action === 'audit') {
|
|
2828
|
+
const { auditLessons } = await import('./lessonAudit.js');
|
|
2829
|
+
const [sessionId] = requirePositionals(positional, usageFor('lessons audit'), 1);
|
|
2830
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
2831
|
+
// Lineage-wide (D-119), and here the reason is sharper than elsewhere: the
|
|
2832
|
+
// question is whether an entry has EVER fired, and a session-scoped read
|
|
2833
|
+
// answers "not in this half of the epic" while printing `retire`.
|
|
2834
|
+
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2835
|
+
const lessons = parseLessons(readFileSync(flags.lessons ?? LESSONS_MD_PATH, 'utf8'));
|
|
2836
|
+
const report = auditLessons(events, lessons, { sessionId });
|
|
2837
|
+
printJson(report);
|
|
2838
|
+
return report.ok ? 0 : 1;
|
|
2839
|
+
}
|
|
2840
|
+
if (namespace === 'dream') {
|
|
2841
|
+
const { dream } = await import('./lessons.js');
|
|
2842
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2843
|
+
// Before the lineage read, so a typo costs a message and not a full log walk.
|
|
2844
|
+
const since = isoDateFlag(flags, 'since')?.toISOString();
|
|
2845
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2846
|
+
requireSession(sessionId, eventOpts);
|
|
2847
|
+
// Lineage-wide (D-119). Dreaming is the pass that distils what went wrong
|
|
2848
|
+
// into lessons, and what went wrong belongs to the epic: an error logged
|
|
2849
|
+
// before a session split, and the retry that fixed it after, are one story
|
|
2850
|
+
// that a session-scoped read tells in halves. `--since` still bounds it.
|
|
2851
|
+
const events = await readLineageEvents(sessionId, eventOpts);
|
|
2852
|
+
const ctx = eventContextFromFlags(flags);
|
|
2853
|
+
const result = await dream(events, {
|
|
2854
|
+
sessionId: ctx.sessionId,
|
|
2855
|
+
planVersion: ctx.planVersion,
|
|
2856
|
+
causalParent: ctx.causalParent,
|
|
2857
|
+
actor: ctx.actor,
|
|
2858
|
+
}, eventOpts, { since, ...(await noveltyOptsFromFlags(flags)) });
|
|
2859
|
+
printJson(result);
|
|
2860
|
+
return 0;
|
|
2861
|
+
}
|
|
2862
|
+
// D-31, D-20 / P9-11. The two halves of a judge turn and the gap between
|
|
2863
|
+
// them. Dispatch declares the file; report proves it exists and parses;
|
|
2864
|
+
// outstanding is the difference, and exits non-zero while it is non-empty so
|
|
2865
|
+
// a re-poke loop can branch on the status instead of parsing stdout.
|
|
2866
|
+
if (namespace === 'judge' && action === 'dispatch') {
|
|
2867
|
+
const stored = await recordJudgeDispatch({
|
|
2868
|
+
taskId: requireFlag(flags, 'task'),
|
|
2869
|
+
role: requireFlag(flags, 'role'),
|
|
2870
|
+
round: boundedIntFlag(flags, 'round', { min: 1 }) ?? 1,
|
|
2871
|
+
artifactPath: requireFlag(flags, 'artifact'),
|
|
2872
|
+
// Required, unlike --provider/--model-tier below: P9-23's dispatch
|
|
2873
|
+
// asymmetry audit compares model ids, and this verb records one half of
|
|
2874
|
+
// the reviewer/verifier pair it checks.
|
|
2875
|
+
model: requireFlag(flags, 'model'),
|
|
2876
|
+
...(flags.provider ? { provider: flags.provider } : {}),
|
|
2877
|
+
...(flags['model-tier'] ? { modelTier: flags['model-tier'] } : {}),
|
|
2878
|
+
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2879
|
+
printJson(stored);
|
|
2880
|
+
return 0;
|
|
2881
|
+
}
|
|
2882
|
+
if (namespace === 'judge' && action === 'report') {
|
|
2883
|
+
// Optional here, unlike on dispatch, so an absent --round must stay absent
|
|
2884
|
+
// rather than defaulting: recordJudgeReport reads the dispatched round when
|
|
2885
|
+
// the caller names none. A present one is still a count (D-210's class) --
|
|
2886
|
+
// `--round abc` used to reach the reporter as NaN and come back as "is on
|
|
2887
|
+
// round 1, not round NaN", which blames the round for what the flag did.
|
|
2888
|
+
const reportRound = boundedIntFlag(flags, 'round', { min: 1 });
|
|
2889
|
+
const report = await recordJudgeReport({
|
|
2890
|
+
taskId: requireFlag(flags, 'task'),
|
|
2891
|
+
role: requireFlag(flags, 'role'),
|
|
2892
|
+
...(reportRound === undefined ? {} : { round: reportRound }),
|
|
2893
|
+
...(flags.artifact ? { artifactPath: flags.artifact } : {}),
|
|
2894
|
+
// Here the role is already named by --role, so the valueless spelling
|
|
2895
|
+
// is the right one and 'true' means what it says.
|
|
2896
|
+
...(flags['no-findings'] === 'true' ? { noFindings: true } : {}),
|
|
2897
|
+
}, eventContextFromFlags(flags), eventOptsFromFlags(flags));
|
|
2898
|
+
printJson(report);
|
|
2899
|
+
return 0;
|
|
2900
|
+
}
|
|
2901
|
+
if (namespace === 'judge' && action === 'outstanding') {
|
|
2902
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2903
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2904
|
+
requireSession(sessionId, eventOpts);
|
|
2905
|
+
const open = outstandingJudges(await readJudgeTurns(requireFlag(flags, 'task'), { sessionId }, eventOpts));
|
|
2906
|
+
printJson(open);
|
|
2907
|
+
return open.length > 0 ? 1 : 0;
|
|
2908
|
+
}
|
|
2909
|
+
if (namespace === 'judge' && action === 'escalations') {
|
|
2910
|
+
// Kept out of the boot graph by habit rather than by need -- the module
|
|
2911
|
+
// imports nothing at runtime -- so that a later dependency added to it
|
|
2912
|
+
// cannot quietly cost `smith --help` a database (P9-2, test/cliBoot.test.ts).
|
|
2913
|
+
const { openQuorumEscalations, summariseEscalations } = await import('./quorumEscalations.js');
|
|
2914
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2915
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2916
|
+
requireSession(sessionId, eventOpts);
|
|
2917
|
+
// The lineage and not the session: an escalation raised in one operator
|
|
2918
|
+
// session is owed until answered, and the answer routinely lands in the
|
|
2919
|
+
// next one (judges.ts:248 -- an epic outgrowing one session is the
|
|
2920
|
+
// recommended shape). Folding one session would report a settled case as
|
|
2921
|
+
// open, and an open one as nothing at all.
|
|
2922
|
+
const summary = summariseEscalations(openQuorumEscalations(await readLineageEvents(sessionId, eventOpts)));
|
|
2923
|
+
printJson(summary);
|
|
2924
|
+
return summary.exitCode;
|
|
2925
|
+
}
|
|
2926
|
+
if (namespace === 'judge' && action === 'preflight') {
|
|
2927
|
+
// The one provider question that can be answered without spending a call.
|
|
2928
|
+
// Deliberately ahead of `judge run` in this file for the same reason it is
|
|
2929
|
+
// ahead of it in the runbook: an operator reaching for a calibration call
|
|
2930
|
+
// to find out why a provider keeps failing usually needed this instead.
|
|
2931
|
+
const report = judgePreflight(flags.policy);
|
|
2932
|
+
printJson(report);
|
|
2933
|
+
return report.problems.length > 0 ? 1 : 0;
|
|
2934
|
+
}
|
|
2935
|
+
if (namespace === 'judge' && action === 'run') {
|
|
2936
|
+
// Manual invocation for calibration (docs/runbooks/providers.md) — never
|
|
2937
|
+
// touches the event log or quorum.ts; `--shadow` is an output-only note
|
|
2938
|
+
// for the operator's own bookkeeping (crosscheck.yml's provider `mode`
|
|
2939
|
+
// is the only thing that ever grants real gating power).
|
|
2940
|
+
const providerName = requireFlag(flags, 'provider');
|
|
2941
|
+
const request = readJsonFile(requireFlag(flags, 'request'));
|
|
2942
|
+
const result = await runJudge(providerName, request);
|
|
2943
|
+
printJson({ ...result, shadow: flags.shadow === 'true' });
|
|
2944
|
+
return 0;
|
|
2945
|
+
}
|
|
2946
|
+
if (namespace === 'crossfind') {
|
|
2947
|
+
// crosscheck.yml's `independent_finder`, driven by hand. Three verbs, and
|
|
2948
|
+
// the split between them is the operator mandate made operable: `request`
|
|
2949
|
+
// shows exactly what would leave the machine WITHOUT sending it, `run`
|
|
2950
|
+
// sends it, and `reconcile` needs no provider at all.
|
|
2951
|
+
const policy = loadCrosscheckPolicy(flags.policy);
|
|
2952
|
+
if (action === 'request') {
|
|
2953
|
+
// Read-only and network-free by construction: it builds the JudgeRequest
|
|
2954
|
+
// and prints it. Every refusal independentFinderRequest() can raise —
|
|
2955
|
+
// send_diff false, empty diff, oversized diff — fires here too, so an
|
|
2956
|
+
// operator can find out what the policy forbids before spending a call.
|
|
2957
|
+
const request = independentFinderRequest({
|
|
2958
|
+
taskId: requireFlag(flags, 'task'),
|
|
2959
|
+
diff: readFileSync(requireFlag(flags, 'diff'), 'utf8'),
|
|
2960
|
+
diffRef: requireFlag(flags, 'diff-ref'),
|
|
2961
|
+
...(repeated.criterion ? { criteria: repeated.criterion } : {}),
|
|
2962
|
+
budget: judgeBudgetFromFlags(flags),
|
|
2963
|
+
policy: policy.independentFinder,
|
|
2964
|
+
});
|
|
2965
|
+
printJson(request);
|
|
2966
|
+
return 0;
|
|
2967
|
+
}
|
|
2968
|
+
if (action === 'reconcile') {
|
|
2969
|
+
// Offline calibration: two saved lists in, one report out, no provider
|
|
2970
|
+
// invoked and nothing appended to the log. This is the verb to run over
|
|
2971
|
+
// a shadow-mode backlog before deciding whether to flip `mode: active`.
|
|
2972
|
+
const report = reconcile({
|
|
2973
|
+
taskId: requireFlag(flags, 'task'),
|
|
2974
|
+
native: readJsonFile(requireFlag(flags, 'native')),
|
|
2975
|
+
independent: readJsonFile(requireFlag(flags, 'independent')),
|
|
2976
|
+
policy: policy.independentFinder,
|
|
2977
|
+
});
|
|
2978
|
+
printJson(report);
|
|
2979
|
+
return report.gates ? 1 : 0;
|
|
2980
|
+
}
|
|
2981
|
+
if (action === 'run') {
|
|
2982
|
+
const sessionId = requireFlag(flags, 'session');
|
|
2983
|
+
const taskId = requireFlag(flags, 'task');
|
|
2984
|
+
const eventOpts = eventOptsFromFlags(flags);
|
|
2985
|
+
requireSession(sessionId, eventOpts);
|
|
2986
|
+
// Lineage-wide (D-119), like every other read of a task's findings: a
|
|
2987
|
+
// finding raised before a session split is still a finding this diff has
|
|
2988
|
+
// to be reconciled against.
|
|
2989
|
+
const native = (await listFindings(sessionId, { taskId }, eventOpts)).filter((f) => flags.status === undefined
|
|
2990
|
+
? OPEN_FINDING_STATUSES.has(f.finding_status)
|
|
2991
|
+
: f.finding_status === flags.status);
|
|
2992
|
+
// Built separately from the run for the reason RunIndependentFinderInput
|
|
2993
|
+
// says: `crossfind request` and `crossfind run` must assemble the same
|
|
2994
|
+
// bytes, so the thing the operator inspected is the thing that is sent.
|
|
2995
|
+
const request = independentFinderRequest({
|
|
2996
|
+
taskId,
|
|
2997
|
+
diff: readFileSync(requireFlag(flags, 'diff'), 'utf8'),
|
|
2998
|
+
diffRef: requireFlag(flags, 'diff-ref'),
|
|
2999
|
+
...(repeated.criterion ? { criteria: repeated.criterion } : {}),
|
|
3000
|
+
budget: judgeBudgetFromFlags(flags),
|
|
3001
|
+
policy: policy.independentFinder,
|
|
3002
|
+
});
|
|
3003
|
+
const result = await runIndependentFinder({ taskId, request, native, policy }, eventContextFromFlags(flags), eventOpts);
|
|
3004
|
+
// `raise` is printed, never minted (crossFinding.ts's contract): which
|
|
3005
|
+
// findings enter a gate is the operator's call, and `smith findings
|
|
3006
|
+
// raise` is where they make it.
|
|
3007
|
+
printJson({
|
|
3008
|
+
report: result.report,
|
|
3009
|
+
runs: result.runs,
|
|
3010
|
+
raise: result.raise,
|
|
3011
|
+
reconciled_event_id: result.reconciledEventId,
|
|
3012
|
+
native_considered: native.length,
|
|
3013
|
+
});
|
|
3014
|
+
return result.report.gates ? 1 : 0;
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
if (namespace === 'ui' && action === 'serve') {
|
|
3018
|
+
// Before the dynamic import below, so a mistyped port costs a message and
|
|
3019
|
+
// not a build: unbuilt, ui.not-built preempted the parse and the operator
|
|
3020
|
+
// was told to run pnpm build:server for a typo that would still be there
|
|
3021
|
+
// afterwards. Built, listen() threw ERR_SOCKET_BAD_PORT -- exit 1, but a
|
|
3022
|
+
// Node stack trace on stdout with no error.code, the one shape every other
|
|
3023
|
+
// error from this CLI has.
|
|
3024
|
+
const port = boundedIntFlag(flags, 'port', { min: 1, max: 65535 }) ?? 4680;
|
|
3025
|
+
// ui/server is a separate TS project (ui/server/tsconfig.json) built to
|
|
3026
|
+
// ui/server/dist/index.js — dynamically imported here (via a computed,
|
|
3027
|
+
// non-literal specifier, so tsc never tries to fold it into THIS
|
|
3028
|
+
// project's rootDir) rather than statically imported, so `smith`'s own
|
|
3029
|
+
// build/bin contract (factory/orchestrator/dist/cli.js) stays untouched
|
|
3030
|
+
// whether or not the UI has been built. See ui/server/src/app.ts's
|
|
3031
|
+
// header comment for why ui/server depends on the BUILT orchestrator
|
|
3032
|
+
// dist/, not this file's src/ tree.
|
|
3033
|
+
const serverEntry = path.join(REPO_ROOT, 'ui', 'server', 'dist', 'index.js');
|
|
3034
|
+
let mod;
|
|
3035
|
+
try {
|
|
3036
|
+
mod = await import(pathToFileURL(serverEntry).href);
|
|
3037
|
+
}
|
|
3038
|
+
catch (err) {
|
|
3039
|
+
throw new SmithError('ui.not-built', `ui/server is not built (expected ${serverEntry}). Run "pnpm build:server" first.`, { cause: err instanceof Error ? err.message : String(err) });
|
|
3040
|
+
}
|
|
3041
|
+
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
3042
|
+
const stateDir = flags['state-dir'];
|
|
3043
|
+
// --roadmap-path travels with --db/--state-dir, and dropping it was not a
|
|
3044
|
+
// missing feature but a silent data swap: the read path re-projects each
|
|
3045
|
+
// session on the first request (app.ts's createRefresher), and apply()
|
|
3046
|
+
// rebuilds the whole milestones table from a roadmap file while it is
|
|
3047
|
+
// there. Unset, that file falls back to black-smith's own
|
|
3048
|
+
// factory/specs/roadmap.md — so serving another project's db showed this
|
|
3049
|
+
// repo's roadmap from the first page load onward.
|
|
3050
|
+
const roadmapPath = flags['roadmap-path'];
|
|
3051
|
+
// Same argument for --specs-dir: createRefresher re-projects on every
|
|
3052
|
+
// request, and the plan files it consults to place an unstamped epic
|
|
3053
|
+
// (D-246) must be the served db's, not this repo's.
|
|
3054
|
+
const specsDir = flags['specs-dir'];
|
|
3055
|
+
mod.serve({
|
|
3056
|
+
port,
|
|
3057
|
+
dbPath,
|
|
3058
|
+
...(stateDir ? { stateDir } : {}),
|
|
3059
|
+
...(roadmapPath ? { roadmapPath } : {}),
|
|
3060
|
+
...(specsDir ? { specsDir } : {}),
|
|
3061
|
+
});
|
|
3062
|
+
return 0;
|
|
3063
|
+
}
|
|
3064
|
+
if (namespace === 'db' && action === 'rebuild') {
|
|
3065
|
+
const { rebuild: rebuildDb } = await import('./db/projector.js');
|
|
3066
|
+
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
3067
|
+
const sessions = flags.session ? [flags.session] : 'all';
|
|
3068
|
+
// A named session must exist; `all` legitimately finds nothing (P9-28).
|
|
3069
|
+
if (flags.session)
|
|
3070
|
+
requireSession(flags.session, eventOptsFromFlags(flags));
|
|
3071
|
+
const result = await rebuildDb(dbPath, sessions, dbOptsFromFlags(flags));
|
|
3072
|
+
printJson(result);
|
|
3073
|
+
return 0;
|
|
3074
|
+
}
|
|
3075
|
+
if (namespace === 'db' && action === 'apply') {
|
|
3076
|
+
const { apply: applyDb } = await import('./db/projector.js');
|
|
3077
|
+
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
3078
|
+
const sessionId = requireFlag(flags, 'session');
|
|
3079
|
+
// Applying a session that has no log would report 0 events applied and
|
|
3080
|
+
// exit 0 — a successful-looking no-op is the worst possible answer for a
|
|
3081
|
+
// command whose whole job is "make the DB match the log" (P9-28).
|
|
3082
|
+
requireSession(sessionId, eventOptsFromFlags(flags));
|
|
3083
|
+
const result = await applyDb(dbPath, sessionId, dbOptsFromFlags(flags));
|
|
3084
|
+
printJson(result);
|
|
3085
|
+
return 0;
|
|
3086
|
+
}
|
|
3087
|
+
if (namespace === 'stats') {
|
|
3088
|
+
const { openDb } = await import('./db/projector.js');
|
|
3089
|
+
const { analytics, errorsPage, kanban, lessonsPage, overview, providerAgreement, projectedLineage, roadmapPage, taskDetail, timeline, } = await import('./db/queries.js');
|
|
3090
|
+
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
3091
|
+
const handle = openDb(dbPath);
|
|
3092
|
+
try {
|
|
3093
|
+
const scope = flags.session
|
|
3094
|
+
? { sessionId: flags.session }
|
|
3095
|
+
: {};
|
|
3096
|
+
if (flags.lineage) {
|
|
3097
|
+
// --lineage widens the scope from the session asked about to the whole
|
|
3098
|
+
// chain it continues, resolved off the projection so `stats` still needs
|
|
3099
|
+
// nothing but a db. It has nothing to widen without --session, and
|
|
3100
|
+
// saying so beats silently reporting every session as "the lineage".
|
|
3101
|
+
scope.sessionIds = projectedLineage(handle.db, requireFlag(flags, 'session'));
|
|
3102
|
+
}
|
|
3103
|
+
if (action === 'overview') {
|
|
3104
|
+
printJson(overview(handle.db, scope));
|
|
3105
|
+
return 0;
|
|
3106
|
+
}
|
|
3107
|
+
if (action === 'timeline') {
|
|
3108
|
+
printJson(timeline(handle.db, {
|
|
3109
|
+
...scope,
|
|
3110
|
+
taskId: flags.task,
|
|
3111
|
+
epicId: flags.epic,
|
|
3112
|
+
causalChainFor: flags['causal-chain-for'],
|
|
3113
|
+
}));
|
|
3114
|
+
return 0;
|
|
3115
|
+
}
|
|
3116
|
+
if (action === 'kanban') {
|
|
3117
|
+
const epic = requireFlag(flags, 'epic');
|
|
3118
|
+
printJson(kanban(handle.db, epic, scope));
|
|
3119
|
+
return 0;
|
|
3120
|
+
}
|
|
3121
|
+
if (action === 'task') {
|
|
3122
|
+
const taskId = requireFlag(flags, 'task');
|
|
3123
|
+
const detail = taskDetail(handle.db, taskId);
|
|
3124
|
+
printJson(detail);
|
|
3125
|
+
return detail ? 0 : 1;
|
|
3126
|
+
}
|
|
3127
|
+
if (action === 'lessons') {
|
|
3128
|
+
printJson(lessonsPage(handle.db, scope));
|
|
3129
|
+
return 0;
|
|
3130
|
+
}
|
|
3131
|
+
if (action === 'errors') {
|
|
3132
|
+
printJson(errorsPage(handle.db, scope));
|
|
3133
|
+
return 0;
|
|
3134
|
+
}
|
|
3135
|
+
if (action === 'analytics') {
|
|
3136
|
+
printJson(analytics(handle.db, scope));
|
|
3137
|
+
return 0;
|
|
3138
|
+
}
|
|
3139
|
+
if (action === 'roadmap') {
|
|
3140
|
+
printJson(roadmapPage(handle.db, scope));
|
|
3141
|
+
return 0;
|
|
3142
|
+
}
|
|
3143
|
+
if (action === 'providers') {
|
|
3144
|
+
const since = isoDateFlag(flags, 'since')?.toISOString();
|
|
3145
|
+
printJson(providerAgreement(handle.db, scope, { since }));
|
|
3146
|
+
return 0;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
finally {
|
|
3150
|
+
handle.sqlite.close();
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
// Unreachable while usage.ts and the branches above agree, because
|
|
3154
|
+
// isDocumented rejected everything they do not both name — but a function
|
|
3155
|
+
// returning `Promise<number>` has to return on every path, and an
|
|
3156
|
+
// unreachable `throw` would be a worse answer than the honest one. What
|
|
3157
|
+
// keeps the two in agreement is test/usage.test.ts, not this line.
|
|
3158
|
+
printJson({
|
|
3159
|
+
error: {
|
|
3160
|
+
message: `Unknown command: ${[namespace, action].filter(Boolean).join(' ')}`,
|
|
3161
|
+
},
|
|
3162
|
+
});
|
|
3163
|
+
return 1;
|
|
3164
|
+
}
|
|
3165
|
+
main()
|
|
3166
|
+
.then((code) => {
|
|
3167
|
+
process.exitCode = code;
|
|
3168
|
+
})
|
|
3169
|
+
.catch((err) => {
|
|
3170
|
+
// A SmithError is a designed answer: its code names the failure and its
|
|
3171
|
+
// details name the record, so a stack would only add noise. Anything else
|
|
3172
|
+
// is a bug, and D-135 is what that costs — `{"message":"Cannot read
|
|
3173
|
+
// properties of undefined (reading 'indexOf')"}` was the entire output of
|
|
3174
|
+
// a failed `smith findings list`, with no file, no line and no clue which
|
|
3175
|
+
// of the three helpers that call `.indexOf` had thrown. The stack is the
|
|
3176
|
+
// only part of an unexpected error worth having.
|
|
3177
|
+
const details = err instanceof SmithError
|
|
3178
|
+
? { code: err.code, message: err.message, details: err.details }
|
|
3179
|
+
: {
|
|
3180
|
+
message: err instanceof Error ? err.message : String(err),
|
|
3181
|
+
...(err instanceof Error && err.stack ? { stack: err.stack.split('\n') } : {}),
|
|
3182
|
+
};
|
|
3183
|
+
printJson({ error: details });
|
|
3184
|
+
process.exitCode = 1;
|
|
3185
|
+
});
|