@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.
Files changed (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +109 -53
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +48 -14
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. 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' ? commandOnPath(command) : declared,
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' ? apiKeyPresent(apiKeyEnv) : declared,
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 [name, raw] of Object.entries(doc.providers)) {
364
- const config = parseProvider(name, raw ?? {});
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 `SMITH_CROSSCHECK_OFFLINE`: set it to anything
395
- * non-empty and no external provider can be invoked, whatever the file says.
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: options.offline ?? Boolean(process.env.SMITH_CROSSCHECK_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 { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
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, { sessionId });
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 \`smith plan approve ` +
182
- `${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
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
- `\`smith issues report --session ${proposal.sessionId}\`; ` +
238
- `\`smith issues preview --session ${proposal.sessionId}\` is the dry run.`,
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). `smith epic width` reads every close back; ' +
336
- '`smith wave audit --session <id>` reads the waves behind a live one.',
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 \`smith daemon ` +
375
- 'run|start` (and to `smith scheduler run|admit`). `smith projects list` ' +
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 `smith status` that are now stale.',
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
- function readJsonFile(filePath) {
662
- if (!existsSync(filePath))
663
- return null;
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
- return JSON.parse(readFileSync(filePath, 'utf8'));
719
+ raw = readFileSync(filePath, 'utf8');
666
720
  }
667
- catch {
668
- return null;
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
- const lock = readJsonFile(lockPath(dir));
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, or nothing readable).
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. Likewise an
695
- * unparseable file — half a JSON document is what a crash mid-write looks
696
- * like, and it is evidence of a dead writer, not a live one.
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 `smith daemon stop` before starting another.', { pid: incumbent.pid, lock_path: file });
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
- /** True when this call removed the lock; false when it belongs to someone else. */
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
- const lock = readLock(dir);
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
- * Missing or corrupt reads as empty rather than throwing, for the same reason
752
- * `unreadable-log` is a finding and not a crash. A watchdog that dies over its
753
- * own scratch file is silent exactly when something is wrong, and the cost of
754
- * losing this one is a single tick that calls every standing finding new.
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 half-written memory reads as none. */
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
- const report = await tick({ ...tickOptions, memory: readFindingMemory(opts.dir) });
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;