@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
@@ -30,11 +30,12 @@ import { readFileSync } from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import picomatch from 'picomatch';
32
32
  import { isOperatorActor } from './actors.js';
33
+ import { templateFileFor } from './agentNames.js';
33
34
  import { foldLessons } from './db/projector.js';
34
35
  import { SmithError } from './errors.js';
35
36
  import { appendEvent, readLineageEvents } from './events.js';
36
37
  import { AGENTS_DIR, lessonsReadPath } from './paths.js';
37
- import { FILE_SCOPED_SCOPES, isFileScopedScope, LESSON_SCOPES, parseLessons, } from './severity.js';
38
+ import { BULLET, CONTINUATION, ENTRY_HEADING, FILE_SCOPED_SCOPES, isFileScopedScope, LESSON_SCOPES, parseLessons, SECTION_HEADING, } from './severity.js';
38
39
  import { loadTaxonomy, validateTag } from './taxonomy.js';
39
40
  export class LessonsError extends SmithError {
40
41
  }
@@ -301,8 +302,8 @@ select on. Each names its own bullet, validated against \`taxonomy.yml\`:
301
302
  At dispatch, an \`agent-role\` entry reaches only the role its \`agent_role\`
302
303
  names, and a \`case-type\` entry only a task whose \`case\` its \`case_type\`
303
304
  names. An entry in one of those scopes with **no** selector reaches NOBODY —
304
- it is inert, not universal, and \`smith lessons for-dispatch\` reports it as
305
- such. \`smith lessons raise\` refuses to mint one; the entries that predate
305
+ it is inert, not universal, and \`bs lessons for-dispatch\` reports it as
306
+ such. \`bs lessons raise\` refuses to mint one; the entries that predate
306
307
  the selector (D-129) are the only ones that can still be in that state.
307
308
 
308
309
  Two kinds of entry are therefore injected at dispatch and still never
@@ -388,22 +389,186 @@ function renderEntry(lesson) {
388
389
  bullets.push(renderBulletLines('statement', lesson.statement));
389
390
  return `### ${headingFor(lesson)}\n\n${bullets.join('\n')}\n`;
390
391
  }
