@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
@@ -539,6 +539,250 @@ function findFromSpecifier(masked, from, to) {
539
539
  function normalizeSignature(text) {
540
540
  return text.trim().replace(/\s+/g, ' ');
541
541
  }
542
+ /** Type words after which a `{` opens an object type, as it does after `:`. */
543
+ const TYPE_OPERATORS = new Set([
544
+ 'is',
545
+ 'extends',
546
+ 'keyof',
547
+ 'readonly',
548
+ 'new',
549
+ 'abstract',
550
+ ]);
551
+ /**
552
+ * Whether the group `open`…`close` reads as a function type's parameters, as
553
+ * TypeScript decides it: empty, a rest parameter, or a name or binding pattern
554
+ * followed by `:`, `,`, `?`, `=` or the `)`. `(A | B)` is a parenthesised type.
555
+ */
556
+ function isTypeParameterList(masked, open, close) {
557
+ const first = skipSpace(masked, open + 1);
558
+ if (first === close || masked.startsWith('...', first))
559
+ return true;
560
+ const ch = masked.charAt(first);
561
+ let after = first + readIdent(masked, first).length;
562
+ if (ch === '{' || ch === '[')
563
+ after = matchBracket(masked, first, close) + 1;
564
+ return after > first && ':,?=)'.includes(masked.charAt(skipSpace(masked, after)));
565
+ }
566
+ /**
567
+ * The index just past the template literal opening at `open`. Its text is
568
+ * already blanked, so the next backtick closes it, and a `${` interpolation
569
+ * is stepped over as a bracket group. An unclosed one runs to `limit`.
570
+ */
571
+ function skipTemplate(masked, open, limit) {
572
+ let i = open + 1;
573
+ while (i < limit) {
574
+ const ch = masked.charAt(i);
575
+ if (ch === '`')
576
+ return i + 1;
577
+ if (ch === '$' && masked.charAt(i + 1) === '{') {
578
+ const close = matchBracket(masked, i + 1, limit);
579
+ if (close === -1)
580
+ return limit;
581
+ i = close + 1;
582
+ }
583
+ else
584
+ i += 1;
585
+ }
586
+ return limit;
587
+ }
588
+ /**
589
+ * Walk a return-type annotation (`from` is just past its `:`) and answer where
590
+ * it stops: the index of the function body's `{`, the index of the `=>`,
591
+ * `limit` when the type runs to it, or -1 when what follows the `:` is not a
592
+ * return type at all (a ternary branch, say). A `{` directly after `:`, `|`,
593
+ * `&`, `<`, `,`, `?`, a word in TYPE_OPERATORS or a function type's `=>` is an
594
+ * object type; a template literal type is skipped whole.
595
+ */
596
+ function skipReturnType(masked, from, limit) {
597
+ let prev = ':';
598
+ let angle = 0;
599
+ let i = from;
600
+ while (i < limit) {
601
+ const ch = masked.charAt(i);
602
+ if (/\s/.test(ch)) {
603
+ i += 1;
604
+ continue;
605
+ }
606
+ if (ch === '`') {
607
+ // A template literal type is skipped whole: its `${…}` braces are not a body.
608
+ i = skipTemplate(masked, i, limit);
609
+ prev = 'a';
610
+ continue;
611
+ }
612
+ if (ch === '=' && masked.charAt(i + 1) === '>')
613
+ return i;
614
+ if (ch === '{' && !':|&<,?'.includes(prev))
615
+ return i;
616
+ if (ch === '{' || ch === '(' || ch === '[') {
617
+ const close = matchBracket(masked, i, limit);
618
+ if (close === -1)
619
+ return -1;
620
+ const arrow = skipSpace(masked, close + 1);
621
+ const functionType = ch === '(' &&
622
+ ':|&<,?>'.includes(prev) &&
623
+ masked.startsWith('=>', arrow) &&
624
+ isTypeParameterList(masked, i, close);
625
+ // A function type's `=>` is not the arrow function's; its return type follows.
626
+ i = functionType ? arrow + 2 : close + 1;
627
+ prev = functionType ? ':' : ')';
628
+ continue;
629
+ }
630
+ if (isIdentStart(ch)) {
631
+ const word = readIdent(masked, i);
632
+ prev = prev !== '.' && TYPE_OPERATORS.has(word) ? ':' : 'a';
633
+ i += word.length;
634
+ continue;
635
+ }
636
+ if (ch === '<')
637
+ angle += 1;
638
+ else if (ch === '>')
639
+ angle -= 1;
640
+ else if (ch === ')' || ch === ']' || ch === '}' || ch === ';')
641
+ return -1;
642
+ else if (ch === ',' && angle <= 0)
643
+ return -1;
644
+ prev = ch;
645
+ i += 1;
646
+ }
647
+ return limit;
648
+ }
649
+ /**
650
+ * Whether the call opening at `open` is a class heritage, `extends mixin(Base) {`,
651
+ * whose `{` is a class body. It reads back from `open` only as far as the
652
+ * heritage name and the `extends` before it, never to `start`.
653
+ */
654
+ function isHeritageCall(masked, start, open) {
655
+ let i = open;
656
+ while (i > start && /\s/.test(masked.charAt(i - 1)))
657
+ i -= 1;
658
+ const nameEnd = i;
659
+ while (i > start && /[\w$.]/.test(masked.charAt(i - 1)))
660
+ i -= 1;
661
+ const nameStart = i;
662
+ while (i > start && /\s/.test(masked.charAt(i - 1)))
663
+ i -= 1;
664
+ const keyword = i - 'extends'.length;
665
+ return (nameStart < nameEnd &&
666
+ i < nameStart &&
667
+ keyword >= start &&
668
+ masked.startsWith('extends', keyword) &&
669
+ (keyword === start || !/\w/.test(masked.charAt(keyword - 1))));
670
+ }
671
+ /** Words after `export default` that make it a declaration, not an expression. */
672
+ const DEFAULT_DECLARATIONS = new Set([
673
+ 'function',
674
+ 'class',
675
+ 'interface',
676
+ 'abstract',
677
+ 'async',
678
+ ]);
679
+ /**
680
+ * Where the clause's value starts — just past the `=` of `export const|let|var`
681
+ * or past `default` in `export default <expression>` — or -1 for any other
682
+ * clause. Only a value can hold a function body: a declaration's own body
683
+ * lies past the clause, and an annotation, a type alias, an interface or a
684
+ * `declare` is all types, where a `{` after `=>` is an object type.
685
+ */
686
+ function valueStart(masked, from, end) {
687
+ const at = skipSpace(masked, from);
688
+ const word = readIdent(masked, at);
689
+ if (word === 'default') {
690
+ const next = readIdent(masked, skipSpace(masked, at + word.length));
691
+ return DEFAULT_DECLARATIONS.has(next) ? -1 : at + word.length;
692
+ }
693
+ if (word !== 'const' && word !== 'let' && word !== 'var')
694
+ return -1;
695
+ let depth = 0;
696
+ for (let i = at + word.length; i < end; i += 1) {
697
+ const ch = masked.charAt(i);
698
+ if (ch === '=' && masked.charAt(i + 1) === '>')
699
+ i += 1;
700
+ else if ('([{<'.includes(ch))
701
+ depth += 1;
702
+ else if (')]}>'.includes(ch))
703
+ depth -= 1;
704
+ else if (ch === '=' && depth === 0)
705
+ return i + 1;
706
+ }
707
+ return -1;
708
+ }
709
+ /** What a function body reads as in a signature. */
710
+ const BODY_PLACEHOLDER = '{…}';
711
+ /**
712
+ * The value text an importer can see (`start` is from valueStart): every
713
+ * function body in it becomes a placeholder, so an edit inside a body is not a
714
+ * signature edit, while what follows a body (a `memo` comparator, an `as` cast,
715
+ * a chained call) still is. A body is the `{` right after a parameter list,
716
+ * after its return type, or after `=>`; any other brace is text. The scan steps
717
+ * over a parameter list and its return type once it finds the body or the
718
+ * `=>` after them, or when they run to the `{` that ends the clause (that body
719
+ * lies past `end`); any other parenthesised group, a call or a ternary branch,
720
+ * it reads into. Nothing past `end` is read, and an unmatched bracket keeps the
721
+ * text whole.
722
+ *
723
+ * Known limits, both rare: a function type with an object return inside a type
724
+ * argument or an `as` type within the value, `memo(forwardRef<R, (a: A) => { b: B }>(…))`
725
+ * or `memo(C as (p: P) => { a: A })`, reads its object type as a body; and a
726
+ * heritage that is not `extends name(…) {`, such as `extends mixin(A)(B) {`,
727
+ * `extends (c ? A : B) {` or `extends mixin<T>(Base) {`, has its class body
728
+ * read as a function body.
729
+ */
730
+ function signatureText(source, masked, start, end) {
731
+ let text = '';
732
+ let kept = start;
733
+ let i = start;
734
+ while (i < end) {
735
+ const ch = masked.charAt(i);
736
+ let body = -1;
737
+ if (ch === '=' && masked.charAt(i + 1) === '>') {
738
+ const next = skipSpace(masked, i + 2);
739
+ if (next >= end || masked.charAt(next) !== '{') {
740
+ i = next;
741
+ continue;
742
+ }
743
+ body = next;
744
+ }
745
+ else if (ch === '(') {
746
+ const close = matchBracket(masked, i, end);
747
+ if (close === -1)
748
+ return source.slice(start, end);
749
+ const after = skipSpace(masked, close + 1);
750
+ const next = after < end ? masked.charAt(after) : '';
751
+ const stop = next === ':' ? skipReturnType(masked, after + 1, end) : after;
752
+ if (stop >= end && masked.charAt(end) === '{') {
753
+ // The `{` that ends the clause is this function's body: keep its parameters whole.
754
+ i = end;
755
+ continue;
756
+ }
757
+ if (next === '{' && !isHeritageCall(masked, start, i))
758
+ body = after;
759
+ else if (next === '=' && masked.charAt(after + 1) === '>') {
760
+ i = after;
761
+ continue;
762
+ }
763
+ else if (next === ':' && stop !== -1 && stop < end) {
764
+ if (masked.charAt(stop) === '{')
765
+ body = stop;
766
+ else {
767
+ i = stop;
768
+ continue;
769
+ }
770
+ }
771
+ }
772
+ if (body === -1) {
773
+ // A call, a group or plain text: look inside it.
774
+ i += 1;
775
+ continue;
776
+ }
777
+ const close = matchBracket(masked, body, end);
778
+ if (close === -1)
779
+ return source.slice(start, end);
780
+ text += source.slice(kept, body) + BODY_PLACEHOLDER;
781
+ kept = close + 1;
782
+ i = kept;
783
+ }
784
+ return text + source.slice(kept, end);
785
+ }
542
786
  /**
543
787
  * Record the declaration text against every export the clause introduced.
544
788
  * Wrapping is what keeps this honest: the clause parsers below push export
@@ -548,7 +792,15 @@ function normalizeSignature(text) {
548
792
  function readClauseFacts(source, masked, start, clause, keyword, facts) {
549
793
  const before = facts.exports.length;
550
794
  readClauseExports(source, masked, start, clause, keyword, facts);
551
- const signature = normalizeSignature(source.slice(start, clause.textEnd));
795
+ // An import introduces no export, so it has no signature to read.
796
+ if (facts.exports.length === before)
797
+ return;
798
+ const end = clause.textEnd;
799
+ const value = valueStart(masked, start + keyword.length, end);
800
+ const text = value === -1
801
+ ? source.slice(start, end)
802
+ : source.slice(start, value) + signatureText(source, masked, value, end);
803
+ const signature = normalizeSignature(text);
552
804
  for (let i = before; i < facts.exports.length; i += 1) {
553
805
  const name = facts.exports[i];
554
806
  if (name !== undefined)
@@ -664,13 +916,16 @@ function readStatementClauses(source, masked, facts) {
664
916
  i += 1;
665
917
  }
666
918
  }
667
- function matchParen(masked, open) {
919
+ /** Index of the bracket closing the one at `open`, before `limit`; -1 when unclosed. */
920
+ function matchBracket(masked, open, limit = masked.length) {
921
+ const opener = masked.charAt(open);
922
+ const closer = opener === '{' ? '}' : opener === '[' ? ']' : ')';
668
923
  let depth = 0;
669
- for (let i = open; i < masked.length; i += 1) {
924
+ for (let i = open; i < limit; i += 1) {
670
925
  const ch = masked.charAt(i);
671
- if (ch === '(')
926
+ if (ch === opener)
672
927
  depth += 1;
673
- else if (ch === ')') {
928
+ else if (ch === closer) {
674
929
  depth -= 1;
675
930
  if (depth === 0)
676
931
  return i;
@@ -683,7 +938,7 @@ function readDynamicImports(source, masked, facts) {
683
938
  let match = pattern.exec(masked);
684
939
  while (match !== null) {
685
940
  const open = match.index + match[0].length - 1;
686
- const close = matchParen(masked, open);
941
+ const close = matchBracket(masked, open);
687
942
  const inner = close === -1 ? masked.length : close;
688
943
  const quoteStart = skipSpace(masked, open + 1);
689
944
  const quote = masked.charAt(quoteStart);
@@ -1,6 +1,8 @@
1
1
  import { appendEdge, appendEvent, readLineageEvents, } from './events.js';
2
2
  import { resolveTaskId } from './plan.js';
3
- import { taskBranchName } from './worktree.js';
3
+ import { epicOfTaskId, taskIdsMatch } from './taskId.js';
4
+ import { CLOSED_TO_FURTHER_WORK } from './taskStatus.js';
5
+ import { BRANCH_PREFIX, epicBranchPrefix, taskBranchName } from './worktree.js';
4
6
  function envelope(ctx, eventType, payload, taskId) {
5
7
  return {
6
8
  session_id: ctx.sessionId,
@@ -22,7 +24,7 @@ function envelope(ctx, eventType, payload, taskId) {
22
24
  * emitTasksAdded, which used it for the epic id and the task specs and let the
23
25
  * project fall on the floor. Every event of a demo-rpg run therefore landed
24
26
  * with no project at all, and db/queries.ts's projectOf() resolves an absent
25
- * one to the default: a whole epic filed under black-smith, missing from the
27
+ * one to the default: a whole epic filed under blacksmith, missing from the
26
28
  * board of the project it was actually part of.
27
29
  *
28
30
  * The plan is the fallback and not an override, because a caller holding a
@@ -34,7 +36,7 @@ function planScoped(ctx, plan) {
34
36
  const project = ctx.project ?? plan.project;
35
37
  return project === undefined ? ctx : { ...ctx, project };
36
38
  }
37
- function addedPayload(plan, task) {
39
+ function addedPayload(plan, task, branchPrefix) {
38
40
  const budget = task.budget;
39
41
  // A task spec is a `Record<string, unknown>`, so its `epic_id` is only a
40
42
  // string by convention. `taskBranchName` needs a real one, and a task
@@ -47,14 +49,20 @@ function addedPayload(plan, task) {
47
49
  task_status: task.task_status,
48
50
  plan_version: task.plan_version,
49
51
  objective: task.objective,
52
+ title: typeof task.title === 'string' ? task.title : undefined,
53
+ summary: typeof task.summary === 'string' ? task.summary : undefined,
50
54
  claims: task.claims,
51
55
  budget_tokens: budget?.tokens,
52
56
  // D-23/P9-12. Declared here rather than derived by the reader: the board
53
57
  // wants a branch link the moment a task is added, which is well before a
54
58
  // worktree exists, and worktree.ts's convention is the one that decides
55
- // what `smith worktree create` will actually cut. Reading it off the same
56
- // function that creates it means the two cannot say different things.
57
- branch: taskBranchName(epicId, task.task_id),
59
+ // what `smith worktree create` will actually cut. `branchPrefix` is
60
+ // `epicBranchPrefix`'s answer for this epic when `emitTasksAdded` was
61
+ // handed a project dir (a legacy `smith/<epic>/integration` keeps this
62
+ // epic's new branches there too), or the plain `bs` default otherwise —
63
+ // the same choice `worktree create` makes, so the two cannot say
64
+ // different things.
65
+ branch: taskBranchName(epicId, task.task_id, branchPrefix),
58
66
  };
59
67
  }
60
68
  /**
@@ -108,6 +116,48 @@ async function addedPlanVersions(ctx, opts) {
108
116
  }
109
117
  return highest;
110
118
  }
119
+ /**
120
+ * The plan record `plan amend` carries for a task only the log added, when it
121
+ * supersedes that task: a dead (`superseded`) record under the task's own id,
122
+ * so the next plan version can say what the log alone could not and
123
+ * `plan ingest` has a record to emit `task-superseded` from.
124
+ *
125
+ * Built from the `task-added` payload where it speaks (objective, claims,
126
+ * case, origin, title, summary, `budget_tokens`) and from honest placeholders
127
+ * where the schema demands a field a follow-up never carried
128
+ * (`output_schema_ref`, acceptance criteria, contract clauses, `diff_lines`):
129
+ * the record is never dispatched, only has to validate. `ui_affecting: false`
130
+ * is set for the same reason; the UI-path rule asks every record claiming one
131
+ * to say so, and this one will never change a screen. `claims` is passed
132
+ * through as written, so a payload with unreadable claims fails the plan's own
133
+ * validation and the amendment is refused, rather than a claim being invented.
134
+ */
135
+ export function supersededStubFromLog(added, epicId, planVersion) {
136
+ const p = added.payload;
137
+ const str = (v) => typeof v === 'string' && v !== '' ? v : undefined;
138
+ const tokens = p.budget_tokens;
139
+ const note = `Added by the event log; superseded by plan v${planVersion}.`;
140
+ return {
141
+ task_id: added.taskId,
142
+ epic_id: epicId,
143
+ plan_version: planVersion,
144
+ objective: str(p.objective) ?? note,
145
+ output_schema_ref: 'result.schema.json',
146
+ acceptance_criteria: [note],
147
+ claims: added.claims,
148
+ budget: {
149
+ tokens: typeof tokens === 'number' && Number.isInteger(tokens) && tokens >= 1 ? tokens : 1,
150
+ diff_lines: 1,
151
+ },
152
+ contract: { functional_clauses: [note], nonfunctional_clauses: [] },
153
+ case: str(p.case) ?? 'chore',
154
+ origin: str(p.origin) ?? 'user',
155
+ task_status: 'superseded',
156
+ ui_affecting: false,
157
+ ...(str(p.title) ? { title: str(p.title) } : {}),
158
+ ...(str(p.summary) ? { summary: str(p.summary) } : {}),
159
+ };
160
+ }
111
161
  /**
112
162
  * Read the lineage's log as a register of tasks — every id a `task-added`
113
163
  * has introduced, with the claims it was introduced with.
@@ -139,7 +189,13 @@ ctx, opts = {}) {
139
189
  for (const { record } of events) {
140
190
  if (record.event_type !== 'task-added' || !record.task_id)
141
191
  continue;
142
- byId.set(record.task_id, { taskId: record.task_id, claims: record.payload?.claims });
192
+ const epicId = record.payload?.epic_id;
193
+ byId.set(record.task_id, {
194
+ taskId: record.task_id,
195
+ claims: record.payload?.claims,
196
+ epicId: typeof epicId === 'string' ? epicId : undefined,
197
+ payload: record.payload ?? {},
198
+ });
143
199
  }
144
200
  return [...byId.values()];
145
201
  }
@@ -177,23 +233,14 @@ function specsToIngest(plan) {
177
233
  return specs;
178
234
  }
179
235
  /**
180
- * Write the plan's backlog into the log: one `task-added` per task id the
181
- * session has not already recorded, plus a `task-superseded` for any id the
182
- * plan has no live spec left for (a v(n+1) cut records that as plan state,
183
- * and the projector needs it as an event to reach the same conclusion).
184
- *
185
- * Per id, not per record — see `specsToIngest`. Emitting the dead copies too
186
- * would say two false things at once: that a task the plan still lists as
187
- * `todo` was superseded, and that a 5-task backlog is 13 tasks.
188
- *
189
- * Returns only the events it actually appended, so a caller can report "5
190
- * added, 0 already present" truthfully rather than guessing from the plan.
236
+ * The events an ingest of `plan` would still write into this lineage, in the
237
+ * order it writes them — the whole of `emitTasksAdded`'s decision, kept apart
238
+ * from the appending so a reader can ask the question without writing.
191
239
  */
192
- export async function emitTasksAdded(plan, ctx, opts = {}) {
240
+ async function pendingIngest(plan, ctx, opts) {
193
241
  const added = await addedPlanVersions(ctx, opts);
194
242
  const superseded = await idsAlreadyEmitted('task-superseded', ctx, opts);
195
- const scoped = planScoped(ctx, plan);
196
- const written = [];
243
+ const pending = [];
197
244
  // The ids the plan marks as superseded-and-replaced: a dead record beside a
198
245
  // live one under the same id is how `draftNextVersion` records an amendment.
199
246
  const supersededIds = new Set(plan.tasks.filter((r) => r.task_status === 'superseded').map((r) => r.task_id));
@@ -211,19 +258,103 @@ export async function emitTasksAdded(plan, ctx, opts = {}) {
211
258
  // the number would re-add tasks whose spec never changed. The amendment's
212
259
  // own marker is what counts — an id the amendment touched carries a dead
213
260
  // record beside its live one, which is exactly what `supersededIds` holds.
261
+ //
262
+ // And only for a task that still HAS a live record. A task every record of
263
+ // which is dead (a log-added follow-up `plan amend` retired, or a renamed-
264
+ // away id) has no spec worth adding again; re-adding it as `superseded`
265
+ // would only repeat the row the log already holds.
214
266
  const amended = recorded !== undefined &&
267
+ task.task_status !== 'superseded' &&
215
268
  supersededIds.has(taskId) &&
216
269
  typeof liveVersion === 'number' &&
217
270
  liveVersion > recorded;
218
- if (recorded === undefined || amended) {
219
- written.push(await appendEvent(envelope(scoped, 'task-added', addedPayload(plan, task), taskId), opts));
220
- }
271
+ if (recorded === undefined || amended)
272
+ pending.push({ task, eventType: 'task-added' });
221
273
  if (task.task_status === 'superseded' && !superseded.has(taskId)) {
222
- written.push(await appendEvent(envelope(scoped, 'task-superseded', { epic_id: plan.epic_id }, taskId), opts));
274
+ pending.push({ task, eventType: 'task-superseded' });
223
275
  }
224
276
  }
277
+ return pending;
278
+ }
279
+ /**
280
+ * What `plan ingest` would still write for `plan` into this lineage — empty
281
+ * exactly when the plan in hand has been ingested. Read-only.
282
+ *
283
+ * #250. `wave check --session` admits against the log's task rows, and a plan
284
+ * version that was never ingested has rows the log does not know about, or
285
+ * rows at an older version, so a wave admitted from it is admitted against a
286
+ * backlog nobody recorded. Asked through the same decision `emitTasksAdded`
287
+ * makes, so "ingested" cannot mean one thing here and another at the ingest.
288
+ */
289
+ export async function planIngestGaps(plan, ctx, opts = {}) {
290
+ const pending = await pendingIngest(plan, ctx, opts);
291
+ return pending.map(({ task, eventType }) => ({ taskId: task.task_id, eventType }));
292
+ }
293
+ /**
294
+ * Write the plan's backlog into the log: one `task-added` per task id the
295
+ * session has not already recorded, plus a `task-superseded` for any id the
296
+ * plan has no live spec left for (a v(n+1) cut records that as plan state,
297
+ * and the projector needs it as an event to reach the same conclusion).
298
+ *
299
+ * Per id, not per record — see `specsToIngest`. Emitting the dead copies too
300
+ * would say two false things at once: that a task the plan still lists as
301
+ * `todo` was superseded, and that a 5-task backlog is 13 tasks.
302
+ *
303
+ * Returns only the events it actually appended, so a caller can report "5
304
+ * added, 0 already present" truthfully rather than guessing from the plan.
305
+ *
306
+ * `projectDir` is optional: the CLI's `--project` flag. Given, the branch
307
+ * prefix each `task-added` declares is resolved once per epic via
308
+ * `epicBranchPrefix` — a legacy `smith/<epic>/integration` epic keeps landing
309
+ * `smith/...` here, matching what `worktree create` will actually cut.
310
+ * Absent, every task gets the plain `bs` default — correct for a brand-new
311
+ * epic, which has no worktree convention to ask git about yet.
312
+ */
313
+ export async function emitTasksAdded(plan, ctx, opts = {}, projectDir) {
314
+ const scoped = planScoped(ctx, plan);
315
+ const written = [];
316
+ const branchPrefixes = new Map();
317
+ for (const { task, eventType } of await pendingIngest(plan, ctx, opts)) {
318
+ let payload;
319
+ if (eventType === 'task-added') {
320
+ const epicId = typeof task.epic_id === 'string' ? task.epic_id : plan.epic_id;
321
+ let prefix = branchPrefixes.get(epicId);
322
+ if (prefix === undefined) {
323
+ prefix = projectDir ? epicBranchPrefix(projectDir, epicId) : BRANCH_PREFIX;
324
+ branchPrefixes.set(epicId, prefix);
325
+ }
326
+ payload = addedPayload(plan, task, prefix);
327
+ }
328
+ else {
329
+ payload = { epic_id: plan.epic_id };
330
+ }
331
+ written.push(await appendEvent(envelope(scoped, eventType, payload, task.task_id), opts));
332
+ }
225
333
  return written;
226
334
  }
335
+ /**
336
+ * The ids in `taskIds` a wave may not admit, read against the lineage's task
337
+ * rows: `missing` has no row at all, `terminal` has one whose `task_status`
338
+ * is closed to further work (CLOSED_TO_FURTHER_WORK: completed, superseded,
339
+ * waived). A `failed` or `escalated` task is terminal but still held by an
340
+ * operator, and the escalation ladder re-admits it, so it passes. Pure; the
341
+ * caller folds the rows and canonicalizes the ids.
342
+ *
343
+ * #250. A superseded id admitted by `wave check --session` went on to be
344
+ * dispatched, gated and merged as if it were live work.
345
+ */
346
+ export function unadmissibleTasks(taskIds, statusOf) {
347
+ const missing = [];
348
+ const terminal = [];
349
+ for (const taskId of taskIds) {
350
+ const taskStatus = statusOf.get(taskId);
351
+ if (taskStatus === undefined)
352
+ missing.push(taskId);
353
+ else if (CLOSED_TO_FURTHER_WORK.has(taskStatus))
354
+ terminal.push({ taskId, taskStatus });
355
+ }
356
+ return { missing, terminal };
357
+ }
227
358
  /** One arrow's identity in the log: who depends on whom, and in what sense. */
228
359
  function edgeKey(task, dependsOn, edgeType) {
229
360
  return `${task}\u0000${dependsOn}\u0000${edgeType}`;
@@ -361,6 +492,24 @@ export async function emitTaskBlocked(taskId, error, ctx, opts = {}) {
361
492
  export function followUpTaskId(epicId, fingerprint) {
362
493
  return `${epicId}/followup-${fingerprint.slice(0, 8)}`;
363
494
  }
495
+ /** The one project the lineage stamped on `fromTaskId`'s events, or undefined. */
496
+ function fromTaskProject(lineage, fromTaskId) {
497
+ if (fromTaskId === undefined)
498
+ return undefined;
499
+ const fromEpic = epicOfTaskId(fromTaskId);
500
+ const stamped = new Set();
501
+ for (const { record } of lineage) {
502
+ if (!record.project || !record.task_id || !taskIdsMatch(record.task_id, fromTaskId))
503
+ continue;
504
+ // A bare id does not say which epic it is in; a record that names another
505
+ // epic (task events carry `payload.epic_id`) belongs to another task.
506
+ const named = record.payload?.epic_id;
507
+ if (fromEpic !== null && typeof named === 'string' && named !== fromEpic)
508
+ continue;
509
+ stamped.add(record.project);
510
+ }
511
+ return stamped.size === 1 ? [...stamped][0] : undefined;
512
+ }
364
513
  /**
365
514
  * Record a follow-up task for a finding no open task can own: the file's
366
515
  * owner already merged, two tasks claim it equally, or nobody claims it at
@@ -369,15 +518,27 @@ export function followUpTaskId(epicId, fingerprint) {
369
518
  *
370
519
  * Returns null when the session already carries this task, so a re-run is a
371
520
  * no-op rather than a duplicate. The task is `todo`, which is not terminal-OK,
372
- * so epic.ts's `summarizeEpic` blocks the epic verdict on it until an operator
373
- * plans or waives it — the "blocks the epic verdict instead of an unrelated
374
- * diff" half of P9-24, using the machinery that was already there.
521
+ * so epic.ts's `summarizeEpic` blocks the epic verdict on it — the "blocks the
522
+ * epic verdict instead of an unrelated diff" half of P9-24, using the
523
+ * machinery that was already there. Nothing writes a task to `waived`, so the
524
+ * two ways out are completing it (plan it, gate it) or waiving the findings
525
+ * it owns: summarizeEpic reads a follow-up terminal-OK once at least one
526
+ * finding is attributed to it and every one of them is `waived`
527
+ * (epic.ts's clearedFollowUpFindings).
375
528
  */
376
529
  export async function emitFollowUpTask(input, ctx, opts = {}) {
377
- const added = await idsAlreadyEmitted('task-added', ctx, opts);
378
- if (added.has(input.taskId))
530
+ const lineage = await readLineageEvents(ctx.sessionId, opts);
531
+ if (lineage.some(({ record }) => record.event_type === 'task-added' && record.task_id === input.taskId)) {
379
532
  return null;
380
- return appendEvent(envelope(ctx, 'task-added', {
533
+ }
534
+ // A follow-up is minted inside a wave, where no plan is at hand and the
535
+ // wave-runner stamps nothing. So the caller's project wins (a caller holding
536
+ // the plan passes its project here, planScoped's fallback), then the one
537
+ // the log already holds for the task the finding came from. Two different
538
+ // stamps on that task settle nothing: the envelope stays absent rather
539
+ // than picking one.
540
+ const project = ctx.project ?? fromTaskProject(lineage, input.fromTaskId);
541
+ return appendEvent(envelope(project === undefined ? ctx : { ...ctx, project }, 'task-added', {
381
542
  epic_id: input.epicId,
382
543
  case: 'bugfix',
383
544
  origin: 'escalation',
@@ -72,3 +72,17 @@ export const TERMINAL_OK_TASK_STATUSES = new Set(['completed', 'waived']);
72
72
  * parking silently on a task nobody is working.
73
73
  */
74
74
  export const HELD_OPEN_BY_AN_OPERATOR = new Set(['failed', 'escalated']);
75
+ /**
76
+ * Task statuses that mean "this task's diff is closed to further work": the
77
+ * branch already landed (`completed`), the team already accepted the gap
78
+ * (`waived`), or a later plan version replaced the task outright
79
+ * (`superseded`).
80
+ *
81
+ * That is every terminal status except the ones an operator is still holding,
82
+ * subtracted here so the remainder is derived from the two rosters above
83
+ * rather than typed out as a third. Read by claims.ts (a finding about a
84
+ * closed task's files mints a follow-up) and by `wave check --session` (a
85
+ * closed task is refused admission; a held one is re-admitted by the
86
+ * escalation ladder).
87
+ */
88
+ export const CLOSED_TO_FURTHER_WORK = new Set([...TERMINAL_TASK_STATUSES].filter((status) => !HELD_OPEN_BY_AN_OPERATOR.has(status)));
@@ -15,6 +15,14 @@ const NON_DIMENSION_KEYS = new Set(['version', 'rules', 'error']);
15
15
  const PRESENCE_ONLY_FIELDS = new Set(['task_ref', 'provenance_event_ids', 'model']);
16
16
  /** The `error` dimension is validated as a `group.class` pair, not a flat tag. */
17
17
  const ERROR_CLASS_FIELD = 'error';
18
+ // `task_ref` names exactly one task; it is otherwise presence-only (above),
19
+ // which let a comma-joined "epic-1/task-1,epic-1/task-2" through as one
20
+ // non-empty string. db/projector.ts's fold read that string as a single task
21
+ // id and minted a phantom row for it that stayed `todo` forever, holding
22
+ // epic verdict on a task that was never real. Reject the shape here so the
23
+ // log never grows another one — a producer with two failing tasks logs two
24
+ // error-logged events, not one with two ids stitched together.
25
+ const TASK_REF_FIELD = 'task_ref';
18
26
  export function parseTaxonomy(yamlText) {
19
27
  const doc = parseYaml(yamlText);
20
28
  if (!doc || typeof doc !== 'object') {
@@ -102,6 +110,9 @@ export function validateRequiredDimensions(taxonomy, recordType, record) {
102
110
  if (isEmpty) {
103
111
  throw new TaxonomyError('taxonomy.missing-required-dimension', `Record type "${recordType}" is missing required field "${field}".`, { recordType, field });
104
112
  }
113
+ if (field === TASK_REF_FIELD && String(value).includes(',')) {
114
+ throw new TaxonomyError('taxonomy.multi-value-task-ref', `Record type "${recordType}" field "task_ref" names more than one task ("${String(value)}"). Log one error-logged event per task instead.`, { recordType, field, value });
115
+ }
105
116
  if (PRESENCE_ONLY_FIELDS.has(field))
106
117
  continue;
107
118
  if (field === ERROR_CLASS_FIELD) {