@juzser/blacksmith 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +109 -53
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +13 -37
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +4 -4
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +7 -7
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bs-rename, operator decision 3: `smith`/`smith-run` keep working as
|
|
3
|
+
* deprecated aliases of `bs`/`bs-run` for a transition period, each printing
|
|
4
|
+
* one deprecation line to stderr (never stdout -- stdout is parsed as JSON by
|
|
5
|
+
* playbooks) when invoked under the old name.
|
|
6
|
+
*
|
|
7
|
+
* Detection is argv[1]'s basename. `npm`/`pnpm`'s `bin` field creates one
|
|
8
|
+
* shim per key, and each is an unextended file (a symlink on POSIX) literally
|
|
9
|
+
* named after that key, sharing the same target script with its non-
|
|
10
|
+
* deprecated sibling -- so this reads back exactly what the operator typed to
|
|
11
|
+
* reach the running process, never the shared script file underneath it.
|
|
12
|
+
*/
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
export function invokedName(argv1) {
|
|
15
|
+
return argv1 === undefined ? undefined : path.basename(argv1);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Writes the deprecation line when the current process was invoked as
|
|
19
|
+
* `legacyName`; a no-op for every other invocation, `currentName` included.
|
|
20
|
+
*/
|
|
21
|
+
export function warnIfLegacyName(argv1, legacyName, currentName, writeStderr = (text) => {
|
|
22
|
+
process.stderr.write(text);
|
|
23
|
+
}) {
|
|
24
|
+
if (invokedName(argv1) !== legacyName)
|
|
25
|
+
return;
|
|
26
|
+
writeStderr(`${legacyName}: deprecated, use "${currentName}" instead -- ${legacyName} will be removed in a future release.\n`);
|
|
27
|
+
}
|
|
@@ -12,11 +12,27 @@
|
|
|
12
12
|
// backward compatibility with the Phase-1 scaffold (it predates the transport
|
|
13
13
|
// split and no longer carries meaning on its own — `transport` is now the
|
|
14
14
|
// field that actually selects behavior).
|
|
15
|
-
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
16
17
|
import { parse as parseYaml } from 'yaml';
|
|
18
|
+
import { readEnv } from './env.js';
|
|
17
19
|
import { SmithError } from './errors.js';
|
|
18
20
|
import { CROSSCHECK_POLICY_PATH } from './paths.js';
|
|
19
21
|
import { apiKeyPresent, commandOnPath } from './preconditions.js';
|
|
22
|
+
/**
|
|
23
|
+
* OpenRouter (https://openrouter.ai/api/v1) speaks the same OpenAI
|
|
24
|
+
* chat/completions wire format api-transport.ts already sends, so it plays
|
|
25
|
+
* two roles here without a new transport module:
|
|
26
|
+
*
|
|
27
|
+
* - Substitute: a default external provider (codex, deepseek) whose own
|
|
28
|
+
* precondition is unmet on this box runs through OpenRouter instead, using
|
|
29
|
+
* `openrouter_fallback.model` from crosscheck.yml — same provider name,
|
|
30
|
+
* mode and gating power, `via: 'openrouter'` recorded for the log.
|
|
31
|
+
* - Extra judge: `OPENROUTER_MODELS` (comma-separated model ids) synthesizes
|
|
32
|
+
* additional `openrouter:<model>` providers, `mode: shadow` by default.
|
|
33
|
+
*/
|
|
34
|
+
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
35
|
+
const OPENROUTER_API_KEY_ENV = 'OPENROUTER_API_KEY';
|
|
20
36
|
export class CrosscheckError extends SmithError {
|
|
21
37
|
}
|
|
22
38
|
/**
|
|
@@ -246,6 +262,13 @@ function parseProvider(name, raw) {
|
|
|
246
262
|
throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" has transport: cli but no command.`, { provider: name });
|
|
247
263
|
}
|
|
248
264
|
const command = quorumString(`providers.${name}.command`, raw.command);
|
|
265
|
+
// Computed unconditionally (not only under `auto`) so a substitution
|
|
266
|
+
// decision below can ask "can this provider run as declared" even when
|
|
267
|
+
// the file says `enabled: true` outright.
|
|
268
|
+
const preconditionMet = commandOnPath(command);
|
|
269
|
+
if (shouldSubstituteViaOpenrouter(declared, preconditionMet, raw.openrouter_fallback)) {
|
|
270
|
+
return substituteViaOpenrouter(name, mode, modelTier, raw.openrouter_fallback, enabledSource);
|
|
271
|
+
}
|
|
249
272
|
return {
|
|
250
273
|
name,
|
|
251
274
|
kind: 'api',
|
|
@@ -254,7 +277,7 @@ function parseProvider(name, raw) {
|
|
|
254
277
|
// binary runnable here. Not whether it is authenticated -- only a real
|
|
255
278
|
// call knows that, so `auto` can still resolve true on a box where
|
|
256
279
|
// `codex login` was never run, and the quorum records the failure.
|
|
257
|
-
enabled: declared === 'auto' ?
|
|
280
|
+
enabled: declared === 'auto' ? preconditionMet : declared,
|
|
258
281
|
enabledSource,
|
|
259
282
|
mode,
|
|
260
283
|
modelTier,
|
|
@@ -271,13 +294,18 @@ function parseProvider(name, raw) {
|
|
|
271
294
|
throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" has transport: api but is missing base_url/model/api_key_env.`, { provider: name });
|
|
272
295
|
}
|
|
273
296
|
const apiKeyEnv = quorumString(`providers.${name}.api_key_env`, raw.api_key_env);
|
|
297
|
+
// Computed unconditionally for the same reason as the cli branch above.
|
|
298
|
+
const preconditionMet = apiKeyPresent(apiKeyEnv);
|
|
299
|
+
if (shouldSubstituteViaOpenrouter(declared, preconditionMet, raw.openrouter_fallback)) {
|
|
300
|
+
return substituteViaOpenrouter(name, mode, modelTier, raw.openrouter_fallback, enabledSource);
|
|
301
|
+
}
|
|
274
302
|
return {
|
|
275
303
|
name,
|
|
276
304
|
kind: 'api',
|
|
277
305
|
transport: 'api',
|
|
278
306
|
// Set and non-empty, which is all that is knowable without spending a
|
|
279
307
|
// call; whether the key is VALID is not.
|
|
280
|
-
enabled: declared === 'auto' ?
|
|
308
|
+
enabled: declared === 'auto' ? preconditionMet : declared,
|
|
281
309
|
enabledSource,
|
|
282
310
|
mode,
|
|
283
311
|
modelTier,
|
|
@@ -292,6 +320,85 @@ function parseProvider(name, raw) {
|
|
|
292
320
|
}
|
|
293
321
|
throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" is not kind: native and has no recognized transport (got "${String(raw.transport)}").`, { provider: name, transport: raw.transport });
|
|
294
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Operator decision 1a's eligibility rule: a provider substitutes through
|
|
325
|
+
* OpenRouter when it is not explicitly turned off (`enabled: false` stays an
|
|
326
|
+
* opt-out, whether the reason is cost, policy, or "I don't want a
|
|
327
|
+
* substitute"), its own precondition failed on this box, the policy names a
|
|
328
|
+
* fallback model for it, and OPENROUTER_API_KEY is actually set here.
|
|
329
|
+
*/
|
|
330
|
+
function shouldSubstituteViaOpenrouter(declared, preconditionMet, fallback) {
|
|
331
|
+
return (declared !== false &&
|
|
332
|
+
!preconditionMet &&
|
|
333
|
+
Boolean(fallback) &&
|
|
334
|
+
apiKeyPresent(OPENROUTER_API_KEY_ENV));
|
|
335
|
+
}
|
|
336
|
+
function substituteViaOpenrouter(name, mode, modelTier, fallback, enabledSource) {
|
|
337
|
+
const model = quorumString(`providers.${name}.openrouter_fallback.model`, fallback.model);
|
|
338
|
+
return {
|
|
339
|
+
name,
|
|
340
|
+
kind: 'api',
|
|
341
|
+
transport: 'api',
|
|
342
|
+
enabled: true,
|
|
343
|
+
enabledSource,
|
|
344
|
+
mode,
|
|
345
|
+
modelTier,
|
|
346
|
+
baseUrl: OPENROUTER_BASE_URL,
|
|
347
|
+
model,
|
|
348
|
+
apiKeyEnv: OPENROUTER_API_KEY_ENV,
|
|
349
|
+
responseFormatJsonObject: fallback.response_format_json_object ?? true,
|
|
350
|
+
via: 'openrouter',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `OPENROUTER_MODELS` (comma-separated OpenRouter model ids) synthesizes
|
|
355
|
+
* extra judges, operator decision 1b: `openrouter:<model>` as the provider
|
|
356
|
+
* name (deterministic, so a repeated run of the same env names the same
|
|
357
|
+
* provider), `mode: shadow` unless a project/policy overlay promotes it.
|
|
358
|
+
*/
|
|
359
|
+
function envOpenrouterProviders() {
|
|
360
|
+
const raw = (process.env.OPENROUTER_MODELS ?? '').trim();
|
|
361
|
+
if (!raw)
|
|
362
|
+
return {};
|
|
363
|
+
const providers = {};
|
|
364
|
+
for (const model of raw
|
|
365
|
+
.split(',')
|
|
366
|
+
.map((entry) => entry.trim())
|
|
367
|
+
.filter(Boolean)) {
|
|
368
|
+
providers[`openrouter:${model}`] = {
|
|
369
|
+
kind: 'api',
|
|
370
|
+
transport: 'api',
|
|
371
|
+
enabled: 'auto',
|
|
372
|
+
mode: 'shadow',
|
|
373
|
+
model_tier: DEFAULT_MODEL_TIER,
|
|
374
|
+
base_url: OPENROUTER_BASE_URL,
|
|
375
|
+
model,
|
|
376
|
+
api_key_env: OPENROUTER_API_KEY_ENV,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return providers;
|
|
380
|
+
}
|
|
381
|
+
/** `<project-dir>/.blacksmith/crosscheck.yml` — mirrors auditStorePath()'s STORE_DIR pattern in audit.ts. */
|
|
382
|
+
export function crosscheckOverlayPath(projectDir) {
|
|
383
|
+
return path.join(path.resolve(projectDir), '.blacksmith', 'crosscheck.yml');
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The per-project overlay (config source 1, highest precedence): only a
|
|
387
|
+
* `providers:` map is legal here, merged by provider name over env and the
|
|
388
|
+
* shipped policy. Quorum rules and everything else stay repo policy, so any
|
|
389
|
+
* other top-level key is refused loudly rather than silently ignored.
|
|
390
|
+
*/
|
|
391
|
+
export function loadProjectProviderOverlay(projectDir) {
|
|
392
|
+
const filePath = crosscheckOverlayPath(projectDir);
|
|
393
|
+
if (!existsSync(filePath))
|
|
394
|
+
return {};
|
|
395
|
+
const doc = (parseYaml(readFileSync(filePath, 'utf8')) ?? {});
|
|
396
|
+
const extraKeys = Object.keys(doc).filter((key) => key !== 'providers');
|
|
397
|
+
if (extraKeys.length > 0) {
|
|
398
|
+
throw new CrosscheckError('crosscheck.invalid-overlay', `${filePath} may only set "providers" -- rejected ${extraKeys.map((key) => JSON.stringify(key)).join(', ')}. Quorum rules and every other crosscheck.yml knob stay repo policy; only which providers run is a per-project decision.`, { file: filePath, keys: extraKeys });
|
|
399
|
+
}
|
|
400
|
+
return doc.providers ?? {};
|
|
401
|
+
}
|
|
295
402
|
/**
|
|
296
403
|
* The isolation pair list, rebuilt entry by entry for quorumPairs()' reason:
|
|
297
404
|
* a half-readable list is the same hole as no list. An empty list stays legal
|
|
@@ -359,9 +466,27 @@ export function parseCrosscheckPolicy(yamlText, options = {}) {
|
|
|
359
466
|
if (!doc.providers || Object.keys(doc.providers).length === 0) {
|
|
360
467
|
throw new CrosscheckError('crosscheck.invalid-policy', 'crosscheck.yml has no providers.');
|
|
361
468
|
}
|
|
469
|
+
const declaredProviders = doc.providers;
|
|
470
|
+
const envProviders = envOpenrouterProviders();
|
|
471
|
+
const overlay = options.providerOverlay ?? {};
|
|
472
|
+
const names = new Set([
|
|
473
|
+
...Object.keys(declaredProviders),
|
|
474
|
+
...Object.keys(envProviders),
|
|
475
|
+
...Object.keys(overlay),
|
|
476
|
+
]);
|
|
362
477
|
const providers = {};
|
|
363
|
-
for (const
|
|
364
|
-
const
|
|
478
|
+
for (const name of names) {
|
|
479
|
+
const fromPolicy = declaredProviders[name];
|
|
480
|
+
const fromEnv = envProviders[name];
|
|
481
|
+
const fromOverlay = overlay[name];
|
|
482
|
+
const merged = {
|
|
483
|
+
...(fromPolicy ?? {}),
|
|
484
|
+
...(fromEnv ?? {}),
|
|
485
|
+
...(fromOverlay ?? {}),
|
|
486
|
+
};
|
|
487
|
+
const configSource = fromOverlay ? 'project' : fromEnv ? 'env' : 'policy';
|
|
488
|
+
const parsed = parseProvider(name, merged);
|
|
489
|
+
const config = parsed.kind === 'native' ? parsed : { ...parsed, configSource };
|
|
365
490
|
providers[name] =
|
|
366
491
|
options.offline && config.kind !== 'native'
|
|
367
492
|
? // The switch is the decider now, and says so: a reader that saw
|
|
@@ -391,8 +516,9 @@ export function parseCrosscheckPolicy(yamlText, options = {}) {
|
|
|
391
516
|
};
|
|
392
517
|
}
|
|
393
518
|
/**
|
|
394
|
-
* Reads the policy, honouring `
|
|
395
|
-
*
|
|
519
|
+
* Reads the policy, honouring `BS_CROSSCHECK_OFFLINE` (or its legacy
|
|
520
|
+
* `SMITH_CROSSCHECK_OFFLINE` fallback): set it to anything non-empty and no
|
|
521
|
+
* external provider can be invoked, whatever the file says.
|
|
396
522
|
*
|
|
397
523
|
* The switch is read here rather than passed down from each call site because
|
|
398
524
|
* the property it protects is process-wide — "this process makes no judge
|
|
@@ -401,7 +527,9 @@ export function parseCrosscheckPolicy(yamlText, options = {}) {
|
|
|
401
527
|
* built binary as a subprocess; nothing else would reach them.
|
|
402
528
|
*/
|
|
403
529
|
export function loadCrosscheckPolicy(filePath = CROSSCHECK_POLICY_PATH, options = {}) {
|
|
530
|
+
const { projectDir, ...rest } = options;
|
|
404
531
|
return parseCrosscheckPolicy(readFileSync(filePath, 'utf8'), {
|
|
405
|
-
offline:
|
|
532
|
+
offline: rest.offline ?? Boolean(readEnv(process.env, 'SMITH_CROSSCHECK_OFFLINE')),
|
|
533
|
+
providerOverlay: projectDir ? loadProjectProviderOverlay(projectDir) : rest.providerOverlay,
|
|
406
534
|
});
|
|
407
535
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// already owns — budgetAlarm.ts for spend, agents-registry.ts for stalls,
|
|
13
13
|
// scheduler.ts for work that is due. Nothing here re-derives them, so the
|
|
14
14
|
// daemon and `smith status` can never disagree.
|
|
15
|
-
import {
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { DEFAULT_STALE_HOURS, detectStale, foldAgents, } from './agents-registry.js';
|
|
18
18
|
import { admitProposals, } from './autonomy.js';
|
|
@@ -20,6 +20,7 @@ import { checkBudgetAlarm } from './budgetAlarm.js';
|
|
|
20
20
|
import { loadBudgetPolicy } from './budgets.js';
|
|
21
21
|
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
22
22
|
import { apply, foldTasks, rebuild } from './db/projector.js';
|
|
23
|
+
import { epicBudgetPolicies } from './epicBudget.js';
|
|
23
24
|
import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
|
|
24
25
|
import { SmithError } from './errors.js';
|
|
25
26
|
import { listSessionIds, mergeSessionLogs, parseEventId } from './events.js';
|
|
@@ -88,7 +89,12 @@ function originSessionId(eventId) {
|
|
|
88
89
|
*/
|
|
89
90
|
export function inspectSession(sessionId, events, opts = {}) {
|
|
90
91
|
const now = opts.now ?? new Date();
|
|
92
|
+
// An injected policy judges every epic; otherwise each epic is judged
|
|
93
|
+
// against budgets.yml sized for its own plan's effort tier.
|
|
91
94
|
const budgetPolicy = opts.budgetPolicy ?? loadBudgetPolicy();
|
|
95
|
+
const policyForEpic = opts.budgetPolicy === undefined
|
|
96
|
+
? epicBudgetPolicies(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts })
|
|
97
|
+
: undefined;
|
|
92
98
|
const schedulerPolicy = opts.schedulerPolicy ?? loadSchedulerPolicy();
|
|
93
99
|
const staleHours = opts.staleHours ?? DEFAULT_STALE_HOURS;
|
|
94
100
|
const findings = [];
|
|
@@ -104,7 +110,10 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
104
110
|
if (typeof epicId === 'string')
|
|
105
111
|
closedEpics.add(epicId);
|
|
106
112
|
}
|
|
107
|
-
const budget = checkBudgetAlarm(events, budgetPolicy, {
|
|
113
|
+
const budget = checkBudgetAlarm(events, budgetPolicy, {
|
|
114
|
+
sessionId,
|
|
115
|
+
...(policyForEpic ? { policyForEpic } : {}),
|
|
116
|
+
});
|
|
108
117
|
for (const epic of budget.epics) {
|
|
109
118
|
// `under` is the only status that is an answer rather than a question.
|
|
110
119
|
if (epic.status === 'under')
|
|
@@ -178,8 +187,8 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
178
187
|
subject: proposal.taskId,
|
|
179
188
|
detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
|
|
180
189
|
`(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
|
|
181
|
-
`${proposal.sites.length} site(s)). Answer it with \`
|
|
182
|
-
`${proposal.proposalId}\` or \`
|
|
190
|
+
`${proposal.sites.length} site(s)). Answer it with \`bs plan approve ` +
|
|
191
|
+
`${proposal.proposalId}\` or \`bs plan reject ${proposal.proposalId}\`.`,
|
|
183
192
|
});
|
|
184
193
|
}
|
|
185
194
|
const proposals = computeProposals({
|
|
@@ -234,8 +243,8 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
234
243
|
detail: `Error ${proposal.fingerprint} in ${projectPhrase} ` +
|
|
235
244
|
`(${proposal.errorClass}, task ${proposal.taskRef}, ${proposal.occurrences} occurrence(s)) ` +
|
|
236
245
|
'has no issue-reported event. Report it with ' +
|
|
237
|
-
`\`
|
|
238
|
-
`\`
|
|
246
|
+
`\`bs issues report --session ${proposal.sessionId}\`; ` +
|
|
247
|
+
`\`bs issues preview --session ${proposal.sessionId}\` is the dry run.`,
|
|
239
248
|
...(admission === undefined ? {} : { admission }),
|
|
240
249
|
});
|
|
241
250
|
}
|
|
@@ -332,8 +341,8 @@ export function inspectFactory(events, opts = {}) {
|
|
|
332
341
|
detail: `The last epic this factory closed ran narrow: its widest wave was admitted for ` +
|
|
333
342
|
`${newest.widest.declared} tasks and ${newest.widest.observed} ran ` +
|
|
334
343
|
`(closed ${newest.closedAt}, ${width.serialized.length} of ${width.epics.length} ` +
|
|
335
|
-
'closes read here are narrow). `
|
|
336
|
-
'`
|
|
344
|
+
'closes read here are narrow). `bs epic width` reads every close back; ' +
|
|
345
|
+
'`bs wave audit --session <id>` reads the waves behind a live one.',
|
|
337
346
|
});
|
|
338
347
|
}
|
|
339
348
|
else if (width.hint === UNMEASURED_HINT) {
|
|
@@ -371,8 +380,8 @@ export function inspectFactory(events, opts = {}) {
|
|
|
371
380
|
subject: ref.dir,
|
|
372
381
|
detail: `${ref.self ? 'This clone' : `\`${ref.name}\`, built by this factory`} is not in ` +
|
|
373
382
|
'this pass, so nothing is reading its lockfile and no maintenance ' +
|
|
374
|
-
`proposal can ever name it. Add \`--project ${ref.dir}\` to \`
|
|
375
|
-
'run|start` (and to `
|
|
383
|
+
`proposal can ever name it. Add \`--project ${ref.dir}\` to \`bs daemon ` +
|
|
384
|
+
'run|start` (and to `bs scheduler run|admit`). `bs projects list` ' +
|
|
376
385
|
'prints the whole flag line.',
|
|
377
386
|
});
|
|
378
387
|
}
|
|
@@ -533,6 +542,7 @@ export async function runTick(opts = {}) {
|
|
|
533
542
|
now,
|
|
534
543
|
admission,
|
|
535
544
|
...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
|
|
545
|
+
...(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts }),
|
|
536
546
|
...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
|
|
537
547
|
...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
|
|
538
548
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
@@ -557,7 +567,7 @@ export async function runTick(opts = {}) {
|
|
|
557
567
|
sessionId,
|
|
558
568
|
subject: dbPath,
|
|
559
569
|
detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
|
|
560
|
-
'it is the UI and `
|
|
570
|
+
'it is the UI and `bs status` that are now stale.',
|
|
561
571
|
});
|
|
562
572
|
for (const [root, group] of leavesByRoot) {
|
|
563
573
|
// Sorted, so a forked tree attributes its shared findings to the same
|
|
@@ -640,6 +650,21 @@ export async function runTick(opts = {}) {
|
|
|
640
650
|
// nothing still has repos to tend.
|
|
641
651
|
const all = [...logs.entries()].map(([sessionId, events]) => ({ sessionId, events }));
|
|
642
652
|
findings.push(...inspectFactory(mergeSessionLogs(all), inspectOpts));
|
|
653
|
+
// The loop's own memory file failed to read this tick (see `TickOptions`).
|
|
654
|
+
// Filed as a finding, not swallowed: an operator watching the report is the
|
|
655
|
+
// one person who can tell whether "everything reads as new" is real churn
|
|
656
|
+
// or this.
|
|
657
|
+
if (opts.memoryUnreadable !== undefined) {
|
|
658
|
+
findings.push({
|
|
659
|
+
kind: 'unreadable-state',
|
|
660
|
+
severity: 'attention',
|
|
661
|
+
sessionId: null,
|
|
662
|
+
subject: opts.memoryUnreadable.path,
|
|
663
|
+
detail: `${opts.memoryUnreadable.path} exists but cannot be read ` +
|
|
664
|
+
`(${opts.memoryUnreadable.reason}): memory was read as empty, so every ` +
|
|
665
|
+
'standing finding is dated as new this tick.',
|
|
666
|
+
});
|
|
667
|
+
}
|
|
643
668
|
const aged = ageFindings(opts.memory ?? {}, findings, now);
|
|
644
669
|
return {
|
|
645
670
|
at: now.toISOString(),
|
|
@@ -658,21 +683,66 @@ export function lockPath(dir) {
|
|
|
658
683
|
export function statusPath(dir) {
|
|
659
684
|
return path.join(dir, STATUS_FILE);
|
|
660
685
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
686
|
+
/**
|
|
687
|
+
* Recoverable failure to read a daemon state file — a corrupt `daemon.pid`,
|
|
688
|
+
* `status.json` or `findings.json`. Always names the file and the reason, and
|
|
689
|
+
* always the same recovery: nothing here suggests `smith daemon stop`, which
|
|
690
|
+
* reads the same file and refuses it the same way.
|
|
691
|
+
*/
|
|
692
|
+
function unreadableState(filePath, reason) {
|
|
693
|
+
return new DaemonError('daemon.unreadable-state', `${filePath} exists but cannot be read (${reason}), so it is not treated as ` +
|
|
694
|
+
'absent. Confirm no `bs daemon run` process is using this directory, ' +
|
|
695
|
+
'then delete the file.', { path: filePath, reason });
|
|
696
|
+
}
|
|
697
|
+
function isRecordShape(value) {
|
|
698
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
699
|
+
}
|
|
700
|
+
function isLockShape(value) {
|
|
701
|
+
return isRecordShape(value) && typeof value.pid === 'number';
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Read and parse one state file, or null when there was none.
|
|
705
|
+
*
|
|
706
|
+
* ABSENT and UNREADABLE are two different outcomes, not one. ABSENT is
|
|
707
|
+
* `readFileSync` throwing ENOENT — the ordinary "no daemon has run here yet"
|
|
708
|
+
* case, answered with null. Everything else — a permission error, a
|
|
709
|
+
* `JSON.parse` failure, or a parsed value the wrong shape for `T` — is
|
|
710
|
+
* UNREADABLE: the file exists, so silently treating it as absent would let a
|
|
711
|
+
* corrupt lock be taken over, a corrupt `status.json` read as "not running",
|
|
712
|
+
* or a corrupt memory read as empty, none of which is true. Reading the file
|
|
713
|
+
* directly and catching ENOENT (rather than `existsSync` then `readFileSync`)
|
|
714
|
+
* also closes the gap where the file could vanish between the two calls.
|
|
715
|
+
*/
|
|
716
|
+
function readJsonFile(filePath, isValidShape) {
|
|
717
|
+
let raw;
|
|
664
718
|
try {
|
|
665
|
-
|
|
719
|
+
raw = readFileSync(filePath, 'utf8');
|
|
666
720
|
}
|
|
667
|
-
catch {
|
|
668
|
-
|
|
721
|
+
catch (err) {
|
|
722
|
+
const code = err.code;
|
|
723
|
+
if (code === 'ENOENT')
|
|
724
|
+
return null;
|
|
725
|
+
throw unreadableState(filePath, code ?? errorMessage(err));
|
|
726
|
+
}
|
|
727
|
+
let parsed;
|
|
728
|
+
try {
|
|
729
|
+
parsed = JSON.parse(raw);
|
|
669
730
|
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
throw unreadableState(filePath, errorMessage(err));
|
|
733
|
+
}
|
|
734
|
+
if (!isValidShape(parsed))
|
|
735
|
+
throw unreadableState(filePath, 'unexpected shape');
|
|
736
|
+
return parsed;
|
|
670
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Null when there is no lock to read. Throws `daemon.unreadable-state` when
|
|
740
|
+
* the file exists but cannot be parsed, or parses to something without a
|
|
741
|
+
* numeric `pid` — a half-written file from a crash mid-write looks exactly
|
|
742
|
+
* like this, and it must not be mistaken for "nobody is running".
|
|
743
|
+
*/
|
|
671
744
|
export function readLock(dir) {
|
|
672
|
-
|
|
673
|
-
if (lock === null || typeof lock.pid !== 'number')
|
|
674
|
-
return null;
|
|
675
|
-
return lock;
|
|
745
|
+
return readJsonFile(lockPath(dir), isLockShape);
|
|
676
746
|
}
|
|
677
747
|
/** `kill(pid, 0)` — EPERM means a process exists that this user may not signal. */
|
|
678
748
|
export function processIsAlive(pid) {
|
|
@@ -686,14 +756,20 @@ export function processIsAlive(pid) {
|
|
|
686
756
|
}
|
|
687
757
|
/**
|
|
688
758
|
* Claim `dir` for `lock.pid`, returning the lock this one displaced (null when
|
|
689
|
-
* there was nothing to displace
|
|
759
|
+
* there was nothing to displace).
|
|
690
760
|
*
|
|
691
761
|
* Two daemons ticking the same state dir would double every projection write
|
|
692
762
|
* and every finding, so a live incumbent is refused. A DEAD incumbent is not:
|
|
693
763
|
* a crashed daemon leaves its pid file behind, and refusing to start until a
|
|
694
|
-
* human runs `rm` would make the first crash permanent.
|
|
695
|
-
*
|
|
696
|
-
*
|
|
764
|
+
* human runs `rm` would make the first crash permanent.
|
|
765
|
+
*
|
|
766
|
+
* An UNREADABLE incumbent is refused rather than taken over: an unparseable
|
|
767
|
+
* file could be a crash mid-write, but it could also be a live daemon's lock
|
|
768
|
+
* a filesystem fault damaged out from under it, and the two are not
|
|
769
|
+
* distinguishable from the bytes alone. `readLock` throws
|
|
770
|
+
* `daemon.unreadable-state` in that case, and this function does not catch
|
|
771
|
+
* it — the throw happens before anything is written, so the file's bytes are
|
|
772
|
+
* left exactly as found.
|
|
697
773
|
*/
|
|
698
774
|
export function acquireLock(dir, lock, opts = {}) {
|
|
699
775
|
const isAlive = opts.isAlive ?? processIsAlive;
|
|
@@ -711,14 +787,28 @@ export function acquireLock(dir, lock, opts = {}) {
|
|
|
711
787
|
const incumbent = readLock(dir);
|
|
712
788
|
if (incumbent !== null && isAlive(incumbent.pid)) {
|
|
713
789
|
throw new DaemonError('daemon.already-running', `A daemon (pid ${incumbent.pid}, started ${incumbent.startedAt}) already holds ${file}. ` +
|
|
714
|
-
'Stop it with `
|
|
790
|
+
'Stop it with `bs daemon stop` before starting another.', { pid: incumbent.pid, lock_path: file });
|
|
715
791
|
}
|
|
716
792
|
writeFileSync(file, body, 'utf8');
|
|
717
793
|
return incumbent;
|
|
718
794
|
}
|
|
719
|
-
/**
|
|
795
|
+
/**
|
|
796
|
+
* True when this call removed the lock; false when it belongs to someone
|
|
797
|
+
* else, or cannot be read at all.
|
|
798
|
+
*
|
|
799
|
+
* Never throws, unlike every other reader of this file: it runs from
|
|
800
|
+
* `runDaemon`'s `finally`, where a throw would replace the loop's own
|
|
801
|
+
* outcome — a tick that failed would be reported instead as "could not
|
|
802
|
+
* release the lock", losing the failure that actually mattered.
|
|
803
|
+
*/
|
|
720
804
|
export function releaseLock(dir, pid) {
|
|
721
|
-
|
|
805
|
+
let lock;
|
|
806
|
+
try {
|
|
807
|
+
lock = readLock(dir);
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
722
812
|
if (lock === null || lock.pid !== pid)
|
|
723
813
|
return false;
|
|
724
814
|
rmSync(lockPath(dir), { force: true });
|
|
@@ -735,8 +825,9 @@ export function writeStatus(dir, report) {
|
|
|
735
825
|
writeFileSync(tmp, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
736
826
|
renameSync(tmp, target);
|
|
737
827
|
}
|
|
828
|
+
/** Throws `daemon.unreadable-state` when the file exists but cannot be parsed. */
|
|
738
829
|
export function readStatus(dir) {
|
|
739
|
-
return readJsonFile(statusPath(dir));
|
|
830
|
+
return readJsonFile(statusPath(dir), isRecordShape);
|
|
740
831
|
}
|
|
741
832
|
// ---------------------------------------------------------------------------
|
|
742
833
|
// The finding memory
|
|
@@ -748,15 +839,18 @@ export function memoryPath(dir) {
|
|
|
748
839
|
* The only state this daemon carries across ticks, and deliberately the least
|
|
749
840
|
* it could carry: identity -> when it was first seen.
|
|
750
841
|
*
|
|
751
|
-
*
|
|
752
|
-
* `unreadable-
|
|
753
|
-
*
|
|
754
|
-
*
|
|
842
|
+
* Absent reads as empty — the ordinary first-tick case. Corrupt throws
|
|
843
|
+
* `daemon.unreadable-state`: `runDaemon`'s `tickWithMemory` is the one caller
|
|
844
|
+
* that catches it, and turns it into a finding of its own (`TickOptions`'s
|
|
845
|
+
* `memoryUnreadable`) rather than swallowing it, because a watchdog that
|
|
846
|
+
* dies over its own scratch file is silent exactly when something is wrong,
|
|
847
|
+
* but one that quietly reads it as empty hides the same fact from the report
|
|
848
|
+
* meant to surface it.
|
|
755
849
|
*/
|
|
756
850
|
export function readFindingMemory(dir) {
|
|
757
|
-
return readJsonFile(memoryPath(dir)) ?? {};
|
|
851
|
+
return readJsonFile(memoryPath(dir), isRecordShape) ?? {};
|
|
758
852
|
}
|
|
759
|
-
/** tmp-then-rename, like the status file: a
|
|
853
|
+
/** tmp-then-rename, like the status file: a crash mid-write leaves the old file whole. */
|
|
760
854
|
export function writeFindingMemory(dir, memory) {
|
|
761
855
|
mkdirSync(dir, { recursive: true });
|
|
762
856
|
const target = memoryPath(dir);
|
|
@@ -852,6 +946,7 @@ export async function runDaemon(opts) {
|
|
|
852
946
|
const tickOptions = {
|
|
853
947
|
...(opts.now === undefined ? {} : { now: opts.now }),
|
|
854
948
|
...(opts.budgetPolicy === undefined ? {} : { budgetPolicy: opts.budgetPolicy }),
|
|
949
|
+
...(opts.planOpts === undefined ? {} : { planOpts: opts.planOpts }),
|
|
855
950
|
...(opts.schedulerPolicy === undefined ? {} : { schedulerPolicy: opts.schedulerPolicy }),
|
|
856
951
|
...(opts.staleHours === undefined ? {} : { staleHours: opts.staleHours }),
|
|
857
952
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
@@ -866,8 +961,31 @@ export async function runDaemon(opts) {
|
|
|
866
961
|
// Read from disk rather than held in a variable, so a daemon restarted by
|
|
867
962
|
// cron or by the operator picks up where the last process stopped. A tick
|
|
868
963
|
// knows nothing about the interval it runs on; this is what joins two.
|
|
964
|
+
//
|
|
965
|
+
// `readFindingMemory` throws `daemon.unreadable-state` for a corrupt file
|
|
966
|
+
// (never for an absent one). That is the ONE failure this catches: the
|
|
967
|
+
// tick still runs, with an empty memory and `memoryUnreadable` set so the
|
|
968
|
+
// loss shows up as a finding in the very report it affected, rather than
|
|
969
|
+
// failing the loop over its own scratch file (D-21).
|
|
869
970
|
const tickWithMemory = async () => {
|
|
870
|
-
|
|
971
|
+
let memory = {};
|
|
972
|
+
let memoryUnreadable;
|
|
973
|
+
try {
|
|
974
|
+
memory = readFindingMemory(opts.dir);
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
if (!(err instanceof DaemonError) || err.code !== 'daemon.unreadable-state')
|
|
978
|
+
throw err;
|
|
979
|
+
memoryUnreadable = {
|
|
980
|
+
path: String(err.details.path),
|
|
981
|
+
reason: String(err.details.reason),
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
const report = await tick({
|
|
985
|
+
...tickOptions,
|
|
986
|
+
memory,
|
|
987
|
+
...(memoryUnreadable === undefined ? {} : { memoryUnreadable }),
|
|
988
|
+
});
|
|
871
989
|
writeStatus(opts.dir, report);
|
|
872
990
|
writeFindingMemory(opts.dir, memoryOf(report.findings));
|
|
873
991
|
return report;
|