392
+ /**
393
+ * Walks a PRE-EXISTING lessons.md, salvaging every entry compileLessons()
394
+ * would otherwise have to overwrite from scratch. Deliberately stricter than
395
+ * severity.ts's parseLessons(): that parser is read-only, so silently
396
+ * skipping an entry it can't make sense of just leaves the same-mistake gate
397
+ * with one fewer rule. Here, skipping one means DELETING hand-authored
398
+ * content from the compiled file with no store row left to regenerate it
399
+ * from — so an entry under a real `## <scope>` section that doesn't parse is
400
+ * a hard error instead (never a silent drop), while content above the first
401
+ * `## <scope>` heading (this file's own header, including its illustrative
402
+ * fenced `### ` example) is ignored exactly as parseLessons ignores it: real
403
+ * entries only start once `scope` is one of severity.ts's `LESSON_SCOPES`.
404
+ *
405
+ * Line endings are normalized to `\n` before any of the above line-based
406
+ * regexes run: `SECTION_HEADING`/`BULLET`'s trailing `$` and `.` never match a
407
+ * `\r`, so on an unmodified `\r\n` file every line silently fails to match,
408
+ * `scope` never leaves `''`, and every entry in the file is dropped with no
409
+ * error at all — the exact silent-loss failure this function exists to
410
+ * prevent, just triggered by a different byte.
411
+ *
412
+ * A `### ` entry that carries a `lesson_id` bullet but sits under a `##`
413
+ * heading that is NOT one of `LESSON_SCOPES` — an unknown or misspelled
414
+ * section — is refused the same way an unparseable entry is, but only once a
415
+ * real scope section has already been seen once in the file: before that
416
+ * point the file is still in its own header/doc zone (the `## Schema`
417
+ * walkthrough, with its illustrative fenced example), which parseLessons has
418
+ * always ignored and must keep being ignored here too. After the first real
419
+ * scope section, though, a `## ` heading is no longer prose — it is a typo
420
+ * or a moved section, and an entry stranded under it would otherwise vanish
421
+ * exactly like the CRLF case above: no bucket matches, so it is never
422
+ * pushed, and flush()'s `heading === null` guard means it is never even
423
+ * counted as unparseable.
424
+ */
425
+ function extractExistingLessonEntries(markdown) {
426
+ const lines = markdown.replace(/\r\n/g, '\n').split('\n');
427
+ const entries = [];
428
+ let scope = '';
429
+ let seenValidScope = false;
430
+ let heading = null;
431
+ let entryLines = [];
432
+ let bullets = {};
433
+ let lastKey = null;
434
+ /** Whether the entry currently being tracked sits under an unrecognized scope (see header comment). */
435
+ let orphan = false;
436
+ function flush() {
437
+ if (heading === null)
438
+ return;
439
+ while (entryLines.length > 0 && entryLines[entryLines.length - 1]?.trim() === '') {
440
+ entryLines.pop();
441
+ }
442
+ const lessonId = bullets.lesson_id;
443
+ const statement = bullets.statement;
444
+ if (orphan) {
445
+ if (lessonId) {
446
+ throw new LessonsError('lessons.entry-under-unknown-scope', `Existing lessons.md entry ${JSON.stringify(heading)} carries lesson_id ` +
447
+ `${JSON.stringify(lessonId)} under "## ${scope}", which is not a recognized lesson ` +
448
+ `scope (${VALID_SCOPES.join(', ')}) — refusing to compile, which would silently drop ` +
449
+ 'it. Fix the scope heading or remove the entry by hand, then recompile.', { scope, heading, lessonId });
450
+ }
451
+ // No lesson_id: treated like the header's own doc examples — ignored,
452
+ // not an error, so free-standing prose under a stray heading doesn't
453
+ // become a hard failure.
454
+ heading = null;
455
+ entryLines = [];
456
+ bullets = {};
457
+ lastKey = null;
458
+ orphan = false;
459
+ return;
460
+ }
461
+ if (!lessonId || !statement) {
462
+ throw new LessonsError('lessons.unparseable-existing-entry', `Existing lessons.md entry ${JSON.stringify(heading)} under "## ${scope}" is missing its ` +
463
+ `${!lessonId ? 'lesson_id' : 'statement'} bullet — refusing to compile, which would ` +
464
+ 'silently drop it. Fix or remove the entry by hand, then recompile.', { scope, heading });
465
+ }
466
+ entries.push({ lessonId, scope, raw: `${entryLines.join('\n')}\n` });
467
+ heading = null;
468
+ entryLines = [];
469
+ bullets = {};
470
+ lastKey = null;
471
+ orphan = false;
472
+ }
473
+ for (const line of lines) {
474
+ const sectionMatch = line.match(SECTION_HEADING);
475
+ if (sectionMatch) {
476
+ flush();
477
+ scope = sectionMatch[1].trim();
478
+ if (VALID_SCOPES.includes(scope))
479
+ seenValidScope = true;
480
+ continue;
481
+ }
482
+ if (ENTRY_HEADING.test(line)) {
483
+ flush();
484
+ if (VALID_SCOPES.includes(scope)) {
485
+ heading = line;
486
+ entryLines = [line];
487
+ bullets = {};
488
+ lastKey = null;
489
+ orphan = false;
490
+ }
491
+ else if (seenValidScope) {
492
+ heading = line;
493
+ entryLines = [line];
494
+ bullets = {};
495
+ lastKey = null;
496
+ orphan = true;
497
+ }
498
+ continue;
499
+ }
500
+ if (heading === null)
501
+ continue;
502
+ entryLines.push(line);
503
+ const bulletMatch = line.match(BULLET);
504
+ if (bulletMatch) {
505
+ const key = bulletMatch[1];
506
+ bullets[key] = bulletMatch[2].trim();
507
+ lastKey = key;
508
+ continue;
509
+ }
510
+ if (line.trim() === '') {
511
+ lastKey = null;
512
+ continue;
513
+ }
514
+ const continuationMatch = line.match(CONTINUATION);
515
+ if (continuationMatch && lastKey) {
516
+ const existingValue = bullets[lastKey] ?? '';
517
+ bullets[lastKey] = `${existingValue} ${continuationMatch[1].trim()}`.trim();
518
+ }
519
+ }
520
+ flush();
521
+ return entries;
522
+ }
523
+ /**
524
+ * Which pre-existing entries a compile of `lessons` against `existingMarkdown`
525
+ * would preserve — the same set compileLessons() itself splices in, exposed
526
+ * so a caller (the CLI's `lessons compile` handler) can report how many
527
+ * survived without re-deriving the "now store-tracked, so the fresh render
528
+ * wins" filter a second time and risking the two counts drifting apart.
529
+ */
530
+ export function lessonsToPreserve(lessons, existingMarkdown) {
531
+ if (existingMarkdown === undefined)
532
+ return [];
533
+ const knownIds = new Set(lessons.map((l) => l.lessonId));
534
+ return extractExistingLessonEntries(existingMarkdown).filter((entry) => !knownIds.has(entry.lessonId));
535
+ }
391
536
  /**
392
537
  * Regenerates the full factory/policies/lessons.md content from a list of
393
538
  * APPROVED lessons. Round-trips through severity.ts's parseLessons(): every
394
539
  * entry here that carries a `finding_category` + `statement` (claim-path or
395
540
  * stack-wide scoped) survives the parse back into a `LessonRule`.
541
+ *
542
+ * `existingMarkdown`, when given, is the compiled file's own PRIOR content
543
+ * (e.g. read from `--out` before overwriting it). Compiling `lessons` alone
544
+ * loses every entry lessons.md carries that has no row in the store — a hand
545
+ * lesson raised before the store existed, or one `smith lessons raise` never
546
+ * touched — because this function only ever knew how to build the file from
547
+ * scratch. With `existingMarkdown`, any such entry (its `lesson_id` absent
548
+ * from `lessons`) is preserved verbatim instead of dropped; an entry whose id
549
+ * DOES appear in `lessons` is regenerated fresh from the store, same as
550
+ * before — the store stays the one source of truth for anything it tracks.
551
+ * Compiling the real repo file against the current store this way is
552
+ * insertions-only: nothing already there disappears.
396
553
  */
