@juzser/blacksmith 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  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} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -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} +7 -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 +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  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 +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  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 +143 -50
  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 +70 -13
  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 +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. 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
+ }
@@ -123,6 +123,26 @@ export function reconcile(input) {
123
123
  if (!nativeByFingerprint.has(finding.fingerprint)) {
124
124
  nativeByFingerprint.set(finding.fingerprint, finding);
125
125
  }
126
+ // Also key by the fingerprint computeFingerprint() would produce for this
127
+ // record TODAY, not just the one stored at raise time. `input.native`
128
+ // comes straight out of listFindings() (a lineage-wide, possibly
129
+ // long-lived read) while `group.fingerprint` below is always computed
130
+ // fresh from the independent provider's evidence -- so a native finding
131
+ // raised before issue #178 widened the normalizer (its summary containing
132
+ // a line span the old algorithm did not strip) would otherwise never
133
+ // match an independent finder's evidence for the identical bug, and get
134
+ // reported as `co-located`/`independent-only` plus a duplicate
135
+ // `native-only` instead of `corroborated`.
136
+ if (finding.file_path !== undefined) {
137
+ const recomputed = computeFingerprint({
138
+ filePath: finding.file_path,
139
+ category: finding.finding_category,
140
+ summary: finding.summary,
141
+ });
142
+ if (!nativeByFingerprint.has(recomputed)) {
143
+ nativeByFingerprint.set(recomputed, finding);
144
+ }
145
+ }
126
146
  if (finding.file_path === undefined)
127
147
  continue;
128
148
  const key = coLocationKey(finding.file_path, finding.finding_category);
@@ -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
  }