@structured-vibe-coding/core 0.18.0 → 0.19.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 (29) hide show
  1. package/defaults/teams/research/roles/researcher.md +29 -0
  2. package/defaults/teams/research/team.yaml +7 -0
  3. package/defaults/teams/software/roles/architect.md +31 -0
  4. package/defaults/teams/software/roles/dev-lead.md +32 -0
  5. package/defaults/teams/software/roles/developer.md +30 -0
  6. package/defaults/teams/software/roles/product-manager.md +32 -0
  7. package/defaults/teams/software/roles/product-owner.md +30 -0
  8. package/defaults/teams/software/roles/qa.md +35 -0
  9. package/defaults/teams/software/routing.yaml +28 -0
  10. package/defaults/teams/software/team.yaml +24 -0
  11. package/defaults/teams/solo/roles/developer.md +28 -0
  12. package/defaults/teams/solo/team.yaml +7 -0
  13. package/defaults/templates/plan.md +17 -0
  14. package/defaults/templates/review.md +11 -0
  15. package/defaults/templates/story.md +13 -0
  16. package/defaults/templates/task.md +11 -0
  17. package/defaults/workflows/team-software.yaml +153 -0
  18. package/dist/{agents-AKSUXQHN.js → agents-PSIOQEYH.js} +3 -3
  19. package/dist/{change-context-REE7UKU7.js → change-context-EZJMSGSP.js} +1 -1
  20. package/dist/{chunk-4PIRWYRA.js → chunk-26YG6A4M.js} +166 -81
  21. package/dist/{chunk-LHSR7JG2.js → chunk-2FJBFYVJ.js} +306 -31
  22. package/dist/{chunk-QGP43XCN.js → chunk-H55RFAEI.js} +1 -1
  23. package/dist/{chunk-QHV5KZKX.js → chunk-HG7TPW5V.js} +93 -8
  24. package/dist/{chunk-44LQ5BKZ.js → chunk-TGRV6WGS.js} +812 -4
  25. package/dist/{gather-ZNV2NAUN.js → gather-7TSVKQO2.js} +1 -1
  26. package/dist/{greenfield-I5S5OS2D.js → greenfield-QFA5WVHE.js} +3 -3
  27. package/dist/index.d.ts +804 -10
  28. package/dist/index.js +2765 -524
  29. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  mcpTimeoutMs,
12
12
  toProcessEnv,
13
13
  withMcpSource