397
- export function compileLessons(lessons) {
554
+ export function compileLessons(lessons, existingMarkdown) {
398
555
  const bySection = new Map(VALID_SCOPES.map((s) => [s, []]));
399
556
  for (const lesson of lessons) {
400
557
  const bucket = bySection.get(lesson.lessonScope);
401
558
  if (bucket)
402
559
  bucket.push(lesson);
403
560
  }
561
+ const preservedBySection = new Map(VALID_SCOPES.map((s) => [s, []]));
562
+ for (const entry of lessonsToPreserve(lessons, existingMarkdown)) {
563
+ const bucket = preservedBySection.get(entry.scope);
564
+ if (bucket)
565
+ bucket.push(entry.raw);
566
+ }
404
567
  const sections = VALID_SCOPES.map((scope) => {
405
- const entries = bySection.get(scope) ?? [];
406
- const body = entries.length === 0 ? '_(none yet)_\n' : entries.map(renderEntry).join('\n');
568
+ const freshBlocks = (bySection.get(scope) ?? []).map(renderEntry);
569
+ const keptBlocks = preservedBySection.get(scope) ?? [];
570
+ const blocks = [...keptBlocks, ...freshBlocks];
571
+ const body = blocks.length === 0 ? '_(none yet)_\n' : blocks.join('\n');
407
572
  return `## ${scope}\n\n${body}`;
408
573
  });
409
574
  return `${LESSONS_MD_HEADER}\n${sections.join('\n')}`;
@@ -494,7 +659,7 @@ export function scopesForRole(role, opts = {}) {
494
659
  if (!ROLE_NAME.test(role)) {
495
660
  throw new LessonsError('lessons.invalid-role-name', `Not a role name: ${JSON.stringify(role)} (expected e.g. "coder", "security-reviewer").`, { role });
496
661
  }
497
- const templatePath = path.join(opts.agentsDir ?? AGENTS_DIR, `${role}.md`);
662
+ const templatePath = path.join(opts.agentsDir ?? AGENTS_DIR, templateFileFor(role));
498
663
  let template;
499
664
  try {
500
665
  template = readFileSync(templatePath, 'utf8');
@@ -506,13 +671,13 @@ export function scopesForRole(role, opts = {}) {
506
671
  for (const match of template.matchAll(SCOPE_MARKER)) {
507
672
  const scope = match[1];
508
673
  if (!VALID_SCOPES.includes(scope)) {
509
- throw new LessonsError('lessons.unknown-lesson-scope', `${role}.md declares lesson scope "${scope}", which is not one of: ${VALID_SCOPES.join(', ')}.`, { role, scope, templatePath });
674
+ throw new LessonsError('lessons.unknown-lesson-scope', `${templateFileFor(role)} declares lesson scope "${scope}", which is not one of: ${VALID_SCOPES.join(', ')}.`, { role, scope, templatePath });
510
675
  }
511
676
  if (!scopes.includes(scope))
512
677
  scopes.push(scope);
513
678
  }
514
679
  if (scopes.length === 0) {
515
- throw new LessonsError('lessons.role-template-has-no-scope-marker', `${role}.md carries no <!-- LESSONS:<scope> --> marker, so no lesson can reach it.`, { role, templatePath });
680
+ throw new LessonsError('lessons.role-template-has-no-scope-marker', `${templateFileFor(role)} carries no <!-- LESSONS:<scope> --> marker, so no lesson can reach it.`, { role, templatePath });
516
681
  }
517
682
  return scopes;
518
683
  }
@@ -594,7 +759,7 @@ export function lessonsForDispatch(role, claimPaths, opts = {}) {
594
759
  markdown = readFileSync(lessonsPath, 'utf8');
595
760
  }
596
761
  catch {
597
- throw new LessonsError('lessons.compiled-file-not-found', `No compiled lessons at ${lessonsPath} — run \`smith lessons compile\`.`, { role, lessonsPath });
762
+ throw new LessonsError('lessons.compiled-file-not-found', `No compiled lessons at ${lessonsPath} — run \`bs lessons compile\`.`, { role, lessonsPath });
598
763
  }
599
764
  // A mistyped case would otherwise match no entry and emit no warning — a
600
765
  // silent empty injection, the exact failure D-129 is about. The role needs
@@ -877,6 +1042,17 @@ export async function dream(events, ctx, opts = {}, options = {}) {
877
1042
  noveltyRejected.push(lessonId);
878
1043
  }
879
1044
  }
1045
+ // DS8 plan §2.3 -- a durable mark that a pass happened, even one that
1046
+ // raised nothing, so the Lessons page can show when lessons were last
1047
+ // checked rather than only when one was last raised.
1048
+ await appendEvent({
1049
+ session_id: ctx.sessionId,
1050
+ actor: ctx.actor ?? 'system',
1051
+ event_type: 'lessons-pass-completed',
1052
+ plan_version: ctx.planVersion,
1053
+ causal_parent: parent,
1054
+ payload: { raised: raised.length },
1055
+ }, opts);
880
1056
  return {
881
1057
  checkpointsExtracted: checkpoints.length,
882
1058
  raised,
@@ -1111,8 +1287,9 @@ export async function raiseLessonCandidate(input, ctx, opts = {}, options = {})
1111
1287
  ...(contradictionOf ? { possible_contradiction_of: contradictionOf } : {}),
1112
1288
  },
1113
1289
  }, opts);
1290
+ let lastEventId = raisedEvent.event_id;
1114
1291
  if (!novelty.novel) {
1115
- await appendEvent({
1292
+ const rejectedEvent = await appendEvent({
1116
1293
  session_id: ctx.sessionId,
1117
1294
  actor: 'system',
1118
1295
  event_type: 'lesson-status-changed',
@@ -1120,6 +1297,7 @@ export async function raiseLessonCandidate(input, ctx, opts = {}, options = {})
1120
1297
  causal_parent: raisedEvent.event_id,
1121
1298
  payload: { lesson_id: lessonId, from_status: 'candidate', to_status: 'novelty-rejected' },
1122
1299
  }, opts);
1300
+ lastEventId = rejectedEvent.event_id;
1123
1301
  }
1124
1302
  return {
1125
1303
  lessonId,
@@ -1128,6 +1306,7 @@ export async function raiseLessonCandidate(input, ctx, opts = {}, options = {})
1128
1306
  mostSimilar: novelty.mostSimilar,
1129
1307
  possibleContradictionOf: contradictionOf,
1130
1308
  warnings,
1309
+ event_id: lastEventId,
1131
1310
  };
1132
1311
  }
1133
1312
  // ---------------------------------------------------------------------------
@@ -260,7 +260,7 @@ export function addMcpSurface(opts) {
260
260
  const targetDir = opts.targetDir ?? path.join(opts.projectsDir ?? PROJECTS_DIR, opts.projectName);
261
261
  const manifestPath = path.join(targetDir, 'mcp.manifest.json');
262
262
  if (!existsSync(path.join(targetDir, 'package.json'))) {
263
- throw new McpError('mcp.unknown-project', `${targetDir} is not a scaffolded project (no package.json). Run \`smith new ${opts.projectName}\` first.`, { targetDir, projectName: opts.projectName });
263
+ throw new McpError('mcp.unknown-project', `${targetDir} is not a scaffolded project (no package.json). Run \`bs new ${opts.projectName}\` first.`, { targetDir, projectName: opts.projectName });
264
264
  }
265
265
  if (existsSync(manifestPath)) {
266
266
  throw new McpError('mcp.surface-exists', `${manifestPath} already exists. Edit the manifest in place; re-scaffolding would discard declared tools and their operator sign-off.`, { manifestPath, projectName: opts.projectName });
@@ -314,7 +314,7 @@ export function registerMcpMilestone(projectName, roadmapPath = ensureWritableRo
314
314
  // in the roadmap itself which epic closes the milestone, the way
315
315
  // envkit-config-loader already does, instead of leaving it to convention.
316
316
  `- epics: [${id}]\n` +
317
- '- goal: Declare and harden the MCP surface — `smith mcp check` green against ' +
317
+ '- goal: Declare and harden the MCP surface — `bs mcp check` green against ' +
318
318
  'docs/standards/mcp.md. Required before the final milestone can close.\n';
319
319
  writeFileSync(roadmapPath, `${text.trimEnd()}\n${block}`, 'utf8');
320
320
  return id;
@@ -339,7 +339,7 @@ export function checkRoadmapMcpMilestone(project, milestones) {
339
339
  {
340
340
  rule: 'MCP-M1',
341
341
  path: `/milestones/${project}`,
342
- message: `Project "${project}" has no \`${project}-${MCP_MILESTONE_SUFFIX}\` milestone. Run \`smith mcp init ${project}\` to register it.`,
342
+ message: `Project "${project}" has no \`${project}-${MCP_MILESTONE_SUFFIX}\` milestone. Run \`bs mcp init ${project}\` to register it.`,
343
343
  },
344
344
  ];
345
345
  }
@@ -453,7 +453,7 @@ export function runMcpCheck(opts) {
453
453
  const manifestPath = path.join(targetDir, MANIFEST_NAME);
454
454
  const roadmapPath = opts.roadmapPath ?? roadmapReadPath();
455
455
  if (!existsSync(manifestPath)) {
456
- throw new McpError('mcp.no-surface', `${manifestPath} does not exist. Run \`smith mcp init ${opts.projectName}\` before checking the surface.`, { manifestPath, projectName: opts.projectName });
456
+ throw new McpError('mcp.no-surface', `${manifestPath} does not exist. Run \`bs mcp init ${opts.projectName}\` before checking the surface.`, { manifestPath, projectName: opts.projectName });
457
457
  }
458
458
  let manifest;
459
459
  try {
@@ -548,8 +548,8 @@ export function mcpBlockers(epicId, status) {
548
548
  if (!status.check) {
549
549
  return [
550
550
  status.problem === 'unreadable'
551
- ? `epic ${epicId} is under milestone ${milestone} but its MCP surface at ${manifest} exists and could not be read, so no verdict could be rendered — fix the manifest in place and run \`smith mcp check\` for the parser's own message. Do not re-run \`smith mcp init\`: it refuses over an existing manifest, because re-scaffolding would discard the declared tools and their operator sign-off.`
552
- : `epic ${epicId} is under milestone ${milestone} but has no MCP surface at ${manifest} — run \`smith mcp init\` and declare it.`,
551
+ ? `epic ${epicId} is under milestone ${milestone} but its MCP surface at ${manifest} exists and could not be read, so no verdict could be rendered — fix the manifest in place and run \`bs mcp check\` for the parser's own message. Do not re-run \`bs mcp init\`: it refuses over an existing manifest, because re-scaffolding would discard the declared tools and their operator sign-off.`
552
+ : `epic ${epicId} is under milestone ${milestone} but has no MCP surface at ${manifest} — run \`bs mcp init\` and declare it.`,
553
553
  ];
554
554
  }
555
555
  return status.check.violations.map((v) => `mcp surface ${milestone}: ${v.rule} at ${v.path} — ${v.message}`);
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { readEnv } from './env.js';
4
5
  // factory/orchestrator/src/paths.ts -> repo root is three levels up.
5
6
  const here = path.dirname(fileURLToPath(import.meta.url));
6
7
  /**
@@ -29,7 +30,7 @@ export function isFactoryCheckout(dir) {
29
30
  * `package.json#files` could not ship it if it tried. A linked worktree spells
30
31
  * it as a file rather than a directory, which `existsSync` reads either way.
31
32
  */
32
- const IS_CLONE = existsSync(path.join(REPO_ROOT, '.git'));
33
+ export const IS_CLONE = existsSync(path.join(REPO_ROOT, '.git'));
33
34
  /**
34
35
  * Where the work root goes when the CLI is not standing in a clone.
35
36
  *
@@ -46,9 +47,10 @@ const WORK_DIR_NAME = '.blacksmith';
46
47
  *
47
48
  * Pure, and exported for the test: the constants below call it once at module
48
49
  * load, but the install case can only be stated by a test that passes its own
49
- * roots in. `env.SMITH_HOME` wins outright; otherwise a clone keeps writing
50
- * into itself, and an install writes beside the operator instead of into
51
- * `node_modules/@juzser/blacksmith`, which the next `npm i` replaces wholesale.
50
+ * roots in. `BS_HOME` (or its legacy `SMITH_HOME` fallback) wins outright;
51
+ * otherwise a clone keeps writing into itself, and an install writes beside
52
+ * the operator instead of into `node_modules/@juzser/blacksmith`, which the
53
+ * next `npm i` replaces wholesale.
52
54
  *
53
55
  * The layout *under* the root is identical in both cases on purpose. A path
54
56
  * like `state/events/<session>.jsonl` is spelled in runbooks, in agent
@@ -56,9 +58,10 @@ const WORK_DIR_NAME = '.blacksmith';
56
58
  * if only the anchor moves.
57
59
  */
58
60
  export function resolveWorkRoot(repoRoot, cwd, env, isClone) {
59
- const declared = env.SMITH_HOME?.trim();
60
- // An empty `SMITH_HOME=` is how an unset variable gets spelled by accident in
61
- // a shell profile or a CI matrix; resolved literally it would mean cwd.
61
+ const declared = readEnv(env, 'BS_HOME')?.trim();
62
+ // An empty `BS_HOME=`/`SMITH_HOME=` is how an unset variable gets spelled by
63
+ // accident in a shell profile or a CI matrix; resolved literally it would
64
+ // mean cwd.
62
65
  if (declared)
63
66
  return path.resolve(cwd, declared);
64
67
  return isClone ? repoRoot : path.join(cwd, WORK_DIR_NAME);