@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
@@ -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
  // ---------------------------------------------------------------------------
@@ -0,0 +1,291 @@
1
+ // The daemon's fix for finding 50acc356: `runTick` used to call `readEvents`
2
+ // for every session on every tick, re-reading and re-parsing every byte ever
3
+ // written to every log, forever. This module is the one place that reads a
4
+ // session log more than once in a process's lifetime, and it reads each log
5
+ // exactly once per byte that was ever appended to it.
6
+ //
7
+ // The fingerprint that decides "did this log change" is taken from an OPEN
8
+ // file descriptor's fstat, never a path stat: `stat(path)` can race a
9
+ // concurrent writer (open, then unlink-and-replace, then stat sees the new
10
+ // inode while a reader mid-read still holds the old one) in a way an fstat on
11
+ // a handle already open for THIS read cannot.
12
+ //
13
+ // Per log, the cache keeps an OFFSET (the byte offset just past the last
14
+ // consumed newline) and an ANCHOR (the bytes of the last complete line,
15
+ // terminating newline included, occupying exactly
16
+ // `[offset - anchor.length, offset)`). A grown file is read once, from
17
+ // `offset - anchor.length` to the new end; only when the leading
18
+ // `anchor.length` bytes of that read still equal the cached anchor is the
19
+ // growth treated as a genuine append, and only the bytes after `offset` are
20
+ // parsed. Every other outcome — a different dev/ino, a shrink, an anchor
21
+ // mismatch, or a cached offset of 0 with no anchor at all — is a full
22
+ // re-read from byte 0.
23
+ //
24
+ // RESIDUAL CONTRACT: session logs are append-only by design (`appendEvent`
25
+ // only ever appends). This cache DETECTS truncation, replacement, and any
26
+ // rewrite that changes the anchor's bytes or offset. It does NOT detect an
27
+ // in-place rewrite, on the same inode, that happens to keep the anchor's
28
+ // bytes — terminating newline included — sitting in the same
29
+ // `[offset - anchor.length, offset)` window, nor a same-size rewrite that
30
+ // lands inside one mtime tick. Both are out of scope: nothing in this repo
31
+ // writes a session log that way, so this is stated as a limit rather than
32
+ // implied to be full change detection.
33
+ import { open } from 'node:fs/promises';
34
+ import path from 'node:path';
35
+ import { EventError } from './events.js';
36
+ import { STATE_EVENTS_DIR } from './paths.js';
37
+ function fpEqual(a, b) {
38
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
39
+ }
40
+ /** Whatever a thrown value has to say for itself, without assuming it is an Error. */
41
+ function errorText(err) {
42
+ return err instanceof Error ? err.message : String(err);
43
+ }
44
+ /**
45
+ * Same algorithm as `events.ts`'s private `readEventsAtPath` — trim trailing
46
+ * whitespace, split on `\n`, drop empty lines, parse each survivor as JSON —
47
+ * copied rather than imported so this module never calls `readEvents` (the
48
+ * `readEventsCalls` seam stays truthfully zero) while still throwing the
49
+ * identical `events.unreadable-session-log` error other readers throw.
50
+ *
51
+ * `startIndex` offsets the event id / line-number math for a delta parse:
52
+ * parsing only the newly appended tail of a log must still number its events
53
+ * (and report a malformed line's number) as if the whole file had been
54
+ * parsed at once.
55
+ */
56
+ function parseCompleteLines(text, sessionId, filePath, startIndex) {
57
+ const lines = text
58
+ .trimEnd()
59
+ .split('\n')
60
+ .filter((line) => line.length > 0);
61
+ return lines.map((line, offset) => {
62
+ const index = startIndex + offset;
63
+ let record;
64
+ try {
65
+ record = JSON.parse(line);
66
+ }
67
+ catch (err) {
68
+ throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
69
+ }
70
+ return { event_id: `${sessionId}#${index}`, record };
71
+ });
72
+ }
73
+ /**
74
+ * Parse a JSON pending tail — a well-formed JSON value with no terminating
75
+ * newline yet — into the one event it names. Throws the same
76
+ * `events.unreadable-session-log` error, with the same line number,
77
+ * `readEvents` would report for a malformed final line.
78
+ */
79
+ function parsePendingTail(text, sessionId, filePath, index) {
80
+ let record;
81
+ try {
82
+ record = JSON.parse(text);
83
+ }
84
+ catch (err) {
85
+ throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
86
+ }
87
+ return { event_id: `${sessionId}#${index}`, record };
88
+ }
89
+ const NEWLINE = 0x0a;
90
+ /**
91
+ * Tail-detection and parse, applied to the RAW, untrimmed bytes of
92
+ * `tailBuf` — everything at and after `baseOffset` in the log.
93
+ *
94
+ * Locates the LAST `\n` in `tailBuf`: everything up to and including it is
95
+ * newline-terminated ("complete") lines, parsed and cached; everything after
96
+ * it is the candidate pending tail. A whitespace-only (or empty) candidate is
97
+ * no pending tail at all. `baseAnchor`/`baseOffset` are reused unchanged when
98
+ * `tailBuf` holds no newline — nothing newline-terminated was added, so
99
+ * nothing the entry remembers may advance.
100
+ */
101
+ function parseTail(tailBuf, sessionId, filePath, baseOffset, baseIndex, baseAnchor, baseCachedEvents) {
102
+ const lastNL = tailBuf.lastIndexOf(NEWLINE);
103
+ let offset = baseOffset;
104
+ let anchor = baseAnchor;
105
+ let newComplete = [];
106
+ let pendingBuf;
107
+ if (lastNL === -1) {
108
+ pendingBuf = tailBuf;
109
+ }
110
+ else {
111
+ const completeBuf = tailBuf.subarray(0, lastNL + 1);
112
+ const prevNL = lastNL <= 0 ? -1 : tailBuf.lastIndexOf(NEWLINE, lastNL - 1);
113
+ const anchorStart = prevNL === -1 ? 0 : prevNL + 1;
114
+ anchor = Buffer.from(tailBuf.subarray(anchorStart, lastNL + 1));
115
+ offset = baseOffset + lastNL + 1;
116
+ newComplete = parseCompleteLines(completeBuf.toString('utf8'), sessionId, filePath, baseIndex);
117
+ pendingBuf = tailBuf.subarray(lastNL + 1);
118
+ }
119
+ const cachedEvents = newComplete.length === 0 ? [...baseCachedEvents] : [...baseCachedEvents, ...newComplete];
120
+ const pendingText = pendingBuf.toString('utf8');
121
+ if (pendingText.trim().length === 0) {
122
+ return { cachedEvents, events: cachedEvents, offset, anchor };
123
+ }
124
+ const pendingIndex = baseIndex + newComplete.length;
125
+ const pendingEvent = parsePendingTail(pendingText, sessionId, filePath, pendingIndex);
126
+ return { cachedEvents, events: [...cachedEvents, pendingEvent], offset, anchor };
127
+ }
128
+ const EMPTY_SEAM = {
129
+ opens: 0,
130
+ fstats: 0,
131
+ bytesRead: 0,
132
+ readEventsCalls: 0,
133
+ fullReparses: 0,
134
+ };
135
+ /**
136
+ * A reusable, package-internal cache of session event logs, keyed by a
137
+ * fingerprint taken from an open handle rather than a path stat.
138
+ *
139
+ * One instance is meant to live across many `runTick` calls (the daemon's
140
+ * long-running loop holds one for its whole run); a fresh instance is always
141
+ * cold, so `runTick` with no shared cache reads exactly what `readEvents`
142
+ * would have — this is an optimization for the repeat-tick case, not a
143
+ * change in what any single read returns.
144
+ */
145
+ export class LogCache {
146
+ entries = new Map();
147
+ appliedSignatures = new Map();
148
+ counters;
149
+ /** `seam` lets a caller inject (and so share, or directly assert on) the counting object. */
150
+ constructor(seam) {
151
+ this.counters = seam ?? { ...EMPTY_SEAM };
152
+ }
153
+ logPath(sessionId, opts) {
154
+ return path.join(opts.stateDir ?? STATE_EVENTS_DIR, `${sessionId}.jsonl`);
155
+ }
156
+ /** This session's log, read through the cache. */
157
+ async read(sessionId, opts = {}) {
158
+ const filePath = this.logPath(sessionId, opts);
159
+ let handle;
160
+ try {
161
+ handle = await open(filePath, 'r');
162
+ this.counters.opens += 1;
163
+ }
164
+ catch (err) {
165
+ if (err.code === 'ENOENT') {
166
+ // The previous entry, if any, is left untouched — same rule as a
167
+ // read that throws below. A log that has vanished (and may come
168
+ // back) is not the same fact as a log this cache has never seen.
169
+ return [];
170
+ }
171
+ throw err;
172
+ }
173
+ try {
174
+ const stats = await handle.stat();
175
+ this.counters.fstats += 1;
176
+ const fp = {
177
+ dev: stats.dev,
178
+ ino: stats.ino,
179
+ size: stats.size,
180
+ mtimeMs: stats.mtimeMs,
181
+ };
182
+ const cached = this.entries.get(sessionId);
183
+ if (cached !== undefined && fpEqual(fp, cached.fp)) {
184
+ return cached.events;
185
+ }
186
+ // Append-only is verified, never inferred from size: the incremental
187
+ // path is only even attempted when the anchor byte-for-byte matches
188
+ // what the file now holds at the same window.
189
+ const canAttemptIncremental = cached !== undefined &&
190
+ fp.dev === cached.fp.dev &&
191
+ fp.ino === cached.fp.ino &&
192
+ cached.anchor.length > 0 &&
193
+ fp.size >= cached.offset;
194
+ if (canAttemptIncremental) {
195
+ // biome-ignore lint/style/noNonNullAssertion: canAttemptIncremental narrows `cached`.
196
+ const entry = cached;
197
+ const start = entry.offset - entry.anchor.length;
198
+ const readLen = fp.size - start;
199
+ const buf = Buffer.alloc(readLen);
200
+ const { bytesRead } = await handle.read(buf, 0, readLen, start);
201
+ this.counters.bytesRead += bytesRead;
202
+ const slice = buf.subarray(0, bytesRead);
203
+ if (slice.subarray(0, entry.anchor.length).equals(entry.anchor)) {
204
+ const tailBuf = slice.subarray(entry.anchor.length);
205
+ const parsed = parseTail(tailBuf, sessionId, filePath, entry.offset, entry.cachedEvents.length, entry.anchor, entry.cachedEvents);
206
+ this.entries.set(sessionId, {
207
+ fp,
208
+ offset: parsed.offset,
209
+ anchor: parsed.anchor,
210
+ cachedEvents: parsed.cachedEvents,
211
+ events: parsed.events,
212
+ });
213
+ return parsed.events;
214
+ }
215
+ // Anchor mismatch: a same-size-or-larger rewrite that is not a
216
+ // genuine append. Fall through to a full re-read.
217
+ }
218
+ this.counters.fullReparses += 1;
219
+ const whole = await handle.readFile();
220
+ this.counters.bytesRead += whole.length;
221
+ const parsed = parseTail(whole, sessionId, filePath, 0, 0, Buffer.alloc(0), []);
222
+ this.entries.set(sessionId, {
223
+ fp,
224
+ offset: parsed.offset,
225
+ anchor: parsed.anchor,
226
+ cachedEvents: parsed.cachedEvents,
227
+ events: parsed.events,
228
+ });
229
+ return parsed.events;
230
+ }
231
+ finally {
232
+ await handle.close();
233
+ }
234
+ }
235
+ /** The `event_id` of this session's last event, or `null` for empty/absent. */
236
+ async lastEventId(sessionId, opts = {}) {
237
+ const events = await this.read(sessionId, opts);
238
+ return events.length === 0 ? null : events[events.length - 1].event_id;
239
+ }
240
+ /**
241
+ * The fingerprint this cache last read for `sessionId`, or `undefined` if
242
+ * it has never been read through this cache. Lets a caller (the daemon's
243
+ * apply-skip below) build a cheap in-memory signature over a set of
244
+ * sessions without re-reading any of them.
245
+ */
246
+ fingerprintOf(sessionId) {
247
+ return this.entries.get(sessionId)?.fp;
248
+ }
249
+ /**
250
+ * The signature this cache last recorded a projector `apply()` against for
251
+ * `leaf` (`setAppliedSignature`'s argument), so the daemon can skip a
252
+ * redundant apply when nothing in the leaf's lineage changed since.
253
+ */
254
+ getAppliedSignature(leaf) {
255
+ return this.appliedSignatures.get(leaf);
256
+ }
257
+ setAppliedSignature(leaf, signature) {
258
+ this.appliedSignatures.set(leaf, signature);
259
+ }
260
+ /**
261
+ * Evicts every key not in `liveSessionIds` from both `entries` and
262
+ * `appliedSignatures`, so the cache's memory is bounded by live logs, not
263
+ * by history. `runTick` calls this once per tick with the session ids
264
+ * `listSessionIds` just returned -- the LISTED set, not the set of
265
+ * sessions whose read succeeded this tick -- so a session that is listed
266
+ * but unreadable keeps its entry (a read that throws leaves the entry
267
+ * untouched; eviction is the tick's decision, driven by the listing, not
268
+ * the read). A session that vanishes and later reappears loses both its
269
+ * fingerprint and its applied signature, so it is read cold and its leaf
270
+ * is re-applied. One pass over each map's own keys; no file I/O.
271
+ */
272
+ retainOnly(liveSessionIds) {
273
+ for (const sessionId of this.entries.keys()) {
274
+ if (!liveSessionIds.has(sessionId))
275
+ this.entries.delete(sessionId);
276
+ }
277
+ for (const leaf of this.appliedSignatures.keys()) {
278
+ if (!liveSessionIds.has(leaf))
279
+ this.appliedSignatures.delete(leaf);
280
+ }
281
+ }
282
+ }
283
+ /**
284
+ * The public factory. `dist/logCache.js` exports this, the `LogCache` type
285
+ * it returns, and the `LogCacheSeam` type its optional argument takes — the
286
+ * three names a consumer (task 3's dashboard wiring) imports to inject a
287
+ * counting cache elsewhere.
288
+ */
289
+ export function createLogCache(seam) {
290
+ return new LogCache(seam);
291
+ }
@@ -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}`);