14
- } from "./chunk-QGP43XCN.js";
14
+ } from "./chunk-H55RFAEI.js";
15
15
  import {
16
16
  McpConfigSchema,
17
17
  McpInvokeSchema,
@@ -39,13 +39,6 @@ import {
39
39
  agentKickoffMessage,
40
40
  assertAgentInstallable,
41
41
  contentHash,
42
- defaultMcpYamlPath,
43
- defaultMemoryDir,
44
- defaultPricesYamlPath,
45
- defaultSearchYamlPath,
46
- defaultTemplatesDir,
47
- defaultWorkflowsDir,
48
- defaultsRoot,
49
42
  describeFsError,
50
43
  describeKept,
51
44
  generatedState,
@@ -55,11 +48,13 @@ import {
55
48
  integrationKeyFor,
56
49
  loadGeneratedManifest,
57
50
  loadInstalledAgent,
51
+ loadProductTeam,
58
52
  markGenerated,
59
53
  optionForTarget,
60
54
  parseAgentTargets,
61
55
  parseIntegration,
62
56
  planAgentIntegration,
57
+ productTeamHome,
63
58
  reconcileGeneratedTree,
64
59
  refreshActiveAgentContext,
65
60
  refreshConfiguredGuidance,
@@ -67,14 +62,16 @@ import {
67
62
  removeOtherAgentHostsDetailed,
68
63
  renderAgentsMd,
69
64
  renderMemoryIndex,
65
+ renderProductTeamSection,
70
66
  renderProtocolMd,
71
67
  renderSddReadme,
72
68
  renderThinAgent,
73
69
  saveGeneratedManifest,
74
70
  writeAgentHandoff
75
- } from "./chunk-4PIRWYRA.js";
71
+ } from "./chunk-26YG6A4M.js";
76
72
  import {
77
73
  ChangeMetricsSchema,
74
+ DecisionMetricSchema,
78
75
  PhaseMetricSchema,
79
76
  TokenUsageMetricSchema,
80
77
  advanceStage,
@@ -93,6 +90,9 @@ import {
93
90
  parseFeaturesBacklog,
94
91
  promoteGreenfieldToMemory,
95
92
  readChangeMetrics,
93
+ recordDecisionMetric,
94
+ recordDecisionRequest,
95
+ recordSignoff,
96
96
  skipStage,
97
97
  startFeatureFromBacklog,
98
98
  startGreenfield,
@@ -100,60 +100,95 @@ import {
100
100
  switchWorkflow,
101
101
  transitionPhase,
102
102
  updateFeatureStatus
103
- } from "./chunk-LHSR7JG2.js";
103
+ } from "./chunk-2FJBFYVJ.js";
104
104
  import {
105
105
  EMBEDDINGS_UNAVAILABLE,
106
106
  EMBEDDING_MODEL,
107
+ ModelsConfigSchema,
108
+ ROLE_TEXT,
107
109
  RRF_K,
108
110
  SUMMARY_TOKEN_CAP,
109
111
  SearchConfigError,
112
+ TeamSchema,
113
+ WORK_TYPES,
114
+ allowFilePath,
110
115
  budgetForStage,
111
116
  buildAgentPrompt,
112
117
  buildRetrievedInner,
118
+ canonicalRoleName,
119
+ checkModels,
120
+ checkTeam,
113
121
  chunkCode,
114
122
  chunkMarkdown,
115
123
  classifyPath,
116
124
  clearRegisteredSearchProviders,
125
+ conversationDir,
117
126
  countTokens,
118
127
  createBuiltinProvider,
119
128
  createFastembedEmbedder,
129
+ createRole,
130
+ createRoleFromTemplate,
120
131
  defaultConfig,
132
+ deleteRole,
133
+ duplicateRole,
134
+ editRole,
121
135
  embedSearchQuery,
122
136
  executeSearch,
123
137
  extractiveSummary,
124
138
  fallbackWarning,
125
139
  fastembedImportCount,
126
140
  formatStatus,
141
+ formatTeam,
127
142
  ftsTokens,
128
143
  fuseByRank,
129
144
  fuseHits,
130
145
  getHeadSha,
146
+ gitUserName,
147
+ hideRole,
148
+ initTeam,
131
149
  isHardDenied,
132
150
  isIgnoredPath,
133
151
  isInitialized,
134
152
  isPrunedDir,
135
153
  isSecretPath,
154
+ isWorkType,
136
155
  keywordHits,
156
+ listConversationRoles,
157
+ listRoleTemplates,
137
158
  listScopeHits,
159
+ listTeamTemplates,
138
160
  loadConfig,
139
161
  loadExcluder,
162
+ loadModels,
140
163
  loadSearchConfig,
164
+ memoryDir as memoryDir2,
141
165
  mergeConfigDefaults,
142
166
  mergeRankedHits,
143
167
  packHits,
168
+ parseRoleDocument,
169
+ pinRole,
144
170
  rankScope,
171
+ readAllowFile,
145
172
  readPackedCache,
173
+ readRole,
174
+ readTeamConfig,
146
175
  registerSearchProvider,
147
176
  renderBlock,
148
177
  resolveActiveDirectory,
178
+ resolveBot,
149
179
  resolveEmbedder,
150
180
  resolveSearchProvider,
181
+ roleFilePath,
151
182
  rrf,
152
183
  saveConfig,
153
184
  scopeForPath,
154
185
  searchPreamble,
186
+ searchProject,
187
+ showRoles,
188
+ showTeam,
189
+ workKind,
155
190
  writePackedCache
156
- } from "./chunk-44LQ5BKZ.js";
191
+ } from "./chunk-TGRV6WGS.js";
157
192
  import {
158
193
  ArtifactDefSchema,
159
194
  ChangeMetaSchema,
@@ -166,15 +201,25 @@ import {
166
201
  buildContext,
167
202
  changeDirName,
168
203
  clearActiveIf,
204
+ defaultMcpYamlPath,
205
+ defaultMemoryDir,
206
+ defaultPricesYamlPath,
207
+ defaultSearchYamlPath,
208
+ defaultTeamsDir,
209
+ defaultTemplatesDir,
210
+ defaultWorkflowsDir,
211
+ defaultsRoot,
169
212
  firstActiveStageId,
170
213
  getActiveChangeId,
171
214
  getStage,
172
215
  inferComplexity,
173
216
  isStageSkipped,
217
+ listBundledWorkflowNames,
174
218
  listChanges,
175
219
  listWorkflowNames,
176
220
  loadChangeMeta,
177
221
  loadWorkflow,
222
+ missingBundledWorkflows,
178
223
  nextStageId,
179
224
  nowIso,
180
225
  recommendWorkflow,
@@ -185,8 +230,9 @@ import {
185
230
  shouldAutoSkip,
186
231
  slugify,
187
232
  todayIsoDate,
188
- uniqueChangeDirName
189
- } from "./chunk-QHV5KZKX.js";
233
+ uniqueChangeDirName,
234
+ workflowUnavailableMessage
235
+ } from "./chunk-HG7TPW5V.js";
190
236
  import {
191
237
  SddError,
192
238
  errorMessage,
@@ -245,6 +291,7 @@ var PricesSchema = z.object({
245
291
  }).catchall(PriceCardSchema)
246
292
  });
247
293
  var TOKEN_HEADER = "source phase provider model requests input cache_read cache_write output usd inr";
294
+ var DECISION_HEADER = "work_type kind provider model calls input cache_read cache_write output usd inr";
248
295
  function finiteMs(startIso, endMs) {
249
296
  const start = Date.parse(startIso);
250
297
  if (!Number.isFinite(start) || !Number.isFinite(endMs)) return 0;
@@ -291,14 +338,14 @@ function stripLeadingZeros(digits) {
291
338
  }
292
339
  function rateUnits(rate) {
293
340
  if (!Number.isFinite(rate) || rate < 0) throw new Error("prices.yaml is invalid");
294
- let text;
341
+ let text2;
295
342
  try {
296
- text = rate.toFixed(9);
343
+ text2 = rate.toFixed(9);
297
344
  } catch {
298
345
  throw new Error("prices.yaml is invalid");
299
346
  }
300
- const negative = text.startsWith("-");
301
- const unsigned = negative ? text.slice(1) : text;
347
+ const negative = text2.startsWith("-");
348
+ const unsigned = negative ? text2.slice(1) : text2;
302
349
  const dot = unsigned.indexOf(".");
303
350
  const whole = dot === -1 ? unsigned : unsigned.slice(0, dot);
304
351
  const frac = dot === -1 ? "" : unsigned.slice(dot + 1, dot + 10);
@@ -476,9 +523,67 @@ function renderChangeReport(input) {
476
523
  for (const request of input.metrics.requests) addRequest(total, request);
477
524
  lines.push(` ${totalLine(total, input.metrics, input.prices)}`);
478
525
  }
526
+ const decisions = decisionSection(input.metrics, input.prices);
527
+ if (decisions.length > 0) {
528
+ lines.push("Decisions:");
529
+ lines.push(` ${DECISION_HEADER}`);
530
+ for (const line of decisions) lines.push(` ${line}`);
531
+ }
479
532
  if (input.prices === null) lines.push("prices.yaml is missing");
480
533
  return lines.join("\n");
481
534
  }
535
+ function groupDecisions(metrics) {
536
+ const groups = /* @__PURE__ */ new Map();
537
+ for (const row of metrics.decisions ?? []) {
538
+ const model = row.model ?? null;
539
+ const key = `${row.workType}\0${row.kind}\0${row.provider}\0${model ?? ""}`;
540
+ let group = groups.get(key);
541
+ if (!group) {
542
+ group = {
543
+ workType: row.workType,
544
+ kind: row.kind,
545
+ provider: row.provider,
546
+ model,
547
+ totals: emptyTotals()
548
+ };
549
+ groups.set(key, group);
550
+ }
551
+ group.totals.requests += 1;
552
+ group.totals.input = addCount(group.totals.input, row.inputTokens);
553
+ group.totals.cacheRead = addCount(group.totals.cacheRead, row.cacheReadTokens);
554
+ group.totals.cacheWrite = addCount(group.totals.cacheWrite, row.cacheWriteTokens);
555
+ group.totals.output = addCount(group.totals.output, row.outputTokens);
556
+ }
557
+ const order = ["route", "classify", "check", "draft", "design", "code", "review"];
558
+ return [...groups.values()].sort((left, right) => {
559
+ const workDelta = order.indexOf(left.workType) - order.indexOf(right.workType);
560
+ if (workDelta !== 0) return workDelta;
561
+ const kindDelta = left.kind.localeCompare(right.kind);
562
+ if (kindDelta !== 0) return kindDelta;
563
+ const providerDelta = left.provider.localeCompare(right.provider);
564
+ if (providerDelta !== 0) return providerDelta;
565
+ return (left.model ?? "").localeCompare(right.model ?? "");
566
+ });
567
+ }
568
+ function decisionSection(metrics, prices) {
569
+ return groupDecisions(metrics).map((group) => {
570
+ const card = prices ? cardFor(prices, group.provider, group.model) : null;
571
+ const cost = prices && card ? costFor(group.totals, card, prices.usd_to_inr) : { usd: "n/a", inr: "n/a" };
572
+ return [
573
+ group.workType,
574
+ group.kind,
575
+ group.provider,
576
+ modelCell(group.model),
577
+ String(group.totals.requests),
578
+ cell(group.totals.input),
579
+ cell(group.totals.cacheRead),
580
+ cell(group.totals.cacheWrite),
581
+ cell(group.totals.output),
582
+ cost.usd,
583
+ cost.inr
584
+ ].join(" ");
585
+ });
586
+ }
482
587
  function requestUsdNumerator(request, card) {
483
588
  const parts = [
484
589
  [request.inputTokens, card.input_per_million_usd],
@@ -535,8 +640,8 @@ function compareChangesByFirstPhase(left, right) {
535
640
  }
536
641
  async function readHandoffCodePoints(projectRoot) {
537
642
  try {
538
- const text = await readText(join(sddRoot(projectRoot), "handoff.md"));
539
- return Array.from(text).length;
643
+ const text2 = await readText(join(sddRoot(projectRoot), "handoff.md"));
644
+ return Array.from(text2).length;
540
645
  } catch (err) {
541
646
  const code = typeof err === "object" && err !== null && "code" in err ? err.code : void 0;
542
647
  if (code === "ENOENT") return null;
@@ -612,13 +717,13 @@ metrics.json is invalid`
612
717
  import { join as join2 } from "pathe";
613
718
  var VERIFY_MARKER = /<!-- sdd:verify stage="([^"]+)" head_sha="(?:none|[0-9a-f]{40}|[0-9a-f]{64})" result_count="(\d+)" -->/gi;
614
719
  async function syncChangeHead(projectRoot, config, changeId, opts) {
615
- const headSha = opts?.headSha !== void 0 ? opts.headSha : await getHeadSha(projectRoot);
616
- if (!headSha) throw new Error("Cannot sync: Git HEAD is unavailable in this project.");
720
+ const headSha2 = opts?.headSha !== void 0 ? opts.headSha : await getHeadSha(projectRoot);
721
+ if (!headSha2) throw new Error("Cannot sync: Git HEAD is unavailable in this project.");
617
722
  const ctx = await buildContext(projectRoot, config, changeId);
618
723
  let updatedResults = 0;
619
- for (const result of Object.values(ctx.meta.verify_results ?? {})) {
620
- if (!result.results.length || result.head_sha === headSha) continue;
621
- result.head_sha = headSha;
724
+ for (const result2 of Object.values(ctx.meta.verify_results ?? {})) {
725
+ if (!result2.results.length || result2.head_sha === headSha2) continue;
726
+ result2.head_sha = headSha2;
622
727
  updatedResults += 1;
623
728
  }
624
729
  if (updatedResults) await saveChangeMeta(projectRoot, config, ctx.meta);
@@ -631,7 +736,7 @@ async function syncChangeHead(projectRoot, config, changeId, opts) {
631
736
  if (markers.length) {
632
737
  const updated = report.replace(
633
738
  VERIFY_MARKER,
634
- (_marker, stage, resultCount) => `<!-- sdd:verify stage="${stage}" head_sha="${headSha}" result_count="${resultCount}" -->`
739
+ (_marker, stage, resultCount) => `<!-- sdd:verify stage="${stage}" head_sha="${headSha2}" result_count="${resultCount}" -->`
635
740
  );
636
741
  if (updated !== report) {
637
742
  await writeText(reportPath, updated);
@@ -641,20 +746,144 @@ async function syncChangeHead(projectRoot, config, changeId, opts) {
641
746
  reportPreserved = true;
642
747
  }
643
748
  }
644
- return { changeId, headSha, updatedResults, reportUpdated, reportPreserved };
749
+ return { changeId, headSha: headSha2, updatedResults, reportUpdated, reportPreserved };
750
+ }
751
+
752
+ // src/token-inventory.ts
753
+ import { join as join3 } from "pathe";
754
+ async function readIfExists(path) {
755
+ if (!await pathExists(path)) return null;
756
+ return readText(path);
757
+ }
758
+ async function workflowFileTokens(projectRoot, name) {
759
+ const dir = workflowsDir(projectRoot);
760
+ for (const ext of ["yaml", "yml"]) {
761
+ const text2 = await readIfExists(join3(dir, `${name}.${ext}`));
762
+ if (text2 !== null) return countTokens(text2);
763
+ }
764
+ return null;
765
+ }
766
+ async function templateBody(projectRoot, artifact) {
767
+ const dir = templatesDir(projectRoot);
768
+ const names = artifact.template ? [artifact.template, artifact.template.replace(/^templates\//, ""), `${artifact.id}.md`] : [`${artifact.id}.md`];
769
+ const seen = /* @__PURE__ */ new Set();
770
+ for (const name of names) {
771
+ const path = join3(dir, name);
772
+ if (seen.has(path)) continue;
773
+ seen.add(path);
774
+ const text2 = await readIfExists(path);
775
+ if (text2 !== null) return text2;
776
+ }
777
+ return null;
778
+ }
779
+ function pushArtifactLines(lines, stageId, path, label) {
780
+ lines.push(` ${stageId} ${path} ${label}`);
781
+ const tokens = Number(label);
782
+ return Number.isInteger(tokens) && String(tokens) === label ? tokens : 0;
783
+ }
784
+ async function workflowLines(projectRoot, name) {
785
+ let workflow;
786
+ try {
787
+ workflow = await loadWorkflow(projectRoot, name);
788
+ } catch {
789
+ return [`${name} unreadable`];
790
+ }
791
+ const fileTokens = await workflowFileTokens(projectRoot, name);
792
+ const lines = [`${name} workflow ${fileTokens === null ? "missing" : fileTokens}`];
793
+ let total = 0;
794
+ let any = false;
795
+ for (const stage of workflow.stages) {
796
+ for (const artifact of stage.artifacts) {
797
+ any = true;
798
+ if (artifact.path.endsWith("/")) {
799
+ total += pushArtifactLines(lines, stage.id, artifact.path, "directory");
800
+ continue;
801
+ }
802
+ const body = await templateBody(projectRoot, artifact);
803
+ total += pushArtifactLines(
804
+ lines,
805
+ stage.id,
806
+ artifact.path,
807
+ body === null ? "missing" : String(countTokens(body))
808
+ );
809
+ }
810
+ }
811
+ lines.push(any ? ` artifacts ${total}` : " (no artifacts)");
812
+ return lines;
813
+ }
814
+ async function changeLines(projectRoot, config, id) {
815
+ const dir = changePath(projectRoot, config, id);
816
+ let workflowName = "";
817
+ let workflow;
818
+ try {
819
+ const meta = ChangeMetaSchema.parse(await readYaml(join3(dir, "meta.yaml")));
820
+ workflowName = meta.workflow;
821
+ workflow = await loadWorkflow(projectRoot, meta.workflow);
822
+ } catch {
823
+ return [`Change ${id} unreadable`];
824
+ }
825
+ const lines = [`Change ${id} workflow ${workflowName}`];
826
+ let total = 0;
827
+ let any = false;
828
+ for (const stage of workflow.stages) {
829
+ for (const artifact of stage.artifacts) {
830
+ any = true;
831
+ if (artifact.path.endsWith("/")) {
832
+ const folder = join3(dir, artifact.path);
833
+ const names = (await listFiles(folder)).sort();
834
+ if (names.length === 0) {
835
+ total += pushArtifactLines(lines, stage.id, artifact.path, "directory");
836
+ continue;
837
+ }
838
+ for (const name of names) {
839
+ const text3 = await readText(join3(folder, name));
840
+ total += pushArtifactLines(
841
+ lines,
842
+ stage.id,
843
+ `${artifact.path}${name}`,
844
+ String(countTokens(text3))
845
+ );
846
+ }
847
+ continue;
848
+ }
849
+ const text2 = await readIfExists(join3(dir, artifact.path));
850
+ total += pushArtifactLines(
851
+ lines,
852
+ stage.id,
853
+ artifact.path,
854
+ text2 === null ? "not written" : String(countTokens(text2))
855
+ );
856
+ }
857
+ }
858
+ lines.push(any ? ` artifacts ${total}` : " (no artifacts)");
859
+ return lines;
860
+ }
861
+ async function formatTokenInventory(projectRoot, config) {
862
+ const lines = ["Tokens (cl100k):"];
863
+ const names = await listWorkflowNames(projectRoot);
864
+ if (names.length === 0) lines.push("No workflows.");
865
+ for (const name of names) {
866
+ lines.push(...await workflowLines(projectRoot, name));
867
+ }
868
+ const active = await getActiveChangeId(projectRoot, config);
869
+ if (active) {
870
+ lines.push("");
871
+ lines.push(...await changeLines(projectRoot, config, active));
872
+ }
873
+ return lines.join("\n");
645
874
  }
646
875
 
647
876
  // src/init.ts
648
877
  import { spawn } from "child_process";
649
878
  import { lstat } from "fs/promises";
650
- import { join as join3 } from "pathe";
879
+ import { join as join4 } from "pathe";
651
880
  async function initProject(opts) {
652
881
  const { projectRoot, force = false } = opts;
653
882
  const created = [];
654
883
  const kept = [];
655
884
  const warnings = [];
656
885
  const root = sddRoot(projectRoot);
657
- const configFile = join3(root, "config.yaml");
886
+ const configFile = join4(root, "config.yaml");
658
887
  if (await pathExists(configFile) && !force) {
659
888
  await repairRequiredIgnores(projectRoot);
660
889
  return { created, config: await loadConfig(projectRoot), kept, warnings };
@@ -670,12 +899,12 @@ async function initProject(opts) {
670
899
  const config = await pathExists(configFile) ? mergeConfigDefaults(await readYaml(configFile)) : defaultConfig();
671
900
  if (opts.layoutRoot !== void 0) {
672
901
  const layoutRoot = validateLayoutRoot(opts.layoutRoot);
673
- config.memory_path = join3(layoutRoot, "memory");
674
- config.changes_path = join3(layoutRoot, "changes");
675
- config.domains_path = join3(layoutRoot, "domains");
676
- config.archive_path = join3(layoutRoot, "archive");
902
+ config.memory_path = join4(layoutRoot, "memory");
903
+ config.changes_path = join4(layoutRoot, "changes");
904
+ config.domains_path = join4(layoutRoot, "domains");
905
+ config.archive_path = join4(layoutRoot, "archive");
677
906
  }
678
- const manifestPath = join3(root, ".generated.json");
907
+ const manifestPath = join4(root, ".generated.json");
679
908
  const manifestExisted = await pathExists(manifestPath);
680
909
  const manifest = await loadGeneratedManifest(manifestPath);
681
910
  if (opts.agents) {
@@ -683,11 +912,11 @@ async function initProject(opts) {
683
912
  }
684
913
  await ensureDir(root);
685
914
  await saveConfig(projectRoot, config);
686
- created.push(join3(root, "config.yaml"));
915
+ created.push(join4(root, "config.yaml"));
687
916
  const wfDest = workflowsDir(projectRoot);
688
917
  const wfSrc = defaultWorkflowsDir();
689
918
  if (await pathExists(wfSrc)) {
690
- const result = await reconcileGeneratedTree(
919
+ const result2 = await reconcileGeneratedTree(
691
920
  projectRoot,
692
921
  wfSrc,
693
922
  wfDest,
@@ -695,7 +924,7 @@ async function initProject(opts) {
695
924
  manifest,
696
925
  force
697
926
  );
698
- kept.push(...result.kept);
927
+ kept.push(...result2.kept);
699
928
  created.push(wfDest);
700
929
  } else {
701
930
  await ensureDir(wfDest);
@@ -703,7 +932,7 @@ async function initProject(opts) {
703
932
  const tDest = templatesDir(projectRoot);
704
933
  const tSrc = defaultTemplatesDir();
705
934
  if (await pathExists(tSrc)) {
706
- const result = await reconcileGeneratedTree(
935
+ const result2 = await reconcileGeneratedTree(
707
936
  projectRoot,
708
937
  tSrc,
709
938
  tDest,
@@ -711,7 +940,7 @@ async function initProject(opts) {
711
940
  manifest,
712
941
  force
713
942
  );
714
- kept.push(...result.kept);
943
+ kept.push(...result2.kept);
715
944
  created.push(tDest);
716
945
  } else {
717
946
  await ensureDir(tDest);
@@ -725,13 +954,13 @@ async function initProject(opts) {
725
954
  if (wrote) created.push(mcpDest);
726
955
  }
727
956
  const pricesSrc = defaultPricesYamlPath();
728
- const pricesDest = join3(root, "prices.yaml");
957
+ const pricesDest = join4(root, "prices.yaml");
729
958
  if (await pathExists(pricesSrc)) {
730
959
  const wrote = await writeTextIfMissing(pricesDest, await readText(pricesSrc));
731
960
  if (wrote) created.push(pricesDest);
732
961
  }
733
962
  const searchSrc = defaultSearchYamlPath();
734
- const searchDest = join3(root, "search.yaml");
963
+ const searchDest = join4(root, "search.yaml");
735
964
  if (await pathExists(searchSrc)) {
736
965
  const wrote = await writeTextIfMissing(searchDest, await readText(searchSrc));
737
966
  if (wrote) created.push(searchDest);
@@ -749,7 +978,7 @@ async function initProject(opts) {
749
978
  await copyDirSkipExisting(mSrc, mDest);
750
979
  } else {
751
980
  await writeTextIfMissing(
752
- join3(mDest, "index.md"),
981
+ join4(mDest, "index.md"),
753
982
  `# Documentation map
754
983
 
755
984
  Parent page for **stable** project docs. Start here, then open only what you need.
@@ -765,11 +994,11 @@ Active work: \`.sdd/active-context.md\` and the configured changes folder.
765
994
  `
766
995
  );
767
996
  await writeTextIfMissing(
768
- join3(mDest, "product.md"),
997
+ join4(mDest, "product.md"),
769
998
  "# Product\n\n<!-- What are you building? -->\n"
770
999
  );
771
1000
  await writeTextIfMissing(
772
- join3(mDest, "constitution.md"),
1001
+ join4(mDest, "constitution.md"),
773
1002
  `# Constitution
774
1003
 
775
1004
  <!-- Non-negotiables agents must not violate. Broader style lives in conventions.md. -->
@@ -796,21 +1025,21 @@ Active work: \`.sdd/active-context.md\` and the configured changes folder.
796
1025
  `
797
1026
  );
798
1027
  await writeTextIfMissing(
799
- join3(mDest, "architecture.md"),
1028
+ join4(mDest, "architecture.md"),
800
1029
  "# Architecture\n\n<!-- High-level system shape -->\n"
801
1030
  );
802
1031
  await writeTextIfMissing(
803
- join3(mDest, "conventions.md"),
1032
+ join4(mDest, "conventions.md"),
804
1033
  "# Conventions\n\n<!-- Coding and process conventions -->\n"
805
1034
  );
806
1035
  }
807
- if (!await pathExists(join3(mDest, "constitution.md"))) {
808
- const fromDefault = join3(mSrc, "constitution.md");
1036
+ if (!await pathExists(join4(mDest, "constitution.md"))) {
1037
+ const fromDefault = join4(mSrc, "constitution.md");
809
1038
  if (await pathExists(fromDefault)) {
810
1039
  await copyDirSkipExisting(mSrc, mDest);
811
1040
  } else {
812
1041
  await writeTextIfMissing(
813
- join3(mDest, "constitution.md"),
1042
+ join4(mDest, "constitution.md"),
814
1043
  `# Constitution
815
1044
 
816
1045
  <!-- Non-negotiables agents must not violate. Broader style lives in conventions.md. -->
@@ -843,10 +1072,10 @@ Active work: \`.sdd/active-context.md\` and the configured changes folder.
843
1072
  await ensureDir(domainsDir(projectRoot, config));
844
1073
  created.push(changesDir(projectRoot, config));
845
1074
  created.push(domainsDir(projectRoot, config));
846
- await writeTextIfMissing(join3(changesDir(projectRoot, config), ".gitkeep"), "");
847
- await writeTextIfMissing(join3(domainsDir(projectRoot, config), ".gitkeep"), "");
848
- const sddIgnore = join3(root, ".gitignore");
849
- const changesIgnore = join3(changesDir(projectRoot, config), ".gitignore");
1075
+ await writeTextIfMissing(join4(changesDir(projectRoot, config), ".gitkeep"), "");
1076
+ await writeTextIfMissing(join4(domainsDir(projectRoot, config), ".gitkeep"), "");
1077
+ const sddIgnore = join4(root, ".gitignore");
1078
+ const changesIgnore = join4(changesDir(projectRoot, config), ".gitignore");
850
1079
  if ((await ensureIgnoreEntries(sddIgnore, SDD_IGNORE_LINES)).created) {
851
1080
  created.push(sddIgnore);
852
1081
  }
@@ -867,7 +1096,7 @@ Active work: \`.sdd/active-context.md\` and the configured changes folder.
867
1096
  );
868
1097
  }
869
1098
  }
870
- const sddReadme = join3(root, "README.md");
1099
+ const sddReadme = join4(root, "README.md");
871
1100
  const readmeState = await pathExists(sddReadme) ? generatedState(await readText(sddReadme)) : null;
872
1101
  if (readmeState === "edited" || readmeState === "not-generated") {
873
1102
  kept.push({ path: ".sdd/README.md", reason: readmeState });
@@ -901,8 +1130,8 @@ var SDD_IGNORE_LINES = [
901
1130
  async function repairRequiredIgnores(projectRoot) {
902
1131
  const config = await loadConfig(projectRoot);
903
1132
  const root = sddRoot(projectRoot);
904
- const sdd = await ensureIgnoreEntries(join3(root, ".gitignore"), SDD_IGNORE_LINES);
905
- const changes = await ensureIgnoreEntries(join3(changesDir(projectRoot, config), ".gitignore"), [
1133
+ const sdd = await ensureIgnoreEntries(join4(root, ".gitignore"), SDD_IGNORE_LINES);
1134
+ const changes = await ensureIgnoreEntries(join4(changesDir(projectRoot, config), ".gitignore"), [
906
1135
  ".active"
907
1136
  ]);
908
1137
  return sdd.added || changes.added;
@@ -957,7 +1186,7 @@ async function listRelativeFiles(directory, prefix = "") {
957
1186
  );
958
1187
  const nested = await Promise.all(
959
1188
  (await listDirs(directory)).map(
960
- (name) => listRelativeFiles(join3(directory, name), [prefix, name].filter(Boolean).join("/"))
1189
+ (name) => listRelativeFiles(join4(directory, name), [prefix, name].filter(Boolean).join("/"))
961
1190
  )
962
1191
  );
963
1192
  return [...files, ...nested.flat()];
@@ -965,7 +1194,7 @@ async function listRelativeFiles(directory, prefix = "") {
965
1194
 
966
1195
  // src/verify.ts
967
1196
  import { spawn as spawn2 } from "child_process";
968
- import { join as join4 } from "pathe";
1197
+ import { join as join5 } from "pathe";
969
1198
  function runCommand(command, cwd) {
970
1199
  return new Promise((resolve) => {
971
1200
  const child = spawn2(command, {
@@ -1013,7 +1242,7 @@ var STATUS_LABEL = {
1013
1242
  fail: "FAIL",
1014
1243
  not_run: "NOT RUN"
1015
1244
  };
1016
- function buildSummaryLines(title, stageId, headSha, status, reason, results, checklist) {
1245
+ function buildSummaryLines(title, stageId, headSha2, status, reason, results, checklist) {
1017
1246
  const lines = [
1018
1247
  `<!-- sdd:verify-report:start -->`,
1019
1248
  ``,
@@ -1021,7 +1250,7 @@ function buildSummaryLines(title, stageId, headSha, status, reason, results, che
1021
1250
  ``,
1022
1251
  `> Change: ${title}`,
1023
1252
  `> Stage: ${stageId}`,
1024
- `<!-- sdd:verify stage="${stageId}" head_sha="${headSha ?? "none"}" result_count="${results.length}" -->`,
1253
+ `<!-- sdd:verify stage="${stageId}" head_sha="${headSha2 ?? "none"}" result_count="${results.length}" -->`,
1025
1254
  `> At: ${nowIso()}`,
1026
1255
  `> Overall: ${STATUS_LABEL[status]}${reason ? ` \u2014 ${reason}` : ""}`,
1027
1256
  ``,
@@ -1072,23 +1301,23 @@ async function runLocalVerify(projectRoot, config, changeId, opts) {
1072
1301
  ok: false
1073
1302
  };
1074
1303
  }
1075
- const headSha = opts?.headSha !== void 0 ? opts.headSha : await getHeadSha(projectRoot);
1304
+ const headSha2 = opts?.headSha !== void 0 ? opts.headSha : await getHeadSha(projectRoot);
1076
1305
  const anyRecordedSha = Object.entries(ctx.meta.verify_results ?? {}).find(
1077
1306
  ([, recorded]) => recorded.head_sha
1078
1307
  );
1079
- if (anyRecordedSha && !headSha) {
1308
+ if (anyRecordedSha && !headSha2) {
1080
1309
  throw new Error(
1081
- `Cannot validate recorded SHA in ${join4(ctx.path, "meta.yaml")} at verify_results.${anyRecordedSha[0]}.head_sha because Git HEAD is unavailable.`
1310
+ `Cannot validate recorded SHA in ${join5(ctx.path, "meta.yaml")} at verify_results.${anyRecordedSha[0]}.head_sha because Git HEAD is unavailable.`
1082
1311
  );
1083
1312
  }
1084
1313
  for (const [resultStage, recorded] of Object.entries(ctx.meta.verify_results ?? {})) {
1085
- if (recorded.head_sha && headSha && recorded.head_sha !== headSha) {
1314
+ if (recorded.head_sha && headSha2 && recorded.head_sha !== headSha2) {
1086
1315
  throw new Error(
1087
- `Stale SHA in ${join4(ctx.path, "meta.yaml")} at verify_results.${resultStage}.head_sha: recorded ${recorded.head_sha}, current HEAD ${headSha}. Run sdd sync ${changeId}.`
1316
+ `Stale SHA in ${join5(ctx.path, "meta.yaml")} at verify_results.${resultStage}.head_sha: recorded ${recorded.head_sha}, current HEAD ${headSha2}. Run sdd sync ${changeId}.`
1088
1317
  );
1089
1318
  }
1090
1319
  }
1091
- const summaryPath = join4(ctx.path, "local-test-results.md");
1320
+ const summaryPath = join5(ctx.path, "local-test-results.md");
1092
1321
  if (await pathExists(summaryPath)) {
1093
1322
  const existing = await readText(summaryPath);
1094
1323
  const markerPattern = /<!-- sdd:verify stage="([^"]+)" head_sha="(none|[0-9a-f]{40}|[0-9a-f]{64})" result_count="(\d+)" -->/gi;
@@ -1099,17 +1328,17 @@ async function runLocalVerify(projectRoot, config, changeId, opts) {
1099
1328
  const reportResult = ctx.meta.verify_results?.[reportStage];
1100
1329
  if (!reportResult) {
1101
1330
  throw new Error(
1102
- `Missing verification metadata in ${join4(ctx.path, "meta.yaml")} for ${summaryPath}:${line} (${reportStage}).`
1331
+ `Missing verification metadata in ${join5(ctx.path, "meta.yaml")} for ${summaryPath}:${line} (${reportStage}).`
1103
1332
  );
1104
1333
  }
1105
- if (reportSha !== "none" && !headSha) {
1334
+ if (reportSha !== "none" && !headSha2) {
1106
1335
  throw new Error(
1107
1336
  `Cannot validate SHA in ${summaryPath}:${line} because Git HEAD is unavailable.`
1108
1337
  );
1109
1338
  }
1110
- if (headSha && reportSha !== "none" && reportSha !== headSha) {
1339
+ if (headSha2 && reportSha !== "none" && reportSha !== headSha2) {
1111
1340
  throw new Error(
1112
- `Stale SHA in ${summaryPath}:${line}: recorded ${reportSha}, current HEAD ${headSha}. Run sdd sync ${changeId}.`
1341
+ `Stale SHA in ${summaryPath}:${line}: recorded ${reportSha}, current HEAD ${headSha2}. Run sdd sync ${changeId}.`
1113
1342
  );
1114
1343
  }
1115
1344
  if (reportResult && Number(marker[3]) !== reportResult.results.length) {
@@ -1119,13 +1348,13 @@ async function runLocalVerify(projectRoot, config, changeId, opts) {
1119
1348
  }
1120
1349
  if (reportResult?.head_sha && reportSha !== reportResult.head_sha) {
1121
1350
  throw new Error(
1122
- `SHA mismatch between ${join4(ctx.path, "meta.yaml")} and ${summaryPath}:${line}.`
1351
+ `SHA mismatch between ${join5(ctx.path, "meta.yaml")} and ${summaryPath}:${line}.`
1123
1352
  );
1124
1353
  }
1125
1354
  }
1126
1355
  }
1127
1356
  const evidenceDirRaw = stage.verify?.evidence_dir?.trim();
1128
- const evidenceDir = evidenceDirRaw && evidenceDirRaw.length > 0 ? join4(ctx.path, evidenceDirRaw) : null;
1357
+ const evidenceDir = evidenceDirRaw && evidenceDirRaw.length > 0 ? join5(ctx.path, evidenceDirRaw) : null;
1129
1358
  if (evidenceDir) {
1130
1359
  await ensureDir(evidenceDir);
1131
1360
  }
@@ -1142,7 +1371,7 @@ async function runLocalVerify(projectRoot, config, changeId, opts) {
1142
1371
  output: output.slice(0, 5e4)
1143
1372
  });
1144
1373
  if (evidenceDir) {
1145
- const logPath = join4(evidenceDir, `${cmd.name.replace(/\s+/g, "-")}.log`);
1374
+ const logPath = join5(evidenceDir, `${cmd.name.replace(/\s+/g, "-")}.log`);
1146
1375
  await writeText(logPath, `# ${cmd.name}
1147
1376
  $ ${cmd.run}
1148
1377
  exit: ${exitCode}
@@ -1164,12 +1393,12 @@ ${output}`);
1164
1393
  status = failed.length ? "fail" : "pass";
1165
1394
  reason = failed.length ? `failed: ${failed.join(", ")}` : void 0;
1166
1395
  }
1167
- const ok4 = status === "pass";
1396
+ const ok8 = status === "pass";
1168
1397
  const checklist = stage.gate?.checklist ?? [];
1169
1398
  const lines = buildSummaryLines(
1170
1399
  ctx.meta.title,
1171
1400
  stage.id,
1172
- results.length ? headSha : null,
1401
+ results.length ? headSha2 : null,
1173
1402
  status,
1174
1403
  reason,
1175
1404
  results,
@@ -1191,7 +1420,7 @@ ${output}`);
1191
1420
  ``,
1192
1421
  `> Overall: ${STATUS_LABEL[status]}${reason ? ` \u2014 ${reason}` : ""}`,
1193
1422
  `> Stage: ${stage.id}`,
1194
- `<!-- sdd:verify stage="${stage.id}" head_sha="${results.length ? headSha ?? "none" : "none"}" result_count="${results.length}" -->`,
1423
+ `<!-- sdd:verify stage="${stage.id}" head_sha="${results.length ? headSha2 ?? "none" : "none"}" result_count="${results.length}" -->`,
1195
1424
  ``
1196
1425
  ];
1197
1426
  if (results.length === 0) {
@@ -1207,7 +1436,7 @@ ${output}`);
1207
1436
  }
1208
1437
  if (evidenceDir) {
1209
1438
  const stamp = nowIso();
1210
- await writeText(join4(evidenceDir, `run-${stamp.replace(/[:.]/g, "-")}.md`), summaryBody);
1439
+ await writeText(join5(evidenceDir, `run-${stamp.replace(/[:.]/g, "-")}.md`), summaryBody);
1211
1440
  }
1212
1441
  } else {
1213
1442
  await writeText(summaryPath, summaryBody);
@@ -1215,9 +1444,9 @@ ${output}`);
1215
1444
  ctx.meta.verify_results = {
1216
1445
  ...ctx.meta.verify_results,
1217
1446
  [stage.id]: {
1218
- ok: ok4,
1447
+ ok: ok8,
1219
1448
  status,
1220
- ...results.length && headSha ? { head_sha: headSha } : {},
1449
+ ...results.length && headSha2 ? { head_sha: headSha2 } : {},
1221
1450
  at: nowIso(),
1222
1451
  results: results.map((r) => ({ name: r.name, exitCode: r.exitCode }))
1223
1452
  }
@@ -1231,17 +1460,17 @@ ${output}`);
1231
1460
  checklist,
1232
1461
  evidencePath: evidenceDir,
1233
1462
  resultsPath: summaryPath,
1234
- ok: ok4
1463
+ ok: ok8
1235
1464
  };
1236
1465
  }
1237
1466
 
1238
1467
  // src/code-context/index.ts
1239
1468
  import { stat } from "fs/promises";
1240
- import { extname as extname3, isAbsolute as isAbsolute2, join as join6, relative as relative2 } from "pathe";
1469
+ import { extname as extname3, isAbsolute as isAbsolute2, join as join7, relative as relative2 } from "pathe";
1241
1470
 
1242
1471
  // src/code-context/focus.ts
1243
1472
  import { promises as fs } from "fs";
1244
- import { isAbsolute, join as join5, normalize, relative } from "pathe";
1473
+ import { isAbsolute, join as join6, normalize, relative } from "pathe";
1245
1474
  var KEYWORD_STOPWORDS = new Set(
1246
1475
  [
1247
1476
  "a",
@@ -1304,8 +1533,8 @@ var KEYWORD_STOPWORDS = new Set(
1304
1533
  );
1305
1534
  var SOURCE_ROOTS = ["packages", "src", "lib"];
1306
1535
  var PATH_LIKE_RE = /(?:packages|src|lib)\/[\w./@+-]+\.\w{1,10}|`([^`]+\.\w{1,10})`|([\w./@+-]+\.(?:ts|tsx|js|jsx|mts|cts|mjs|cjs))/g;
1307
- function tokenizeKeywords(text) {
1308
- const raw = text.toLowerCase().replace(/[^a-z0-9_./-]+/g, " ").split(/\s+/).filter(Boolean);
1536
+ function tokenizeKeywords(text2) {
1537
+ const raw = text2.toLowerCase().replace(/[^a-z0-9_./-]+/g, " ").split(/\s+/).filter(Boolean);
1309
1538
  const out = [];
1310
1539
  const seen = /* @__PURE__ */ new Set();
1311
1540
  for (const t of raw) {
@@ -1318,12 +1547,12 @@ function tokenizeKeywords(text) {
1318
1547
  }
1319
1548
  return out;
1320
1549
  }
1321
- function extractPathLikeTokens(text) {
1550
+ function extractPathLikeTokens(text2) {
1322
1551
  const found = [];
1323
1552
  const seen = /* @__PURE__ */ new Set();
1324
1553
  let m;
1325
1554
  const re = new RegExp(PATH_LIKE_RE.source, "g");
1326
- while ((m = re.exec(text)) !== null) {
1555
+ while ((m = re.exec(text2)) !== null) {
1327
1556
  const p = (m[1] || m[2] || m[0]).replace(/`/g, "");
1328
1557
  if (!p || seen.has(p)) continue;
1329
1558
  seen.add(p);
@@ -1332,12 +1561,12 @@ function extractPathLikeTokens(text) {
1332
1561
  return found;
1333
1562
  }
1334
1563
  function toRepoRelative(projectRoot, p) {
1335
- const abs = isAbsolute(p) ? p : join5(projectRoot, p);
1564
+ const abs = isAbsolute(p) ? p : join6(projectRoot, p);
1336
1565
  const rel = relative(projectRoot, abs);
1337
1566
  return normalize(rel).replace(/\\/g, "/");
1338
1567
  }
1339
1568
  function toAbsolute(projectRoot, p) {
1340
- return isAbsolute(p) ? p : join5(projectRoot, p);
1569
+ return isAbsolute(p) ? p : join6(projectRoot, p);
1341
1570
  }
1342
1571
  async function walkFiles(absDir, projectRoot, maxFiles, out, gaps) {
1343
1572
  if (out.length >= maxFiles) return;
@@ -1350,7 +1579,7 @@ async function walkFiles(absDir, projectRoot, maxFiles, out, gaps) {
1350
1579
  }
1351
1580
  for (const entry of entries) {
1352
1581
  if (out.length >= maxFiles) return;
1353
- const abs = join5(absDir, entry.name);
1582
+ const abs = join6(absDir, entry.name);
1354
1583
  const rel = toRepoRelative(projectRoot, abs);
1355
1584
  const kind = classifyPath(rel);
1356
1585
  if (kind === "secret" || kind === "ignored") continue;
@@ -1415,8 +1644,8 @@ async function collectUnderPaths(projectRoot, paths, maxWalk, gaps) {
1415
1644
  async function readBounded(absPath, maxChars) {
1416
1645
  if (!await pathExists(absPath)) return null;
1417
1646
  try {
1418
- const text = await readText(absPath);
1419
- return text.slice(0, maxChars);
1647
+ const text2 = await readText(absPath);
1648
+ return text2.slice(0, maxChars);
1420
1649
  } catch {
1421
1650
  return null;
1422
1651
  }
@@ -1444,7 +1673,7 @@ async function resolveChangeMeta(projectRoot, changeId, gaps) {
1444
1673
  return null;
1445
1674
  }
1446
1675
  const cpath = changePath(projectRoot, config, id);
1447
- const metaPath = join5(cpath, "meta.yaml");
1676
+ const metaPath = join6(cpath, "meta.yaml");
1448
1677
  if (!await pathExists(metaPath)) {
1449
1678
  if (!await pathExists(cpath)) {
1450
1679
  gaps.push({
@@ -1477,7 +1706,7 @@ async function searchByKeywords(projectRoot, keywords, symbols, maxFiles) {
1477
1706
  const terms = [...keywords, ...symbols.map((s) => s.toLowerCase())];
1478
1707
  if (!terms.length) return out;
1479
1708
  for (const root of SOURCE_ROOTS) {
1480
- const abs = join5(projectRoot, root);
1709
+ const abs = join6(projectRoot, root);
1481
1710
  if (!await pathExists(abs)) continue;
1482
1711
  const files = [];
1483
1712
  await walkFiles(abs, projectRoot, maxFiles * 4, files, []);
@@ -1491,8 +1720,8 @@ async function searchByKeywords(projectRoot, keywords, symbols, maxFiles) {
1491
1720
  }
1492
1721
  if (symbols.length) {
1493
1722
  try {
1494
- const text = await readText(join5(projectRoot, rel));
1495
- if (symbols.some((s) => text.includes(s))) {
1723
+ const text2 = await readText(join6(projectRoot, rel));
1724
+ if (symbols.some((s) => text2.includes(s))) {
1496
1725
  out.push(rel);
1497
1726
  }
1498
1727
  } catch {
@@ -1527,7 +1756,7 @@ async function resolveFocus(request, options) {
1527
1756
  }
1528
1757
  notes.push(`Change signals from: ${change.id}`);
1529
1758
  for (const artifact of ["tasks.md", "feature.md", "stories.md", "lld.md"]) {
1530
- const body = await readBounded(join5(change.path, artifact), 8e3);
1759
+ const body = await readBounded(join6(change.path, artifact), 8e3);
1531
1760
  if (!body) continue;
1532
1761
  if (!request.paths?.length) {
1533
1762
  for (const p of extractPathLikeTokens(body)) {
@@ -1644,10 +1873,10 @@ function formatMarkdown(input) {
1644
1873
  `;
1645
1874
  return { markdown: packed ? frame + packed : frame, truncated: input.summary.truncated };
1646
1875
  }
1647
- function formatJsonSummary(summary, gaps, slices, ok4) {
1876
+ function formatJsonSummary(summary, gaps, slices, ok8) {
1648
1877
  return JSON.stringify(
1649
1878
  {
1650
- ok: ok4,
1879
+ ok: ok8,
1651
1880
  summary,
1652
1881
  gaps,
1653
1882
  slices: slices.map((s) => ({
@@ -1938,7 +2167,7 @@ function hardFailure(gaps, message, summary) {
1938
2167
  };
1939
2168
  }
1940
2169
  function toRepoPath(projectRoot, input) {
1941
- const abs = isAbsolute2(input) ? input : join6(projectRoot, input);
2170
+ const abs = isAbsolute2(input) ? input : join7(projectRoot, input);
1942
2171
  let rel = relative2(projectRoot, abs).replaceAll("\\", "/");
1943
2172
  while (rel.startsWith("./")) rel = rel.slice(2);
1944
2173
  return rel;
@@ -1979,7 +2208,7 @@ async function generateCodeContext(request, deps) {
1979
2208
  let changeTitle = plan.changeId;
1980
2209
  let stage;
1981
2210
  if (request.changeId) {
1982
- const mp = join6(changePath(projectRoot, config, request.changeId), "meta.yaml");
2211
+ const mp = join7(changePath(projectRoot, config, request.changeId), "meta.yaml");
1983
2212
  if (await pathExists(mp)) {
1984
2213
  const meta = await readYaml(mp);
1985
2214
  changeTitle = meta.title ?? request.changeId;
@@ -1996,7 +2225,7 @@ async function generateCodeContext(request, deps) {
1996
2225
  let query = request.query?.trim() ? request.query : "";
1997
2226
  if (!query && request.symbols?.length) query = request.symbols.join(" ");
1998
2227
  if (!query && changeTitle && request.changeId) query = changeTitle;
1999
- const activeChange = await resolveActiveDirectory(projectRoot, config);
2228
+ const activeChange2 = await resolveActiveDirectory(projectRoot, config);
2000
2229
  const cacheRequest = {
2001
2230
  projectRoot,
2002
2231
  query: query.trim() ? query : "",
@@ -2005,7 +2234,7 @@ async function generateCodeContext(request, deps) {
2005
2234
  k,
2006
2235
  budget,
2007
2236
  embeddings: searchConfig.embeddings,
2008
- activeChange,
2237
+ activeChange: activeChange2,
2009
2238
  filterPaths: request.paths ?? [],
2010
2239
  filterSymbols: request.symbols ?? [],
2011
2240
  scopes: ["code"]
@@ -2041,7 +2270,7 @@ async function generateCodeContext(request, deps) {
2041
2270
  const rel = toRepoPath(projectRoot, input);
2042
2271
  let info = null;
2043
2272
  try {
2044
- info = await stat(join6(projectRoot, rel));
2273
+ info = await stat(join7(projectRoot, rel));
2045
2274
  } catch {
2046
2275
  info = null;
2047
2276
  }
@@ -2206,7 +2435,7 @@ async function generateCodeContext(request, deps) {
2206
2435
  }
2207
2436
 
2208
2437
  // src/refine.ts
2209
- import { join as join7, relative as relative3 } from "pathe";
2438
+ import { join as join8, relative as relative3 } from "pathe";
2210
2439
  function stageArtifactRefs(ctx, stageId) {
2211
2440
  const stage = getStage(ctx.workflow, ctx.meta, stageId);
2212
2441
  if (!stage) return [];
@@ -2214,7 +2443,7 @@ function stageArtifactRefs(ctx, stageId) {
2214
2443
  stageId,
2215
2444
  path: a.path,
2216
2445
  required: Boolean(a.required),
2217
- absPath: join7(ctx.path, a.path)
2446
+ absPath: join8(ctx.path, a.path)
2218
2447
  }));
2219
2448
  }
2220
2449
  function listMarkdown(refs, displayDir) {
@@ -2255,11 +2484,11 @@ async function buildRefinePlan(opts) {
2255
2484
  laterArtifacts.push(...stageArtifactRefs(ctx, s.id));
2256
2485
  }
2257
2486
  const focusArtifacts = stageArtifactRefs(ctx, focusStageId);
2258
- const constitutionPath = join7(memoryDir(opts.projectRoot, opts.config), "constitution.md");
2487
+ const constitutionPath = join8(memoryDir(opts.projectRoot, opts.config), "constitution.md");
2259
2488
  const constitutionExists = await pathExists(constitutionPath);
2260
- const briefPath = join7(ctx.path, "refine-brief.md");
2261
- const reportPath = join7(ctx.path, "quality-report.md");
2262
- const openItemsPath = join7(ctx.path, "open-items.md");
2489
+ const briefPath = join8(ctx.path, "refine-brief.md");
2490
+ const reportPath = join8(ctx.path, "quality-report.md");
2491
+ const openItemsPath = join8(ctx.path, "open-items.md");
2263
2492
  const paths = guidancePaths(opts.config);
2264
2493
  const briefRel = `${ctx.displayPath}/refine-brief.md`;
2265
2494
  const reportRel = `${ctx.displayPath}/quality-report.md`;
@@ -2396,7 +2625,7 @@ Do **not** invent content for later stages that were not materialized yet. If a
2396
2625
  }
2397
2626
  async function writeRefineBrief(plan, projectRoot) {
2398
2627
  await writeText(plan.briefPath, plan.briefMarkdown);
2399
- const pointer = join7(sddRoot(projectRoot), "refine-brief.md");
2628
+ const pointer = join8(sddRoot(projectRoot), "refine-brief.md");
2400
2629
  const briefRel = relative3(projectRoot, plan.briefPath);
2401
2630
  await writeText(
2402
2631
  pointer,
@@ -2412,310 +2641,1740 @@ Run: \`sdd refine${plan.focusStageId ? ` ${plan.focusStageId}` : ""}\`
2412
2641
  }
2413
2642
 
2414
2643
  // src/role/handoff.ts
2415
- import { join as join10 } from "pathe";
2416
- import { parse as parseYaml3, stringify as stringifyYaml2 } from "yaml";
2644
+ import { join as join16 } from "pathe";
2645
+ import { parse as parseYaml3, stringify as stringifyYaml } from "yaml";
2417
2646
 
2418
- // src/role/profile.ts
2419
- import { readFile, writeFile } from "fs/promises";
2420
- import { join as join8 } from "pathe";
2421
- import { parse as parseYaml2, stringify as stringifyYaml } from "yaml";
2647
+ // src/role/routing.ts
2648
+ import { join as join15 } from "pathe";
2649
+ import { parse as parseYaml2 } from "yaml";
2650
+ import { z as z6 } from "zod";
2422
2651
 
2423
- // src/role/types.ts
2424
- var ROLE_TEXT = {
2425
- needsJob: "A role needs a job.",
2426
- notFound: "Role not found.",
2427
- exists: "Role already exists.",
2428
- badName: "A role name is not valid.",
2429
- self: "A role cannot review itself.",
2430
- x: "X is not a role tool.",
2431
- needsSection: "This needs an accepted section before a draft.",
2432
- noModel: "No role model is configured.",
2433
- cannot: "A role cannot do that.",
2434
- askFirst: "Ask first. This stays stopped.",
2435
- choice: "Allow once, Always allow, or Deny.",
2436
- unavailable: "This action is not available in this slice.",
2437
- expired: "Expired. The action did not run.",
2438
- runNotFound: "Run not found.",
2439
- badYaml: "role.yaml is invalid"
2440
- };
2652
+ // src/role/models/decide.ts
2653
+ import { z as z2 } from "zod";
2441
2654
 
2442
- // src/role/profile.ts
2443
- var NAME_LIMIT = 80;
2444
- function canonicalRoleName(input) {
2445
- const name = input.trim();
2446
- if (!name || name === "." || name === "..") return null;
2447
- if (name.length > NAME_LIMIT) return null;
2448
- if (name.includes("/") || name.includes("\\") || name.includes("\0")) return null;
2449
- return name;
2450
- }
2451
- function roleFilePath(root, name) {
2452
- return join8(root, ".sdd", "roles", `${name}.md`);
2453
- }
2454
- function conversationDir(root, name) {
2455
- return join8(root, ".sdd", "role-runs", "conversation", name);
2456
- }
2457
- function memoryDir2(root, name) {
2458
- return join8(root, ".sdd", "role-runs", "memory", name);
2459
- }
2460
- function allowFilePath(root) {
2461
- return join8(root, ".sdd", "role-runs", "allow.json");
2655
+ // src/role/models/command.ts
2656
+ import { spawn as spawn3 } from "child_process";
2657
+ function decideStdin(input) {
2658
+ return `${JSON.stringify({
2659
+ mode: "decide",
2660
+ state: input.state,
2661
+ question: input.question,
2662
+ options: input.options
2663
+ })}
2664
+ `;
2462
2665
  }
2463
- function ok(stdout = "") {
2464
- return { exitCode: 0, stdout };
2666
+ function spawnModelCommand(command, stdin) {
2667
+ return new Promise((resolve, reject) => {
2668
+ const child = spawn3(command, { shell: true, stdio: ["pipe", "pipe", "ignore"] });
2669
+ const out = [];
2670
+ child.stdout?.on("data", (chunk) => out.push(chunk));
2671
+ child.on("error", reject);
2672
+ child.stdin?.on("error", () => void 0);
2673
+ child.on("close", (code) => {
2674
+ if (code !== 0) {
2675
+ reject(new Error(`role model exited ${code ?? "unknown"}`));
2676
+ return;
2677
+ }
2678
+ resolve(Buffer.concat(out).toString("utf8").trim());
2679
+ });
2680
+ child.stdin?.write(stdin);
2681
+ child.stdin?.end();
2682
+ });
2465
2683
  }
2466
- function fail(stdout) {
2467
- return { exitCode: 1, stdout };
2684
+ function modelFromEnv(env) {
2685
+ const command = env.SDD_ROLE_MODEL?.trim();
2686
+ if (!command) return void 0;
2687
+ return {
2688
+ async complete(input) {
2689
+ const text2 = await spawnModelCommand(
2690
+ command,
2691
+ `${JSON.stringify({ roleId: input.roleId, prompt: input.prompt })}
2692
+ `
2693
+ );
2694
+ let parsed;
2695
+ try {
2696
+ parsed = JSON.parse(text2);
2697
+ } catch {
2698
+ throw new Error("role model stdout is not JSON");
2699
+ }
2700
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2701
+ throw new Error("role model stdout is not a JSON object");
2702
+ }
2703
+ return text2;
2704
+ }
2705
+ };
2468
2706
  }
2469
- function asText(value) {
2470
- return typeof value === "string" ? value.trim() : "";
2707
+
2708
+ // src/role/models/ollama.ts
2709
+ function count(value) {
2710
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
2471
2711
  }
2472
- function parseRoleDocument(name, raw) {
2473
- const text = raw.replace(/^\uFEFF/, "");
2474
- const nl = text.indexOf("\n");
2475
- if (nl < 0 || text.slice(0, nl).trim() !== "---") return null;
2476
- const rest = text.slice(nl + 1);
2477
- const close = /\r?\n---\s*(?:\r?\n|$)/.exec(rest);
2478
- if (!close) return null;
2479
- let data;
2712
+ function chatUrl(base) {
2480
2713
  try {
2481
- data = parseYaml2(rest.slice(0, close.index));
2714
+ const trimmed = base.trim();
2715
+ const absolute = trimmed !== "" && !/^https?:/i.test(trimmed) ? `http://${trimmed}` : trimmed;
2716
+ const withSlash = absolute.endsWith("/") ? absolute : `${absolute}/`;
2717
+ return new URL("api/chat", withSlash).toString();
2482
2718
  } catch {
2483
2719
  return null;
2484
2720
  }
2485
- if (!data || typeof data !== "object" || Array.isArray(data)) return null;
2486
- const rec = data;
2487
- const body = rest.slice(close.index + close[0].length).trim();
2488
- return {
2489
- name,
2490
- label: asText(rec.label),
2491
- job: asText(rec.job),
2492
- description: body,
2493
- askFirst: rec.ask_first === true,
2494
- pinned: rec.pinned === true,
2495
- hidden: rec.hidden === true
2496
- };
2497
2721
  }
2498
- function renderRole(role) {
2499
- const front = stringifyYaml(
2500
- {
2501
- name: role.name,
2502
- label: role.label,
2503
- job: role.job,
2504
- ask_first: role.askFirst,
2505
- pinned: role.pinned,
2506
- hidden: role.hidden
2722
+ async function ollamaDecision(input) {
2723
+ const url = chatUrl(input.baseUrl);
2724
+ if (!url) {
2725
+ return {
2726
+ called: false,
2727
+ model: input.model,
2728
+ content: null,
2729
+ inputTokens: null,
2730
+ outputTokens: null
2731
+ };
2732
+ }
2733
+ const body = JSON.stringify({
2734
+ model: input.model,
2735
+ stream: false,
2736
+ format: {
2737
+ type: "object",
2738
+ properties: {
2739
+ pick: { type: "string" },
2740
+ probs: { type: "object", additionalProperties: { type: "number" } }
2741
+ },
2742
+ required: ["pick", "probs"]
2507
2743
  },
2508
- { lineWidth: 100 }
2509
- ).trimEnd();
2510
- const description = role.description.trim();
2511
- if (!description) return `---
2512
- ${front}
2513
- ---
2514
- `;
2515
- return `---
2516
- ${front}
2517
- ---
2518
- ${description}
2519
- `;
2520
- }
2521
- async function readRole(root, name) {
2522
- const canonical = canonicalRoleName(name);
2523
- if (!canonical) return null;
2524
- const path = roleFilePath(root, canonical);
2525
- if (!await pathExists(path)) return null;
2744
+ messages: [
2745
+ {
2746
+ role: "user",
2747
+ content: JSON.stringify({
2748
+ state: input.state,
2749
+ question: input.question,
2750
+ options: input.options
2751
+ })
2752
+ }
2753
+ ]
2754
+ });
2526
2755
  try {
2527
- return parseRoleDocument(canonical, await readText(path));
2756
+ const response = await input.fetch(url, {
2757
+ method: "POST",
2758
+ headers: { "content-type": "application/json" },
2759
+ body
2760
+ });
2761
+ const text2 = await response.text();
2762
+ if (!response.ok) {
2763
+ return {
2764
+ called: true,
2765
+ model: input.model,
2766
+ content: null,
2767
+ inputTokens: null,
2768
+ outputTokens: null
2769
+ };
2770
+ }
2771
+ let parsed;
2772
+ try {
2773
+ parsed = JSON.parse(text2);
2774
+ } catch {
2775
+ return {
2776
+ called: true,
2777
+ model: input.model,
2778
+ content: null,
2779
+ inputTokens: null,
2780
+ outputTokens: null
2781
+ };
2782
+ }
2783
+ const record = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2784
+ const message = record.message && typeof record.message === "object" && !Array.isArray(record.message) ? record.message : {};
2785
+ const content = typeof message.content === "string" ? message.content : message.content && typeof message.content === "object" ? JSON.stringify(message.content) : null;
2786
+ const model = typeof record.model === "string" && record.model.trim() ? record.model : input.model;
2787
+ return {
2788
+ called: true,
2789
+ model,
2790
+ content,
2791
+ inputTokens: count(record.prompt_eval_count),
2792
+ outputTokens: count(record.eval_count)
2793
+ };
2528
2794
  } catch {
2529
- return null;
2795
+ return {
2796
+ called: true,
2797
+ model: input.model,
2798
+ content: null,
2799
+ inputTokens: null,
2800
+ outputTokens: null
2801
+ };
2530
2802
  }
2531
2803
  }
2532
- async function writeRole(root, role) {
2533
- await writeText(roleFilePath(root, role.name), renderRole(role));
2804
+
2805
+ // src/role/models/rules.ts
2806
+ function rulesDecision(options) {
2807
+ return { pick: options.includes("human") ? "human" : "human" };
2534
2808
  }
2535
- async function createRole(root, input) {
2536
- const name = canonicalRoleName(input.name);
2537
- if (!name) return fail(ROLE_TEXT.badName);
2538
- const job = input.job.trim();
2539
- if (!job) return fail(ROLE_TEXT.needsJob);
2540
- if (await pathExists(roleFilePath(root, name))) return fail(ROLE_TEXT.exists);
2541
- await writeRole(root, {
2542
- name,
2543
- label: input.label.trim(),
2544
- job,
2545
- description: (input.description ?? "").trim(),
2546
- askFirst: input.askFirst === true,
2547
- pinned: false,
2548
- hidden: false
2549
- });
2550
- return ok();
2551
- }
2552
- async function editRole(root, nameInput, patch) {
2553
- const name = canonicalRoleName(nameInput);
2554
- if (!name) return fail(ROLE_TEXT.badName);
2555
- const role = await readRole(root, name);
2556
- if (!role) return fail(ROLE_TEXT.notFound);
2557
- if (patch.job !== void 0 && !patch.job.trim()) return fail(ROLE_TEXT.needsJob);
2558
- if (patch.label !== void 0) role.label = patch.label.trim();
2559
- if (patch.job !== void 0) role.job = patch.job.trim();
2560
- if (patch.description !== void 0) role.description = patch.description.trim();
2561
- if (patch.askFirst !== void 0) role.askFirst = patch.askFirst;
2562
- await writeRole(root, role);
2563
- return ok();
2564
- }
2565
- async function setFlag(root, nameInput, flag) {
2566
- const name = canonicalRoleName(nameInput);
2567
- if (!name) return fail(ROLE_TEXT.badName);
2568
- const role = await readRole(root, name);
2569
- if (!role) return fail(ROLE_TEXT.notFound);
2570
- role[flag] = true;
2571
- await writeRole(root, role);
2572
- return ok();
2573
- }
2574
- function pinRole(root, name) {
2575
- return setFlag(root, name, "pinned");
2576
- }
2577
- function hideRole(root, name) {
2578
- return setFlag(root, name, "hidden");
2579
- }
2580
- async function showRoles(root, opts = {}) {
2581
- if (opts.name !== void 0) {
2582
- const name = canonicalRoleName(opts.name);
2583
- if (!name) return fail(ROLE_TEXT.badName);
2584
- const path = roleFilePath(root, name);
2585
- if (!await pathExists(path) || !await readRole(root, name)) return fail(ROLE_TEXT.notFound);
2586
- return ok((await readText(path)).replace(/\s*$/, ""));
2587
- }
2588
- const dir = join8(root, ".sdd", "roles");
2589
- if (!await pathExists(dir)) return ok();
2590
- const roles = [];
2591
- for (const file of await listFiles(dir)) {
2592
- if (!file.endsWith(".md")) continue;
2593
- const role = await readRole(root, file.slice(0, -".md".length));
2594
- if (!role) continue;
2595
- if (role.hidden && !opts.all) continue;
2596
- roles.push(role);
2597
- }
2598
- roles.sort((a, b) => {
2599
- if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
2600
- if (a.name < b.name) return -1;
2601
- if (a.name > b.name) return 1;
2602
- return 0;
2603
- });
2604
- return ok(roles.map((role) => `${role.name} ${role.label}`).join("\n"));
2605
- }
2606
- async function duplicateRole(root, nameInput) {
2607
- const name = canonicalRoleName(nameInput);
2608
- if (!name) return fail(ROLE_TEXT.badName);
2609
- const source = await readRole(root, name);
2610
- if (!source) return fail(ROLE_TEXT.notFound);
2611
- const copyName = canonicalRoleName(`${name} copy`);
2612
- if (!copyName) return fail(ROLE_TEXT.badName);
2613
- if (await pathExists(roleFilePath(root, copyName))) return fail(ROLE_TEXT.exists);
2614
- await writeRole(root, { ...source, name: copyName });
2615
- return ok();
2616
- }
2617
- async function deleteRole(root, nameInput) {
2618
- const name = canonicalRoleName(nameInput);
2619
- if (!name) return fail(ROLE_TEXT.badName);
2620
- const path = roleFilePath(root, name);
2621
- if (!await pathExists(path)) return fail(ROLE_TEXT.notFound);
2622
- await removePath(path);
2623
- await removePath(conversationDir(root, name));
2624
- await removePath(memoryDir2(root, name));
2625
- await removeAllowKey(root, name);
2626
- return ok();
2627
- }
2628
- async function removeAllowKey(root, name) {
2629
- const path = allowFilePath(root);
2630
- if (!await pathExists(path)) return;
2631
- let data;
2632
- try {
2633
- data = JSON.parse(await readText(path));
2634
- } catch {
2635
- return;
2809
+
2810
+ // src/role/models/select.ts
2811
+ function slot(map, workType) {
2812
+ const id = map?.[workType]?.trim();
2813
+ return id || void 0;
2814
+ }
2815
+ function selectModel(config, ctx) {
2816
+ const bot = ctx.bot?.trim();
2817
+ const stage = ctx.stage?.trim();
2818
+ const role = ctx.role?.trim();
2819
+ const fromBot = bot ? slot(config.overrides?.bots?.[bot], ctx.workType) : void 0;
2820
+ const fromStage = stage ? slot(config.overrides?.stages?.[stage], ctx.workType) : void 0;
2821
+ const fromRole = role ? slot(config.overrides?.roles?.[role], ctx.workType) : void 0;
2822
+ const fromWork = slot(config.defaults.by_work_type, ctx.workType);
2823
+ let id;
2824
+ let why;
2825
+ if (fromBot && bot) {
2826
+ id = fromBot;
2827
+ why = `bot ${bot}`;
2828
+ } else if (fromStage && stage) {
2829
+ id = fromStage;
2830
+ why = `stage ${stage}`;
2831
+ } else if (fromRole && role) {
2832
+ id = fromRole;
2833
+ why = `role ${role}`;
2834
+ } else if (fromWork) {
2835
+ id = fromWork;
2836
+ why = "work-type default";
2837
+ } else {
2838
+ id = workKind(ctx.workType) === "decide" ? config.defaults.fallback_decide : config.defaults.fallback_reason;
2839
+ why = "fallback";
2636
2840
  }
2637
- if (!data || typeof data !== "object" || Array.isArray(data)) return;
2638
- const rec = data;
2639
- if (!(name in rec)) return;
2640
- delete rec[name];
2641
- await writeFile(path, `${JSON.stringify(rec, null, 2)}
2642
- `, "utf8");
2841
+ const model = config.models[id];
2842
+ if (!model) return { id, kind: workKind(ctx.workType), provider: "rules", why };
2843
+ return { id, kind: model.kind, provider: model.provider, why };
2844
+ }
2845
+
2846
+ // src/role/models/decide.ts
2847
+ var tokenField = z2.number().int().nonnegative().nullable().optional();
2848
+ var DecisionReplySchema = z2.object({
2849
+ pick: z2.string(),
2850
+ probs: z2.record(z2.string(), z2.number()),
2851
+ model: z2.string().trim().min(1).optional(),
2852
+ input_tokens: tokenField,
2853
+ cache_read_tokens: tokenField,
2854
+ cache_write_tokens: tokenField,
2855
+ output_tokens: tokenField
2856
+ }).strict();
2857
+ function minimumOf(entry) {
2858
+ if (!entry || entry.provider === "rules") return void 0;
2859
+ return entry.min_confidence;
2860
+ }
2861
+ function humanOutcome(choice, confidence2) {
2862
+ return { pick: "human", source: "human", choice, confidence: confidence2 };
2863
+ }
2864
+ function emptyUsage() {
2865
+ return {
2866
+ inputTokens: null,
2867
+ cacheReadTokens: null,
2868
+ cacheWriteTokens: null,
2869
+ outputTokens: null
2870
+ };
2643
2871
  }
2644
- async function listConversationRoles(root) {
2645
- return listDirs(join8(root, ".sdd", "role-runs", "conversation"));
2872
+ function usageFrom(reply, base) {
2873
+ return {
2874
+ model: reply.model ?? base.model,
2875
+ inputTokens: reply.input_tokens === void 0 ? base.inputTokens : reply.input_tokens,
2876
+ cacheReadTokens: reply.cache_read_tokens === void 0 ? base.cacheReadTokens : reply.cache_read_tokens,
2877
+ cacheWriteTokens: reply.cache_write_tokens === void 0 ? base.cacheWriteTokens : reply.cache_write_tokens,
2878
+ outputTokens: reply.output_tokens === void 0 ? base.outputTokens : reply.output_tokens
2879
+ };
2646
2880
  }
2647
- async function readAllowFile(root) {
2648
- const path = allowFilePath(root);
2649
- if (!await pathExists(path)) return null;
2881
+ function scoreReply(raw, options, base) {
2882
+ let value;
2650
2883
  try {
2651
- const data = JSON.parse(await readFile(path, "utf8"));
2652
- if (!data || typeof data !== "object" || Array.isArray(data)) return null;
2653
- return data;
2884
+ value = JSON.parse(raw);
2654
2885
  } catch {
2655
- return null;
2886
+ return { ok: false, usage: base };
2887
+ }
2888
+ const parsed = DecisionReplySchema.safeParse(value);
2889
+ if (!parsed.success) return { ok: false, usage: base };
2890
+ const reply = parsed.data;
2891
+ const usage = usageFrom(reply, base);
2892
+ if (!options.includes(reply.pick)) return { ok: false, usage };
2893
+ const keys = Object.keys(reply.probs);
2894
+ if (keys.length !== options.length) return { ok: false, usage };
2895
+ let sum = 0;
2896
+ for (const option of options) {
2897
+ const probability = reply.probs[option];
2898
+ if (typeof probability !== "number" || probability < 0 || probability > 1) {
2899
+ return { ok: false, usage };
2900
+ }
2901
+ sum += probability;
2656
2902
  }
2903
+ if (Math.abs(sum - 1) > 0.01 + 1e-9) return { ok: false, usage };
2904
+ const confidence2 = reply.probs[reply.pick];
2905
+ if (typeof confidence2 !== "number") return { ok: false, usage };
2906
+ return { ok: true, pick: reply.pick, confidence: confidence2, usage };
2657
2907
  }
2658
-
2659
- // src/role/run.ts
2660
- import { appendFile } from "fs/promises";
2661
- import { isAbsolute as isAbsolute3, join as join9 } from "pathe";
2662
- var DRAFT_KINDS = /* @__PURE__ */ new Set(["code", "scenario", "release"]);
2663
- var FORBIDDEN = /* @__PURE__ */ new Set([
2664
- "accept_spec",
2665
- "replace_decision",
2666
- "write_qa",
2667
- "generate_release",
2668
- "sdd_next",
2669
- "sdd_complete",
2670
- "create_change"
2671
- ]);
2672
- var UNAVAILABLE = /* @__PURE__ */ new Set(["commit", "push", "tag", "pull_request", "merge", "deploy"]);
2673
- var CLOSED = /* @__PURE__ */ new Set(["applied", "denied", "rejected", "unavailable", "stopped", "expired"]);
2674
- function ok2(stdout = "", runId) {
2675
- return runId ? { exitCode: 0, stdout, runId } : { exitCode: 0, stdout };
2908
+ function storedModel(entry, usage) {
2909
+ if (usage.model) return usage.model;
2910
+ if (entry.provider === "ollama") return entry.model;
2911
+ return null;
2676
2912
  }
2677
- function fail2(stdout, runId) {
2678
- return runId ? { exitCode: 1, stdout, runId } : { exitCode: 1, stdout };
2913
+ async function recordCall(input, entry, usage) {
2914
+ if (!input.metrics) return;
2915
+ if (entry.provider === "rules") return;
2916
+ await recordDecisionMetric(
2917
+ input.metrics.projectRoot,
2918
+ input.metrics.config,
2919
+ input.metrics.changeId,
2920
+ {
2921
+ workType: input.ctx.workType,
2922
+ kind: entry.kind,
2923
+ provider: entry.provider,
2924
+ model: storedModel(entry, usage),
2925
+ startedAt: (input.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
2926
+ inputTokens: usage.inputTokens,
2927
+ cacheReadTokens: usage.cacheReadTokens,
2928
+ cacheWriteTokens: usage.cacheWriteTokens,
2929
+ outputTokens: usage.outputTokens
2930
+ }
2931
+ );
2679
2932
  }
2680
- function linesOf(...parts) {
2681
- return parts.filter((part) => part.length > 0).join("\n");
2933
+ function judge(choice, entry, scored, floor) {
2934
+ const minimum = minimumOf(entry) ?? floor;
2935
+ if (!scored.ok) {
2936
+ return {
2937
+ kept: false,
2938
+ missing: false,
2939
+ escalate: minimum !== void 0,
2940
+ confidence: 0,
2941
+ outcome: humanOutcome(choice, 0)
2942
+ };
2943
+ }
2944
+ if (minimum !== void 0 && scored.confidence < minimum) {
2945
+ return {
2946
+ kept: false,
2947
+ missing: false,
2948
+ escalate: true,
2949
+ confidence: scored.confidence,
2950
+ outcome: humanOutcome(choice, scored.confidence)
2951
+ };
2952
+ }
2953
+ const source = choice.kind === "reason" ? "reason" : "decide";
2954
+ return {
2955
+ kept: true,
2956
+ missing: false,
2957
+ escalate: false,
2958
+ confidence: scored.confidence,
2959
+ outcome: { pick: scored.pick, source, choice, confidence: scored.confidence }
2960
+ };
2682
2961
  }
2683
- function runIdFrom(date) {
2684
- const iso = date.toISOString();
2685
- const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/.exec(iso);
2686
- if (!match) return "unknown";
2687
- return `${match[1]}${match[2]}${match[3]}T${match[4]}${match[5]}${match[6]}Z`;
2962
+ async function runOne(input, choice, floor) {
2963
+ const entry = input.models?.models[choice.id];
2964
+ if (!entry) {
2965
+ return {
2966
+ kept: false,
2967
+ missing: true,
2968
+ escalate: false,
2969
+ confidence: null,
2970
+ outcome: humanOutcome(choice, null)
2971
+ };
2972
+ }
2973
+ if (entry.provider === "rules") {
2974
+ rulesDecision(input.options);
2975
+ return {
2976
+ kept: true,
2977
+ missing: false,
2978
+ escalate: false,
2979
+ confidence: null,
2980
+ outcome: { pick: "human", source: "rules", choice, confidence: null }
2981
+ };
2982
+ }
2983
+ if (entry.provider === "command") {
2984
+ const command = input.env[entry.command_env]?.trim();
2985
+ if (!command || !input.runCommand) {
2986
+ return {
2987
+ kept: false,
2988
+ missing: true,
2989
+ escalate: false,
2990
+ confidence: null,
2991
+ outcome: humanOutcome(choice, null)
2992
+ };
2993
+ }
2994
+ let stdout = "";
2995
+ try {
2996
+ stdout = await input.runCommand(command, decideStdin(input));
2997
+ } catch {
2998
+ await recordCall(input, entry, emptyUsage());
2999
+ return judge(choice, entry, { ok: false }, floor);
3000
+ }
3001
+ const scored2 = scoreReply(stdout, input.options, emptyUsage());
3002
+ await recordCall(input, entry, scored2.usage);
3003
+ return judge(choice, entry, scored2, floor);
3004
+ }
3005
+ const baseUrl = input.env[entry.base_url_env]?.trim();
3006
+ if (!baseUrl || !input.fetch) {
3007
+ return {
3008
+ kept: false,
3009
+ missing: true,
3010
+ escalate: false,
3011
+ confidence: null,
3012
+ outcome: humanOutcome(choice, null)
3013
+ };
3014
+ }
3015
+ const remote = await ollamaDecision({
3016
+ baseUrl,
3017
+ model: entry.model,
3018
+ state: input.state,
3019
+ question: input.question,
3020
+ options: input.options,
3021
+ fetch: input.fetch
3022
+ });
3023
+ if (!remote.called) {
3024
+ return {
3025
+ kept: false,
3026
+ missing: true,
3027
+ escalate: false,
3028
+ confidence: null,
3029
+ outcome: humanOutcome(choice, null)
3030
+ };
3031
+ }
3032
+ const base = {
3033
+ model: remote.model,
3034
+ inputTokens: remote.inputTokens,
3035
+ cacheReadTokens: null,
3036
+ cacheWriteTokens: null,
3037
+ outputTokens: remote.outputTokens
3038
+ };
3039
+ const scored = remote.content ? scoreReply(remote.content, input.options, base) : { ok: false, usage: base };
3040
+ await recordCall(input, entry, scored.usage);
3041
+ return judge(choice, entry, scored, floor);
3042
+ }
3043
+ async function decide(input) {
3044
+ if (!input.models) return { pick: "human", source: "human", choice: null, confidence: null };
3045
+ const choice = selectModel(input.models, input.ctx);
3046
+ const first = await runOne(input, choice);
3047
+ if (first.kept) return first.outcome;
3048
+ if (first.missing || !first.escalate) return first.outcome;
3049
+ const floor = minimumOf(input.models.models[choice.id]);
3050
+ const fallbackId = input.models.defaults.fallback_reason;
3051
+ if (choice.id === fallbackId) return first.outcome;
3052
+ const fallbackEntry = input.models.models[fallbackId];
3053
+ if (!fallbackEntry) return first.outcome;
3054
+ const fallback = {
3055
+ id: fallbackId,
3056
+ kind: fallbackEntry.kind,
3057
+ provider: fallbackEntry.provider,
3058
+ why: "fallback"
3059
+ };
3060
+ const second = await runOne(input, fallback, floor);
3061
+ if (second.kept) return second.outcome;
3062
+ return second.outcome;
2688
3063
  }
2689
- function mentionsX(task) {
2690
- return /@X|x\.com|twitter\.com/.test(task);
3064
+
3065
+ // src/role/run.ts
3066
+ import { appendFile as appendFile5 } from "fs/promises";
3067
+ import { isAbsolute as isAbsolute4, join as join14 } from "pathe";
3068
+
3069
+ // src/team/facts.ts
3070
+ import { appendFile } from "fs/promises";
3071
+ import { join as join9 } from "pathe";
3072
+ function ok(stdout = "") {
3073
+ return { exitCode: 0, stdout };
2691
3074
  }
2692
- function safeChangeId(id) {
2693
- if (!id || id.length > 200) return false;
2694
- if (id === "." || id === "..") return false;
2695
- return !id.includes("/") && !id.includes("\\") && !id.includes("\0");
3075
+ function fail(stdout) {
3076
+ return { exitCode: 1, stdout };
2696
3077
  }
2697
- function safeRunId(id) {
2698
- if (!id || id.length > 80) return false;
2699
- if (id === "." || id === "..") return false;
2700
- return !id.includes("/") && !id.includes("\\") && !id.includes("\0");
3078
+ function factsPath(root) {
3079
+ return join9(root, ".sdd", "facts.jsonl");
2701
3080
  }
2702
- function isSafeProductPath(rel) {
2703
- if (typeof rel !== "string" || rel.length === 0 || rel.includes("\0")) return false;
2704
- if (isAbsolute3(rel) || rel.startsWith("\\")) return false;
2705
- if (/^[A-Za-z]:/.test(rel)) return false;
2706
- const parts = rel.split(/[/\\]/);
2707
- for (const part of parts) {
2708
- if (part === "" || part === "." || part === ".." || part === ".active" || part === ".sdd") {
2709
- return false;
2710
- }
3081
+ function sharesWords(left, right) {
3082
+ const bag = wordsOf(left);
3083
+ for (const word of wordsOf(right)) {
3084
+ if (bag.has(word)) return true;
2711
3085
  }
2712
- return parts[parts.length - 1] !== "accepted.md";
3086
+ return false;
2713
3087
  }
2714
- function canonicalJson(value) {
2715
- return JSON.stringify(sortKeys(value));
3088
+ function wordsOf(text2) {
3089
+ return new Set(text2.toLowerCase().match(/[\p{L}\p{N}_]{4,}/gu) ?? []);
2716
3090
  }
2717
- function sortKeys(value) {
2718
- if (Array.isArray(value)) return value.map(sortKeys);
3091
+ async function readFactLines(root) {
3092
+ const file = factsPath(root);
3093
+ if (!await pathExists(file)) return [];
3094
+ const out = [];
3095
+ for (const line of (await readText(file)).split(/\r?\n/)) {
3096
+ if (!line.trim()) continue;
3097
+ try {
3098
+ const value = JSON.parse(line);
3099
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
3100
+ const rec = value;
3101
+ if (typeof rec.key !== "string" || typeof rec.value !== "string") continue;
3102
+ out.push({
3103
+ key: rec.key,
3104
+ value: rec.value,
3105
+ ts: typeof rec.ts === "string" ? rec.ts : "",
3106
+ by: typeof rec.by === "string" ? rec.by : "",
3107
+ source: typeof rec.source === "string" ? rec.source : ""
3108
+ });
3109
+ } catch {
3110
+ }
3111
+ }
3112
+ return out;
3113
+ }
3114
+ async function getFacts(root, query) {
3115
+ const lines = await readFactLines(root);
3116
+ const order = [];
3117
+ const newest = /* @__PURE__ */ new Map();
3118
+ for (const fact of lines) {
3119
+ if (!newest.has(fact.key)) order.push(fact.key);
3120
+ newest.set(fact.key, fact);
3121
+ }
3122
+ let facts = order.map((key) => newest.get(key));
3123
+ if (query?.keys) {
3124
+ const want = new Set(query.keys);
3125
+ facts = facts.filter((fact) => want.has(fact.key));
3126
+ }
3127
+ if (query?.query) {
3128
+ const text2 = query.query;
3129
+ facts = facts.filter((fact) => sharesWords(text2, `${fact.key} ${fact.value}`));
3130
+ }
3131
+ return facts;
3132
+ }
3133
+ async function setFact(root, input) {
3134
+ const rawKey = input.key ?? "";
3135
+ const rawValue = input.value ?? "";
3136
+ const rawSource = input.source ?? "";
3137
+ if (rawKey.includes("\n") || rawKey.includes("\r") || rawValue.includes("\n") || rawValue.includes("\r") || rawSource.includes("\n") || rawSource.includes("\r")) {
3138
+ return fail("A fact is one line.");
3139
+ }
3140
+ const key = rawKey.trim();
3141
+ const value = rawValue.trim();
3142
+ if (!key || !value) return fail("A fact needs a key and a value.");
3143
+ if (key.includes("/") || key.includes("\\") || key.includes("\0")) {
3144
+ return fail("A fact key is not valid.");
3145
+ }
3146
+ if (await contradicts(root, value)) return escalate(root, key);
3147
+ const fact = {
3148
+ key,
3149
+ value,
3150
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3151
+ by: gitUserName(root) ?? "unknown",
3152
+ source: rawSource.trim() || "person"
3153
+ };
3154
+ const file = factsPath(root);
3155
+ await ensureDir(join9(file, ".."));
3156
+ await appendFile(file, `${JSON.stringify(fact)}
3157
+ `, "utf8");
3158
+ return ok("Fact set.");
3159
+ }
3160
+ async function showFact(root, key) {
3161
+ const name = (key ?? "").trim();
3162
+ if (!name) return fail("A fact needs a key and a value.");
3163
+ const facts = await getFacts(root, { keys: [name] });
3164
+ const hit = facts[0];
3165
+ if (!hit) return fail("Fact not set.");
3166
+ return ok(hit.value);
3167
+ }
3168
+ async function listFacts(root, query) {
3169
+ const facts = await getFacts(root, query ? { query } : void 0);
3170
+ if (facts.length === 0) return ok("No facts.");
3171
+ return ok(facts.map((fact) => `${fact.key}: ${fact.value}`).join("\n"));
3172
+ }
3173
+ async function contradicts(root, value) {
3174
+ if (!/\b(ignore|skip|override)\b/i.test(value)) return false;
3175
+ let text2 = "";
3176
+ try {
3177
+ const config = await loadConfig(root);
3178
+ const file = join9(root, guidancePaths(config).constitution);
3179
+ if (await pathExists(file)) text2 = await readText(file);
3180
+ } catch {
3181
+ const file = join9(root, "memory", "constitution.md");
3182
+ if (await pathExists(file)) text2 = await readText(file);
3183
+ }
3184
+ for (const line of text2.split(/\r?\n/)) {
3185
+ if (!line.startsWith("- ")) continue;
3186
+ const bullet = line.slice(2).trim();
3187
+ if (bullet.length >= 12 && value.includes(bullet)) return true;
3188
+ }
3189
+ return false;
3190
+ }
3191
+ async function escalate(root, key) {
3192
+ const note = `fact ${key} contradicts constitution`;
3193
+ let changeId = null;
3194
+ try {
3195
+ const config = await loadConfig(root);
3196
+ changeId = await getActiveChangeId(root, config);
3197
+ } catch {
3198
+ changeId = null;
3199
+ }
3200
+ if (!changeId) return fail("A fact contradicts the constitution.");
3201
+ return recordDecisionRequest(root, { note, change: changeId });
3202
+ }
3203
+
3204
+ // src/team/memory.ts
3205
+ import { appendFile as appendFile2 } from "fs/promises";
3206
+ import { join as join10 } from "pathe";
3207
+ var BRAIN_FILES = ["memory.md", "lessons.jsonl"];
3208
+ function ok2(stdout = "") {
3209
+ return { exitCode: 0, stdout };
3210
+ }
3211
+ function fail2(stdout) {
3212
+ return { exitCode: 1, stdout };
3213
+ }
3214
+ async function knownBot(root, name) {
3215
+ const team = await readTeamConfig(root);
3216
+ if ("error" in team) return fail2(team.error);
3217
+ if (!team.team.bots.some((bot) => bot.name === name)) return fail2("Bot not found.");
3218
+ return null;
3219
+ }
3220
+ async function readMemory(root, bot) {
3221
+ const file = join10(memoryDir2(root, bot, true), "memory.md");
3222
+ if (!await pathExists(file)) return "";
3223
+ return readText(file);
3224
+ }
3225
+ async function readLessons(root, bot) {
3226
+ const file = join10(memoryDir2(root, bot, true), "lessons.jsonl");
3227
+ if (!await pathExists(file)) return [];
3228
+ const lessons = [];
3229
+ for (const line of (await readText(file)).split(/\r?\n/)) {
3230
+ if (!line.trim()) continue;
3231
+ try {
3232
+ const value = JSON.parse(line);
3233
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
3234
+ const rec = value;
3235
+ if (typeof rec.text !== "string" || !rec.text.trim()) continue;
3236
+ const tags = Array.isArray(rec.tags) ? rec.tags.filter((tag) => typeof tag === "string") : void 0;
3237
+ lessons.push({
3238
+ ts: typeof rec.ts === "string" ? rec.ts : "",
3239
+ by: typeof rec.by === "string" ? rec.by : "",
3240
+ text: rec.text,
3241
+ source: typeof rec.source === "string" ? rec.source : void 0,
3242
+ tags
3243
+ });
3244
+ } catch {
3245
+ }
3246
+ }
3247
+ return lessons;
3248
+ }
3249
+ async function appendLesson(root, bot, lesson) {
3250
+ const file = join10(memoryDir2(root, bot, true), "lessons.jsonl");
3251
+ await ensureDir(join10(file, ".."));
3252
+ await appendFile2(file, `${JSON.stringify(lesson)}
3253
+ `, "utf8");
3254
+ }
3255
+ async function showMemory(root, bot) {
3256
+ const name = canonicalRoleName(bot);
3257
+ if (!name) return fail2(ROLE_TEXT.badName);
3258
+ const missing = await knownBot(root, name);
3259
+ if (missing) return missing;
3260
+ const text2 = await readMemory(root, name);
3261
+ if (!text2) return ok2("No memory.");
3262
+ return ok2(text2.endsWith("\n") ? text2.slice(0, -1) : text2);
3263
+ }
3264
+ async function addLesson(root, bot, text2) {
3265
+ const name = canonicalRoleName(bot);
3266
+ if (!name) return fail2(ROLE_TEXT.badName);
3267
+ const missing = await knownBot(root, name);
3268
+ if (missing) return missing;
3269
+ if (text2.includes("\n") || text2.includes("\r")) return fail2("A lesson is one line.");
3270
+ const words = text2.trim();
3271
+ if (!words) return fail2("A lesson needs text.");
3272
+ await appendLesson(root, name, {
3273
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3274
+ by: gitUserName(root) ?? "unknown",
3275
+ text: words,
3276
+ source: "person"
3277
+ });
3278
+ return ok2("Lesson added.");
3279
+ }
3280
+ async function addReviewerLesson(root, bot, lesson) {
3281
+ const text2 = lesson.text.trim();
3282
+ if (!text2 || text2.includes("\n") || text2.includes("\r")) return;
3283
+ const existing = await readLessons(root, bot);
3284
+ if (existing.some((item) => item.text === text2)) return;
3285
+ await appendLesson(root, bot, {
3286
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3287
+ by: lesson.by || "reviewer",
3288
+ text: text2,
3289
+ source: "reviewer"
3290
+ });
3291
+ }
3292
+ async function migrateTeamMemory(root) {
3293
+ const team = await readTeamConfig(root);
3294
+ if ("error" in team) return fail2(team.error);
3295
+ const base = join10(root, ".sdd", "role-runs", "memory");
3296
+ const lines = [];
3297
+ for (const name of (await listDirs(base)).sort()) {
3298
+ if (!canonicalRoleName(name)) continue;
3299
+ for (const file of BRAIN_FILES) {
3300
+ const from = join10(base, name, file);
3301
+ if (!await pathExists(from)) continue;
3302
+ const fromRel = `.sdd/role-runs/memory/${name}/${file}`;
3303
+ const toRel = `.sdd/agents/${name}/${file}`;
3304
+ const to = join10(memoryDir2(root, name, true), file);
3305
+ if (await pathExists(to)) {
3306
+ lines.push(`left ${fromRel}`);
3307
+ continue;
3308
+ }
3309
+ await ensureDir(join10(to, ".."));
3310
+ await writeText(to, await readText(from));
3311
+ await removePath(from);
3312
+ lines.push(`moved ${fromRel} -> ${toRel}`);
3313
+ }
3314
+ if ((await listFiles(join10(base, name))).length === 0) await removePath(join10(base, name));
3315
+ }
3316
+ if (lines.length === 0) return ok2("Nothing to migrate.");
3317
+ return ok2(lines.join("\n"));
3318
+ }
3319
+
3320
+ // src/team/brain.ts
3321
+ var WARNING = "Memory is longer than the budget.";
3322
+ async function loadBrain(root, cfg, bot, item, budgetTokens) {
3323
+ const spec = cfg.bots.find((entry) => entry.name === bot);
3324
+ if (!spec) return { error: "Bot not found." };
3325
+ const role = await readRole(root, spec.role);
3326
+ if (!role) return { error: ROLE_TEXT.notFound };
3327
+ const file = roleFilePath(root, spec.role);
3328
+ if (!await pathExists(file)) return { error: ROLE_TEXT.notFound };
3329
+ const roleText = await readText(file);
3330
+ const memoryAll = await readMemory(root, spec.name);
3331
+ const lessonsNewest = (await readLessons(root, spec.name)).slice().reverse();
3332
+ const factsNewest = (await getFacts(root, { query: item.text })).slice().reverse();
3333
+ const packed = pack({
3334
+ roleText,
3335
+ memoryAll,
3336
+ lessons: lessonsNewest,
3337
+ facts: factsNewest,
3338
+ item: item.text,
3339
+ budget: budgetTokens
3340
+ });
3341
+ return {
3342
+ role,
3343
+ bot: spec,
3344
+ memory: packed.memory,
3345
+ lessons: packed.lessons,
3346
+ facts: packed.facts,
3347
+ inputs: [],
3348
+ prompt: packed.prompt,
3349
+ warning: packed.warning
3350
+ };
3351
+ }
3352
+ function pack(input) {
3353
+ let memory = input.memoryAll;
3354
+ let lessons = input.lessons.slice();
3355
+ let facts = input.facts.slice();
3356
+ let warning = "";
3357
+ const itemBlock = `Item:
3358
+ ${input.item}`;
3359
+ const render = () => {
3360
+ const parts = [input.roleText.trimEnd()];
3361
+ if (warning) parts.push(warning);
3362
+ if (memory.trim()) parts.push(`Memory:
3363
+ ${memory.trimEnd()}`);
3364
+ if (lessons.length > 0) {
3365
+ parts.push(`Lessons:
3366
+ ${lessons.map((lesson) => `- ${lesson.text}`).join("\n")}`);
3367
+ }
3368
+ if (facts.length > 0) {
3369
+ parts.push(`Facts:
3370
+ ${facts.map((fact) => `${fact.key}: ${fact.value}`).join("\n")}`);
3371
+ }
3372
+ parts.push(itemBlock);
3373
+ return parts.join("\n");
3374
+ };
3375
+ const roleOnly = countTokens(input.roleText) > input.budget;
3376
+ if (roleOnly) {
3377
+ memory = "";
3378
+ lessons = [];
3379
+ facts = [];
3380
+ warning = input.memoryAll.trim() ? WARNING : "";
3381
+ return { memory, lessons, facts, prompt: render(), warning };
3382
+ }
3383
+ while (countTokens(render()) > input.budget && facts.length > 0) facts.pop();
3384
+ while (countTokens(render()) > input.budget && lessons.length > 0) lessons.pop();
3385
+ if (countTokens(render()) > input.budget && memory.trim()) {
3386
+ const lines = memory.split(/\r?\n/);
3387
+ while (lines.length > 0 && countTokens(render()) > input.budget) {
3388
+ lines.shift();
3389
+ memory = lines.join("\n");
3390
+ }
3391
+ }
3392
+ if (input.memoryAll.trim() && memory.trim() !== input.memoryAll.trim()) {
3393
+ warning = WARNING;
3394
+ while (countTokens(render()) > input.budget && facts.length > 0) facts.pop();
3395
+ while (countTokens(render()) > input.budget && lessons.length > 0) lessons.pop();
3396
+ if (countTokens(render()) > input.budget) memory = "";
3397
+ }
3398
+ return { memory, lessons, facts, prompt: render(), warning };
3399
+ }
3400
+
3401
+ // src/team/loop.ts
3402
+ import { appendFile as appendFile4 } from "fs/promises";
3403
+ import { basename, join as join13 } from "pathe";
3404
+ import { z as z5 } from "zod";
3405
+
3406
+ // src/team/room.ts
3407
+ import { appendFile as appendFile3 } from "fs/promises";
3408
+ import { join as join12 } from "pathe";
3409
+ import { z as z4 } from "zod";
3410
+
3411
+ // src/role/prompts.ts
3412
+ function critiquePrompt(role, section, draft) {
3413
+ return [
3414
+ `Job: ${role.job}`,
3415
+ "Check ids:",
3416
+ ...section.checks,
3417
+ `Cite: ${section.id}:${section.decision}`,
3418
+ "Draft:",
3419
+ draft
3420
+ ].join("\n");
3421
+ }
3422
+ function revisionPrompt(role, draft, gaps) {
3423
+ return [
3424
+ `Job: ${role.job}`,
3425
+ "Draft:",
3426
+ draft,
3427
+ "Gaps:",
3428
+ ...gaps.map((gap) => `${gap.cite}: ${gap.text}`),
3429
+ "changed must list every gap cite."
3430
+ ].join("\n");
3431
+ }
3432
+
3433
+ // src/team/tools.ts
3434
+ import { spawn as spawn4 } from "child_process";
3435
+ import { mkdtemp, rm } from "fs/promises";
3436
+ import { tmpdir } from "os";
3437
+ import { isAbsolute as isAbsolute3, join as join11 } from "pathe";
3438
+ import { z as z3 } from "zod";
3439
+ var OUTPUT_CAP = 4e3;
3440
+ var SCOPES = /* @__PURE__ */ new Set(["code", "specs", "memory"]);
3441
+ var ToolsSchema = z3.object({
3442
+ allow: z3.array(z3.object({ run: z3.string().trim().min(1) }).strict()).default([])
3443
+ }).strict();
3444
+ var EvidenceSchema = z3.object({
3445
+ sha: z3.string(),
3446
+ runs: z3.array(
3447
+ z3.object({
3448
+ command: z3.string(),
3449
+ exitCode: z3.number().int().nullable(),
3450
+ output: z3.string()
3451
+ }).strict()
3452
+ )
3453
+ }).strict();
3454
+ var BLOCKED_CHECK = /\bsdd\s+(next|complete)\b|\bgit\s+(push|merge)\b|\bgh\s+pr\s+(create|merge)\b/;
3455
+ var NEGATED = /\b(?:not|no|never|cannot|can't|cant|dont|don't|doesnt|doesn't|isnt|isn't)\s+(?:yet\s+)?(?:pass|works|fixed)\b/gi;
3456
+ function claimsPass(text2) {
3457
+ return /\b(?:pass|works|fixed)\b/i.test(text2.replace(NEGATED, " "));
3458
+ }
3459
+ function safePath(rel) {
3460
+ if (typeof rel !== "string" || rel.length === 0 || rel.includes("\0")) return false;
3461
+ if (isAbsolute3(rel) || rel.startsWith("\\")) return false;
3462
+ if (/^[A-Za-z]:/.test(rel)) return false;
3463
+ const parts = rel.split(/[/\\]/);
3464
+ for (const part of parts) {
3465
+ if (part === "" || part === "." || part === ".." || part === ".active" || part === ".sdd") {
3466
+ return false;
3467
+ }
3468
+ }
3469
+ return parts[parts.length - 1] !== "accepted.md";
3470
+ }
3471
+ function clip(text2) {
3472
+ return text2.length > OUTPUT_CAP ? text2.slice(-OUTPUT_CAP) : text2;
3473
+ }
3474
+ function asRecord(value) {
3475
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
3476
+ return value;
3477
+ }
3478
+ function result(name, ok8, text2) {
3479
+ return { ok: ok8, name, text: clip(text2) };
3480
+ }
3481
+ async function git(cwd, args) {
3482
+ return new Promise((resolve) => {
3483
+ const child = spawn4("git", args, { cwd, env: process.env });
3484
+ let stdout = "";
3485
+ let stderr = "";
3486
+ child.stdout?.on("data", (chunk) => {
3487
+ stdout += chunk.toString();
3488
+ });
3489
+ child.stderr?.on("data", (chunk) => {
3490
+ stderr += chunk.toString();
3491
+ });
3492
+ child.on("error", (error) => resolve({ code: 1, stdout, stderr: String(error) }));
3493
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
3494
+ });
3495
+ }
3496
+ async function shell(command, cwd) {
3497
+ return new Promise((resolve) => {
3498
+ const child = spawn4(command, { cwd, shell: true, env: process.env });
3499
+ let output = "";
3500
+ child.stdout?.on("data", (chunk) => {
3501
+ output += chunk.toString();
3502
+ });
3503
+ child.stderr?.on("data", (chunk) => {
3504
+ output += chunk.toString();
3505
+ });
3506
+ child.on("error", (error) => resolve({ code: 1, output: String(error) }));
3507
+ child.on("close", (code) => resolve({ code, output }));
3508
+ });
3509
+ }
3510
+ async function headOf(root) {
3511
+ try {
3512
+ return await getHeadSha(root);
3513
+ } catch {
3514
+ return null;
3515
+ }
3516
+ }
3517
+ async function activeChange(root) {
3518
+ const config = await loadConfig(root);
3519
+ const id = await getActiveChangeId(root, config);
3520
+ if (!id) return null;
3521
+ return { id, path: changePath(root, config, id) };
3522
+ }
3523
+ function evidenceRel(changesPath, changeId, sha) {
3524
+ return `${changesPath}/${changeId}/evidence/${sha}.json`.replace(/\\/g, "/");
3525
+ }
3526
+ async function evidenceFile(root, changeId, sha) {
3527
+ const config = await loadConfig(root);
3528
+ return join11(changePath(root, config, changeId), "evidence", `${sha}.json`);
3529
+ }
3530
+ async function evidenceSupports(root, changeId, cite) {
3531
+ const sha = await headOf(root);
3532
+ if (!sha) return false;
3533
+ const config = await loadConfig(root);
3534
+ const expected = evidenceRel(config.changes_path, changeId, sha);
3535
+ if (cite.trim().replace(/\\/g, "/").replace(/^\.\//, "") !== expected) return false;
3536
+ const file = join11(root, ...expected.split("/"));
3537
+ if (!await pathExists(file)) return false;
3538
+ let parsed;
3539
+ try {
3540
+ const value = EvidenceSchema.safeParse(JSON.parse(await readText(file)));
3541
+ if (!value.success) return false;
3542
+ parsed = value.data;
3543
+ } catch {
3544
+ return false;
3545
+ }
3546
+ if (parsed.sha !== sha || parsed.runs.length === 0) return false;
3547
+ return parsed.runs[parsed.runs.length - 1]?.exitCode === 0;
3548
+ }
3549
+ async function passClaimRefusal(root, text2, cites) {
3550
+ if (!claimsPass(text2)) return null;
3551
+ const change = await activeChange(root).catch(() => null);
3552
+ if (!change) return "A pass claim needs evidence at this SHA.";
3553
+ for (const cite of cites) {
3554
+ if (await evidenceSupports(root, change.id, cite)) return null;
3555
+ }
3556
+ return "A pass claim needs evidence at this SHA.";
3557
+ }
3558
+ async function allowList(root) {
3559
+ const change = await activeChange(root);
3560
+ const stageRuns = [];
3561
+ if (change) {
3562
+ const config = await loadConfig(root);
3563
+ const ctx = await buildContext(root, config, change.id);
3564
+ const stage = getStage(ctx.workflow, ctx.meta, ctx.meta.stage);
3565
+ for (const command of stage?.verify?.commands ?? []) stageRuns.push(command.run);
3566
+ }
3567
+ const file = join11(root, ".sdd", "tools.yaml");
3568
+ if (!await pathExists(file)) return stageRuns;
3569
+ let raw;
3570
+ try {
3571
+ raw = await readYaml(file);
3572
+ } catch {
3573
+ return { error: "tools.yaml is invalid: tools could not be parsed" };
3574
+ }
3575
+ const parsed = ToolsSchema.safeParse(raw ?? {});
3576
+ if (!parsed.success) {
3577
+ const issue = parsed.error.issues[0];
3578
+ const path = issue?.path.join(".") || "tools";
3579
+ return { error: `tools.yaml is invalid: ${path} ${issue?.message ?? "invalid"}` };
3580
+ }
3581
+ return [...stageRuns, ...parsed.data.allow.map((entry) => entry.run)];
3582
+ }
3583
+ async function readFileTool(root, args) {
3584
+ const rel = typeof args.path === "string" ? args.path : "";
3585
+ if (!safePath(rel)) return result("read_file", false, "That path is outside the project.");
3586
+ if (isSecretPath(rel)) return result("read_file", false, "That path is not readable.");
3587
+ const file = join11(root, ...rel.split(/[/\\]/));
3588
+ if (!await pathExists(file)) return result("read_file", false, "That path is not readable.");
3589
+ try {
3590
+ const text2 = await readText(file);
3591
+ return result("read_file", true, `${rel}
3592
+ ${text2}`);
3593
+ } catch {
3594
+ return result("read_file", false, "That path is not readable.");
3595
+ }
3596
+ }
3597
+ async function searchTool(root, args) {
3598
+ const query = typeof args.query === "string" ? args.query.trim() : "";
3599
+ if (!query) return result("search", false, "A search needs a query.");
3600
+ const scope = typeof args.scope === "string" && args.scope ? args.scope : "code";
3601
+ if (!SCOPES.has(scope)) {
3602
+ return result("search", false, "A search scope is code, specs, or memory.");
3603
+ }
3604
+ const found = await searchProject(root, query, {
3605
+ scope,
3606
+ k: 8,
3607
+ budget: 2e3
3608
+ });
3609
+ return result("search", true, found.text || query);
3610
+ }
3611
+ async function contextTool(root, args) {
3612
+ const path = typeof args.path === "string" ? args.path : "";
3613
+ const query = typeof args.query === "string" ? args.query : "";
3614
+ const symbol = typeof args.symbol === "string" ? args.symbol : "";
3615
+ const change = await activeChange(root).catch(() => null);
3616
+ const found = await generateCodeContext({
3617
+ projectRoot: root,
3618
+ changeId: change?.id,
3619
+ paths: path ? [path] : void 0,
3620
+ query: query || void 0,
3621
+ symbols: symbol ? [symbol] : void 0
3622
+ });
3623
+ return result(
3624
+ "context",
3625
+ found.ok,
3626
+ found.markdown || found.gaps.map((gap) => gap.message).join("\n")
3627
+ );
3628
+ }
3629
+ async function appendRun(root, changeId, sha, command, exitCode, output) {
3630
+ const file = await evidenceFile(root, changeId, sha);
3631
+ let body = { sha, runs: [] };
3632
+ if (await pathExists(file)) {
3633
+ try {
3634
+ const parsed = EvidenceSchema.safeParse(JSON.parse(await readText(file)));
3635
+ if (!parsed.success || parsed.data.sha !== sha) {
3636
+ return { error: "Evidence at this commit cannot be read." };
3637
+ }
3638
+ body = parsed.data;
3639
+ } catch {
3640
+ return { error: "Evidence at this commit cannot be read." };
3641
+ }
3642
+ }
3643
+ body.runs.push({ command, exitCode, output: clip(output) });
3644
+ await ensureDir(join11(file, ".."));
3645
+ await writeText(file, `${JSON.stringify(body, null, 2)}
3646
+ `);
3647
+ const config = await loadConfig(root);
3648
+ return evidenceRel(config.changes_path, changeId, sha);
3649
+ }
3650
+ async function runCheck(root, args) {
3651
+ const command = typeof args.run === "string" ? args.run.trim() : "";
3652
+ if (!command) return result("run_check", false, "That check is not allowlisted.");
3653
+ if (BLOCKED_CHECK.test(command)) {
3654
+ return result("run_check", false, "That check cannot run sdd next, sdd complete, or merge.");
3655
+ }
3656
+ const allowed = await allowList(root);
3657
+ if ("error" in allowed) return result("run_check", false, allowed.error);
3658
+ if (!allowed.includes(command))
3659
+ return result("run_check", false, "That check is not allowlisted.");
3660
+ const change = await activeChange(root);
3661
+ if (!change) return result("run_check", false, "No active change.");
3662
+ const sha = await headOf(root);
3663
+ if (!sha) return result("run_check", false, "Git HEAD is unavailable.");
3664
+ const dir = await mkdtemp(join11(tmpdir(), "sdd-check-"));
3665
+ const added = await git(root, ["worktree", "add", "--detach", dir, sha]);
3666
+ if (added.code !== 0) {
3667
+ await rm(dir, { recursive: true, force: true });
3668
+ return result("run_check", false, "Git HEAD is unavailable.");
3669
+ }
3670
+ let ran = { code: 1, output: "" };
3671
+ let removeFailed = false;
3672
+ try {
3673
+ ran = await shell(command, dir);
3674
+ } finally {
3675
+ const removed = await git(root, ["worktree", "remove", "--force", dir]);
3676
+ await rm(dir, { recursive: true, force: true });
3677
+ removeFailed = removed.code !== 0;
3678
+ }
3679
+ if (removeFailed) return result("run_check", false, "The check worktree could not be removed.");
3680
+ const stored = await appendRun(root, change.id, sha, command, ran.code, ran.output);
3681
+ if (typeof stored !== "string") return result("run_check", false, stored.error);
3682
+ const text2 = [`${command}`, `exit ${ran.code ?? "none"}`, stored, clip(ran.output)].join("\n");
3683
+ return result("run_check", ran.code === 0, text2);
3684
+ }
3685
+ async function writeFileTool(root, args) {
3686
+ const rel = typeof args.path === "string" ? args.path : "";
3687
+ const contents = args.contents;
3688
+ if (!safePath(rel) || typeof contents !== "string" || isSecretPath(rel)) {
3689
+ return result("write_file", false, "That file cannot be written.");
3690
+ }
3691
+ await writeText(join11(root, ...rel.split(/[/\\]/)), contents);
3692
+ return result("write_file", true, `Wrote ${rel}.`);
3693
+ }
3694
+ function branchName(team, bot, changeId) {
3695
+ return team.branch.replaceAll("{bot}", bot).replaceAll("{change}", changeId);
3696
+ }
3697
+ async function currentBranch(root) {
3698
+ const found = await git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
3699
+ return found.code === 0 ? found.stdout.trim() : "";
3700
+ }
3701
+ async function commitTool(root, team, bot, args) {
3702
+ const change = await activeChange(root);
3703
+ if (!change) return result("commit", false, "No active change.");
3704
+ const expected = branchName(team, bot, change.id);
3705
+ const branch = await currentBranch(root);
3706
+ if (branch !== expected) return result("commit", false, "A bot commits only on its own branch.");
3707
+ const message = typeof args.message === "string" ? args.message.trim() : "";
3708
+ const paths = Array.isArray(args.paths) ? args.paths.filter((item) => typeof item === "string") : [];
3709
+ if (!message || message.includes("\n") || paths.length === 0) {
3710
+ return result("commit", false, "A commit needs a message and a path.");
3711
+ }
3712
+ for (const rel of paths) {
3713
+ if (!safePath(rel) || isSecretPath(rel))
3714
+ return result("commit", false, "That path is outside the project.");
3715
+ }
3716
+ const added = await git(root, ["add", "--", ...paths]);
3717
+ if (added.code !== 0) return result("commit", false, clip(added.stderr || added.stdout));
3718
+ const committed = await git(root, ["commit", "-m", message]);
3719
+ if (committed.code !== 0)
3720
+ return result("commit", false, clip(committed.stderr || committed.stdout));
3721
+ return result("commit", true, clip(committed.stdout || message));
3722
+ }
3723
+ async function refusedShip(root, name, text2) {
3724
+ const sha = await headOf(root);
3725
+ return result(name, false, text2.replace("this SHA", sha ?? "this SHA"));
3726
+ }
3727
+ async function runTool(root, team, bot, call) {
3728
+ const args = asRecord(call.args);
3729
+ const name = call.name.trim();
3730
+ if (name === "read_file") return readFileTool(root, args);
3731
+ if (name === "search") return searchTool(root, args);
3732
+ if (name === "context") return contextTool(root, args);
3733
+ if (name === "run_check") return runCheck(root, args);
3734
+ if (name === "write_file") return writeFileTool(root, args);
3735
+ if (name === "commit") return commitTool(root, team, bot, args);
3736
+ if (name === "push") return refusedShip(root, name, "Push needs a person at this SHA.");
3737
+ if (name === "pull_request")
3738
+ return refusedShip(root, name, "A pull request needs a person at this SHA.");
3739
+ if (name === "merge" || name === "tag" || name === "deploy") {
3740
+ return result(name, false, "A bot cannot merge.");
3741
+ }
3742
+ if (name === "sdd_next" || name === "sdd_complete" || name === "accept_spec") {
3743
+ return result(name, false, "A role cannot do that.");
3744
+ }
3745
+ return result(name, false, "That tool is not available.");
3746
+ }
3747
+ async function reviewNeedsEvidence(root, changeId, commands, type, text2, cite) {
3748
+ const claim = claimsPass(text2);
3749
+ const agree = type === "agree" && commands > 0;
3750
+ if (!claim && !agree) return null;
3751
+ if (await evidenceSupports(root, changeId, cite)) return null;
3752
+ return claim ? "A pass claim needs evidence at this SHA." : "A review needs evidence at this SHA.";
3753
+ }
3754
+
3755
+ // src/team/room.ts
3756
+ var ROOM_TYPES = ["post", "objection", "agree", "block", "decision"];
3757
+ var RoomEntrySchema = z4.object({
3758
+ ts: z4.string(),
3759
+ from: z4.string(),
3760
+ type: z4.enum(ROOM_TYPES),
3761
+ text: z4.string(),
3762
+ round: z4.number().int(),
3763
+ cite: z4.string().optional(),
3764
+ sha: z4.string().optional(),
3765
+ evidence: z4.array(z4.string()).optional()
3766
+ }).strict();
3767
+ function ok3(stdout = "") {
3768
+ return { exitCode: 0, stdout };
3769
+ }
3770
+ function fail3(stdout) {
3771
+ return { exitCode: 1, stdout };
3772
+ }
3773
+ function safeId(value) {
3774
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value);
3775
+ }
3776
+ function oneLine(value, empty, broken) {
3777
+ const raw = value ?? "";
3778
+ if (raw.includes("\n") || raw.includes("\r")) return fail3(broken);
3779
+ const words = raw.trim();
3780
+ if (!words) return fail3(empty);
3781
+ return words;
3782
+ }
3783
+ function roomPath(root, change, stage) {
3784
+ return join12(root, ".sdd", "rooms", change, `${stage}.jsonl`);
3785
+ }
3786
+ function roomRounds(team) {
3787
+ return team.limits?.room_rounds ?? 2;
3788
+ }
3789
+ function consensus(entries) {
3790
+ const agreed = [];
3791
+ const open = [];
3792
+ for (const entry of entries) {
3793
+ if (entry.type === "agree" && !agreed.includes(entry.from)) agreed.push(entry.from);
3794
+ if (entry.type === "objection" || entry.type === "block") open.push(entry);
3795
+ }
3796
+ return { agreed, open, blocked: entries.some((entry) => entry.type === "block") };
3797
+ }
3798
+ async function readEntries(file) {
3799
+ if (!await pathExists(file)) return [];
3800
+ const entries = [];
3801
+ for (const line of (await readText(file)).split(/\r?\n/)) {
3802
+ if (!line.trim()) continue;
3803
+ try {
3804
+ const parsed = RoomEntrySchema.safeParse(JSON.parse(line));
3805
+ if (parsed.success) entries.push(parsed.data);
3806
+ } catch {
3807
+ }
3808
+ }
3809
+ return entries;
3810
+ }
3811
+ async function headSha(root) {
3812
+ try {
3813
+ return await getHeadSha(root);
3814
+ } catch {
3815
+ return null;
3816
+ }
3817
+ }
3818
+ async function openChange(root, change) {
3819
+ let config;
3820
+ try {
3821
+ config = await loadConfig(root);
3822
+ } catch (error) {
3823
+ return fail3(error instanceof Error ? error.message : "Config is invalid.");
3824
+ }
3825
+ const id = (change?.trim() || await getActiveChangeId(root, config) || "").trim();
3826
+ if (!id) return fail3("No active change.");
3827
+ if (!safeId(id)) return fail3("A change id is not valid.");
3828
+ try {
3829
+ const ctx = await buildContext(root, config, id);
3830
+ return { id, path: ctx.path, stage: ctx.meta.stage, workflow: ctx.workflow, meta: ctx.meta };
3831
+ } catch (error) {
3832
+ return fail3(error instanceof Error ? error.message : "Change not found.");
3833
+ }
3834
+ }
3835
+ function isFailure(value) {
3836
+ return "exitCode" in value;
3837
+ }
3838
+ function memberBots(team, names) {
3839
+ const bots = [];
3840
+ const seen = /* @__PURE__ */ new Set();
3841
+ for (const name of names) {
3842
+ const exact = team.bots.find((bot) => bot.name === name);
3843
+ const chosen = exact ?? team.bots.find((bot) => bot.role === name);
3844
+ if (!chosen) return { error: "Bot not found." };
3845
+ if (seen.has(chosen.name)) continue;
3846
+ seen.add(chosen.name);
3847
+ bots.push(chosen);
3848
+ }
3849
+ if (bots.length === 0) return { error: "A room needs a member." };
3850
+ return bots;
3851
+ }
3852
+ async function draftOf(changePath2, stage) {
3853
+ const rel = stage?.artifacts[0]?.path;
3854
+ if (!rel || rel.includes("..")) return "";
3855
+ const file = join12(changePath2, rel);
3856
+ if (!await pathExists(file)) return "";
3857
+ return readText(file);
3858
+ }
3859
+ function formatEntry(entry) {
3860
+ const cite = entry.cite ? ` ${entry.cite}` : "";
3861
+ return `${entry.round} ${entry.from} ${entry.type}${cite} ${entry.text}`;
3862
+ }
3863
+ async function appendEntry(file, entry) {
3864
+ await ensureDir(join12(file, ".."));
3865
+ await appendFile3(file, `${JSON.stringify(entry)}
3866
+ `, "utf8");
3867
+ }
3868
+ async function consensusLine(changePath2, stage, sha, entries) {
3869
+ const found = consensus(entries);
3870
+ const agreed = found.agreed.join(",") || "none";
3871
+ const open = found.open.map((entry) => entry.cite || entry.text).join(",") || "none";
3872
+ await appendFile3(
3873
+ join12(changePath2, "decisions.md"),
3874
+ `${nowIso()} room ${stage} ${sha ?? "none"} agreed ${agreed} open ${open}
3875
+ `
3876
+ );
3877
+ }
3878
+ async function showRoom(root, input) {
3879
+ const team = await readTeamConfig(root);
3880
+ if ("error" in team) return fail3(team.error);
3881
+ const opened = await openChange(root, input.change);
3882
+ if (isFailure(opened)) return opened;
3883
+ const stage = (input.stage?.trim() || opened.stage).trim();
3884
+ if (!safeId(stage)) return fail3("A stage id is not valid.");
3885
+ const entries = await readEntries(roomPath(root, opened.id, stage));
3886
+ if (entries.length === 0) return ok3("No room.");
3887
+ return ok3(entries.map(formatEntry).join("\n"));
3888
+ }
3889
+ async function postRoom(root, input) {
3890
+ const team = await readTeamConfig(root);
3891
+ if ("error" in team) return fail3(team.error);
3892
+ const opened = await openChange(root, input.change);
3893
+ if (isFailure(opened)) return opened;
3894
+ const stage = (input.stage?.trim() || opened.stage).trim();
3895
+ if (!safeId(stage)) return fail3("A stage id is not valid.");
3896
+ const text2 = oneLine(input.text, "A room entry needs text.", "A room entry is one line.");
3897
+ if (typeof text2 !== "string") return text2;
3898
+ const type = input.type?.trim() || "post";
3899
+ if (!ROOM_TYPES.includes(type)) return fail3("A room entry is not valid.");
3900
+ const cite = input.cite?.trim() ?? "";
3901
+ if (cite && (cite.includes("\n") || cite.includes("\r"))) return fail3("A cite is one line.");
3902
+ if (type === "objection" && !cite) return fail3("An objection needs a cite.");
3903
+ const stageDef = getStage(opened.workflow, opened.meta, stage);
3904
+ const needed = await reviewNeedsEvidence(
3905
+ root,
3906
+ opened.id,
3907
+ stageDef?.verify?.commands?.length ?? 0,
3908
+ type,
3909
+ text2,
3910
+ cite
3911
+ );
3912
+ if (needed) return fail3(needed);
3913
+ const evidence = [];
3914
+ for (const item of input.evidence ?? []) {
3915
+ const line = oneLine(item, "Evidence needs text.", "Evidence is one line.");
3916
+ if (typeof line !== "string") return line;
3917
+ evidence.push(line);
3918
+ }
3919
+ const file = roomPath(root, opened.id, stage);
3920
+ const prior = await readEntries(file);
3921
+ const round = prior.reduce((max, entry2) => Math.max(max, entry2.round), 1);
3922
+ const sha = await headSha(root);
3923
+ const entry = {
3924
+ ts: nowIso(),
3925
+ from: input.from?.trim() || gitUserName(root) || "person",
3926
+ type,
3927
+ text: text2,
3928
+ round: prior.length === 0 ? 1 : round
3929
+ };
3930
+ if (cite) entry.cite = cite;
3931
+ if (sha) entry.sha = sha;
3932
+ if (evidence.length > 0) entry.evidence = evidence;
3933
+ await appendEntry(file, entry);
3934
+ return ok3("Posted.");
3935
+ }
3936
+ async function classifyReply(root, text2, env, runCommand2) {
3937
+ const loaded = await loadModels(root);
3938
+ if (loaded === null) return "post";
3939
+ if ("error" in loaded) return fail3(loaded.error);
3940
+ const decision = await decide({
3941
+ models: loaded.config,
3942
+ ctx: { workType: "classify" },
3943
+ state: text2,
3944
+ question: "What kind of room entry is this?",
3945
+ options: ROOM_TYPES,
3946
+ env,
3947
+ runCommand: runCommand2
3948
+ });
3949
+ if (decision.pick === "human") return "post";
3950
+ return decision.pick;
3951
+ }
3952
+ async function reviewRoom(root, input) {
3953
+ if (!input.model) return fail3(ROLE_TEXT.noModel);
3954
+ const loaded = await readTeamConfig(root);
3955
+ if ("error" in loaded) return fail3(loaded.error);
3956
+ const opened = await openChange(root, input.change);
3957
+ if (isFailure(opened)) return opened;
3958
+ const stageId = (input.stage?.trim() || opened.stage).trim();
3959
+ if (!safeId(stageId)) return fail3("A stage id is not valid.");
3960
+ const stage = getStage(opened.workflow, opened.meta, stageId);
3961
+ if (!stage) return fail3("Stage not found.");
3962
+ const names = input.roles && input.roles.length > 0 ? input.roles : [stage.owner, ...stage.collaborates ?? []].filter((name) => !!name);
3963
+ const bots = memberBots(loaded.team, names);
3964
+ if ("error" in bots) return fail3(bots.error);
3965
+ const file = roomPath(root, opened.id, stageId);
3966
+ const prior = await readEntries(file);
3967
+ const highest = prior.reduce((max, entry) => Math.max(max, entry.round), 0);
3968
+ const round = highest + 1;
3969
+ const cap = roomRounds(loaded.team);
3970
+ if (round > cap) return ok3("Round limit reached.");
3971
+ const draft = await draftOf(opened.path, stage);
3972
+ const before = consensus(prior.filter((entry) => entry.round === highest));
3973
+ const sha = await headSha(root);
3974
+ const written = [];
3975
+ for (const bot of bots) {
3976
+ const role = await readRole(root, bot.role);
3977
+ if (!role) return fail3(ROLE_TEXT.notFound);
3978
+ const section = {
3979
+ id: stageId,
3980
+ decision: "room",
3981
+ checks: before.open.map((entry2) => entry2.cite || "room"),
3982
+ body: draft
3983
+ };
3984
+ if (section.checks.length === 0) section.checks = ["room"];
3985
+ const rows = [critiquePrompt(role, section, draft || "(none)"), "Room:"];
3986
+ for (const entry2 of prior) rows.push(formatEntry(entry2));
3987
+ const owner = stage.owner === bot.role || stage.owner === bot.name;
3988
+ if (owner && before.open.length > 0) {
3989
+ rows.push(
3990
+ revisionPrompt(
3991
+ role,
3992
+ draft || "(none)",
3993
+ before.open.map((entry2) => ({ cite: entry2.cite || "room", text: entry2.text }))
3994
+ )
3995
+ );
3996
+ }
3997
+ rows.push('Reply with JSON {"type":"post","text":"...","cite":"..."}');
3998
+ rows.push("An objection needs a cite.");
3999
+ let raw = "";
4000
+ try {
4001
+ raw = await input.model.complete({ roleId: role.name, prompt: rows.join("\n") });
4002
+ } catch {
4003
+ return fail3("A room entry is not valid.");
4004
+ }
4005
+ const stored = await entryFromReply(root, raw, input.env, input.runCommand);
4006
+ if (isFailure(stored)) return stored;
4007
+ const needed = await reviewNeedsEvidence(
4008
+ root,
4009
+ opened.id,
4010
+ stage.verify?.commands?.length ?? 0,
4011
+ stored.type,
4012
+ stored.text,
4013
+ stored.cite ?? ""
4014
+ );
4015
+ if (needed) return fail3(needed);
4016
+ const entry = {
4017
+ ts: nowIso(),
4018
+ from: bot.name,
4019
+ type: stored.type,
4020
+ text: stored.text,
4021
+ round
4022
+ };
4023
+ if (stored.cite) entry.cite = stored.cite;
4024
+ if (sha) entry.sha = sha;
4025
+ await appendEntry(file, entry);
4026
+ written.push(entry);
4027
+ }
4028
+ const latest = written;
4029
+ await consensusLine(opened.path, stageId, sha, latest);
4030
+ if (round >= cap && consensus(latest).open.length > 0) {
4031
+ const asked = await recordDecisionRequest(root, {
4032
+ change: opened.id,
4033
+ note: `Room ${stageId} still open after ${round} rounds.`
4034
+ });
4035
+ if (asked.exitCode !== 0) return asked;
4036
+ return ok3("Decision needed.");
4037
+ }
4038
+ return ok3(`Round ${round}.`);
4039
+ }
4040
+ async function entryFromReply(root, raw, env = process.env, runCommand2) {
4041
+ let value;
4042
+ try {
4043
+ value = JSON.parse(raw);
4044
+ } catch {
4045
+ value = null;
4046
+ }
4047
+ if (value && typeof value === "object" && !Array.isArray(value)) {
4048
+ const rec = value;
4049
+ if (typeof rec.type === "string" && typeof rec.text === "string") {
4050
+ return checked(rec.type, rec.text, typeof rec.cite === "string" ? rec.cite : "");
4051
+ }
4052
+ }
4053
+ const text2 = raw.trim();
4054
+ if (!text2 || text2.includes("\n") || text2.includes("\r"))
4055
+ return fail3("A room entry is not valid.");
4056
+ const typed = await classifyReply(root, text2, env, runCommand2);
4057
+ if (typeof typed !== "string") return typed;
4058
+ return checked(typed, text2, "");
4059
+ }
4060
+ function checked(type, text2, cite) {
4061
+ if (!ROOM_TYPES.includes(type)) return fail3("A room entry is not valid.");
4062
+ const words = oneLine(text2, "A room entry needs text.", "A room entry is one line.");
4063
+ if (typeof words !== "string") return words;
4064
+ const citeWords = cite.trim();
4065
+ if (citeWords && (cite.includes("\n") || cite.includes("\r"))) return fail3("A cite is one line.");
4066
+ if (type === "objection" && !citeWords) return fail3("An objection needs a cite.");
4067
+ return citeWords ? { type, text: words, cite: citeWords } : { type, text: words };
4068
+ }
4069
+ async function activeRoom(root) {
4070
+ try {
4071
+ const config = await loadConfig(root);
4072
+ const id = await getActiveChangeId(root, config);
4073
+ if (!id || !safeId(id)) return null;
4074
+ const ctx = await buildContext(root, config, id);
4075
+ const stage = ctx.meta.stage;
4076
+ if (!safeId(stage)) return null;
4077
+ const file = roomPath(root, id, stage);
4078
+ if (!await pathExists(file)) return null;
4079
+ return { file, text: await readText(file) };
4080
+ } catch {
4081
+ return null;
4082
+ }
4083
+ }
4084
+ async function roomForPrompt(root) {
4085
+ const room = await activeRoom(root);
4086
+ return room?.text.trim() ?? "";
4087
+ }
4088
+ async function postLoopDone(root, bot, text2, cite) {
4089
+ const words = text2.trim();
4090
+ if (!words || words.includes("\n") || words.includes("\r")) return;
4091
+ const room = await activeRoom(root);
4092
+ if (!room) return;
4093
+ let storedCite = "";
4094
+ if (claimsPass(words)) {
4095
+ try {
4096
+ const config = await loadConfig(root);
4097
+ const id = await getActiveChangeId(root, config);
4098
+ if (!id || !cite || !await evidenceSupports(root, id, cite)) return;
4099
+ storedCite = cite;
4100
+ } catch {
4101
+ return;
4102
+ }
4103
+ }
4104
+ const prior = await readEntries(room.file);
4105
+ const round = prior.reduce((max, entry2) => Math.max(max, entry2.round), 1);
4106
+ const sha = await headSha(root);
4107
+ const entry = {
4108
+ ts: nowIso(),
4109
+ from: bot,
4110
+ type: "post",
4111
+ text: words,
4112
+ round: prior.length === 0 ? 1 : round
4113
+ };
4114
+ if (storedCite) entry.cite = storedCite;
4115
+ if (sha) entry.sha = sha;
4116
+ await appendEntry(room.file, entry);
4117
+ }
4118
+
4119
+ // src/team/loop.ts
4120
+ var planStep = z5.object({ step: z5.literal("plan"), items: z5.array(z5.string()) }).strict();
4121
+ var toolStep = z5.object({ step: z5.literal("tool"), name: z5.string().min(1), args: z5.unknown() }).strict();
4122
+ var checkStep = z5.object({
4123
+ step: z5.literal("check"),
4124
+ claim: z5.string().min(1),
4125
+ evidence: z5.array(z5.string()).optional()
4126
+ }).strict();
4127
+ var askStep = z5.object({
4128
+ step: z5.literal("ask"),
4129
+ question: z5.string().min(1),
4130
+ options: z5.array(z5.string()),
4131
+ suggest: z5.string().optional()
4132
+ }).strict();
4133
+ var doneStep = z5.object({
4134
+ step: z5.literal("done"),
4135
+ summary: z5.string(),
4136
+ outputs: z5.array(z5.string()),
4137
+ evidence: z5.array(z5.string()).optional()
4138
+ }).strict();
4139
+ var LoopStepSchema = z5.discriminatedUnion("step", [
4140
+ planStep,
4141
+ toolStep,
4142
+ checkStep,
4143
+ askStep,
4144
+ doneStep
4145
+ ]);
4146
+ function ok4(stdout = "") {
4147
+ return { exitCode: 0, stdout };
4148
+ }
4149
+ function fail4(stdout) {
4150
+ return { exitCode: 1, stdout };
4151
+ }
4152
+ async function appendLine(file, value) {
4153
+ await ensureDir(join13(file, ".."));
4154
+ await appendFile4(file, `${JSON.stringify(value)}
4155
+ `, "utf8");
4156
+ }
4157
+ async function runLoop(input) {
4158
+ const max = input.cfg.limits?.loop_max_steps ?? 8;
4159
+ const room = await roomForPrompt(input.root);
4160
+ const base = room ? `${input.brain.prompt}
4161
+ Room:
4162
+ ${room}` : input.brain.prompt;
4163
+ let tools = "";
4164
+ const promptFor = () => tools ? `${base}
4165
+ Tools:
4166
+ ${tools}` : base;
4167
+ const remember = (text2) => {
4168
+ const piece = text2.trim().slice(-4e3);
4169
+ tools = `${tools}
4170
+ ${piece}`.trim().slice(-8e3);
4171
+ };
4172
+ let taken = 0;
4173
+ let copied = false;
4174
+ const copyReviewer = async () => {
4175
+ if (copied) return;
4176
+ copied = true;
4177
+ const lesson = await priorLesson(input.root, input.logName, input.file, input.roleName);
4178
+ if (!lesson) return;
4179
+ await addReviewerLesson(input.root, input.brain.bot.name, lesson);
4180
+ };
4181
+ while (taken < max) {
4182
+ let raw;
4183
+ try {
4184
+ raw = await input.model.complete({ roleId: input.roleName, prompt: promptFor() });
4185
+ } catch {
4186
+ await appendLine(input.file, { type: "bad_call", roleId: input.roleName });
4187
+ return fail4("A loop step is not valid.");
4188
+ }
4189
+ taken += 1;
4190
+ const step = parseStep(raw);
4191
+ if (!step) {
4192
+ await appendLine(input.file, { type: "bad_call", roleId: input.roleName });
4193
+ return fail4("A loop step is not valid.");
4194
+ }
4195
+ if (step.kind === "understand") {
4196
+ await appendLine(input.file, { type: "step", step: "understand" });
4197
+ continue;
4198
+ }
4199
+ if (step.kind === "reflect") {
4200
+ await appendLine(input.file, { type: "step", step: "reflect" });
4201
+ await copyReviewer();
4202
+ continue;
4203
+ }
4204
+ if (step.kind === "schema") {
4205
+ const body = step.step;
4206
+ if (body.step === "plan") {
4207
+ await appendLine(input.file, { type: "step", step: "plan", items: body.items });
4208
+ continue;
4209
+ }
4210
+ if (body.step === "tool") {
4211
+ const ran = await runTool(input.root, input.cfg, input.brain.bot.name, {
4212
+ name: body.name,
4213
+ args: body.args
4214
+ });
4215
+ remember(ran.text);
4216
+ await appendLine(input.file, { type: "step", step: "tool", name: body.name, ok: ran.ok });
4217
+ continue;
4218
+ }
4219
+ if (body.step === "check") {
4220
+ const refusal2 = await passClaimRefusal(input.root, body.claim, body.evidence ?? []);
4221
+ if (refusal2) {
4222
+ remember(refusal2);
4223
+ await appendLine(input.file, { type: "step", step: "refused", text: refusal2 });
4224
+ continue;
4225
+ }
4226
+ await appendLine(input.file, {
4227
+ type: "step",
4228
+ step: "check",
4229
+ claim: body.claim,
4230
+ evidence: body.evidence ?? []
4231
+ });
4232
+ continue;
4233
+ }
4234
+ if (body.step === "ask") return ask(input.root, input.file, body.question, body.options);
4235
+ const cites = body.evidence ?? [];
4236
+ const refusal = await passClaimRefusal(input.root, body.summary, cites);
4237
+ if (refusal) {
4238
+ remember(refusal);
4239
+ await appendLine(input.file, { type: "step", step: "refused", text: refusal });
4240
+ continue;
4241
+ }
4242
+ await appendLine(input.file, { type: "step", step: "done", summary: body.summary });
4243
+ await copyReviewer();
4244
+ await postLoopDone(input.root, input.brain.bot.name, body.summary, cites[0]);
4245
+ return ok4(body.summary);
4246
+ }
4247
+ }
4248
+ return ask(input.root, input.file, `Stopped after ${max} steps.`, ["continue", "stop"]);
4249
+ }
4250
+ async function ask(root, file, question, options) {
4251
+ await appendLine(file, { type: "step", step: "ask", question, options });
4252
+ const note = question.trim();
4253
+ if (note && !note.includes("\n") && !note.includes("\r")) {
4254
+ await recordDecisionRequest(root, { note });
4255
+ }
4256
+ return ok4(question);
4257
+ }
4258
+ function parseStep(raw) {
4259
+ let value;
4260
+ try {
4261
+ value = JSON.parse(raw);
4262
+ } catch {
4263
+ return null;
4264
+ }
4265
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
4266
+ const rec = { ...value };
4267
+ if (rec.step === "understand") return { kind: "understand" };
4268
+ if (rec.step === "reflect") return { kind: "reflect" };
4269
+ if (rec.step === "stop") {
4270
+ rec.step = "done";
4271
+ if (typeof rec.summary !== "string") rec.summary = typeof rec.text === "string" ? rec.text : "";
4272
+ if (!Array.isArray(rec.outputs)) rec.outputs = [];
4273
+ }
4274
+ if (rec.step === "act") {
4275
+ rec.step = "tool";
4276
+ if (typeof rec.name !== "string" || !rec.name) rec.name = "act";
4277
+ if (rec.args === void 0) rec.args = {};
4278
+ }
4279
+ if (rec.step === void 0 && typeof rec.text === "string") {
4280
+ const summary = rec.text;
4281
+ const { text: _ignored, ...rest } = rec;
4282
+ const done = LoopStepSchema.safeParse({ ...rest, step: "done", summary, outputs: [] });
4283
+ if (!done.success) return null;
4284
+ return { kind: "schema", step: done.data };
4285
+ }
4286
+ const parsed = LoopStepSchema.safeParse(rec);
4287
+ if (!parsed.success) return null;
4288
+ return { kind: "schema", step: parsed.data };
4289
+ }
4290
+ async function priorLesson(root, logName, file, roleName) {
4291
+ const dir = conversationDir(root, logName);
4292
+ const names = (await listFiles(dir)).filter((item) => item.endsWith(".jsonl")).sort();
4293
+ const index = names.indexOf(basename(file));
4294
+ if (index <= 0) return null;
4295
+ const prior = join13(dir, names[index - 1]);
4296
+ if (!await pathExists(prior)) return null;
4297
+ let fromChange = null;
4298
+ let fromOther = null;
4299
+ for (const line of (await readText(prior)).split(/\r?\n/)) {
4300
+ if (!line.trim()) continue;
4301
+ let value;
4302
+ try {
4303
+ value = JSON.parse(line);
4304
+ } catch {
4305
+ continue;
4306
+ }
4307
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
4308
+ const rec = value;
4309
+ if (rec.type !== "model") continue;
4310
+ const roleId = typeof rec.roleId === "string" ? rec.roleId : "reviewer";
4311
+ const changed = Array.isArray(rec.changed) ? rec.changed.filter((cite) => typeof cite === "string" && cite.trim() !== "") : [];
4312
+ if (changed.length > 0) fromChange = { by: roleId, text: changed.join(", ") };
4313
+ else if (roleId !== roleName && typeof rec.text === "string" && !rec.text.includes("\n")) {
4314
+ fromOther = { by: roleId, text: rec.text };
4315
+ }
4316
+ }
4317
+ return fromChange ?? fromOther;
4318
+ }
4319
+
4320
+ // src/role/run.ts
4321
+ var DRAFT_KINDS = /* @__PURE__ */ new Set(["code", "scenario", "release"]);
4322
+ var FORBIDDEN = /* @__PURE__ */ new Set([
4323
+ "accept_spec",
4324
+ "replace_decision",
4325
+ "write_qa",
4326
+ "generate_release",
4327
+ "sdd_next",
4328
+ "sdd_complete",
4329
+ "create_change"
4330
+ ]);
4331
+ var UNAVAILABLE = /* @__PURE__ */ new Set(["commit", "push", "tag", "pull_request", "merge", "deploy"]);
4332
+ var CLOSED = /* @__PURE__ */ new Set(["applied", "denied", "rejected", "unavailable", "stopped", "expired"]);
4333
+ function ok5(stdout = "", runId) {
4334
+ return runId ? { exitCode: 0, stdout, runId } : { exitCode: 0, stdout };
4335
+ }
4336
+ function fail5(stdout, runId) {
4337
+ return runId ? { exitCode: 1, stdout, runId } : { exitCode: 1, stdout };
4338
+ }
4339
+ function linesOf(...parts) {
4340
+ return parts.filter((part) => part.length > 0).join("\n");
4341
+ }
4342
+ function runIdFrom(date) {
4343
+ const iso = date.toISOString();
4344
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/.exec(iso);
4345
+ if (!match) return "unknown";
4346
+ return `${match[1]}${match[2]}${match[3]}T${match[4]}${match[5]}${match[6]}Z`;
4347
+ }
4348
+ function mentionsX(task) {
4349
+ return /@X|x\.com|twitter\.com/.test(task);
4350
+ }
4351
+ function safeChangeId(id) {
4352
+ if (!id || id.length > 200) return false;
4353
+ if (id === "." || id === "..") return false;
4354
+ return !id.includes("/") && !id.includes("\\") && !id.includes("\0");
4355
+ }
4356
+ function safeRunId(id) {
4357
+ if (!id || id.length > 80) return false;
4358
+ if (id === "." || id === "..") return false;
4359
+ return !id.includes("/") && !id.includes("\\") && !id.includes("\0");
4360
+ }
4361
+ function isSafeProductPath(rel) {
4362
+ if (typeof rel !== "string" || rel.length === 0 || rel.includes("\0")) return false;
4363
+ if (isAbsolute4(rel) || rel.startsWith("\\")) return false;
4364
+ if (/^[A-Za-z]:/.test(rel)) return false;
4365
+ const parts = rel.split(/[/\\]/);
4366
+ for (const part of parts) {
4367
+ if (part === "" || part === "." || part === ".." || part === ".active" || part === ".sdd") {
4368
+ return false;
4369
+ }
4370
+ }
4371
+ return parts[parts.length - 1] !== "accepted.md";
4372
+ }
4373
+ function canonicalJson(value) {
4374
+ return JSON.stringify(sortKeys(value));
4375
+ }
4376
+ function sortKeys(value) {
4377
+ if (Array.isArray(value)) return value.map(sortKeys);
2719
4378
  if (value && typeof value === "object") {
2720
4379
  const rec = value;
2721
4380
  const out = {};
@@ -2811,8 +4470,8 @@ function parseReply(raw) {
2811
4470
  return { text: rec.text, gaps, changed, action };
2812
4471
  }
2813
4472
  async function appendJson(file, value) {
2814
- await ensureDir(join9(file, ".."));
2815
- await appendFile(file, `${JSON.stringify(value)}
4473
+ await ensureDir(join14(file, ".."));
4474
+ await appendFile5(file, `${JSON.stringify(value)}
2816
4475
  `, "utf8");
2817
4476
  }
2818
4477
  async function readJsonLines(file) {
@@ -2828,7 +4487,7 @@ async function readJsonLines(file) {
2828
4487
  return lines;
2829
4488
  }
2830
4489
  async function timeoutMinutes(root) {
2831
- const path = join9(root, ".sdd", "role.yaml");
4490
+ const path = join14(root, ".sdd", "role.yaml");
2832
4491
  if (!await pathExists(path)) return 10;
2833
4492
  let data;
2834
4493
  try {
@@ -2865,30 +4524,10 @@ function authorPrompt(role, task, kind, section) {
2865
4524
  }
2866
4525
  return rows.join("\n");
2867
4526
  }
2868
- function critiquePrompt(role, section, draft) {
2869
- return [
2870
- `Job: ${role.job}`,
2871
- "Check ids:",
2872
- ...section.checks,
2873
- `Cite: ${section.id}:${section.decision}`,
2874
- "Draft:",
2875
- draft
2876
- ].join("\n");
2877
- }
2878
- function revisionPrompt(role, draft, gaps) {
2879
- return [
2880
- `Job: ${role.job}`,
2881
- "Draft:",
2882
- draft,
2883
- "Gaps:",
2884
- ...gaps.map((gap) => `${gap.cite}: ${gap.text}`),
2885
- "changed must list every gap cite."
2886
- ].join("\n");
2887
- }
2888
4527
  async function acceptedFile(root, changeId) {
2889
- if (!await pathExists(configPath(root))) return join9(root, "changes", changeId, "accepted.md");
4528
+ if (!await pathExists(configPath(root))) return join14(root, "changes", changeId, "accepted.md");
2890
4529
  const config = await loadConfig(root);
2891
- return join9(changesDir(root, config), changeId, "accepted.md");
4530
+ return join14(changesDir(root, config), changeId, "accepted.md");
2892
4531
  }
2893
4532
  async function callModel(model, file, roleId, prompt, pass) {
2894
4533
  let raw;
@@ -2915,12 +4554,16 @@ async function readRule(root, roleName) {
2915
4554
  const name = typeof rec.tool === "string" ? rec.tool : typeof rec.name === "string" ? rec.name : "";
2916
4555
  if (!name) return null;
2917
4556
  if (!rec.inputs || typeof rec.inputs !== "object" || Array.isArray(rec.inputs)) return null;
4557
+ const stored = typeof rec.sha === "string" ? rec.sha : "";
4558
+ const head = await getHeadSha(root);
4559
+ if (head && stored !== head) return null;
2918
4560
  return { name, inputs: rec.inputs };
2919
4561
  }
2920
4562
  async function saveRule(root, roleName, rule) {
2921
4563
  const path = allowFilePath(root);
2922
4564
  const existing = await readAllowFile(root) ?? {};
2923
- existing[roleName] = { tool: rule.name, inputs: rule.inputs };
4565
+ const head = await getHeadSha(root);
4566
+ existing[roleName] = head ? { tool: rule.name, inputs: rule.inputs, sha: head } : { tool: rule.name, inputs: rule.inputs };
2924
4567
  await writeText(path, `${JSON.stringify(existing, null, 2)}
2925
4568
  `);
2926
4569
  }
@@ -2939,7 +4582,7 @@ async function applyAction(root, file, roleName, action, mode) {
2939
4582
  await appendJson(file, { type: "rejected" });
2940
4583
  return { exitCode: 1, stdout: ROLE_TEXT.cannot, applied: false };
2941
4584
  }
2942
- await writeText(join9(root, ...path.split(/[/\\]/)), contents);
4585
+ await writeText(join14(root, ...path.split(/[/\\]/)), contents);
2943
4586
  if (mode === "always")
2944
4587
  await saveRule(root, roleName, { name: action.name, inputs: action.inputs });
2945
4588
  await appendJson(file, { type: "applied", name: "write_file" });
@@ -2947,16 +4590,16 @@ async function applyAction(root, file, roleName, action, mode) {
2947
4590
  }
2948
4591
  async function finishRun(root, file, role, final, minutes, now, runId) {
2949
4592
  const action = final.action;
2950
- if (!action) return ok2(final.text, runId);
4593
+ if (!action) return ok5(final.text, runId);
2951
4594
  if (FORBIDDEN.has(action.name) || !UNAVAILABLE.has(action.name) && action.name !== "write_file") {
2952
4595
  await appendJson(file, { type: "rejected" });
2953
- return fail2(linesOf(final.text, ROLE_TEXT.cannot), runId);
4596
+ return fail5(linesOf(final.text, ROLE_TEXT.cannot), runId);
2954
4597
  }
2955
4598
  const rule = await readRule(root, role.name);
2956
4599
  if (!role.askFirst && rule && rule.name === action.name && sameInputs(rule.inputs, action.inputs)) {
2957
4600
  const applied = await applyAction(root, file, role.name, action, "always");
2958
4601
  const stdout = applied.applied ? final.text : linesOf(final.text, applied.stdout);
2959
- return applied.exitCode === 0 ? ok2(stdout, runId) : fail2(stdout, runId);
4602
+ return applied.exitCode === 0 ? ok5(stdout, runId) : fail5(stdout, runId);
2960
4603
  }
2961
4604
  const expiresAt = new Date(now.getTime() + minutes * 6e4).toISOString();
2962
4605
  await appendJson(file, {
@@ -2964,12 +4607,12 @@ async function finishRun(root, file, role, final, minutes, now, runId) {
2964
4607
  action: { name: action.name, words: action.words, inputs: action.inputs },
2965
4608
  expiresAt
2966
4609
  });
2967
- if (role.askFirst) return ok2(linesOf(action.words, ROLE_TEXT.askFirst), runId);
2968
- return ok2(linesOf(action.words, ROLE_TEXT.choice), runId);
4610
+ if (role.askFirst) return ok5(linesOf(action.words, ROLE_TEXT.askFirst), runId);
4611
+ return ok5(linesOf(action.words, ROLE_TEXT.choice), runId);
2969
4612
  }
2970
4613
  async function settleRoleReply(root, file, role, reply, now, runId) {
2971
4614
  const minutes = await timeoutMinutes(root);
2972
- if (minutes === "invalid") return fail2(ROLE_TEXT.badYaml, runId);
4615
+ if (minutes === "invalid") return fail5(ROLE_TEXT.badYaml, runId);
2973
4616
  return finishRun(
2974
4617
  root,
2975
4618
  file,
@@ -2984,54 +4627,69 @@ async function latestRunFile(root, name) {
2984
4627
  const dir = conversationDir(root, name);
2985
4628
  const files = (await listFiles(dir)).filter((file) => file.endsWith(".jsonl")).sort();
2986
4629
  const last = files[files.length - 1];
2987
- return last ? join9(dir, last) : null;
4630
+ return last ? join14(dir, last) : null;
2988
4631
  }
2989
4632
  async function runRole(root, input) {
2990
4633
  const name = canonicalRoleName(input.name);
2991
- if (!name) return fail2(ROLE_TEXT.badName);
4634
+ if (!name) return fail5(ROLE_TEXT.badName);
2992
4635
  if (input.stop) {
2993
- if (!await readRole(root, name)) return fail2(ROLE_TEXT.notFound);
4636
+ if (!await readRole(root, name)) return fail5(ROLE_TEXT.notFound);
2994
4637
  const latest = await latestRunFile(root, name);
2995
- if (!latest) return fail2(ROLE_TEXT.runNotFound);
4638
+ if (!latest) return fail5(ROLE_TEXT.runNotFound);
2996
4639
  await appendJson(latest, { type: "stopped" });
2997
- return ok2();
4640
+ return ok5();
4641
+ }
4642
+ let roleName = name;
4643
+ let team = null;
4644
+ let botName = "";
4645
+ if (await pathExists(join14(root, ".sdd", "team.yaml"))) {
4646
+ const loaded = await readTeamConfig(root);
4647
+ if ("error" in loaded) return fail5(loaded.error);
4648
+ const resolved = resolveBot(loaded.team, name);
4649
+ if ("error" in resolved) {
4650
+ if (resolved.error === "Name the bot.") return fail5(resolved.error);
4651
+ } else {
4652
+ team = loaded.team;
4653
+ botName = resolved.name;
4654
+ roleName = resolved.role;
4655
+ }
2998
4656
  }
2999
- const role = await readRole(root, name);
3000
- if (!role) return fail2(ROLE_TEXT.notFound);
3001
- if (!role.job.trim()) return fail2(ROLE_TEXT.needsJob);
4657
+ const role = await readRole(root, roleName);
4658
+ if (!role) return fail5(ROLE_TEXT.notFound);
4659
+ if (!role.job.trim()) return fail5(ROLE_TEXT.needsJob);
3002
4660
  const task = input.task ?? "";
3003
4661
  let withName = "";
3004
4662
  if (input.withRole !== void 0 && input.withRole.trim() !== "") {
3005
4663
  const canonical = canonicalRoleName(input.withRole);
3006
- if (!canonical) return fail2(ROLE_TEXT.badName);
3007
- if (canonical === name) return fail2(ROLE_TEXT.self);
4664
+ if (!canonical) return fail5(ROLE_TEXT.badName);
4665
+ if (canonical === name) return fail5(ROLE_TEXT.self);
3008
4666
  withName = canonical;
3009
4667
  }
3010
- if (mentionsX(task)) return fail2(ROLE_TEXT.x);
4668
+ if (mentionsX(task)) return fail5(ROLE_TEXT.x);
3011
4669
  const kind = input.kind?.trim() || "reading";
3012
4670
  let section;
3013
4671
  if (kind !== "reading") {
3014
- if (!DRAFT_KINDS.has(kind)) return fail2(ROLE_TEXT.needsSection);
4672
+ if (!DRAFT_KINDS.has(kind)) return fail5(ROLE_TEXT.needsSection);
3015
4673
  const changeId = input.change?.trim() ?? "";
3016
4674
  const sectionId = input.section?.trim() ?? "";
3017
- if (!safeChangeId(changeId) || !sectionId) return fail2(ROLE_TEXT.needsSection);
4675
+ if (!safeChangeId(changeId) || !sectionId) return fail5(ROLE_TEXT.needsSection);
3018
4676
  const acceptedPath = await acceptedFile(root, changeId);
3019
- if (!await pathExists(acceptedPath)) return fail2(ROLE_TEXT.needsSection);
4677
+ if (!await pathExists(acceptedPath)) return fail5(ROLE_TEXT.needsSection);
3020
4678
  const parsed = parseSection(await readText(acceptedPath), sectionId);
3021
- if (!parsed) return fail2(ROLE_TEXT.needsSection);
4679
+ if (!parsed) return fail5(ROLE_TEXT.needsSection);
3022
4680
  section = parsed;
3023
4681
  }
3024
4682
  let critic = null;
3025
4683
  if (withName && section) {
3026
4684
  critic = await readRole(root, withName);
3027
- if (!critic) return fail2(ROLE_TEXT.notFound);
4685
+ if (!critic) return fail5(ROLE_TEXT.notFound);
3028
4686
  }
3029
4687
  const minutes = await timeoutMinutes(root);
3030
- if (minutes === "invalid") return fail2(ROLE_TEXT.badYaml);
3031
- if (!input.model) return fail2(ROLE_TEXT.noModel);
4688
+ if (minutes === "invalid") return fail5(ROLE_TEXT.badYaml);
4689
+ if (!input.model) return fail5(ROLE_TEXT.noModel);
3032
4690
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3033
4691
  const runId = runIdFrom(now);
3034
- const file = join9(conversationDir(root, name), `${runId}.jsonl`);
4692
+ const file = join14(conversationDir(root, name), `${runId}.jsonl`);
3035
4693
  if (section) {
3036
4694
  await appendJson(file, {
3037
4695
  type: "checks",
@@ -3040,6 +4698,27 @@ async function runRole(root, input) {
3040
4698
  decision: section.decision
3041
4699
  });
3042
4700
  }
4701
+ if (team && botName) {
4702
+ let budget = 12e3;
4703
+ try {
4704
+ const search = await loadSearchConfig(root);
4705
+ budget = budgetForStage(search, void 0);
4706
+ } catch (error) {
4707
+ if (error instanceof SearchConfigError) return fail5("search.yaml is invalid", runId);
4708
+ throw error;
4709
+ }
4710
+ const brain = await loadBrain(root, team, botName, { text: task }, budget);
4711
+ if ("error" in brain) return fail5(brain.error, runId);
4712
+ return runLoop({
4713
+ root,
4714
+ cfg: team,
4715
+ brain,
4716
+ model: input.model,
4717
+ file,
4718
+ roleName: role.name,
4719
+ logName: name
4720
+ });
4721
+ }
3043
4722
  const draft = await callModel(
3044
4723
  input.model,
3045
4724
  file,
@@ -3047,7 +4726,7 @@ async function runRole(root, input) {
3047
4726
  authorPrompt(role, task, kind, section),
3048
4727
  1
3049
4728
  );
3050
- if (!draft) return fail2("", runId);
4729
+ if (!draft) return fail5("", runId);
3051
4730
  let final = draft;
3052
4731
  if (critic && section) {
3053
4732
  const accepted = section;
@@ -3058,10 +4737,10 @@ async function runRole(root, input) {
3058
4737
  critiquePrompt(critic, accepted, draft.text),
3059
4738
  2
3060
4739
  );
3061
- if (!critique) return fail2(draft.text, runId);
4740
+ if (!critique) return fail5(draft.text, runId);
3062
4741
  if (critique.gaps.some((gap) => !citeOk(gap.cite, accepted))) {
3063
4742
  await appendJson(file, { type: "bad_call", roleId: critic.name });
3064
- return fail2(draft.text, runId);
4743
+ return fail5(draft.text, runId);
3065
4744
  }
3066
4745
  if (critique.gaps.length > 0) {
3067
4746
  const revision = await callModel(
@@ -3071,11 +4750,11 @@ async function runRole(root, input) {
3071
4750
  revisionPrompt(role, draft.text, critique.gaps),
3072
4751
  3
3073
4752
  );
3074
- if (!revision) return fail2(draft.text, runId);
4753
+ if (!revision) return fail5(draft.text, runId);
3075
4754
  const cites = critique.gaps.map((gap) => gap.cite);
3076
4755
  if (revision.changed === null || !sameCiteSet(revision.changed, cites)) {
3077
4756
  await appendJson(file, { type: "bad_call", roleId: role.name });
3078
- return fail2(draft.text, runId);
4757
+ return fail5(draft.text, runId);
3079
4758
  }
3080
4759
  final = revision;
3081
4760
  }
@@ -3114,7 +4793,7 @@ async function findRun(root, runId) {
3114
4793
  if (!safeRunId(runId)) return null;
3115
4794
  const hits = [];
3116
4795
  for (const roleName of await listConversationRoles(root)) {
3117
- const file = join9(conversationDir(root, roleName), `${runId}.jsonl`);
4796
+ const file = join14(conversationDir(root, roleName), `${runId}.jsonl`);
3118
4797
  if (await pathExists(file)) hits.push({ file, roleName });
3119
4798
  }
3120
4799
  if (hits.length === 1) return hits[0];
@@ -3126,33 +4805,482 @@ async function findRun(root, runId) {
3126
4805
  }
3127
4806
  async function allowRole(root, input) {
3128
4807
  const found = await findRun(root, input.runId);
3129
- if (!found) return fail2(ROLE_TEXT.runNotFound);
4808
+ if (!found) return fail5(ROLE_TEXT.runNotFound);
3130
4809
  const pending = openPending(await readJsonLines(found.file));
3131
- if (!pending) return fail2(ROLE_TEXT.runNotFound);
4810
+ if (!pending) return fail5(ROLE_TEXT.runNotFound);
3132
4811
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3133
4812
  const expires = Date.parse(pending.expiresAt);
3134
4813
  if (!Number.isFinite(expires) || now.getTime() >= expires) {
3135
4814
  await appendJson(found.file, { type: "expired" });
3136
- return fail2(ROLE_TEXT.expired);
4815
+ return fail5(ROLE_TEXT.expired);
3137
4816
  }
3138
4817
  if (input.mode === "deny") {
3139
4818
  await appendJson(found.file, { type: "denied" });
3140
- return ok2("", input.runId);
4819
+ return ok5("", input.runId);
3141
4820
  }
3142
4821
  const role = await readRole(root, found.roleName);
3143
- if (!role) return fail2(ROLE_TEXT.notFound);
3144
- if (role.askFirst) return ok2(ROLE_TEXT.askFirst, input.runId);
4822
+ if (!role) return fail5(ROLE_TEXT.notFound);
4823
+ if (role.askFirst) return ok5(ROLE_TEXT.askFirst, input.runId);
3145
4824
  const applied = await applyAction(root, found.file, found.roleName, pending, input.mode);
3146
- return applied.exitCode === 0 ? ok2(applied.stdout, input.runId) : fail2(applied.stdout, input.runId);
4825
+ return applied.exitCode === 0 ? ok5(applied.stdout, input.runId) : fail5(applied.stdout, input.runId);
4826
+ }
4827
+
4828
+ // src/role/routing.ts
4829
+ var matcher = z6.string().trim().min(1);
4830
+ var RoutingRuleSchema = z6.object({
4831
+ id: matcher,
4832
+ role: matcher,
4833
+ stage: matcher.optional(),
4834
+ artifact: matcher.optional(),
4835
+ owns: matcher.optional(),
4836
+ skill: matcher.optional()
4837
+ }).strict().superRefine((rule, ctx) => {
4838
+ if (!rule.stage && !rule.artifact && !rule.owns && !rule.skill) {
4839
+ ctx.addIssue({
4840
+ code: "custom",
4841
+ message: "needs stage, artifact, owns, or skill"
4842
+ });
4843
+ }
4844
+ });
4845
+ var RoutingSchema = z6.object({
4846
+ rules: z6.array(RoutingRuleSchema)
4847
+ }).strict().superRefine((file, ctx) => {
4848
+ const seen = /* @__PURE__ */ new Set();
4849
+ for (const [index, rule] of file.rules.entries()) {
4850
+ if (seen.has(rule.id)) {
4851
+ ctx.addIssue({
4852
+ code: "custom",
4853
+ path: ["rules", index, "id"],
4854
+ message: "is repeated"
4855
+ });
4856
+ }
4857
+ seen.add(rule.id);
4858
+ }
4859
+ });
4860
+ function ok6(stdout = "") {
4861
+ return { exitCode: 0, stdout };
4862
+ }
4863
+ function fail6(stdout) {
4864
+ return { exitCode: 1, stdout };
4865
+ }
4866
+ function formatRoutingError(error) {
4867
+ const issue = error.issues[0];
4868
+ const path = issue?.path.join(".") || "routing";
4869
+ const message = issue?.message ?? "invalid";
4870
+ return `routing.yaml is invalid: ${path} ${message}`;
4871
+ }
4872
+ function text(value) {
4873
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
4874
+ }
4875
+ function cleanItem(input) {
4876
+ const item = {};
4877
+ const id = input.id?.trim();
4878
+ const stage = input.stage?.trim();
4879
+ const artifact = input.artifact?.trim();
4880
+ const skill = input.skill?.trim();
4881
+ const bot = input.bot?.trim();
4882
+ if (id) item.id = id;
4883
+ if (stage) item.stage = stage;
4884
+ if (artifact) item.artifact = artifact;
4885
+ if (skill) item.skill = skill;
4886
+ if (bot) item.bot = bot;
4887
+ return item;
4888
+ }
4889
+ function inboxFile(root, name) {
4890
+ const canonical = canonicalRoleName(name);
4891
+ if (!canonical) return null;
4892
+ return join15(root, ".sdd", "agents", canonical, "inbox.jsonl");
4893
+ }
4894
+ async function writeHandoffInboxes(root, record) {
4895
+ const seen = /* @__PURE__ */ new Set();
4896
+ for (const name of [record.from, record.to]) {
4897
+ if (!name || seen.has(name)) continue;
4898
+ seen.add(name);
4899
+ const file = inboxFile(root, name);
4900
+ if (!file) continue;
4901
+ await appendJson(file, record);
4902
+ }
4903
+ }
4904
+ async function readRouting(root) {
4905
+ const path = join15(root, ".sdd", "routing.yaml");
4906
+ if (!await pathExists(path)) return { rules: [] };
4907
+ let raw;
4908
+ try {
4909
+ raw = parseYaml2(await readText(path));
4910
+ } catch {
4911
+ return { error: "routing.yaml is invalid: routing could not be parsed" };
4912
+ }
4913
+ const parsed = RoutingSchema.safeParse(raw);
4914
+ if (!parsed.success) return { error: formatRoutingError(parsed.error) };
4915
+ return parsed.data;
4916
+ }
4917
+ async function loadRoles(root) {
4918
+ const dir = join15(root, ".sdd", "roles");
4919
+ const files = (await listFiles(dir)).filter((file) => file.endsWith(".md")).sort();
4920
+ const roles = [];
4921
+ for (const file of files) {
4922
+ const name = file.slice(0, -".md".length);
4923
+ let parsed;
4924
+ try {
4925
+ parsed = parseRoleDocument(name, await readText(join15(dir, file)));
4926
+ } catch {
4927
+ return { error: `Role file is invalid: ${name}.` };
4928
+ }
4929
+ if (!parsed) return { error: `Role file is invalid: ${name}.` };
4930
+ roles.push(parsed);
4931
+ }
4932
+ return roles;
4933
+ }
4934
+ function ruleMatches(rule, item) {
4935
+ if (rule.stage && rule.stage !== item.stage) return false;
4936
+ if (rule.artifact && rule.artifact !== item.artifact) return false;
4937
+ if (rule.owns && rule.owns !== item.artifact) return false;
4938
+ if (rule.skill && rule.skill !== item.skill) return false;
4939
+ return true;
4940
+ }
4941
+ function listedOwners(roles, item) {
4942
+ if (item.artifact) {
4943
+ const names = roles.filter((role) => role.owns.includes(item.artifact ?? "")).map((role) => role.name);
4944
+ if (names.length > 0) return { rule: "owns", names };
4945
+ }
4946
+ if (item.skill) {
4947
+ const names = roles.filter((role) => role.skills.includes(item.skill ?? "")).map((role) => role.name);
4948
+ if (names.length > 0) return { rule: "skill", names };
4949
+ }
4950
+ return null;
4951
+ }
4952
+ async function stageOwner(root, stageId) {
4953
+ let config;
4954
+ try {
4955
+ config = await loadConfig(root);
4956
+ } catch {
4957
+ return void 0;
4958
+ }
4959
+ const changeId = await getActiveChangeId(root, config);
4960
+ if (!changeId) return void 0;
4961
+ try {
4962
+ const context = await buildContext(root, config, changeId);
4963
+ return context.workflow.stages.find((stage) => stage.id === stageId)?.owner;
4964
+ } catch {
4965
+ return { error: "Change is unreadable." };
4966
+ }
4967
+ }
4968
+ async function matchRoute(root, input) {
4969
+ const item = cleanItem(input);
4970
+ const routing = await readRouting(root);
4971
+ if ("error" in routing) return { ok: false, message: routing.error };
4972
+ const rule = routing.rules.find((candidate) => ruleMatches(candidate, item));
4973
+ if (rule) {
4974
+ return {
4975
+ ok: true,
4976
+ match: { kind: "role", role: rule.role, reason: `Rule ${rule.id} matches.`, rule: rule.id }
4977
+ };
4978
+ }
4979
+ const roles = await loadRoles(root);
4980
+ if ("error" in roles) return { ok: false, message: roles.error };
4981
+ const listed = listedOwners(roles, item);
4982
+ if (listed && listed.names.length > 1) {
4983
+ return { ok: true, match: { kind: "ask", reason: "Two roles own this." } };
4984
+ }
4985
+ if (listed && listed.names.length === 1) {
4986
+ const role = listed.names[0] ?? "";
4987
+ if (listed.rule === "owns") {
4988
+ return {
4989
+ ok: true,
4990
+ match: {
4991
+ kind: "role",
4992
+ role,
4993
+ reason: `One role owns ${item.artifact}.`,
4994
+ rule: "owns"
4995
+ }
4996
+ };
4997
+ }
4998
+ return {
4999
+ ok: true,
5000
+ match: {
5001
+ kind: "role",
5002
+ role,
5003
+ reason: `One role has skill ${item.skill}.`,
5004
+ rule: "skill"
5005
+ }
5006
+ };
5007
+ }
5008
+ if (item.stage) {
5009
+ const owner = await stageOwner(root, item.stage);
5010
+ if (owner && typeof owner === "object") return { ok: false, message: owner.error };
5011
+ if (typeof owner === "string" && roles.some((role) => role.name === owner)) {
5012
+ return {
5013
+ ok: true,
5014
+ match: {
5015
+ kind: "role",
5016
+ role: owner,
5017
+ reason: `Stage ${item.stage} is owned by ${owner}.`,
5018
+ rule: "stage-owner"
5019
+ }
5020
+ };
5021
+ }
5022
+ }
5023
+ return { ok: true, match: { kind: "ask", reason: "No rule matched." } };
5024
+ }
5025
+ async function activeStageItem(root) {
5026
+ let config;
5027
+ try {
5028
+ config = await loadConfig(root);
5029
+ } catch {
5030
+ return { error: "No active change." };
5031
+ }
5032
+ const changeId = await getActiveChangeId(root, config);
5033
+ if (!changeId) return { error: "No active change." };
5034
+ try {
5035
+ const context = await buildContext(root, config, changeId);
5036
+ const stage = context.workflow.stages.find((item2) => item2.id === context.meta.stage) ?? context.active[0];
5037
+ if (!stage) return { error: "No active change." };
5038
+ const artifact = stage.artifacts.find((item2) => !item2.path.endsWith("/"))?.path;
5039
+ const item = { stage: stage.id };
5040
+ if (artifact) item.artifact = artifact;
5041
+ return item;
5042
+ } catch {
5043
+ return { error: "Change is unreadable." };
5044
+ }
5045
+ }
5046
+ async function prepareItem(root, input) {
5047
+ const item = cleanItem(input);
5048
+ if (item.stage || item.artifact || item.skill) return item;
5049
+ const active = await activeStageItem(root);
5050
+ if ("error" in active) return active;
5051
+ const merged = { ...active };
5052
+ if (item.id) merged.id = item.id;
5053
+ if (item.bot) merged.bot = item.bot;
5054
+ return merged;
5055
+ }
5056
+ function formatHit(match) {
5057
+ if (match.kind === "ask") return `Ask the human.
5058
+ ${match.reason}`;
5059
+ return `role ${match.role}
5060
+ reason ${match.reason}
5061
+ rule ${match.rule}`;
5062
+ }
5063
+ function routeState(item) {
5064
+ return JSON.stringify({
5065
+ stage: item.stage ?? null,
5066
+ artifact: item.artifact ?? null,
5067
+ skill: item.skill ?? null,
5068
+ id: item.id ?? null
5069
+ });
5070
+ }
5071
+ async function routeMetrics(root) {
5072
+ try {
5073
+ const config = await loadConfig(root);
5074
+ const changeId = await getActiveChangeId(root, config);
5075
+ if (!changeId) return void 0;
5076
+ return { projectRoot: root, config, changeId };
5077
+ } catch {
5078
+ return void 0;
5079
+ }
5080
+ }
5081
+ async function routeRole(root, input, deps) {
5082
+ const item = await prepareItem(root, input);
5083
+ if ("error" in item) return fail6(item.error);
5084
+ const matched = await matchRoute(root, item);
5085
+ if (!matched.ok) return fail6(matched.message);
5086
+ if (matched.match.kind !== "ask" || matched.match.reason !== "No rule matched.") {
5087
+ return ok6(formatHit(matched.match));
5088
+ }
5089
+ const loaded = await loadModels(root);
5090
+ if (loaded === null) return ok6(formatHit(matched.match));
5091
+ if ("error" in loaded) return fail6(loaded.error);
5092
+ const roles = await loadRoles(root);
5093
+ if ("error" in roles) return fail6(roles.error);
5094
+ const names = roles.map((role) => role.name).sort((left, right) => left.localeCompare(right));
5095
+ const options = names.includes("human") ? names : [...names, "human"];
5096
+ const decision = await decide({
5097
+ models: loaded.config,
5098
+ ctx: { workType: "route", stage: item.stage, bot: item.bot },
5099
+ state: routeState(item),
5100
+ question: "No rule matched.",
5101
+ options,
5102
+ env: deps?.env ?? {},
5103
+ fetch: deps?.fetch,
5104
+ runCommand: deps?.runCommand,
5105
+ now: deps?.now,
5106
+ metrics: await routeMetrics(root)
5107
+ });
5108
+ if (decision.pick !== "human" && names.includes(decision.pick)) {
5109
+ const id = decision.choice?.id ?? "model";
5110
+ return ok6(`role ${decision.pick}
5111
+ reason Model ${id} picked ${decision.pick}.`);
5112
+ }
5113
+ return ok6("Ask the human.\nNo rule matched.");
5114
+ }
5115
+ async function readInbox(root, name) {
5116
+ const file = inboxFile(root, name);
5117
+ if (!file) return { error: ROLE_TEXT.badName };
5118
+ if (!await pathExists(file)) return [];
5119
+ let raw;
5120
+ try {
5121
+ raw = await readText(file);
5122
+ } catch {
5123
+ return { error: "inbox.jsonl is invalid" };
5124
+ }
5125
+ const entries = [];
5126
+ for (const line of raw.split(/\r?\n/)) {
5127
+ if (!line.trim()) continue;
5128
+ let value;
5129
+ try {
5130
+ value = JSON.parse(line);
5131
+ } catch {
5132
+ return { error: "inbox.jsonl is invalid" };
5133
+ }
5134
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
5135
+ return { error: "inbox.jsonl is invalid" };
5136
+ }
5137
+ const record = value;
5138
+ if (record.type === "item") {
5139
+ const id = text(record.id);
5140
+ if (!id || record.status !== "active" && record.status !== "done") {
5141
+ return { error: "inbox.jsonl is invalid" };
5142
+ }
5143
+ entries.push({
5144
+ type: "item",
5145
+ id,
5146
+ status: record.status,
5147
+ stage: text(record.stage),
5148
+ artifact: text(record.artifact)
5149
+ });
5150
+ continue;
5151
+ }
5152
+ if (record.type === "handoff") {
5153
+ const from = text(record.from);
5154
+ const to = text(record.to);
5155
+ if (!from || !to) return { error: "inbox.jsonl is invalid" };
5156
+ entries.push({ type: "handoff", from, to });
5157
+ continue;
5158
+ }
5159
+ if (typeof record.type === "string" && record.type.trim()) {
5160
+ entries.push({ type: "other", label: record.type.trim() });
5161
+ continue;
5162
+ }
5163
+ return { error: "inbox.jsonl is invalid" };
5164
+ }
5165
+ return entries;
5166
+ }
5167
+ function formatInbox(name, entries) {
5168
+ if (entries.length === 0) return `${name}
5169
+ - (empty)`;
5170
+ const lines = entries.map((entry) => {
5171
+ if (entry.type === "item") {
5172
+ const stage = entry.stage ? ` ${entry.stage}` : "";
5173
+ const artifact = entry.artifact ? ` ${entry.artifact}` : "";
5174
+ return `- ${entry.id} ${entry.status}${stage}${artifact}`;
5175
+ }
5176
+ if (entry.type === "handoff") return `- handoff from ${entry.from} to ${entry.to}`;
5177
+ return `- ${entry.label}`;
5178
+ });
5179
+ return `${name}
5180
+ ${lines.join("\n")}`;
5181
+ }
5182
+ async function queueNames(root, bot) {
5183
+ if (bot) {
5184
+ if (!canonicalRoleName(bot)) return { error: ROLE_TEXT.badName };
5185
+ return [bot];
5186
+ }
5187
+ const team = await readTeamConfig(root);
5188
+ if ("team" in team) return team.team.bots.map((item) => item.name);
5189
+ const dirs = (await listDirs(join15(root, ".sdd", "agents"))).sort();
5190
+ if (dirs.length === 0) return { error: team.error };
5191
+ return dirs;
5192
+ }
5193
+ async function queueRole(root, bot) {
5194
+ const names = await queueNames(root, bot?.trim());
5195
+ if ("error" in names) return fail6(names.error);
5196
+ const blocks = [];
5197
+ for (const name of names) {
5198
+ const entries = await readInbox(root, name);
5199
+ if ("error" in entries) return fail6(entries.error);
5200
+ blocks.push(formatInbox(name, entries));
5201
+ }
5202
+ return ok6(blocks.join("\n\n"));
5203
+ }
5204
+ function itemId(item) {
5205
+ if (item.id) return item.id;
5206
+ if (item.artifact) {
5207
+ const base = item.artifact.split("/").pop() ?? "";
5208
+ const stem = base.replace(/\.[A-Za-z0-9]+$/, "");
5209
+ if (stem) return stem;
5210
+ }
5211
+ return item.stage ?? null;
5212
+ }
5213
+ async function takeRole(root, input) {
5214
+ const item = await prepareItem(root, input);
5215
+ if ("error" in item) return fail6(item.error);
5216
+ const matched = await matchRoute(root, item);
5217
+ if (!matched.ok) return fail6(matched.message);
5218
+ const match = matched.match;
5219
+ if (match.kind === "ask") return fail6(formatHit(match));
5220
+ const team = await readTeamConfig(root);
5221
+ if ("error" in team) return fail6(team.error);
5222
+ const bots = team.team.bots.filter((bot) => bot.role === match.role);
5223
+ if (bots.length === 0) return fail6(`No bot has role ${match.role}.`);
5224
+ const requested = input.bot?.trim();
5225
+ if (requested && !canonicalRoleName(requested)) return fail6(ROLE_TEXT.badName);
5226
+ if (requested && !bots.some((bot) => bot.name === requested)) {
5227
+ const known = team.team.bots.some((bot) => bot.name === requested);
5228
+ if (!known) return fail6("Bot not found.");
5229
+ return fail6(`${requested} is not a ${match.role}.`);
5230
+ }
5231
+ const counts = /* @__PURE__ */ new Map();
5232
+ for (const bot of team.team.bots) {
5233
+ const entries = await readInbox(root, bot.name);
5234
+ if ("error" in entries) return fail6(entries.error);
5235
+ counts.set(
5236
+ bot.name,
5237
+ entries.filter((entry) => entry.type === "item" && entry.status === "active").length
5238
+ );
5239
+ }
5240
+ const roleCount = bots.reduce((sum, bot) => sum + (counts.get(bot.name) ?? 0), 0);
5241
+ const roleCap = team.team.role_wip?.[match.role];
5242
+ if (roleCap !== void 0 && roleCount >= roleCap) {
5243
+ return fail6(`${match.role} is at the limit.`);
5244
+ }
5245
+ const chosen = requested ? bots.find((bot) => bot.name === requested) : bots.find((bot) => (counts.get(bot.name) ?? 0) < bot.wip);
5246
+ if (!chosen || (counts.get(chosen.name) ?? 0) >= chosen.wip) {
5247
+ return fail6(`${requested || bots[0]?.name || match.role} is at the limit.`);
5248
+ }
5249
+ const id = itemId(item);
5250
+ if (!id) return fail6("An item needs an id.");
5251
+ for (const bot of team.team.bots) {
5252
+ const entries = await readInbox(root, bot.name);
5253
+ if ("error" in entries) return fail6(entries.error);
5254
+ if (entries.some((entry) => entry.type === "item" && entry.status === "active" && entry.id === id)) {
5255
+ return fail6(`${id} is already active.`);
5256
+ }
5257
+ }
5258
+ const record = {
5259
+ type: "item",
5260
+ id,
5261
+ status: "active",
5262
+ role: match.role,
5263
+ rule: match.rule,
5264
+ reason: match.reason
5265
+ };
5266
+ if (item.stage) record.stage = item.stage;
5267
+ if (item.artifact) record.artifact = item.artifact;
5268
+ if (item.skill) record.skill = item.skill;
5269
+ const file = inboxFile(root, chosen.name);
5270
+ if (!file) return fail6(ROLE_TEXT.badName);
5271
+ await appendJson(file, record);
5272
+ return ok6(`${chosen.name} took ${id}
5273
+ role ${match.role}
5274
+ rule ${match.rule}`);
3147
5275
  }
3148
5276
 
3149
5277
  // src/role/handoff.ts
3150
5278
  var ID = /^[a-z][a-z0-9-]{0,39}$/;
3151
5279
  var ROLE_YAML = ["role.yaml"];
3152
- function ok3(stdout = "", runId) {
5280
+ function ok7(stdout = "", runId) {
3153
5281
  return runId ? { exitCode: 0, stdout, runId } : { exitCode: 0, stdout };
3154
5282
  }
3155
- function fail3(stdout, runId) {
5283
+ function fail7(stdout, runId) {
3156
5284
  return runId ? { exitCode: 1, stdout, runId } : { exitCode: 1, stdout };
3157
5285
  }
3158
5286
  function mentionsX2(task) {
@@ -3163,10 +5291,10 @@ function safeChangeId2(id) {
3163
5291
  return !id.includes("/") && !id.includes("\\") && !id.includes("\0");
3164
5292
  }
3165
5293
  function yamlPath(root) {
3166
- return join10(root, ".sdd", ...ROLE_YAML);
5294
+ return join16(root, ".sdd", ...ROLE_YAML);
3167
5295
  }
3168
5296
  function planPath(root, changeId, sectionId) {
3169
- return join10(root, ".sdd", "role-runs", "plans", changeId, `${sectionId}.json`);
5297
+ return join16(root, ".sdd", "role-runs", "plans", changeId, `${sectionId}.json`);
3170
5298
  }
3171
5299
  async function loadYaml(root) {
3172
5300
  const path = yamlPath(root);
@@ -3197,11 +5325,11 @@ async function messagingOn(root) {
3197
5325
  }
3198
5326
  async function setRoleMessaging(root, enabled) {
3199
5327
  const loaded = await loadYaml(root);
3200
- if (loaded === "invalid") return fail3(ROLE_TEXT.badYaml);
5328
+ if (loaded === "invalid") return fail7(ROLE_TEXT.badYaml);
3201
5329
  const raw = loaded === "missing" ? {} : loaded;
3202
5330
  raw.role_messaging = enabled;
3203
- await writeText(yamlPath(root), stringifyYaml2(raw));
3204
- return ok3(enabled ? "Messaging between roles is on." : "Messaging between roles is off.");
5331
+ await writeText(yamlPath(root), stringifyYaml(raw));
5332
+ return ok7(enabled ? "Messaging between roles is on." : "Messaging between roles is off.");
3205
5333
  }
3206
5334
  function field(body, prefix) {
3207
5335
  const found = [];
@@ -3300,29 +5428,30 @@ function receiverPrompt(role, task, section) {
3300
5428
  async function handoffRole(root, input) {
3301
5429
  const from = canonicalRoleName(input.from);
3302
5430
  const to = canonicalRoleName(input.to);
3303
- if (!from || !to) return fail3(ROLE_TEXT.badName);
3304
- if (from === to) return fail3("A role cannot hand off to itself.");
5431
+ if (!from || !to) return fail7(ROLE_TEXT.badName);
5432
+ if (from === to) return fail7("A role cannot hand off to itself.");
3305
5433
  const sender = await readRole(root, from);
3306
5434
  const receiver = await readRole(root, to);
3307
- if (!sender || !receiver) return fail3(ROLE_TEXT.notFound);
5435
+ if (!sender || !receiver) return fail7(ROLE_TEXT.notFound);
3308
5436
  const task = input.task ?? "";
3309
- if (mentionsX2(task)) return fail3(ROLE_TEXT.x);
5437
+ if (mentionsX2(task)) return fail7(ROLE_TEXT.x);
3310
5438
  const loaded = await acceptedFacts(root, input.change?.trim() ?? "", input.section?.trim() ?? "");
3311
- if (!loaded) return fail3(ROLE_TEXT.needsSection);
5439
+ if (!loaded) return fail7(ROLE_TEXT.needsSection);
3312
5440
  if (loaded.current.owners.length > 0 && !loaded.current.owners.includes(to)) {
3313
- return fail3("One owner handles this section.");
5441
+ return fail7("One owner handles this section.");
3314
5442
  }
3315
5443
  const messaging = await messagingOn(root);
3316
- if (messaging === false) return ok3("Messaging between roles is off.");
3317
- if (!input.model) return fail3(ROLE_TEXT.noModel);
3318
- if (messaging === "invalid") return fail3(ROLE_TEXT.badYaml);
5444
+ if (messaging === false) return ok7("Messaging between roles is off.");
5445
+ if (!input.model) return fail7(ROLE_TEXT.noModel);
5446
+ if (messaging === "invalid") return fail7(ROLE_TEXT.badYaml);
3319
5447
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3320
5448
  const runId = runIdFrom(now);
3321
- const senderFile = join10(conversationDir(root, from), `${runId}.jsonl`);
3322
- const receiverFile = join10(conversationDir(root, to), `${runId}.jsonl`);
5449
+ const senderFile = join16(conversationDir(root, from), `${runId}.jsonl`);
5450
+ const receiverFile = join16(conversationDir(root, to), `${runId}.jsonl`);
3323
5451
  const record = handoffRecord(from, to, input.change?.trim() ?? "", loaded.current);
3324
5452
  await appendJson(senderFile, record);
3325
5453
  await appendJson(receiverFile, record);
5454
+ await writeHandoffInboxes(root, record);
3326
5455
  let raw;
3327
5456
  try {
3328
5457
  raw = await input.model.complete({
@@ -3331,12 +5460,12 @@ async function handoffRole(root, input) {
3331
5460
  });
3332
5461
  } catch {
3333
5462
  await appendJson(receiverFile, { type: "bad_call", roleId: to });
3334
- return fail3("", runId);
5463
+ return fail7("", runId);
3335
5464
  }
3336
5465
  const parsed = parseReply(raw);
3337
5466
  if (!parsed) {
3338
5467
  await appendJson(receiverFile, { type: "bad_call", roleId: to });
3339
- return fail3("", runId);
5468
+ return fail7("", runId);
3340
5469
  }
3341
5470
  await appendJson(receiverFile, { type: "model", pass: 1, roleId: to, text: parsed.text });
3342
5471
  const settled = await settleRoleReply(root, receiverFile, receiver, parsed, now, runId);
@@ -3353,7 +5482,7 @@ async function handoffRole(root, input) {
3353
5482
  }
3354
5483
  async function showRoleGroup(root, changeId) {
3355
5484
  const path = safeChangeId2(changeId) ? await acceptedFile(root, changeId) : "";
3356
- if (!path || !await pathExists(path)) return ok3("No roles in this group.");
5485
+ if (!path || !await pathExists(path)) return ok7("No roles in this group.");
3357
5486
  const seen = /* @__PURE__ */ new Set();
3358
5487
  const owners = [];
3359
5488
  for (const section of factsFrom(await readText(path))) {
@@ -3363,7 +5492,7 @@ async function showRoleGroup(root, changeId) {
3363
5492
  owners.push(owner);
3364
5493
  }
3365
5494
  }
3366
- return ok3(owners.length > 0 ? owners.join("\n") : "No roles in this group.");
5495
+ return ok7(owners.length > 0 ? owners.join("\n") : "No roles in this group.");
3367
5496
  }
3368
5497
  function distinctServices(all) {
3369
5498
  const services = /* @__PURE__ */ new Map();
@@ -3491,19 +5620,20 @@ function leadPrompt(task, services) {
3491
5620
  }
3492
5621
  async function connectionResult(root, changeId, section, now) {
3493
5622
  const messaging = await messagingOn(root);
3494
- if (messaging === "invalid") return fail3(ROLE_TEXT.badYaml);
3495
- if (!messaging) return ok3("Messaging between roles is off.");
5623
+ if (messaging === "invalid") return fail7(ROLE_TEXT.badYaml);
5624
+ if (!messaging) return ok7("Messaging between roles is off.");
3496
5625
  const owners = unique(section.owners);
3497
5626
  const runId = runIdFrom(now);
3498
5627
  for (let i = 0; i < owners.length - 1; i++) {
3499
5628
  const from = owners[i] ?? "";
3500
5629
  const to = owners[i + 1] ?? "";
3501
5630
  const record = handoffRecord(from, to, changeId, section);
3502
- await appendJson(join10(conversationDir(root, from), `${runId}.jsonl`), record);
3503
- await appendJson(join10(conversationDir(root, to), `${runId}.jsonl`), record);
5631
+ await appendJson(join16(conversationDir(root, from), `${runId}.jsonl`), record);
5632
+ await appendJson(join16(conversationDir(root, to), `${runId}.jsonl`), record);
5633
+ await writeHandoffInboxes(root, record);
3504
5634
  }
3505
5635
  const lines = ["Connection hands off. Lead is absent.", ...owners];
3506
- return ok3(lines.join("\n"));
5636
+ return ok7(lines.join("\n"));
3507
5637
  }
3508
5638
  function freshPlan(task, nodes) {
3509
5639
  return { status: "pending", rebuilds: 0, stalled: false, task, nodes, results: [] };
@@ -3512,7 +5642,7 @@ async function planNetwork(root, input) {
3512
5642
  const changeId = input.change?.trim() ?? "";
3513
5643
  const sectionId = input.section?.trim() ?? "";
3514
5644
  const loaded = await acceptedFacts(root, changeId, sectionId);
3515
- if (!loaded) return fail3(ROLE_TEXT.needsSection);
5645
+ if (!loaded) return fail7(ROLE_TEXT.needsSection);
3516
5646
  const task = input.task ?? "";
3517
5647
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3518
5648
  const connection = loaded.all.find(
@@ -3520,23 +5650,23 @@ async function planNetwork(root, input) {
3520
5650
  );
3521
5651
  if (connection?.connection) return connectionResult(root, changeId, connection, now);
3522
5652
  const services = distinctServices(loaded.all);
3523
- if (services.size === 0) return ok3("One scope. No lead.");
5653
+ if (services.size === 0) return ok7("One scope. No lead.");
3524
5654
  if (services.size === 1) {
3525
5655
  const entry = [...services.entries()][0];
3526
- if (!entry) return ok3("One scope. No lead.");
5656
+ if (!entry) return ok7("One scope. No lead.");
3527
5657
  const [name, section] = entry;
3528
5658
  const owner = section.owners[0];
3529
- if (!owner) return fail3("This service needs an owner.");
3530
- if (!await readRole(root, owner)) return fail3(ROLE_TEXT.notFound);
3531
- return ok3(`Service ${name} runs ${owner}.`);
5659
+ if (!owner) return fail7("This service needs an owner.");
5660
+ if (!await readRole(root, owner)) return fail7(ROLE_TEXT.notFound);
5661
+ return ok7(`Service ${name} runs ${owner}.`);
3532
5662
  }
3533
- const picked = pickLead(loaded.all, services);
3534
- if (picked.kind === "none") return ok3("One scope. No lead.");
3535
- if (picked.kind === "missing") return fail3(picked.sentence);
5663
+ const picked = await pickLead(root, loaded.all, services);
5664
+ if (picked.kind === "none") return ok7("One scope. No lead.");
5665
+ if (picked.kind === "missing") return fail7(picked.sentence);
3536
5666
  const lead = await readRole(root, picked.name);
3537
- if (!lead) return fail3(ROLE_TEXT.notFound);
3538
- if (await messagingOn(root) === "invalid") return fail3(ROLE_TEXT.badYaml);
3539
- if (!input.model) return fail3(ROLE_TEXT.noModel);
5667
+ if (!lead) return fail7(ROLE_TEXT.notFound);
5668
+ if (await messagingOn(root) === "invalid") return fail7(ROLE_TEXT.badYaml);
5669
+ if (!input.model) return fail7(ROLE_TEXT.noModel);
3540
5670
  return storeLeadPlan(
3541
5671
  root,
3542
5672
  changeId,
@@ -3554,17 +5684,17 @@ async function storeLeadPlan(root, changeId, sectionId, task, lead, services, al
3554
5684
  try {
3555
5685
  raw = await model.complete({ roleId: lead.name, prompt: leadPrompt(task, services) });
3556
5686
  } catch {
3557
- return fail3("");
5687
+ return fail7("");
3558
5688
  }
3559
5689
  const parsed = await parsePlan(root, raw, contextOf(all));
3560
- if (previous && (parsed === "invalid" || parsed === "deep")) return fail3(planFailure(parsed));
3561
- if (parsed === "invalid") return fail3("The plan is not valid.");
3562
- if (parsed === "deep") return fail3("A plan is deeper than 2.");
5690
+ if (previous && (parsed === "invalid" || parsed === "deep")) return fail7(planFailure(parsed));
5691
+ if (parsed === "invalid") return fail7("The plan is not valid.");
5692
+ if (parsed === "deep") return fail7("A plan is deeper than 2.");
3563
5693
  if (previous && sameArtifacts(previous.nodes, parsed)) {
3564
5694
  previous.stalled = true;
3565
5695
  previous.rebuilds += 1;
3566
5696
  await writePlan(root, changeId, sectionId, previous);
3567
- return ok3("Stopped. The plan made no progress.");
5697
+ return ok7("Stopped. The plan made no progress.");
3568
5698
  }
3569
5699
  const next = previous ? {
3570
5700
  ...previous,
@@ -3575,7 +5705,7 @@ async function storeLeadPlan(root, changeId, sectionId, task, lead, services, al
3575
5705
  results: []
3576
5706
  } : freshPlan(task, parsed);
3577
5707
  await writePlan(root, changeId, sectionId, next);
3578
- return ok3(["Accept this plan before anyone writes.", ...planLines(parsed)].join("\n"));
5708
+ return ok7(["Accept this plan before anyone writes.", ...planLines(parsed)].join("\n"));
3579
5709
  }
3580
5710
  function planFailure(parsed) {
3581
5711
  return parsed === "deep" ? "A plan is deeper than 2." : "The plan is not valid.";
@@ -3588,13 +5718,13 @@ function sameArtifacts(left, right) {
3588
5718
  async function acceptNetwork(root, input) {
3589
5719
  const changeId = input.change?.trim() ?? "";
3590
5720
  const sectionId = input.section?.trim() ?? "";
3591
- if (!safeChangeId2(changeId) || !sectionId) return fail3("No plan is stored.");
5721
+ if (!safeChangeId2(changeId) || !sectionId) return fail7("No plan is stored.");
3592
5722
  const plan = await readPlan(root, changeId, sectionId);
3593
- if (!plan) return fail3("No plan is stored.");
3594
- if (plan.status !== "pending") return fail3("The plan is not waiting for acceptance.");
5723
+ if (!plan) return fail7("No plan is stored.");
5724
+ if (plan.status !== "pending") return fail7("The plan is not waiting for acceptance.");
3595
5725
  plan.status = "accepted";
3596
5726
  await writePlan(root, changeId, sectionId, plan);
3597
- return ok3("Plan accepted.");
5727
+ return ok7("Plan accepted.");
3598
5728
  }
3599
5729
  function walk(nodes) {
3600
5730
  const flat = [];
@@ -3615,16 +5745,16 @@ function ready(node, results) {
3615
5745
  async function runNetwork(root, input) {
3616
5746
  const changeId = input.change?.trim() ?? "";
3617
5747
  const sectionId = input.section?.trim() ?? "";
3618
- if (!safeChangeId2(changeId) || !sectionId) return fail3("No plan is stored.");
5748
+ if (!safeChangeId2(changeId) || !sectionId) return fail7("No plan is stored.");
3619
5749
  const plan = await readPlan(root, changeId, sectionId);
3620
- if (!plan) return fail3("No plan is stored.");
3621
- if (plan.status === "pending") return ok3("Accept this plan before anyone writes.");
3622
- if (plan.status === "done") return ok3("The parent is done.");
3623
- if (plan.status !== "accepted" && plan.status !== "open") return fail3("No plan is stored.");
3624
- if (await messagingOn(root) === "invalid") return fail3(ROLE_TEXT.badYaml);
5750
+ if (!plan) return fail7("No plan is stored.");
5751
+ if (plan.status === "pending") return ok7("Accept this plan before anyone writes.");
5752
+ if (plan.status === "done") return ok7("The parent is done.");
5753
+ if (plan.status !== "accepted" && plan.status !== "open") return fail7("No plan is stored.");
5754
+ if (await messagingOn(root) === "invalid") return fail7(ROLE_TEXT.badYaml);
3625
5755
  const minutes = await timeoutMinutes(root);
3626
- if (minutes === "invalid") return fail3(ROLE_TEXT.badYaml);
3627
- if (!input.model) return fail3(ROLE_TEXT.noModel);
5756
+ if (minutes === "invalid") return fail7(ROLE_TEXT.badYaml);
5757
+ if (!input.model) return fail7(ROLE_TEXT.noModel);
3628
5758
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3629
5759
  const summaries = [];
3630
5760
  const called = /* @__PURE__ */ new Set();
@@ -3644,7 +5774,7 @@ async function runNetwork(root, input) {
3644
5774
  const when = new Date(now.getTime() + index * 1e3);
3645
5775
  index += 1;
3646
5776
  const runId = runIdFrom(when);
3647
- const file = join10(conversationDir(root, owner.name), `${runId}.jsonl`);
5777
+ const file = join16(conversationDir(root, owner.name), `${runId}.jsonl`);
3648
5778
  let raw;
3649
5779
  try {
3650
5780
  raw = await input.model.complete({
@@ -3688,7 +5818,7 @@ async function runNetwork(root, input) {
3688
5818
  const waiting = all.some((node) => !plan.results.some((item) => item.decision === node.decision));
3689
5819
  plan.status = waiting ? "open" : "done";
3690
5820
  await writePlan(root, changeId, sectionId, plan);
3691
- if (missingOwner) return fail3(ROLE_TEXT.notFound);
5821
+ if (missingOwner) return fail7(ROLE_TEXT.notFound);
3692
5822
  const lines = [...summaries];
3693
5823
  if (waiting) {
3694
5824
  lines.push("A branch is waiting for its prerequisites.");
@@ -3696,27 +5826,27 @@ async function runNetwork(root, input) {
3696
5826
  } else {
3697
5827
  lines.push("The parent is done.");
3698
5828
  }
3699
- return ok3(lines.join("\n"));
5829
+ return ok7(lines.join("\n"));
3700
5830
  }
3701
5831
  async function rebuildNetwork(root, input) {
3702
5832
  const changeId = input.change?.trim() ?? "";
3703
5833
  const sectionId = input.section?.trim() ?? "";
3704
- if (!safeChangeId2(changeId) || !sectionId) return fail3("No plan is stored.");
5834
+ if (!safeChangeId2(changeId) || !sectionId) return fail7("No plan is stored.");
3705
5835
  const plan = await readPlan(root, changeId, sectionId);
3706
- if (!plan) return fail3("No plan is stored.");
5836
+ if (!plan) return fail7("No plan is stored.");
3707
5837
  const allowed = plan.rebuilds === 0 || plan.rebuilds === 1 && !plan.stalled && plan.status === "accepted";
3708
- if (!allowed) return fail3("A second rebuild does not start.");
5838
+ if (!allowed) return fail7("A second rebuild does not start.");
3709
5839
  const loaded = await acceptedFacts(root, changeId, sectionId);
3710
- if (!loaded) return fail3(ROLE_TEXT.needsSection);
3711
- if (!input.model) return fail3(ROLE_TEXT.noModel);
5840
+ if (!loaded) return fail7(ROLE_TEXT.needsSection);
5841
+ if (!input.model) return fail7(ROLE_TEXT.noModel);
3712
5842
  const services = distinctServices(loaded.all);
3713
- const picked = pickLead(loaded.all, services);
3714
- if (picked.kind === "missing") return fail3(picked.sentence);
3715
- if (picked.kind !== "lead") return fail3("This module needs a lead.");
5843
+ const picked = await pickLead(root, loaded.all, services);
5844
+ if (picked.kind === "missing") return fail7(picked.sentence);
5845
+ if (picked.kind !== "lead") return fail7("This module needs a lead.");
3716
5846
  const lead = await readRole(root, picked.name);
3717
- if (!lead) return fail3(ROLE_TEXT.notFound);
5847
+ if (!lead) return fail7(ROLE_TEXT.notFound);
3718
5848
  const before = JSON.stringify(plan);
3719
- const result = await storeLeadPlan(
5849
+ const result2 = await storeLeadPlan(
3720
5850
  root,
3721
5851
  changeId,
3722
5852
  sectionId,
@@ -3727,15 +5857,35 @@ async function rebuildNetwork(root, input) {
3727
5857
  input.model,
3728
5858
  plan
3729
5859
  );
3730
- if (result.exitCode !== 0) {
5860
+ if (result2.exitCode !== 0) {
3731
5861
  const current = await readPlan(root, changeId, sectionId);
3732
5862
  if (current && JSON.stringify(current) !== before) {
3733
5863
  await writePlan(root, changeId, sectionId, JSON.parse(before));
3734
5864
  }
3735
5865
  }
3736
- return result;
5866
+ return result2;
3737
5867
  }
3738
- function pickLead(all, services) {
5868
+ async function chooseLead(root, sections, missing) {
5869
+ const routed = await matchRoute(root, { stage: "network", skill: "name the next owner" });
5870
+ if (!routed.ok) return { kind: "missing", sentence: routed.message };
5871
+ if (routed.match.kind === "role") return { kind: "lead", name: routed.match.role };
5872
+ if (routed.match.reason !== "No rule matched.") {
5873
+ return { kind: "missing", sentence: "Ask the human." };
5874
+ }
5875
+ const names = [];
5876
+ for (const section of sections) {
5877
+ for (const lead of section.leads) {
5878
+ if (!names.includes(lead)) names.push(lead);
5879
+ }
5880
+ }
5881
+ if (names.length === 1) {
5882
+ const name = names[0];
5883
+ if (name) return { kind: "lead", name };
5884
+ }
5885
+ if (names.length === 0) return { kind: "missing", sentence: missing };
5886
+ return { kind: "missing", sentence: "Ask the human." };
5887
+ }
5888
+ async function pickLead(root, all, services) {
3739
5889
  const modules = /* @__PURE__ */ new Set();
3740
5890
  let missingModule = false;
3741
5891
  for (const section of services.values()) {
@@ -3745,20 +5895,61 @@ function pickLead(all, services) {
3745
5895
  }
3746
5896
  const moduleId = [...modules][0];
3747
5897
  if (!missingModule && modules.size === 1 && moduleId && services.size > 1) {
3748
- const name = all.find((item) => item.modules.includes(moduleId) && item.leads[0])?.leads[0] ?? "";
3749
- if (!name) return { kind: "missing", sentence: "This module needs a lead." };
3750
- return { kind: "lead", name };
5898
+ return chooseLead(
5899
+ root,
5900
+ all.filter((item) => item.modules.includes(moduleId)),
5901
+ "This module needs a lead."
5902
+ );
3751
5903
  }
3752
5904
  if (modules.size > 1) {
3753
- const name = all.find((item) => item.product && item.leads[0])?.leads[0] ?? "";
3754
- if (!name) return { kind: "missing", sentence: "This product needs a lead." };
3755
- return { kind: "lead", name };
5905
+ return chooseLead(
5906
+ root,
5907
+ all.filter((item) => item.product),
5908
+ "This product needs a lead."
5909
+ );
3756
5910
  }
3757
5911
  return { kind: "none" };
3758
5912
  }
3759
5913
 
5914
+ // src/role/models/show.ts
5915
+ function formatModelChoice(workType, choice) {
5916
+ return `${workType} ${choice.id} ${choice.kind} ${choice.provider} ${choice.why}`;
5917
+ }
5918
+ function confidence(model) {
5919
+ return "min_confidence" in model && model.min_confidence !== void 0 ? String(model.min_confidence) : "n/a";
5920
+ }
5921
+ function formatEntry2(id, model) {
5922
+ if (model.provider === "rules") return `${id} decide rules`;
5923
+ if (model.provider === "command") {
5924
+ return `${id} ${model.kind} command command_env ${model.command_env} min_confidence ${confidence(model)}`;
5925
+ }
5926
+ return `${id} decide ollama model ${model.model} base_url_env ${model.base_url_env} min_confidence ${confidence(model)}`;
5927
+ }
5928
+ function formatModels(config) {
5929
+ const lines = Object.keys(config.models).sort().map((id) => formatEntry2(id, config.models[id]));
5930
+ for (const workType of WORK_TYPES) {
5931
+ lines.push(formatModelChoice(workType, selectModel(config, { workType })));
5932
+ }
5933
+ const reason = config.models[config.defaults.fallback_reason];
5934
+ const decide2 = config.models[config.defaults.fallback_decide];
5935
+ if (reason) {
5936
+ lines.push(
5937
+ `fallback_reason ${config.defaults.fallback_reason} ${reason.kind} ${reason.provider} fallback`
5938
+ );
5939
+ }
5940
+ if (decide2) {
5941
+ lines.push(
5942
+ `fallback_decide ${config.defaults.fallback_decide} ${decide2.kind} ${decide2.provider} fallback`
5943
+ );
5944
+ }
5945
+ return lines.join("\n");
5946
+ }
5947
+ function explainModel(config, workType, ctx) {
5948
+ return formatModelChoice(workType, selectModel(config, { workType, ...ctx }));
5949
+ }
5950
+
3760
5951
  // src/stories.ts
3761
- import { join as join11 } from "pathe";
5952
+ import { join as join17 } from "pathe";
3762
5953
  var STORY_ID = /^S-\d+$/i;
3763
5954
  var EPIC_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{0,39}$/;
3764
5955
  function validBranchName(name) {
@@ -3800,7 +5991,7 @@ function parseStoryDraft(input) {
3800
5991
  async function metasIn(dir) {
3801
5992
  const out = [];
3802
5993
  for (const id of await listDirs(dir)) {
3803
- const path = join11(dir, id, "meta.yaml");
5994
+ const path = join17(dir, id, "meta.yaml");
3804
5995
  if (!await pathExists(path)) continue;
3805
5996
  try {
3806
5997
  out.push(ChangeMetaSchema.parse(await readYaml(path)));
@@ -3933,6 +6124,7 @@ export {
3933
6124
  ConfigSchema,
3934
6125
  DEFAULT_CODE_CONTEXT_CAPS,
3935
6126
  DEFAULT_INIT_INTEGRATION,
6127
+ DecisionMetricSchema,
3936
6128
  EMBEDDINGS_UNAVAILABLE,
3937
6129
  EMBEDDING_MODEL,
3938
6130
  GateSchema,
@@ -3940,6 +6132,7 @@ export {
3940
6132
  McpInvokeSchema,
3941
6133
  McpSourceSchema,
3942
6134
  McpWhenSchema,
6135
+ ModelsConfigSchema,
3943
6136
  PROTOCOL_MD,
3944
6137
  PhaseMetricSchema,
3945
6138
  RRF_K,
@@ -3950,12 +6143,15 @@ export {
3950
6143
  SddError,
3951
6144
  SearchConfigError,
3952
6145
  StageSchema,
6146
+ TeamSchema,
3953
6147
  TokenUsageMetricSchema,
3954
6148
  VerifyCommandSchema,
6149
+ WORK_TYPES,
3955
6150
  WorkflowSchema,
3956
6151
  acceptNetwork,
3957
6152
  activePointerPath,
3958
6153
  activeStages,
6154
+ addLesson,
3959
6155
  addMcpSource,
3960
6156
  advanceStage,
3961
6157
  agentHostPaths,
@@ -3978,6 +6174,8 @@ export {
3978
6174
  changePath,
3979
6175
  changesDir,
3980
6176
  changesPathRel,
6177
+ checkModels,
6178
+ checkTeam,
3981
6179
  chunkCode,
3982
6180
  chunkMarkdown,
3983
6181
  classifyPath,
@@ -3995,12 +6193,15 @@ export {
3995
6193
  createChange,
3996
6194
  createFastembedEmbedder,
3997
6195
  createRole,
6196
+ createRoleFromTemplate,
6197
+ decide,
3998
6198
  defaultConfig,
3999
6199
  defaultMcpConfig,
4000
6200
  defaultMcpYamlPath,
4001
6201
  defaultMemoryDir,
4002
6202
  defaultPricesYamlPath,
4003
6203
  defaultSearchYamlPath,
6204
+ defaultTeamsDir,
4004
6205
  defaultTemplatesDir,
4005
6206
  defaultWorkflowsDir,
4006
6207
  defaultsRoot,
@@ -4019,6 +6220,7 @@ export {
4019
6220
  errorMessage,
4020
6221
  executeSearch,
4021
6222
  explainMatch,
6223
+ explainModel,
4022
6224
  extractiveSummary,
4023
6225
  fallbackAdapter,
4024
6226
  fallbackIncompleteStageId,
@@ -4030,7 +6232,10 @@ export {
4030
6232
  formatEpicGraph,
4031
6233
  formatJsonSummary,
4032
6234
  formatMcpContextForHandoff,
6235
+ formatModels,
4033
6236
  formatStatus,
6237
+ formatTeam,
6238
+ formatTokenInventory,
4034
6239
  ftsTokens,
4035
6240
  fuseByRank,
4036
6241
  fuseHits,
@@ -4039,9 +6244,11 @@ export {
4039
6244
  generateCodeContext,
4040
6245
  generatedState,
4041
6246
  getActiveChangeId,
6247
+ getFacts,
4042
6248
  getHeadSha,
4043
6249
  getIntegration,
4044
6250
  getStage,
6251
+ gitUserName,
4045
6252
  guidancePaths,
4046
6253
  handoffRole,
4047
6254
  hideRole,
@@ -4050,6 +6257,7 @@ export {
4050
6257
  indexDbPath,
4051
6258
  inferComplexity,
4052
6259
  initProject,
6260
+ initTeam,
4053
6261
  installAgentIntegration,
4054
6262
  installAgentIntegrations,
4055
6263
  integrationKeyFor,
@@ -4063,15 +6271,21 @@ export {
4063
6271
  isSecretPath,
4064
6272
  isStageSkipped,
4065
6273
  isSubstantiveArtifactContent,
6274
+ isWorkType,
4066
6275
  keywordHits,
4067
6276
  listBacklogFeatures,
6277
+ listBundledWorkflowNames,
4068
6278
  listChanges,
4069
6279
  listDirs,
6280
+ listFacts,
4070
6281
  listFiles,
4071
6282
  listMatchingSourceIds,
4072
6283
  listMcpSourceTools,
6284
+ listRoleTemplates,
4073
6285
  listScopeHits,
6286
+ listTeamTemplates,
4074
6287
  listWorkflowNames,
6288
+ loadBrain,
4075
6289
  loadChangeMeta,
4076
6290
  loadConfig,
4077
6291
  loadEpicStories,
@@ -4079,7 +6293,9 @@ export {
4079
6293
  loadGeneratedManifest,
4080
6294
  loadInstalledAgent,
4081
6295
  loadMcpConfig,
6296
+ loadModels,
4082
6297
  loadPrices,
6298
+ loadProductTeam,
4083
6299
  loadSearchConfig,
4084
6300
  loadWorkflow,
4085
6301
  markGenerated,
@@ -4091,6 +6307,9 @@ export {
4091
6307
  mergeCaps,
4092
6308
  mergeConfigDefaults,
4093
6309
  mergeRankedHits,
6310
+ migrateTeamMemory,
6311
+ missingBundledWorkflows,
6312
+ modelFromEnv,
4094
6313
  nextStageId,
4095
6314
  nowIso,
4096
6315
  optionForTarget,
@@ -4104,15 +6323,22 @@ export {
4104
6323
  pinRole,
4105
6324
  planAgentIntegration,
4106
6325
  planNetwork,
6326
+ postRoom,
6327
+ productTeamHome,
4107
6328
  promoteGreenfieldToMemory,
6329
+ queueRole,
4108
6330
  rankScope,
4109
6331
  readChangeMetrics,
4110
6332
  readHandoffCodePoints,
6333
+ readTeamConfig,
4111
6334
  readText,
4112
6335
  readYaml,
4113
6336
  rebuildNetwork,
4114
6337
  recommendWorkflow,
4115
6338
  reconcileGeneratedTree,
6339
+ recordDecisionMetric,
6340
+ recordDecisionRequest,
6341
+ recordSignoff,
4116
6342
  refreshActiveAgentContext,
4117
6343
  refreshConfiguredGuidance,
4118
6344
  registerSearchProvider,
@@ -4124,6 +6350,7 @@ export {
4124
6350
  renderBlock,
4125
6351
  renderChangeReport,
4126
6352
  renderMemoryIndex,
6353
+ renderProductTeamSection,
4127
6354
  renderProtocolMd,
4128
6355
  renderSddReadme,
4129
6356
  renderThinAgent,
@@ -4131,13 +6358,17 @@ export {
4131
6358
  reportAllChanges,
4132
6359
  reportChange,
4133
6360
  resolveActiveDirectory,
6361
+ resolveBot,
4134
6362
  resolveChangeId,
4135
6363
  resolveEmbedder,
4136
6364
  resolveFocus,
4137
6365
  resolveSearchProvider,
4138
6366
  resolveStages,
6367
+ reviewRoom,
6368
+ routeRole,
4139
6369
  rrf,
4140
6370
  runLocalVerify,
6371
+ runLoop,
4141
6372
  runNetwork,
4142
6373
  runRole,
4143
6374
  saveChangeMeta,
@@ -4148,19 +6379,27 @@ export {
4148
6379
  sddRoot,
4149
6380
  searchConfigPath,
4150
6381
  searchPreamble,
6382
+ selectModel,
4151
6383
  setActiveChange,
6384
+ setFact,
4152
6385
  setRoleMessaging,
4153
6386
  shouldAutoSkip,
6387
+ showFact,
6388
+ showMemory,
4154
6389
  showRoleGroup,
4155
6390
  showRoles,
6391
+ showRoom,
6392
+ showTeam,
4156
6393
  skipStage,
4157
6394
  slugify,
6395
+ spawnModelCommand,
4158
6396
  startFeatureFromBacklog,
4159
6397
  startGreenfield,
4160
6398
  startPhase,
4161
6399
  storyIdTaken,
4162
6400
  switchWorkflow,
4163
6401
  syncChangeHead,
6402
+ takeRole,
4164
6403
  templatesDir,
4165
6404
  toProcessEnv,
4166
6405
  todayIsoDate,
@@ -4171,6 +6410,8 @@ export {
4171
6410
  updateFeatureStatus,
4172
6411
  validBranchName,
4173
6412
  withMcpSource,
6413
+ workKind,
6414
+ workflowUnavailableMessage,
4174
6415
  workflowsDir,
4175
6416
  writeAgentHandoff,
4176
6417
  writeRefineBrief,