@structured-vibe-coding/core 0.17.2 → 0.18.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 (31) hide show
  1. package/defaults/prices.yaml +9 -0
  2. package/defaults/search.yaml +18 -0
  3. package/dist/{agents-RWYBLZQE.js → agents-AKSUXQHN.js} +18 -7
  4. package/dist/{change-context-W4HDO2IK.js → change-context-REE7UKU7.js} +2 -4
  5. package/dist/chunk-44LQ5BKZ.js +2628 -0
  6. package/dist/{chunk-KYWETNFU.js → chunk-4BRO7BOJ.js} +2 -4
  7. package/dist/chunk-4PIRWYRA.js +1125 -0
  8. package/dist/{chunk-7JLDL2OK.js → chunk-LHSR7JG2.js} +569 -39
  9. package/dist/{chunk-EPFURATC.js → chunk-O5SP6X5R.js} +89 -0
  10. package/dist/{chunk-74OGSVJZ.js → chunk-QGP43XCN.js} +2 -2
  11. package/dist/{chunk-A752UVPF.js → chunk-QHV5KZKX.js} +193 -28
  12. package/dist/{gather-BZDW4EN4.js → gather-ZNV2NAUN.js} +3 -4
  13. package/dist/{greenfield-Z425JOMP.js → greenfield-I5S5OS2D.js} +5 -6
  14. package/dist/index.d.ts +1034 -40
  15. package/dist/index.js +2927 -988
  16. package/dist/{sources-PF45EUH2.js → sources-3TXUPU6V.js} +2 -3
  17. package/package.json +11 -3
  18. package/vendor/tree-sitter/NOTICE +29 -0
  19. package/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  20. package/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  21. package/vendor/tree-sitter/tree-sitter-rust.wasm +0 -0
  22. package/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  23. package/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  24. package/dist/chunk-67AUAGNV.js +0 -150
  25. package/dist/chunk-7MJYPJMR.js +0 -51
  26. package/dist/chunk-P3N67UEZ.js +0 -538
  27. package/dist/chunk-R5WQN4HA.js +0 -139
  28. package/dist/chunk-WHDG6OTG.js +0 -46
  29. package/dist/config-X5VDBVR4.js +0 -16
  30. package/dist/fs-J6DIV6CX.js +0 -28
  31. package/dist/paths-64GA32IZ.js +0 -28
package/dist/index.js CHANGED
@@ -1,22 +1,3 @@
1
- import {
2
- advanceStage,
3
- approveGate,
4
- canLeaveStage,
5
- completeChange,
6
- createChange,
7
- fallbackIncompleteStageId,
8
- findFeaturesBacklogPath,
9
- incompletePriorStageErrors,
10
- isSubstantiveArtifactContent,
11
- listBacklogFeatures,
12
- parseFeaturesBacklog,
13
- promoteGreenfieldToMemory,
14
- skipStage,
15
- startFeatureFromBacklog,
16
- startGreenfield,
17
- switchWorkflow,
18
- updateFeatureStatus
19
- } from "./chunk-7JLDL2OK.js";
20
1
  import {
21
2
  SDD_CORE_VERSION,
22
3
  callMcpSourceTool,
@@ -30,7 +11,7 @@ import {
30
11
  mcpTimeoutMs,
31
12
  toProcessEnv,
32
13
  withMcpSource
33
- } from "./chunk-74OGSVJZ.js";
14
+ } from "./chunk-QGP43XCN.js";
34
15
  import {
35
16
  McpConfigSchema,
36
17
  McpInvokeSchema,
@@ -45,40 +26,142 @@ import {
45
26
  mcpConfigPath,
46
27
  removeMcpSource,
47
28
  saveMcpConfig
48
- } from "./chunk-KYWETNFU.js";
29
+ } from "./chunk-4BRO7BOJ.js";
49
30
  import {
50
31
  AGENT_INTEGRATIONS,
51
32
  AGENT_TARGET_OPTIONS,
52
33
  ALL_AGENT_TARGETS,
34
+ AgentInstallPreflightError,
53
35
  DEFAULT_INIT_INTEGRATION,
54
36
  PROTOCOL_MD,
55
37
  SDD_AGENT_ROLES,
56
38
  agentHostPaths,
57
39
  agentKickoffMessage,
40
+ assertAgentInstallable,
41
+ contentHash,
42
+ defaultMcpYamlPath,
43
+ defaultMemoryDir,
44
+ defaultPricesYamlPath,
45
+ defaultSearchYamlPath,
46
+ defaultTemplatesDir,
47
+ defaultWorkflowsDir,
48
+ defaultsRoot,
49
+ describeFsError,
50
+ describeKept,
51
+ generatedState,
58
52
  getIntegration,
59
53
  installAgentIntegration,
60
54
  installAgentIntegrations,
61
55
  integrationKeyFor,
56
+ loadGeneratedManifest,
62
57
  loadInstalledAgent,
58
+ markGenerated,
63
59
  optionForTarget,
64
60
  parseAgentTargets,
65
61
  parseIntegration,
62
+ planAgentIntegration,
63
+ reconcileGeneratedTree,
66
64
  refreshActiveAgentContext,
65
+ refreshConfiguredGuidance,
67
66
  removeOtherAgentHosts,
67
+ removeOtherAgentHostsDetailed,
68
+ renderAgentsMd,
69
+ renderMemoryIndex,
70
+ renderProtocolMd,
71
+ renderSddReadme,
68
72
  renderThinAgent,
73
+ saveGeneratedManifest,
69
74
  writeAgentHandoff
70
- } from "./chunk-P3N67UEZ.js";
75
+ } from "./chunk-4PIRWYRA.js";
71
76
  import {
72
- buildAgentPrompt,
73
- formatStatus
74
- } from "./chunk-R5WQN4HA.js";
77
+ ChangeMetricsSchema,
78
+ PhaseMetricSchema,
79
+ TokenUsageMetricSchema,
80
+ advanceStage,
81
+ approveGate,
82
+ assertSessionLogAllowed,
83
+ canLeaveStage,
84
+ completeChange,
85
+ createChange,
86
+ emptyChangeMetrics,
87
+ fallbackIncompleteStageId,
88
+ findFeaturesBacklogPath,
89
+ importSessionMetrics,
90
+ incompletePriorStageErrors,
91
+ isSubstantiveArtifactContent,
92
+ listBacklogFeatures,
93
+ parseFeaturesBacklog,
94
+ promoteGreenfieldToMemory,
95
+ readChangeMetrics,
96
+ skipStage,
97
+ startFeatureFromBacklog,
98
+ startGreenfield,
99
+ startPhase,
100
+ switchWorkflow,
101
+ transitionPhase,
102
+ updateFeatureStatus
103
+ } from "./chunk-LHSR7JG2.js";
75
104
  import {
105
+ EMBEDDINGS_UNAVAILABLE,
106
+ EMBEDDING_MODEL,
107
+ RRF_K,
108
+ SUMMARY_TOKEN_CAP,
109
+ SearchConfigError,
110
+ budgetForStage,
111
+ buildAgentPrompt,
112
+ buildRetrievedInner,
113
+ chunkCode,
114
+ chunkMarkdown,
115
+ classifyPath,
116
+ clearRegisteredSearchProviders,
117
+ countTokens,
118
+ createBuiltinProvider,
119
+ createFastembedEmbedder,
76
120
  defaultConfig,
121
+ embedSearchQuery,
122
+ executeSearch,
123
+ extractiveSummary,
124
+ fallbackWarning,
125
+ fastembedImportCount,
126
+ formatStatus,
127
+ ftsTokens,
128
+ fuseByRank,
129
+ fuseHits,
130
+ getHeadSha,
131
+ isHardDenied,
132
+ isIgnoredPath,
77
133
  isInitialized,
134
+ isPrunedDir,
135
+ isSecretPath,
136
+ keywordHits,
137
+ listScopeHits,
78
138
  loadConfig,
79
- saveConfig
80
- } from "./chunk-WHDG6OTG.js";
139
+ loadExcluder,
140
+ loadSearchConfig,
141
+ mergeConfigDefaults,
142
+ mergeRankedHits,
143
+ packHits,
144
+ rankScope,
145
+ readPackedCache,
146
+ registerSearchProvider,
147
+ renderBlock,
148
+ resolveActiveDirectory,
149
+ resolveEmbedder,
150
+ resolveSearchProvider,
151
+ rrf,
152
+ saveConfig,
153
+ scopeForPath,
154
+ searchPreamble,
155
+ writePackedCache
156
+ } from "./chunk-44LQ5BKZ.js";
81
157
  import {
158
+ ArtifactDefSchema,
159
+ ChangeMetaSchema,
160
+ ConfigSchema,
161
+ GateSchema,
162
+ StageSchema,
163
+ VerifyCommandSchema,
164
+ WorkflowSchema,
82
165
  activeStages,
83
166
  buildContext,
84
167
  changeDirName,
@@ -103,16 +186,7 @@ import {
103
186
  slugify,
104
187
  todayIsoDate,
105
188
  uniqueChangeDirName
106
- } from "./chunk-A752UVPF.js";
107
- import {
108
- ArtifactDefSchema,
109
- ChangeMetaSchema,
110
- ConfigSchema,
111
- GateSchema,
112
- StageSchema,
113
- VerifyCommandSchema,
114
- WorkflowSchema
115
- } from "./chunk-67AUAGNV.js";
189
+ } from "./chunk-QHV5KZKX.js";
116
190
  import {
117
191
  SddError,
118
192
  errorMessage,
@@ -125,105 +199,557 @@ import {
125
199
  changeMetaPath,
126
200
  changePath,
127
201
  changesDir,
202
+ changesPathRel,
128
203
  configPath,
129
- domainsDir,
130
- memoryDir,
131
- sddRoot,
132
- templatesDir,
133
- workflowsDir
134
- } from "./chunk-7MJYPJMR.js";
135
- import {
136
204
  copyDir,
137
205
  copyDirSkipExisting,
206
+ displayArchivePath,
207
+ displayChangePath,
208
+ domainsDir,
138
209
  ensureDir,
210
+ guidancePaths,
211
+ indexDbPath,
139
212
  listDirs,
140
213
  listFiles,
214
+ memoryDir,
215
+ memoryPathRel,
141
216
  pathExists,
142
217
  readText,
143
218
  readYaml,
144
219
  removePath,
220
+ sddRoot,
221
+ searchConfigPath,
222
+ templatesDir,
223
+ workflowsDir,
145
224
  writeText,
146
225
  writeTextIfMissing,
147
226
  writeYaml
148
- } from "./chunk-EPFURATC.js";
149
-
150
- // src/init.ts
151
- import { join as join2 } from "pathe";
227
+ } from "./chunk-O5SP6X5R.js";
152
228
 
153
- // src/defaults.ts
154
- import { existsSync } from "fs";
155
- import { fileURLToPath } from "url";
156
- import { dirname, join } from "pathe";
157
- function defaultsRoot() {
158
- const here = dirname(fileURLToPath(import.meta.url));
159
- const candidates = [
160
- join(here, "..", "defaults"),
161
- // npm package: dist/../defaults
162
- join(here, "defaults"),
163
- // same-dir defaults if present
164
- join(here, "..", "..", "defaults")
165
- // src/ during some test runners
229
+ // src/report.ts
230
+ import { join } from "pathe";
231
+ import { parse as parseYaml } from "yaml";
232
+ import { z } from "zod";
233
+ var RATE_SCALE = 1000000000n;
234
+ var USD_DENOM = 1000000n * RATE_SCALE;
235
+ var PriceCardSchema = z.object({
236
+ input_per_million_usd: z.number().finite().nonnegative(),
237
+ cache_read_per_million_usd: z.number().finite().nonnegative(),
238
+ cache_write_per_million_usd: z.number().finite().nonnegative(),
239
+ output_per_million_usd: z.number().finite().nonnegative()
240
+ });
241
+ var PricesSchema = z.object({
242
+ usd_to_inr: z.number().finite().nonnegative().default(84),
243
+ models: z.object({
244
+ default: PriceCardSchema
245
+ }).catchall(PriceCardSchema)
246
+ });
247
+ var TOKEN_HEADER = "source phase provider model requests input cache_read cache_write output usd inr";
248
+ function finiteMs(startIso, endMs) {
249
+ const start = Date.parse(startIso);
250
+ if (!Number.isFinite(start) || !Number.isFinite(endMs)) return 0;
251
+ return Math.max(0, endMs - start);
252
+ }
253
+ function formatDuration(ms) {
254
+ if (!Number.isFinite(ms) || ms <= 0) return "0h 0m 0s";
255
+ const totalSeconds = Math.floor(ms / 1e3);
256
+ const hours = Math.floor(totalSeconds / 3600);
257
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
258
+ const seconds = totalSeconds % 60;
259
+ return `${hours}h ${minutes}m ${seconds}s`;
260
+ }
261
+ function phaseTiming(phases, now) {
262
+ const nowMs = now.getTime();
263
+ let activeMs = 0;
264
+ const rows = phases.map((phase) => {
265
+ const endedAt = phase.endedAt;
266
+ const open = endedAt === void 0;
267
+ const active = finiteMs(phase.startedAt, open ? nowMs : Date.parse(endedAt));
268
+ activeMs += active;
269
+ return {
270
+ phase: phase.phase,
271
+ startedAt: phase.startedAt,
272
+ endedAt: phase.endedAt,
273
+ activeMs: active,
274
+ open
275
+ };
276
+ });
277
+ let waitMs = 0;
278
+ for (let i = 0; i < phases.length - 1; i++) {
279
+ const phase = phases[i];
280
+ const next = phases[i + 1];
281
+ if (!phase?.endedAt || !next) continue;
282
+ const gap = Date.parse(next.startedAt) - Date.parse(phase.endedAt);
283
+ if (Number.isFinite(gap)) waitMs += Math.max(0, gap);
284
+ }
285
+ return { activeMs, waitMs, totalMs: activeMs + waitMs, phases: rows };
286
+ }
287
+ function stripLeadingZeros(digits) {
288
+ let i = 0;
289
+ while (i < digits.length - 1 && digits.charCodeAt(i) === 48) i += 1;
290
+ return digits.slice(i);
291
+ }
292
+ function rateUnits(rate) {
293
+ if (!Number.isFinite(rate) || rate < 0) throw new Error("prices.yaml is invalid");
294
+ let text;
295
+ try {
296
+ text = rate.toFixed(9);
297
+ } catch {
298
+ throw new Error("prices.yaml is invalid");
299
+ }
300
+ const negative = text.startsWith("-");
301
+ const unsigned = negative ? text.slice(1) : text;
302
+ const dot = unsigned.indexOf(".");
303
+ const whole = dot === -1 ? unsigned : unsigned.slice(0, dot);
304
+ const frac = dot === -1 ? "" : unsigned.slice(dot + 1, dot + 10);
305
+ const units = BigInt(stripLeadingZeros(`${whole}${frac.padEnd(9, "0")}`));
306
+ return negative ? -units : units;
307
+ }
308
+ function formatRatio(numerator, denominator, decimals) {
309
+ const negative = numerator < 0n;
310
+ const scale = 10n ** BigInt(decimals);
311
+ const num = (negative ? -numerator : numerator) * scale;
312
+ const quot = num / denominator;
313
+ const rem = num % denominator;
314
+ const rounded = rem * 2n >= denominator ? quot + 1n : quot;
315
+ const digits = rounded.toString().padStart(decimals + 1, "0");
316
+ const cut = digits.length - decimals;
317
+ return `${negative ? "-" : ""}${digits.slice(0, cut)}.${digits.slice(cut)}`;
318
+ }
319
+ function costFor(counts, card, usdToInr) {
320
+ const parts = [
321
+ [counts.input, card.input_per_million_usd],
322
+ [counts.cacheRead, card.cache_read_per_million_usd],
323
+ [counts.cacheWrite, card.cache_write_per_million_usd],
324
+ [counts.output, card.output_per_million_usd]
325
+ ];
326
+ let numerator = 0n;
327
+ let any = false;
328
+ for (const [tokens, rate] of parts) {
329
+ if (tokens === null) continue;
330
+ any = true;
331
+ numerator += BigInt(tokens) * rateUnits(rate);
332
+ }
333
+ if (!any) return { usd: "n/a", inr: "n/a" };
334
+ const inrNumerator = numerator * rateUnits(usdToInr);
335
+ return {
336
+ usd: formatRatio(numerator, USD_DENOM, 4),
337
+ inr: formatRatio(inrNumerator, USD_DENOM * RATE_SCALE, 2)
338
+ };
339
+ }
340
+ async function loadPrices(projectRoot) {
341
+ const path = join(sddRoot(projectRoot), "prices.yaml");
342
+ if (!await pathExists(path)) return null;
343
+ try {
344
+ return PricesSchema.parse(parseYaml(await readText(path)));
345
+ } catch {
346
+ throw new Error("prices.yaml is invalid");
347
+ }
348
+ }
349
+ function cardFor(prices, provider, model) {
350
+ if (model && prices.models[model]) return prices.models[model];
351
+ return prices.models[provider] ?? prices.models.default;
352
+ }
353
+ function modelCell(model) {
354
+ return model ? model : "n/a";
355
+ }
356
+ function emptyTotals() {
357
+ return { requests: 0, input: null, cacheRead: null, cacheWrite: null, output: null };
358
+ }
359
+ function addCount(current, value) {
360
+ if (typeof value !== "number") return current;
361
+ return (current ?? 0) + value;
362
+ }
363
+ function addRequest(totals, request) {
364
+ totals.requests += 1;
365
+ totals.input = addCount(totals.input, request.inputTokens);
366
+ totals.cacheRead = addCount(totals.cacheRead, request.cacheReadTokens);
367
+ totals.cacheWrite = addCount(totals.cacheWrite, request.cacheWriteTokens);
368
+ totals.output = addCount(totals.output, request.outputTokens);
369
+ }
370
+ function cell(value) {
371
+ return value === null ? "n/a" : String(value);
372
+ }
373
+ function tokenLine(source, phase, provider, model, totals, prices, card) {
374
+ const cost = prices && card ? costFor(totals, card, prices.usd_to_inr) : { usd: "n/a", inr: "n/a" };
375
+ return [
376
+ source,
377
+ phase,
378
+ provider,
379
+ model,
380
+ String(totals.requests),
381
+ cell(totals.input),
382
+ cell(totals.cacheRead),
383
+ cell(totals.cacheWrite),
384
+ cell(totals.output),
385
+ cost.usd,
386
+ cost.inr
387
+ ].join(" ");
388
+ }
389
+ function groupRequests(metrics) {
390
+ const groups = /* @__PURE__ */ new Map();
391
+ for (const request of metrics.requests) {
392
+ const model = request.model ?? null;
393
+ const key = `${request.phase}\0${request.provider}\0${model ?? ""}`;
394
+ let group = groups.get(key);
395
+ if (!group) {
396
+ group = { phase: request.phase, provider: request.provider, model, totals: emptyTotals() };
397
+ groups.set(key, group);
398
+ }
399
+ addRequest(group.totals, request);
400
+ }
401
+ const phaseOrder = [];
402
+ for (const phase of metrics.phases) {
403
+ if (!phaseOrder.includes(phase.phase)) phaseOrder.push(phase.phase);
404
+ }
405
+ for (const request of metrics.requests) {
406
+ if (!phaseOrder.includes(request.phase)) phaseOrder.push(request.phase);
407
+ }
408
+ return [...groups.values()].sort((left, right) => {
409
+ const phaseDelta = phaseOrder.indexOf(left.phase) - phaseOrder.indexOf(right.phase);
410
+ if (phaseDelta !== 0) return phaseDelta;
411
+ const providerDelta = left.provider.localeCompare(right.provider);
412
+ if (providerDelta !== 0) return providerDelta;
413
+ return (left.model ?? "").localeCompare(right.model ?? "");
414
+ });
415
+ }
416
+ function renderChangeReport(input) {
417
+ const timing = phaseTiming(input.metrics.phases, input.now);
418
+ const lines = [
419
+ `Change: ${input.changeId}`,
420
+ `Active: ${formatDuration(timing.activeMs)}`,
421
+ `Human wait: ${formatDuration(timing.waitMs)}`,
422
+ `Total: ${formatDuration(timing.totalMs)}`,
423
+ "Phases:"
424
+ ];
425
+ if (timing.phases.length === 0) {
426
+ lines.push(" (none)");
427
+ } else {
428
+ for (const phase of timing.phases) {
429
+ const start = new Date(phase.startedAt).toLocaleString();
430
+ const end = phase.open ? "in progress" : new Date(phase.endedAt ?? "").toLocaleString();
431
+ lines.push(` ${phase.phase} ${start} \u2192 ${end} active ${formatDuration(phase.activeMs)}`);
432
+ }
433
+ }
434
+ lines.push("Tokens:");
435
+ lines.push(` ${TOKEN_HEADER}`);
436
+ if (input.metrics.requests.length === 0) {
437
+ if (input.handoffCodePoints === null) {
438
+ lines.push(" (none)");
439
+ lines.push("No estimate: .sdd/handoff.md is missing.");
440
+ } else {
441
+ const estimated = Math.floor(input.handoffCodePoints / 4);
442
+ lines.push(
443
+ ` ${tokenLine(
444
+ "estimated (handoff code points / 4)",
445
+ "n/a",
446
+ "default",
447
+ "n/a",
448
+ {
449
+ requests: 1,
450
+ input: estimated,
451
+ cacheRead: null,
452
+ cacheWrite: null,
453
+ output: null
454
+ },
455
+ input.prices,
456
+ input.prices?.models.default ?? null
457
+ )}`
458
+ );
459
+ }
460
+ } else {
461
+ for (const group of groupRequests(input.metrics)) {
462
+ const card = input.prices ? cardFor(input.prices, group.provider, group.model) : null;
463
+ lines.push(
464
+ ` ${tokenLine(
465
+ "measured",
466
+ group.phase,
467
+ group.provider,
468
+ modelCell(group.model),
469
+ group.totals,
470
+ input.prices,
471
+ card
472
+ )}`
473
+ );
474
+ }
475
+ const total = emptyTotals();
476
+ for (const request of input.metrics.requests) addRequest(total, request);
477
+ lines.push(` ${totalLine(total, input.metrics, input.prices)}`);
478
+ }
479
+ if (input.prices === null) lines.push("prices.yaml is missing");
480
+ return lines.join("\n");
481
+ }
482
+ function requestUsdNumerator(request, card) {
483
+ const parts = [
484
+ [request.inputTokens, card.input_per_million_usd],
485
+ [request.cacheReadTokens, card.cache_read_per_million_usd],
486
+ [request.cacheWriteTokens, card.cache_write_per_million_usd],
487
+ [request.outputTokens, card.output_per_million_usd]
166
488
  ];
167
- for (const c of candidates) {
168
- if (existsSync(c)) return c;
489
+ let numerator = 0n;
490
+ for (const [tokens, rate] of parts) {
491
+ if (tokens === null) continue;
492
+ numerator += BigInt(tokens) * rateUnits(rate);
493
+ }
494
+ return numerator;
495
+ }
496
+ function requestHasNumericToken(request) {
497
+ return request.inputTokens !== null || request.cacheReadTokens !== null || request.cacheWriteTokens !== null || request.outputTokens !== null;
498
+ }
499
+ function totalLine(totals, metrics, prices) {
500
+ if (!prices) return tokenLine("measured", "total", "all", "all", totals, null, null);
501
+ let numerator = 0n;
502
+ let any = false;
503
+ for (const request of metrics.requests) {
504
+ if (!requestHasNumericToken(request)) continue;
505
+ any = true;
506
+ numerator += requestUsdNumerator(request, cardFor(prices, request.provider, request.model));
507
+ }
508
+ const cost = !any ? { usd: "n/a", inr: "n/a" } : {
509
+ usd: formatRatio(numerator, USD_DENOM, 4),
510
+ inr: formatRatio(numerator * rateUnits(prices.usd_to_inr), USD_DENOM * RATE_SCALE, 2)
511
+ };
512
+ return [
513
+ "measured",
514
+ "total",
515
+ "all",
516
+ "all",
517
+ String(totals.requests),
518
+ cell(totals.input),
519
+ cell(totals.cacheRead),
520
+ cell(totals.cacheWrite),
521
+ cell(totals.output),
522
+ cost.usd,
523
+ cost.inr
524
+ ].join(" ");
525
+ }
526
+ function compareChangesByFirstPhase(left, right) {
527
+ if (left.invalid !== right.invalid) return left.invalid ? 1 : -1;
528
+ const leftStart = left.invalid ? null : left.firstStartedAt;
529
+ const rightStart = right.invalid ? null : right.firstStartedAt;
530
+ if (leftStart === null !== (rightStart === null)) return leftStart === null ? 1 : -1;
531
+ if (leftStart !== null && rightStart !== null && leftStart !== rightStart) {
532
+ return leftStart < rightStart ? -1 : 1;
533
+ }
534
+ return left.id.localeCompare(right.id);
535
+ }
536
+ async function readHandoffCodePoints(projectRoot) {
537
+ try {
538
+ const text = await readText(join(sddRoot(projectRoot), "handoff.md"));
539
+ return Array.from(text).length;
540
+ } catch (err) {
541
+ const code = typeof err === "object" && err !== null && "code" in err ? err.code : void 0;
542
+ if (code === "ENOENT") return null;
543
+ throw new Error("Could not read .sdd/handoff.md.");
169
544
  }
170
- return candidates[0];
171
545
  }
172
- function defaultWorkflowsDir() {
173
- return join(defaultsRoot(), "workflows");
546
+ async function readReportMetrics(path) {
547
+ if (!await pathExists(path)) return { status: "missing" };
548
+ try {
549
+ return { status: "ok", metrics: ChangeMetricsSchema.parse(JSON.parse(await readText(path))) };
550
+ } catch {
551
+ return { status: "invalid" };
552
+ }
174
553
  }
175
- function defaultTemplatesDir() {
176
- return join(defaultsRoot(), "templates");
554
+ async function reportChange(input) {
555
+ const found = await readReportMetrics(
556
+ join(changePath(input.projectRoot, input.config, input.changeId), "metrics.json")
557
+ );
558
+ if (found.status === "invalid") throw new Error("metrics.json is invalid");
559
+ const metrics = found.status === "ok" ? found.metrics : emptyChangeMetrics();
560
+ const handoffCodePoints = metrics.requests.length === 0 ? await readHandoffCodePoints(input.projectRoot) : null;
561
+ return renderChangeReport({
562
+ changeId: input.changeId,
563
+ metrics,
564
+ prices: input.prices,
565
+ handoffCodePoints,
566
+ now: input.now
567
+ });
177
568
  }
178
- function defaultMemoryDir() {
179
- return join(defaultsRoot(), "memory");
569
+ async function reportAllChanges(projectRoot, config, prices, now) {
570
+ const ids = await listDirs(changesDir(projectRoot, config));
571
+ const blocks = [];
572
+ let handoff;
573
+ for (const id of ids) {
574
+ const found = await readReportMetrics(
575
+ join(changePath(projectRoot, config, id), "metrics.json")
576
+ );
577
+ if (found.status === "missing") continue;
578
+ if (found.status === "invalid") {
579
+ blocks.push({
580
+ id,
581
+ invalid: true,
582
+ firstStartedAt: null,
583
+ text: `Change: ${id}
584
+ metrics.json is invalid`
585
+ });
586
+ continue;
587
+ }
588
+ let handoffCodePoints = null;
589
+ if (found.metrics.requests.length === 0) {
590
+ if (handoff === void 0) handoff = await readHandoffCodePoints(projectRoot);
591
+ handoffCodePoints = handoff;
592
+ }
593
+ blocks.push({
594
+ id,
595
+ invalid: false,
596
+ firstStartedAt: found.metrics.phases[0]?.startedAt ?? null,
597
+ text: renderChangeReport({
598
+ changeId: id,
599
+ metrics: found.metrics,
600
+ prices,
601
+ handoffCodePoints,
602
+ now
603
+ })
604
+ });
605
+ }
606
+ if (!blocks.length) return "No change metrics found.";
607
+ blocks.sort(compareChangesByFirstPhase);
608
+ return blocks.map((block) => block.text).join("\n\n");
180
609
  }
181
- function defaultMcpYamlPath() {
182
- return join(defaultsRoot(), "mcp.yaml");
610
+
611
+ // src/sync.ts
612
+ import { join as join2 } from "pathe";
613
+ var VERIFY_MARKER = /<!-- sdd:verify stage="([^"]+)" head_sha="(?:none|[0-9a-f]{40}|[0-9a-f]{64})" result_count="(\d+)" -->/gi;
614
+ 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.");
617
+ const ctx = await buildContext(projectRoot, config, changeId);
618
+ 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;
622
+ updatedResults += 1;
623
+ }
624
+ if (updatedResults) await saveChangeMeta(projectRoot, config, ctx.meta);
625
+ const reportPath = join2(ctx.path, "local-test-results.md");
626
+ let reportUpdated = false;
627
+ let reportPreserved = false;
628
+ if (await pathExists(reportPath)) {
629
+ const report = await readText(reportPath);
630
+ const markers = [...report.matchAll(VERIFY_MARKER)];
631
+ if (markers.length) {
632
+ const updated = report.replace(
633
+ VERIFY_MARKER,
634
+ (_marker, stage, resultCount) => `<!-- sdd:verify stage="${stage}" head_sha="${headSha}" result_count="${resultCount}" -->`
635
+ );
636
+ if (updated !== report) {
637
+ await writeText(reportPath, updated);
638
+ reportUpdated = true;
639
+ }
640
+ } else {
641
+ reportPreserved = true;
642
+ }
643
+ }
644
+ return { changeId, headSha, updatedResults, reportUpdated, reportPreserved };
183
645
  }
184
646
 
185
647
  // src/init.ts
648
+ import { spawn } from "child_process";
649
+ import { lstat } from "fs/promises";
650
+ import { join as join3 } from "pathe";
186
651
  async function initProject(opts) {
187
652
  const { projectRoot, force = false } = opts;
188
653
  const created = [];
654
+ const kept = [];
655
+ const warnings = [];
189
656
  const root = sddRoot(projectRoot);
190
- if (await pathExists(join2(root, "config.yaml")) && !force) {
191
- throw new Error(
192
- `Already initialized (${join2(root, "config.yaml")}). Use --force to re-copy defaults.`
193
- );
657
+ const configFile = join3(root, "config.yaml");
658
+ if (await pathExists(configFile) && !force) {
659
+ await repairRequiredIgnores(projectRoot);
660
+ return { created, config: await loadConfig(projectRoot), kept, warnings };
661
+ }
662
+ try {
663
+ const st = await lstat(root);
664
+ if (!st.isDirectory() && !st.isSymbolicLink()) {
665
+ throw new SddError(`Cannot initialize: ${root} exists and is not a directory. Move it away.`);
666
+ }
667
+ } catch (err) {
668
+ if (err instanceof SddError) throw err;
669
+ }
670
+ const config = await pathExists(configFile) ? mergeConfigDefaults(await readYaml(configFile)) : defaultConfig();
671
+ if (opts.layoutRoot !== void 0) {
672
+ 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");
677
+ }
678
+ const manifestPath = join3(root, ".generated.json");
679
+ const manifestExisted = await pathExists(manifestPath);
680
+ const manifest = await loadGeneratedManifest(manifestPath);
681
+ if (opts.agents) {
682
+ await assertAgentInstallable({ projectRoot, target: opts.agents, force });
194
683
  }
195
- const config = defaultConfig();
196
684
  await ensureDir(root);
197
685
  await saveConfig(projectRoot, config);
198
- created.push(join2(root, "config.yaml"));
686
+ created.push(join3(root, "config.yaml"));
199
687
  const wfDest = workflowsDir(projectRoot);
200
- await ensureDir(wfDest);
201
688
  const wfSrc = defaultWorkflowsDir();
202
689
  if (await pathExists(wfSrc)) {
203
- await copyDir(wfSrc, wfDest);
690
+ const result = await reconcileGeneratedTree(
691
+ projectRoot,
692
+ wfSrc,
693
+ wfDest,
694
+ ".sdd/workflows",
695
+ manifest,
696
+ force
697
+ );
698
+ kept.push(...result.kept);
204
699
  created.push(wfDest);
700
+ } else {
701
+ await ensureDir(wfDest);
205
702
  }
206
703
  const tDest = templatesDir(projectRoot);
207
- await ensureDir(tDest);
208
704
  const tSrc = defaultTemplatesDir();
209
705
  if (await pathExists(tSrc)) {
210
- await copyDir(tSrc, tDest);
706
+ const result = await reconcileGeneratedTree(
707
+ projectRoot,
708
+ tSrc,
709
+ tDest,
710
+ ".sdd/templates",
711
+ manifest,
712
+ force
713
+ );
714
+ kept.push(...result.kept);
211
715
  created.push(tDest);
716
+ } else {
717
+ await ensureDir(tDest);
212
718
  }
719
+ await saveGeneratedManifest(manifestPath, manifest);
720
+ if (!manifestExisted) created.push(manifestPath);
213
721
  const mcpSrc = defaultMcpYamlPath();
214
722
  const mcpDest = mcpConfigPath(projectRoot);
215
723
  if (await pathExists(mcpSrc)) {
216
724
  const wrote = await writeTextIfMissing(mcpDest, await readText(mcpSrc));
217
725
  if (wrote) created.push(mcpDest);
218
726
  }
727
+ const pricesSrc = defaultPricesYamlPath();
728
+ const pricesDest = join3(root, "prices.yaml");
729
+ if (await pathExists(pricesSrc)) {
730
+ const wrote = await writeTextIfMissing(pricesDest, await readText(pricesSrc));
731
+ if (wrote) created.push(pricesDest);
732
+ }
733
+ const searchSrc = defaultSearchYamlPath();
734
+ const searchDest = join3(root, "search.yaml");
735
+ if (await pathExists(searchSrc)) {
736
+ const wrote = await writeTextIfMissing(searchDest, await readText(searchSrc));
737
+ if (wrote) created.push(searchDest);
738
+ }
219
739
  const mDest = memoryDir(projectRoot, config);
740
+ const existingMemoryFiles = await listRelativeFiles(mDest);
741
+ if (existingMemoryFiles.length && config.memory_path === "memory") {
742
+ warnings.push(
743
+ `Existing ${config.memory_path}/ content will be preserved. Use --root .sdd to keep SDD memory alongside the project without merging folders.`
744
+ );
745
+ }
220
746
  await ensureDir(mDest);
221
747
  const mSrc = defaultMemoryDir();
222
748
  if (await pathExists(mSrc)) {
223
749
  await copyDirSkipExisting(mSrc, mDest);
224
750
  } else {
225
751
  await writeTextIfMissing(
226
- join2(mDest, "index.md"),
752
+ join3(mDest, "index.md"),
227
753
  `# Documentation map
228
754
 
229
755
  Parent page for **stable** project docs. Start here, then open only what you need.
@@ -235,15 +761,15 @@ Parent page for **stable** project docs. Start here, then open only what you nee
235
761
  | Architecture | [architecture.md](architecture.md) |
236
762
  | Conventions | [conventions.md](conventions.md) |
237
763
 
238
- Active work: \`.sdd/active-context.md\` and \`changes/<id>/\`.
764
+ Active work: \`.sdd/active-context.md\` and the configured changes folder.
239
765
  `
240
766
  );
241
767
  await writeTextIfMissing(
242
- join2(mDest, "product.md"),
768
+ join3(mDest, "product.md"),
243
769
  "# Product\n\n<!-- What are you building? -->\n"
244
770
  );
245
771
  await writeTextIfMissing(
246
- join2(mDest, "constitution.md"),
772
+ join3(mDest, "constitution.md"),
247
773
  `# Constitution
248
774
 
249
775
  <!-- Non-negotiables agents must not violate. Broader style lives in conventions.md. -->
@@ -270,21 +796,21 @@ Active work: \`.sdd/active-context.md\` and \`changes/<id>/\`.
270
796
  `
271
797
  );
272
798
  await writeTextIfMissing(
273
- join2(mDest, "architecture.md"),
799
+ join3(mDest, "architecture.md"),
274
800
  "# Architecture\n\n<!-- High-level system shape -->\n"
275
801
  );
276
802
  await writeTextIfMissing(
277
- join2(mDest, "conventions.md"),
803
+ join3(mDest, "conventions.md"),
278
804
  "# Conventions\n\n<!-- Coding and process conventions -->\n"
279
805
  );
280
806
  }
281
- if (!await pathExists(join2(mDest, "constitution.md"))) {
282
- const fromDefault = join2(mSrc, "constitution.md");
807
+ if (!await pathExists(join3(mDest, "constitution.md"))) {
808
+ const fromDefault = join3(mSrc, "constitution.md");
283
809
  if (await pathExists(fromDefault)) {
284
810
  await copyDirSkipExisting(mSrc, mDest);
285
811
  } else {
286
812
  await writeTextIfMissing(
287
- join2(mDest, "constitution.md"),
813
+ join3(mDest, "constitution.md"),
288
814
  `# Constitution
289
815
 
290
816
  <!-- Non-negotiables agents must not violate. Broader style lives in conventions.md. -->
@@ -317,38 +843,36 @@ Active work: \`.sdd/active-context.md\` and \`changes/<id>/\`.
317
843
  await ensureDir(domainsDir(projectRoot, config));
318
844
  created.push(changesDir(projectRoot, config));
319
845
  created.push(domainsDir(projectRoot, config));
320
- await writeText(join2(changesDir(projectRoot, config), ".gitkeep"), "");
321
- await writeText(join2(domainsDir(projectRoot, config), ".gitkeep"), "");
322
- const sddReadme = join2(root, "README.md");
323
- if (!await pathExists(sddReadme) || force) {
324
- await writeText(
325
- sddReadme,
326
- `# Structured Vibe Coding (SDD)
327
-
328
- This directory configures local Spec-Driven Development for this repo.
329
-
330
- - \`config.yaml\` \u2014 project settings and per-change policy
331
- - \`workflows/\` \u2014 YAML workflow packs (customize freely)
332
- - \`templates/\` \u2014 markdown templates for stage artifacts
333
- - \`protocol.md\` / \`active-context.md\` \u2014 agent playbook + live task (after agent install)
334
-
335
- Work lives in \`../changes/<id>/\`. Completed packs stay there with \`status: completed\`.
336
- Promote lasting decisions into \`../memory/\`. (Archive-on-complete is opt-in.)
337
-
338
- **Documentation map (stable):** \`../memory/index.md\`
339
- **Change-scoped docs:** \`../changes/<id>/\`
340
-
341
- ## Quick commands
342
-
343
- \`\`\`bash
344
- sdd new "My feature"
345
- sdd status
346
- sdd next
347
- sdd verify
348
- sdd complete
349
- \`\`\`
350
- `
351
- );
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");
850
+ if ((await ensureIgnoreEntries(sddIgnore, SDD_IGNORE_LINES)).created) {
851
+ created.push(sddIgnore);
852
+ }
853
+ if ((await ensureIgnoreEntries(changesIgnore, [".active"])).created) {
854
+ created.push(changesIgnore);
855
+ }
856
+ const perMachine = [
857
+ ".sdd/active-context.md",
858
+ ".sdd/handoff.md",
859
+ ".sdd/index.db",
860
+ `${changesPathRel(config)}/.active`
861
+ ];
862
+ const tracked = await (opts.indexLookup ?? gitTrackedPaths)(projectRoot, perMachine);
863
+ if (tracked) {
864
+ for (const rel of tracked) {
865
+ warnings.push(
866
+ `Already tracked (gitignore will not hide edits): ${rel}. sdd does not run git rm.`
867
+ );
868
+ }
869
+ }
870
+ const sddReadme = join3(root, "README.md");
871
+ const readmeState = await pathExists(sddReadme) ? generatedState(await readText(sddReadme)) : null;
872
+ if (readmeState === "edited" || readmeState === "not-generated") {
873
+ kept.push({ path: ".sdd/README.md", reason: readmeState });
874
+ } else if (readmeState === null || force) {
875
+ await writeText(sddReadme, markGenerated(renderSddReadme(config)));
352
876
  created.push(sddReadme);
353
877
  }
354
878
  let agentsResult;
@@ -359,17 +883,92 @@ sdd complete
359
883
  force
360
884
  });
361
885
  created.push(...ag.created);
362
- agentsResult = { created: ag.created, skipped: ag.skipped };
886
+ kept.push(...ag.kept);
887
+ agentsResult = { created: ag.created, skipped: ag.skipped, kept: ag.kept, removed: ag.removed };
888
+ }
889
+ return { created, config, kept, warnings, agents: agentsResult };
890
+ }
891
+ var SDD_IGNORE_LINES = [
892
+ "handoff.md",
893
+ "active-context.md",
894
+ "index.db",
895
+ "index.db-journal",
896
+ "index.db-wal",
897
+ "index.db-shm",
898
+ "models/",
899
+ "role-runs/"
900
+ ];
901
+ async function repairRequiredIgnores(projectRoot) {
902
+ const config = await loadConfig(projectRoot);
903
+ 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"), [
906
+ ".active"
907
+ ]);
908
+ return sdd.added || changes.added;
909
+ }
910
+ async function ensureIgnoreEntries(filePath, required) {
911
+ const existed = await pathExists(filePath);
912
+ const original = existed ? await readText(filePath) : "";
913
+ const present = new Set(
914
+ original.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"))
915
+ );
916
+ const missing = required.filter((line) => !present.has(line));
917
+ if (existed && missing.length === 0) return { created: false, added: false };
918
+ let next = original;
919
+ if (next.length > 0 && !next.endsWith("\n")) next += "\n";
920
+ next += missing.map((line) => `${line}
921
+ `).join("");
922
+ await writeText(filePath, next);
923
+ return { created: !existed, added: missing.length > 0 };
924
+ }
925
+ function gitTrackedPaths(projectRoot, relativePaths) {
926
+ return new Promise((resolve) => {
927
+ const child = spawn("git", ["ls-files", "--", ...relativePaths], {
928
+ cwd: projectRoot,
929
+ stdio: ["ignore", "pipe", "pipe"]
930
+ });
931
+ let stdout = "";
932
+ child.stdout?.on("data", (chunk) => {
933
+ stdout += chunk.toString();
934
+ });
935
+ child.on("error", () => resolve(null));
936
+ child.on("close", (code) => {
937
+ if (code !== 0) resolve(null);
938
+ else
939
+ resolve(
940
+ stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
941
+ );
942
+ });
943
+ });
944
+ }
945
+ function validateLayoutRoot(value) {
946
+ const root = value.trim();
947
+ const segments = root.split(/[\\/]+/);
948
+ if (!root || root !== value || root.startsWith("/") || root.startsWith("\\") || /^[A-Za-z]:/.test(root) || segments.some((segment) => segment === ".." || segment === "")) {
949
+ throw new Error("--root must be a non-empty, project-relative path without '..' segments.");
363
950
  }
364
- return { created, config, agents: agentsResult };
951
+ return segments.join("/");
952
+ }
953
+ async function listRelativeFiles(directory, prefix = "") {
954
+ if (!await pathExists(directory)) return [];
955
+ const files = (await listFiles(directory)).map(
956
+ (name) => [prefix, name].filter(Boolean).join("/")
957
+ );
958
+ const nested = await Promise.all(
959
+ (await listDirs(directory)).map(
960
+ (name) => listRelativeFiles(join3(directory, name), [prefix, name].filter(Boolean).join("/"))
961
+ )
962
+ );
963
+ return [...files, ...nested.flat()];
365
964
  }
366
965
 
367
966
  // src/verify.ts
368
- import { spawn } from "child_process";
369
- import { join as join3 } from "pathe";
967
+ import { spawn as spawn2 } from "child_process";
968
+ import { join as join4 } from "pathe";
370
969
  function runCommand(command, cwd) {
371
970
  return new Promise((resolve) => {
372
- const child = spawn(command, {
971
+ const child = spawn2(command, {
373
972
  cwd,
374
973
  shell: true,
375
974
  env: process.env
@@ -392,30 +991,51 @@ function runCommand(command, cwd) {
392
991
  function isToolGeneratedResults(content) {
393
992
  const trimmed = content.trim();
394
993
  if (!trimmed) return true;
395
- if (trimmed.includes("{{") || trimmed.includes("TODO")) return true;
396
- if (trimmed.startsWith("# Local verify results")) return true;
994
+ if (trimmed.includes("{{")) return true;
995
+ if (trimmed.startsWith("# Local verify results")) {
996
+ return false;
997
+ }
998
+ if (trimmed.startsWith("<!-- sdd:verify-report:start -->")) {
999
+ return trimmed.includes("<!-- sdd:verify-report:end -->") && /<!-- sdd:verify stage="[^"]+" head_sha="(?:none|[0-9a-f]{40}|[0-9a-f]{64})" result_count="\d+" -->/i.test(
1000
+ trimmed
1001
+ );
1002
+ }
1003
+ if (trimmed.startsWith("# Local test results")) {
1004
+ const body = trimmed.replace(/^# Local test results\s*/, "");
1005
+ const filled = !body.includes("{{") && !body.includes("| 1 | PASS / FAIL |") && !/\bTODO\b/.test(body) && body.length > 80;
1006
+ return !filled;
1007
+ }
1008
+ if (/\bTODO\b/.test(trimmed)) return true;
397
1009
  return false;
398
1010
  }
399
- function buildSummaryLines(title, stageId, ok, results, checklist) {
1011
+ var STATUS_LABEL = {
1012
+ pass: "PASS",
1013
+ fail: "FAIL",
1014
+ not_run: "NOT RUN"
1015
+ };
1016
+ function buildSummaryLines(title, stageId, headSha, status, reason, results, checklist) {
400
1017
  const lines = [
1018
+ `<!-- sdd:verify-report:start -->`,
1019
+ ``,
401
1020
  `# Local verify results`,
402
1021
  ``,
403
1022
  `> Change: ${title}`,
404
1023
  `> Stage: ${stageId}`,
1024
+ `<!-- sdd:verify stage="${stageId}" head_sha="${headSha ?? "none"}" result_count="${results.length}" -->`,
405
1025
  `> At: ${nowIso()}`,
406
- `> Overall: ${ok ? "PASS" : "FAIL"}`,
1026
+ `> Overall: ${STATUS_LABEL[status]}${reason ? ` \u2014 ${reason}` : ""}`,
407
1027
  ``,
408
1028
  `## Commands`,
409
1029
  ``
410
1030
  ];
411
1031
  if (results.length === 0) {
412
- lines.push(`_No commands configured for this stage. Complete the checklist manually._`);
1032
+ lines.push(`_No commands ran. Complete the checklist manually._`);
413
1033
  lines.push("");
414
1034
  } else {
415
1035
  for (const r of results) {
416
1036
  lines.push(`### ${r.name}`);
417
1037
  lines.push(`- Command: \`${r.command}\``);
418
- lines.push(`- Exit: ${r.exitCode}`);
1038
+ lines.push(`- Exit: ${r.exitCode}${r.exitCode === 0 ? "" : " (FAILED)"}`);
419
1039
  lines.push("");
420
1040
  }
421
1041
  }
@@ -429,6 +1049,8 @@ function buildSummaryLines(title, stageId, ok, results, checklist) {
429
1049
  lines.push("");
430
1050
  lines.push(`-`);
431
1051
  lines.push("");
1052
+ lines.push(`<!-- sdd:verify-report:end -->`);
1053
+ lines.push("");
432
1054
  return lines;
433
1055
  }
434
1056
  async function runLocalVerify(projectRoot, config, changeId, opts) {
@@ -437,8 +1059,73 @@ async function runLocalVerify(projectRoot, config, changeId, opts) {
437
1059
  if (!stage) {
438
1060
  throw new Error(`Unknown stage: ${ctx.meta.stage}`);
439
1061
  }
1062
+ if (!stage.verify) {
1063
+ const verifyStage = ctx.workflow.stages.find((st) => st.verify);
1064
+ return {
1065
+ stageId: stage.id,
1066
+ status: "not_run",
1067
+ reason: verifyStage ? `stage "${stage.id}" has no verify step; run sdd verify on "${verifyStage.id}"` : `workflow "${ctx.workflow.name}" has no verify step`,
1068
+ results: [],
1069
+ checklist: [],
1070
+ evidencePath: null,
1071
+ resultsPath: null,
1072
+ ok: false
1073
+ };
1074
+ }
1075
+ const headSha = opts?.headSha !== void 0 ? opts.headSha : await getHeadSha(projectRoot);
1076
+ const anyRecordedSha = Object.entries(ctx.meta.verify_results ?? {}).find(
1077
+ ([, recorded]) => recorded.head_sha
1078
+ );
1079
+ if (anyRecordedSha && !headSha) {
1080
+ 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.`
1082
+ );
1083
+ }
1084
+ for (const [resultStage, recorded] of Object.entries(ctx.meta.verify_results ?? {})) {
1085
+ if (recorded.head_sha && headSha && recorded.head_sha !== headSha) {
1086
+ 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}.`
1088
+ );
1089
+ }
1090
+ }
1091
+ const summaryPath = join4(ctx.path, "local-test-results.md");
1092
+ if (await pathExists(summaryPath)) {
1093
+ const existing = await readText(summaryPath);
1094
+ const markerPattern = /<!-- sdd:verify stage="([^"]+)" head_sha="(none|[0-9a-f]{40}|[0-9a-f]{64})" result_count="(\d+)" -->/gi;
1095
+ for (const marker of existing.matchAll(markerPattern)) {
1096
+ const line = existing.slice(0, marker.index).split("\n").length;
1097
+ const reportStage = marker[1];
1098
+ const reportSha = marker[2].toLowerCase();
1099
+ const reportResult = ctx.meta.verify_results?.[reportStage];
1100
+ if (!reportResult) {
1101
+ throw new Error(
1102
+ `Missing verification metadata in ${join4(ctx.path, "meta.yaml")} for ${summaryPath}:${line} (${reportStage}).`
1103
+ );
1104
+ }
1105
+ if (reportSha !== "none" && !headSha) {
1106
+ throw new Error(
1107
+ `Cannot validate SHA in ${summaryPath}:${line} because Git HEAD is unavailable.`
1108
+ );
1109
+ }
1110
+ if (headSha && reportSha !== "none" && reportSha !== headSha) {
1111
+ throw new Error(
1112
+ `Stale SHA in ${summaryPath}:${line}: recorded ${reportSha}, current HEAD ${headSha}. Run sdd sync ${changeId}.`
1113
+ );
1114
+ }
1115
+ if (reportResult && Number(marker[3]) !== reportResult.results.length) {
1116
+ throw new Error(
1117
+ `Recorded result count mismatch in ${summaryPath}:${line}: report has ${marker[3]}, metadata has ${reportResult.results.length}.`
1118
+ );
1119
+ }
1120
+ if (reportResult?.head_sha && reportSha !== reportResult.head_sha) {
1121
+ throw new Error(
1122
+ `SHA mismatch between ${join4(ctx.path, "meta.yaml")} and ${summaryPath}:${line}.`
1123
+ );
1124
+ }
1125
+ }
1126
+ }
440
1127
  const evidenceDirRaw = stage.verify?.evidence_dir?.trim();
441
- const evidenceDir = evidenceDirRaw && evidenceDirRaw.length > 0 ? join3(ctx.path, evidenceDirRaw) : null;
1128
+ const evidenceDir = evidenceDirRaw && evidenceDirRaw.length > 0 ? join4(ctx.path, evidenceDirRaw) : null;
442
1129
  if (evidenceDir) {
443
1130
  await ensureDir(evidenceDir);
444
1131
  }
@@ -455,7 +1142,7 @@ async function runLocalVerify(projectRoot, config, changeId, opts) {
455
1142
  output: output.slice(0, 5e4)
456
1143
  });
457
1144
  if (evidenceDir) {
458
- const logPath = join3(evidenceDir, `${cmd.name.replace(/\s+/g, "-")}.log`);
1145
+ const logPath = join4(evidenceDir, `${cmd.name.replace(/\s+/g, "-")}.log`);
459
1146
  await writeText(logPath, `# ${cmd.name}
460
1147
  $ ${cmd.run}
461
1148
  exit: ${exitCode}
@@ -464,28 +1151,36 @@ ${output}`);
464
1151
  }
465
1152
  }
466
1153
  }
467
- const required = commands.filter((c) => c.required);
468
- let ok = true;
469
- if (required.length) {
470
- if (!runCommands) {
471
- ok = false;
472
- } else {
473
- for (const cmd of required) {
474
- const r = results.find((x) => x.name === cmd.name);
475
- if (!r || r.exitCode !== 0) ok = false;
476
- }
477
- }
478
- } else if (commands.length && runCommands) {
479
- ok = results.every((r) => r.exitCode === 0);
1154
+ let status;
1155
+ let reason;
1156
+ if (!commands.length) {
1157
+ status = "not_run";
1158
+ reason = `no verify commands configured for stage "${stage.id}"`;
1159
+ } else if (!runCommands) {
1160
+ status = "not_run";
1161
+ reason = "commands not run (--no-run)";
1162
+ } else {
1163
+ const failed = results.filter((r) => r.exitCode !== 0).map((r) => r.name);
1164
+ status = failed.length ? "fail" : "pass";
1165
+ reason = failed.length ? `failed: ${failed.join(", ")}` : void 0;
480
1166
  }
1167
+ const ok4 = status === "pass";
481
1168
  const checklist = stage.gate?.checklist ?? [];
482
- const summaryPath = join3(ctx.path, "local-test-results.md");
483
- const lines = buildSummaryLines(ctx.meta.title, stage.id, ok, results, checklist);
1169
+ const lines = buildSummaryLines(
1170
+ ctx.meta.title,
1171
+ stage.id,
1172
+ results.length ? headSha : null,
1173
+ status,
1174
+ reason,
1175
+ results,
1176
+ checklist
1177
+ );
484
1178
  const summaryBody = lines.join("\n");
485
1179
  if (await pathExists(summaryPath)) {
486
1180
  const existing = await readText(summaryPath);
487
1181
  if (isToolGeneratedResults(existing)) {
488
1182
  await writeText(summaryPath, summaryBody);
1183
+ } else if (results.length === 0) {
489
1184
  } else {
490
1185
  const stamp = nowIso();
491
1186
  const append = [
@@ -494,12 +1189,13 @@ ${output}`);
494
1189
  ``,
495
1190
  `## Latest run (${stamp})`,
496
1191
  ``,
497
- `> Overall: ${ok ? "PASS" : "FAIL"}`,
1192
+ `> Overall: ${STATUS_LABEL[status]}${reason ? ` \u2014 ${reason}` : ""}`,
498
1193
  `> Stage: ${stage.id}`,
1194
+ `<!-- sdd:verify stage="${stage.id}" head_sha="${results.length ? headSha ?? "none" : "none"}" result_count="${results.length}" -->`,
499
1195
  ``
500
1196
  ];
501
1197
  if (results.length === 0) {
502
- append.push(`_No commands configured for this stage._`);
1198
+ append.push(`_No commands ran._`);
503
1199
  append.push("");
504
1200
  } else {
505
1201
  for (const r of results) {
@@ -511,7 +1207,7 @@ ${output}`);
511
1207
  }
512
1208
  if (evidenceDir) {
513
1209
  const stamp = nowIso();
514
- await writeText(join3(evidenceDir, `run-${stamp.replace(/[:.]/g, "-")}.md`), summaryBody);
1210
+ await writeText(join4(evidenceDir, `run-${stamp.replace(/[:.]/g, "-")}.md`), summaryBody);
515
1211
  }
516
1212
  } else {
517
1213
  await writeText(summaryPath, summaryBody);
@@ -519,7 +1215,9 @@ ${output}`);
519
1215
  ctx.meta.verify_results = {
520
1216
  ...ctx.meta.verify_results,
521
1217
  [stage.id]: {
522
- ok,
1218
+ ok: ok4,
1219
+ status,
1220
+ ...results.length && headSha ? { head_sha: headSha } : {},
523
1221
  at: nowIso(),
524
1222
  results: results.map((r) => ({ name: r.name, exitCode: r.exitCode }))
525
1223
  }
@@ -527,304 +1225,23 @@ ${output}`);
527
1225
  await saveChangeMeta(projectRoot, config, ctx.meta);
528
1226
  return {
529
1227
  stageId: stage.id,
1228
+ status,
1229
+ reason,
530
1230
  results,
531
1231
  checklist,
532
1232
  evidencePath: evidenceDir,
533
- ok
1233
+ resultsPath: summaryPath,
1234
+ ok: ok4
534
1235
  };
535
1236
  }
536
1237
 
537
1238
  // src/code-context/index.ts
538
- import { extname as extname4, join as join6, normalize as normalize5 } from "pathe";
539
-
540
- // src/code-context/adapters/registry.ts
541
- import { extname as extname2 } from "pathe";
542
-
543
- // src/code-context/adapters/typescript.ts
544
- import { extname } from "pathe";
545
- import ts from "typescript";
546
- var TS_JS_EXTENSIONS = [
547
- ".ts",
548
- ".tsx",
549
- ".js",
550
- ".jsx",
551
- ".mts",
552
- ".cts",
553
- ".mjs",
554
- ".cjs"
555
- ];
556
- function scriptKindForPath(filePath) {
557
- const ext = extname(filePath).toLowerCase();
558
- switch (ext) {
559
- case ".tsx":
560
- return ts.ScriptKind.TSX;
561
- case ".jsx":
562
- return ts.ScriptKind.JSX;
563
- case ".js":
564
- case ".mjs":
565
- case ".cjs":
566
- return ts.ScriptKind.JS;
567
- case ".cts":
568
- case ".mts":
569
- case ".ts":
570
- default:
571
- return ts.ScriptKind.TS;
572
- }
573
- }
574
- function lineOf(sf, pos) {
575
- return sf.getLineAndCharacterOfPosition(pos).line + 1;
576
- }
577
- function hasExportModifier(node) {
578
- const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : void 0;
579
- return Boolean(mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
580
- }
581
- function isTopLevel(node, sf) {
582
- let p = node.parent;
583
- while (p && p !== sf) {
584
- if (ts.isFunctionDeclaration(p) || ts.isFunctionExpression(p) || ts.isArrowFunction(p) || ts.isMethodDeclaration(p) || ts.isConstructorDeclaration(p) || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p)) {
585
- return false;
586
- }
587
- if (ts.isClassDeclaration(p) || ts.isClassExpression(p)) {
588
- return false;
589
- }
590
- p = p.parent;
591
- }
592
- return true;
593
- }
594
- function extractFromSource(filePath, source, repoRelativePath) {
595
- const sf = ts.createSourceFile(
596
- filePath,
597
- source,
598
- ts.ScriptTarget.Latest,
599
- /*setParentNodes*/
600
- true,
601
- scriptKindForPath(filePath)
602
- );
603
- const symbols = [];
604
- const imports = [];
605
- const exports = [];
606
- const references = [];
607
- const pushSymbol = (name, node, kind, exported) => {
608
- if (!name) return;
609
- symbols.push({
610
- name,
611
- kind,
612
- filePath: repoRelativePath,
613
- startLine: lineOf(sf, node.getStart(sf, false)),
614
- endLine: lineOf(sf, node.getEnd()),
615
- exported
616
- });
617
- if (exported) {
618
- exports.push({ name, kind });
619
- }
620
- };
621
- const visit = (node) => {
622
- if (ts.isImportDeclaration(node)) {
623
- const spec = node.moduleSpecifier;
624
- if (ts.isStringLiteral(spec)) {
625
- const names = [];
626
- const clause = node.importClause;
627
- if (clause?.name) names.push(clause.name.text);
628
- if (clause?.namedBindings) {
629
- if (ts.isNamespaceImport(clause.namedBindings)) {
630
- names.push(clause.namedBindings.name.text);
631
- } else if (ts.isNamedImports(clause.namedBindings)) {
632
- for (const el of clause.namedBindings.elements) {
633
- names.push(el.name.text);
634
- }
635
- }
636
- }
637
- imports.push({ from: spec.text, names: names.length ? names : void 0 });
638
- }
639
- }
640
- if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
641
- imports.push({ from: node.moduleSpecifier.text, names: ["*"] });
642
- }
643
- if (ts.isFunctionDeclaration(node) && node.name && isTopLevel(node, sf)) {
644
- pushSymbol(node.name.text, node, "function", hasExportModifier(node));
645
- }
646
- if (ts.isClassDeclaration(node) && node.name && isTopLevel(node, sf)) {
647
- pushSymbol(node.name.text, node, "class", hasExportModifier(node));
648
- }
649
- if (ts.isInterfaceDeclaration(node) && isTopLevel(node, sf)) {
650
- pushSymbol(node.name.text, node, "interface", hasExportModifier(node));
651
- }
652
- if (ts.isTypeAliasDeclaration(node) && isTopLevel(node, sf)) {
653
- pushSymbol(node.name.text, node, "type", hasExportModifier(node));
654
- }
655
- if (ts.isEnumDeclaration(node) && isTopLevel(node, sf)) {
656
- pushSymbol(node.name.text, node, "type", hasExportModifier(node));
657
- }
658
- if (ts.isVariableStatement(node) && isTopLevel(node, sf)) {
659
- const exported = hasExportModifier(node);
660
- for (const decl of node.declarationList.declarations) {
661
- if (!ts.isIdentifier(decl.name)) continue;
662
- const name = decl.name.text;
663
- const init = decl.initializer;
664
- const isFn = init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init));
665
- if (exported || isFn) {
666
- pushSymbol(name, decl, isFn ? "function" : "const", exported);
667
- }
668
- }
669
- }
670
- if (ts.isExportAssignment(node) && !node.isExportEquals) {
671
- exports.push({ name: "default", kind: "export" });
672
- }
673
- if (ts.isIdentifier(node) && node.parent && !ts.isImportSpecifier(node.parent)) {
674
- const parent = node.parent;
675
- if (ts.isCallExpression(parent) || ts.isPropertyAccessExpression(parent) || ts.isTypeReferenceNode(parent)) {
676
- references.push({ name: node.text, line: lineOf(sf, node.getStart(sf)) });
677
- }
678
- }
679
- ts.forEachChild(node, visit);
680
- };
681
- visit(sf);
682
- return {
683
- ok: true,
684
- language: "typescript",
685
- symbols,
686
- imports,
687
- exports,
688
- references: references.slice(0, 200)
689
- // bound noise
690
- };
691
- }
692
- var typescriptAdapter = {
693
- id: "typescript",
694
- extensions: [...TS_JS_EXTENSIONS],
695
- extract(input) {
696
- try {
697
- return extractFromSource(input.filePath, input.source, input.filePath);
698
- } catch (err) {
699
- return {
700
- ok: false,
701
- language: "typescript",
702
- symbols: [],
703
- imports: [],
704
- exports: [],
705
- errors: [err instanceof Error ? err.message : String(err)]
706
- };
707
- }
708
- }
709
- };
710
- function isTypescriptAdapterAvailable() {
711
- try {
712
- return typeof ts.createSourceFile === "function";
713
- } catch {
714
- return false;
715
- }
716
- }
717
-
718
- // src/code-context/adapters/registry.ts
719
- var AdapterRegistry = class {
720
- byExt = /* @__PURE__ */ new Map();
721
- constructor(adapters) {
722
- const list = adapters ?? [typescriptAdapter];
723
- for (const a of list) {
724
- for (const ext of a.extensions) {
725
- this.byExt.set(ext.toLowerCase(), a);
726
- }
727
- }
728
- }
729
- getByPath(filePath) {
730
- const ext = extname2(filePath).toLowerCase();
731
- return this.byExt.get(ext);
732
- }
733
- hasExtension(ext) {
734
- return this.byExt.has(ext.toLowerCase());
735
- }
736
- };
1239
+ import { stat } from "fs/promises";
1240
+ import { extname as extname3, isAbsolute as isAbsolute2, join as join6, relative as relative2 } from "pathe";
737
1241
 
738
1242
  // src/code-context/focus.ts
739
1243
  import { promises as fs } from "fs";
740
- import { isAbsolute, join as join4, normalize as normalize3, relative } from "pathe";
741
-
742
- // src/code-context/ignore.ts
743
- import { basename as basename2, extname as extname3, normalize as normalize2 } from "pathe";
744
-
745
- // src/code-context/secrets.ts
746
- import { basename, normalize } from "pathe";
747
- var SECRET_BASENAME_PATTERNS = [
748
- /^\.env$/i,
749
- /^\.env\..+/i,
750
- /\.pem$/i,
751
- /\.key$/i,
752
- /id_rsa/i,
753
- /credentials/i,
754
- /secret/i
755
- ];
756
- function isSecretPath(filePath) {
757
- const norm = normalize(filePath).replace(/\\/g, "/");
758
- const parts = norm.split("/").filter(Boolean);
759
- if (parts.includes(".git")) return true;
760
- if (norm.includes("/.git/") || norm.startsWith(".git/") || norm === ".git") {
761
- return true;
762
- }
763
- const base = basename(norm);
764
- return SECRET_BASENAME_PATTERNS.some((re) => re.test(base));
765
- }
766
-
767
- // src/code-context/ignore.ts
768
- var IGNORED_DIR_SEGMENTS = /* @__PURE__ */ new Set([
769
- "node_modules",
770
- "dist",
771
- "coverage",
772
- ".turbo",
773
- ".next",
774
- "build",
775
- ".cache"
776
- ]);
777
- var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
778
- ".png",
779
- ".jpg",
780
- ".jpeg",
781
- ".gif",
782
- ".webp",
783
- ".ico",
784
- ".svg",
785
- ".woff",
786
- ".woff2",
787
- ".ttf",
788
- ".eot",
789
- ".pdf",
790
- ".zip",
791
- ".tar",
792
- ".gz",
793
- ".tgz",
794
- ".7z",
795
- ".rar",
796
- ".exe",
797
- ".dll",
798
- ".so",
799
- ".dylib",
800
- ".bin",
801
- ".wasm",
802
- ".mp3",
803
- ".mp4",
804
- ".mov",
805
- ".lock"
806
- ]);
807
- function isIgnoredPath(filePath) {
808
- const norm = normalize2(filePath).replace(/\\/g, "/");
809
- const parts = norm.split("/").filter(Boolean);
810
- for (const p of parts) {
811
- if (IGNORED_DIR_SEGMENTS.has(p)) return true;
812
- }
813
- const ext = extname3(basename2(norm)).toLowerCase();
814
- if (BINARY_EXTENSIONS.has(ext)) return true;
815
- const base = basename2(norm);
816
- if (base === "package-lock.json" || base === "pnpm-lock.yaml" || base === "yarn.lock" || base === "bun.lockb") {
817
- return true;
818
- }
819
- return false;
820
- }
821
- function classifyPath(filePath) {
822
- if (isSecretPath(filePath)) return "secret";
823
- if (isIgnoredPath(filePath)) return "ignored";
824
- return "allowed";
825
- }
826
-
827
- // src/code-context/focus.ts
1244
+ import { isAbsolute, join as join5, normalize, relative } from "pathe";
828
1245
  var KEYWORD_STOPWORDS = new Set(
829
1246
  [
830
1247
  "a",
@@ -915,12 +1332,12 @@ function extractPathLikeTokens(text) {
915
1332
  return found;
916
1333
  }
917
1334
  function toRepoRelative(projectRoot, p) {
918
- const abs = isAbsolute(p) ? p : join4(projectRoot, p);
1335
+ const abs = isAbsolute(p) ? p : join5(projectRoot, p);
919
1336
  const rel = relative(projectRoot, abs);
920
- return normalize3(rel).replace(/\\/g, "/");
1337
+ return normalize(rel).replace(/\\/g, "/");
921
1338
  }
922
1339
  function toAbsolute(projectRoot, p) {
923
- return isAbsolute(p) ? p : join4(projectRoot, p);
1340
+ return isAbsolute(p) ? p : join5(projectRoot, p);
924
1341
  }
925
1342
  async function walkFiles(absDir, projectRoot, maxFiles, out, gaps) {
926
1343
  if (out.length >= maxFiles) return;
@@ -933,7 +1350,7 @@ async function walkFiles(absDir, projectRoot, maxFiles, out, gaps) {
933
1350
  }
934
1351
  for (const entry of entries) {
935
1352
  if (out.length >= maxFiles) return;
936
- const abs = join4(absDir, entry.name);
1353
+ const abs = join5(absDir, entry.name);
937
1354
  const rel = toRepoRelative(projectRoot, abs);
938
1355
  const kind = classifyPath(rel);
939
1356
  if (kind === "secret" || kind === "ignored") continue;
@@ -1027,7 +1444,7 @@ async function resolveChangeMeta(projectRoot, changeId, gaps) {
1027
1444
  return null;
1028
1445
  }
1029
1446
  const cpath = changePath(projectRoot, config, id);
1030
- const metaPath = join4(cpath, "meta.yaml");
1447
+ const metaPath = join5(cpath, "meta.yaml");
1031
1448
  if (!await pathExists(metaPath)) {
1032
1449
  if (!await pathExists(cpath)) {
1033
1450
  gaps.push({
@@ -1060,7 +1477,7 @@ async function searchByKeywords(projectRoot, keywords, symbols, maxFiles) {
1060
1477
  const terms = [...keywords, ...symbols.map((s) => s.toLowerCase())];
1061
1478
  if (!terms.length) return out;
1062
1479
  for (const root of SOURCE_ROOTS) {
1063
- const abs = join4(projectRoot, root);
1480
+ const abs = join5(projectRoot, root);
1064
1481
  if (!await pathExists(abs)) continue;
1065
1482
  const files = [];
1066
1483
  await walkFiles(abs, projectRoot, maxFiles * 4, files, []);
@@ -1074,7 +1491,7 @@ async function searchByKeywords(projectRoot, keywords, symbols, maxFiles) {
1074
1491
  }
1075
1492
  if (symbols.length) {
1076
1493
  try {
1077
- const text = await readText(join4(projectRoot, rel));
1494
+ const text = await readText(join5(projectRoot, rel));
1078
1495
  if (symbols.some((s) => text.includes(s))) {
1079
1496
  out.push(rel);
1080
1497
  }
@@ -1110,7 +1527,7 @@ async function resolveFocus(request, options) {
1110
1527
  }
1111
1528
  notes.push(`Change signals from: ${change.id}`);
1112
1529
  for (const artifact of ["tasks.md", "feature.md", "stories.md", "lld.md"]) {
1113
- const body = await readBounded(join4(change.path, artifact), 8e3);
1530
+ const body = await readBounded(join5(change.path, artifact), 8e3);
1114
1531
  if (!body) continue;
1115
1532
  if (!request.paths?.length) {
1116
1533
  for (const p of extractPathLikeTokens(body)) {
@@ -1183,17 +1600,6 @@ async function resolveFocus(request, options) {
1183
1600
  }
1184
1601
 
1185
1602
  // src/code-context/formatter.ts
1186
- function fenceLang(filePath) {
1187
- const lower = filePath.toLowerCase();
1188
- if (lower.endsWith(".tsx") || lower.endsWith(".jsx")) return "tsx";
1189
- if (lower.endsWith(".ts") || lower.endsWith(".mts") || lower.endsWith(".cts")) {
1190
- return "ts";
1191
- }
1192
- if (lower.endsWith(".js") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
1193
- return "js";
1194
- }
1195
- return "";
1196
- }
1197
1603
  function formatMarkdown(input) {
1198
1604
  const lines = [];
1199
1605
  lines.push("# Code context");
@@ -1233,50 +1639,15 @@ function formatMarkdown(input) {
1233
1639
  lines.push("");
1234
1640
  lines.push("## Slices");
1235
1641
  lines.push("");
1236
- let truncated = input.summary.truncated;
1237
- const maxLines = input.maxOutputLines ?? 8e3;
1238
- const maxTokens = input.maxTokensApprox ?? 12e3;
1239
- let approxTokens = 0;
1240
- for (const slice of input.slices) {
1241
- if (lines.length >= maxLines) {
1242
- truncated = true;
1243
- break;
1244
- }
1245
- const header = `### \`${slice.filePath}\`${slice.symbolName ? ` \xB7 \`${slice.symbolName}\`` : ""} (lines ${slice.startLine}\u2013${slice.endLine}) \xB7 ${slice.reason}`;
1246
- lines.push(header);
1247
- lines.push("```" + fenceLang(slice.filePath));
1248
- lines.push(slice.body);
1249
- lines.push("```");
1250
- lines.push("");
1251
- approxTokens += Math.ceil((header.length + slice.body.length) / 4);
1252
- if (approxTokens > maxTokens) {
1253
- truncated = true;
1254
- break;
1255
- }
1256
- }
1257
- if (!input.slices.length) {
1258
- lines.push("_No slices emitted._");
1259
- lines.push("");
1260
- }
1261
- lines.push("## Structural notes");
1262
- const topFiles = [...new Set(input.slices.map((s) => s.filePath))].slice(0, 15);
1263
- if (topFiles.length) {
1264
- lines.push(`- Ranked files (sample): ${topFiles.join(", ")}`);
1265
- } else {
1266
- lines.push("- No structural neighborhood (empty analysis set).");
1267
- }
1268
- lines.push("");
1269
- let markdown = lines.join("\n");
1270
- if (Math.ceil(markdown.length / 4) > maxTokens) {
1271
- truncated = true;
1272
- markdown = markdown.slice(0, maxTokens * 4) + "\n\n\u2026(truncated)\n";
1273
- }
1274
- return { markdown, truncated };
1642
+ const packed = input.packedText ?? "";
1643
+ const frame = `${lines.join("\n")}
1644
+ `;
1645
+ return { markdown: packed ? frame + packed : frame, truncated: input.summary.truncated };
1275
1646
  }
1276
- function formatJsonSummary(summary, gaps, slices, ok) {
1647
+ function formatJsonSummary(summary, gaps, slices, ok4) {
1277
1648
  return JSON.stringify(
1278
1649
  {
1279
- ok,
1650
+ ok: ok4,
1280
1651
  summary,
1281
1652
  gaps,
1282
1653
  slices: slices.map((s) => ({
@@ -1293,288 +1664,6 @@ function formatJsonSummary(summary, gaps, slices, ok) {
1293
1664
  );
1294
1665
  }
1295
1666
 
1296
- // src/code-context/graph.ts
1297
- import { dirname as dirname2, join as join5, normalize as normalize4 } from "pathe";
1298
- var GraphLite = class {
1299
- files = /* @__PURE__ */ new Map();
1300
- edges = [];
1301
- add(path, source, extract) {
1302
- const norm = normalize4(path).replace(/\\/g, "/");
1303
- this.files.set(norm, { path: norm, extract, source });
1304
- for (const imp of extract.imports) {
1305
- this.edges.push({
1306
- from: norm,
1307
- to: imp.from,
1308
- names: imp.names
1309
- });
1310
- }
1311
- }
1312
- allSymbols() {
1313
- const out = [];
1314
- for (const node of this.files.values()) {
1315
- out.push(...node.extract.symbols);
1316
- }
1317
- return out;
1318
- }
1319
- getSource(path) {
1320
- return this.files.get(normalize4(path).replace(/\\/g, "/"))?.source;
1321
- }
1322
- /**
1323
- * Resolve relative import specifier to a known graph file path, if present.
1324
- */
1325
- resolveImportTarget(fromFile, specifier) {
1326
- if (!specifier.startsWith(".")) return void 0;
1327
- const base = dirname2(fromFile);
1328
- const joined = normalize4(join5(base, specifier)).replace(/\\/g, "/");
1329
- const candidates = [
1330
- joined,
1331
- `${joined}.ts`,
1332
- `${joined}.tsx`,
1333
- `${joined}.js`,
1334
- `${joined}.jsx`,
1335
- `${joined}.mts`,
1336
- `${joined}.cts`,
1337
- `${joined}/index.ts`,
1338
- `${joined}/index.js`
1339
- ];
1340
- for (const c of candidates) {
1341
- if (this.files.has(c)) return c;
1342
- }
1343
- for (const [p] of this.files) {
1344
- if (p === joined || p.startsWith(joined + ".")) return p;
1345
- }
1346
- return void 0;
1347
- }
1348
- /** Import hop distance from any seed file (0 = seed, 1 = direct import, …). */
1349
- hopDistance(filePath, seedPaths, maxHops) {
1350
- const norm = normalize4(filePath).replace(/\\/g, "/");
1351
- const seeds = new Set(seedPaths.map((s) => normalize4(s).replace(/\\/g, "/")));
1352
- if (seeds.has(norm)) return 0;
1353
- let frontier = new Set(seeds);
1354
- const visited = new Set(seeds);
1355
- for (let hop = 1; hop <= maxHops; hop++) {
1356
- const next = /* @__PURE__ */ new Set();
1357
- for (const f of frontier) {
1358
- for (const e of this.edges) {
1359
- if (e.from !== f) continue;
1360
- const target = this.resolveImportTarget(e.from, e.to);
1361
- if (!target || visited.has(target)) continue;
1362
- visited.add(target);
1363
- next.add(target);
1364
- if (target === norm) return hop;
1365
- }
1366
- }
1367
- frontier = next;
1368
- if (!frontier.size) break;
1369
- }
1370
- return null;
1371
- }
1372
- importNeighbors(filePath) {
1373
- const norm = normalize4(filePath).replace(/\\/g, "/");
1374
- const out = [];
1375
- for (const e of this.edges) {
1376
- if (e.from !== norm) continue;
1377
- const t = this.resolveImportTarget(e.from, e.to);
1378
- if (t) out.push(t);
1379
- }
1380
- return out;
1381
- }
1382
- };
1383
-
1384
- // src/code-context/ranker.ts
1385
- import { basename as basename3 } from "pathe";
1386
- function isTestPath(filePath) {
1387
- const p = filePath.replace(/\\/g, "/");
1388
- return p.includes("__tests__/") || p.includes("/__tests__/") || /\.test\.[cm]?[jt]sx?$/.test(p) || /\.spec\.[cm]?[jt]sx?$/.test(p);
1389
- }
1390
- function pathSegments(filePath) {
1391
- return filePath.replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean).flatMap((s) => s.replace(/\.[^.]+$/, "").split(/[-_.]/));
1392
- }
1393
- function scoreSymbol(symbol, plan, graph, caps) {
1394
- let score = 0;
1395
- const reasons = [];
1396
- const seeds = new Set(plan.seedPaths);
1397
- const seedSymbols = plan.seedSymbols;
1398
- if (seedSymbols.length) {
1399
- const exact = seedSymbols.find((s) => s === symbol.name);
1400
- if (exact) {
1401
- score += 100;
1402
- reasons.push("exact-symbol");
1403
- } else {
1404
- const ci = seedSymbols.find((s) => s.toLowerCase() === symbol.name.toLowerCase());
1405
- if (ci) {
1406
- score += 90;
1407
- reasons.push("symbol-ci");
1408
- }
1409
- }
1410
- }
1411
- if (seeds.has(symbol.filePath)) {
1412
- score += 40;
1413
- reasons.push("seed-path");
1414
- }
1415
- const hop = graph.hopDistance(symbol.filePath, plan.seedPaths, caps.maxImportHops);
1416
- if (hop === 0) {
1417
- score += 30;
1418
- reasons.push("hop-0");
1419
- } else if (hop === 1) {
1420
- score += 18;
1421
- reasons.push("hop-1");
1422
- }
1423
- const nameLower = symbol.name.toLowerCase();
1424
- let kwHits = 0;
1425
- for (const k of plan.keywords) {
1426
- if (nameLower.includes(k)) {
1427
- kwHits++;
1428
- if (kwHits >= 3) break;
1429
- }
1430
- }
1431
- if (kwHits) {
1432
- score += 15 * kwHits;
1433
- reasons.push(`keyword-name\xD7${kwHits}`);
1434
- }
1435
- const segs = pathSegments(symbol.filePath);
1436
- let pathHits = 0;
1437
- for (const k of plan.keywords) {
1438
- if (segs.some((s) => s.includes(k) || k.includes(s))) {
1439
- pathHits++;
1440
- if (pathHits >= 3) break;
1441
- }
1442
- }
1443
- if (pathHits) {
1444
- score += 8 * pathHits;
1445
- reasons.push(`keyword-path\xD7${pathHits}`);
1446
- }
1447
- if (symbol.exported) {
1448
- score += 5;
1449
- reasons.push("exported");
1450
- }
1451
- const base = basename3(symbol.filePath).toLowerCase();
1452
- let titleHits = 0;
1453
- for (const k of plan.keywords.slice(0, 10)) {
1454
- if (base.includes(k)) {
1455
- titleHits++;
1456
- if (titleHits >= 2) break;
1457
- }
1458
- }
1459
- if (titleHits) {
1460
- score += 6 * titleHits;
1461
- reasons.push(`title-path\xD7${titleHits}`);
1462
- }
1463
- const focusIsTest = plan.seedPaths.some(isTestPath) || plan.keywords.some((k) => k === "test" || k === "tests" || k === "vitest");
1464
- if (isTestPath(symbol.filePath) && !focusIsTest) {
1465
- score -= 12;
1466
- reasons.push("test-penalty");
1467
- }
1468
- const source = graph.getSource(symbol.filePath);
1469
- if (source) {
1470
- const lines = source.split(/\r?\n/).length;
1471
- if (lines > 400) {
1472
- const over = Math.floor((lines - 400) / 200);
1473
- const pen = Math.min(10, over);
1474
- if (pen > 0) {
1475
- score -= pen;
1476
- reasons.push("huge-file");
1477
- }
1478
- }
1479
- }
1480
- if (symbol.kind === "function" || symbol.kind === "class" || symbol.kind === "interface") {
1481
- score += 2;
1482
- }
1483
- return { symbol, score, reasons };
1484
- }
1485
- function rankSymbols(graph, plan, caps) {
1486
- const items = graph.allSymbols().map((s) => scoreSymbol(s, plan, graph, caps));
1487
- items.sort((a, b) => {
1488
- if (b.score !== a.score) return b.score - a.score;
1489
- const fa = a.symbol.filePath.localeCompare(b.symbol.filePath);
1490
- if (fa !== 0) return fa;
1491
- return a.symbol.startLine - b.symbol.startLine;
1492
- });
1493
- return items.slice(0, caps.maxSymbols);
1494
- }
1495
-
1496
- // src/code-context/slicer.ts
1497
- function sliceBody(source, startLine, endLine, maxLines) {
1498
- const lines = source.split(/\r?\n/);
1499
- const start = Math.max(1, startLine);
1500
- let end = Math.min(lines.length, endLine);
1501
- let truncated = false;
1502
- if (end - start + 1 > maxLines) {
1503
- end = start + maxLines - 1;
1504
- truncated = true;
1505
- }
1506
- const body = lines.slice(start - 1, end).join("\n");
1507
- return { body, end, truncated };
1508
- }
1509
- function emitSlices(ranked, graph, caps, options) {
1510
- const slices = [];
1511
- const gaps = [];
1512
- let truncated = false;
1513
- const seen = /* @__PURE__ */ new Set();
1514
- for (const item of ranked) {
1515
- if (slices.length >= caps.maxSlices) {
1516
- truncated = true;
1517
- break;
1518
- }
1519
- const sym = item.symbol;
1520
- if (isSecretPath(sym.filePath)) continue;
1521
- const source = graph.getSource(sym.filePath);
1522
- if (!source) continue;
1523
- const {
1524
- body,
1525
- end,
1526
- truncated: lineTrunc
1527
- } = sliceBody(source, sym.startLine, sym.endLine, caps.maxLinesPerSlice);
1528
- if (lineTrunc) truncated = true;
1529
- const id = `${sym.filePath}:${sym.startLine}-${end}:${sym.name}`;
1530
- if (seen.has(id)) continue;
1531
- seen.add(id);
1532
- const reasonParts = item.reasons.length ? item.reasons.slice(0, 4).join(", ") : "ranked";
1533
- slices.push({
1534
- id,
1535
- filePath: sym.filePath,
1536
- startLine: sym.startLine,
1537
- endLine: end,
1538
- symbolName: sym.name,
1539
- reason: reasonParts,
1540
- body
1541
- });
1542
- if (options?.includeNeighbors && slices.length < caps.maxSlices) {
1543
- const neighbors = graph.importNeighbors(sym.filePath);
1544
- for (const n of neighbors.slice(0, 1)) {
1545
- if (isSecretPath(n)) continue;
1546
- const nSource = graph.getSource(n);
1547
- const nNode = graph.files.get(n);
1548
- if (!nSource || !nNode) continue;
1549
- const nSym = nNode.extract.symbols.find((s) => s.exported) ?? nNode.extract.symbols[0];
1550
- if (!nSym) continue;
1551
- const nb = sliceBody(nSource, nSym.startLine, nSym.endLine, caps.maxLinesPerSlice);
1552
- const nid = `${nSym.filePath}:${nSym.startLine}-${nb.end}:${nSym.name}:neighbor`;
1553
- if (seen.has(nid)) continue;
1554
- seen.add(nid);
1555
- slices.push({
1556
- id: nid,
1557
- filePath: nSym.filePath,
1558
- startLine: nSym.startLine,
1559
- endLine: nb.end,
1560
- symbolName: nSym.name,
1561
- reason: `neighbor-of ${sym.name}`,
1562
- body: nb.body
1563
- });
1564
- if (nb.truncated) truncated = true;
1565
- break;
1566
- }
1567
- }
1568
- }
1569
- if (truncated) {
1570
- gaps.push({
1571
- code: "cap_truncated",
1572
- message: "Output truncated at configured caps (maxSlices/maxLinesPerSlice)."
1573
- });
1574
- }
1575
- return { slices, gaps, truncated };
1576
- }
1577
-
1578
1667
  // src/code-context/types.ts
1579
1668
  var DEFAULT_CODE_CONTEXT_CAPS = {
1580
1669
  maxFiles: 40,
@@ -1589,13 +1678,204 @@ function mergeCaps(partial) {
1589
1678
  return { ...DEFAULT_CODE_CONTEXT_CAPS, ...partial };
1590
1679
  }
1591
1680
 
1592
- // src/code-context/adapters/fallback.ts
1593
- var fallbackAdapter = {
1594
- id: "fallback",
1595
- extensions: [],
1596
- extract(input) {
1597
- const lines = input.source.split(/\r?\n/);
1598
- const headEnd = Math.min(lines.length, 20);
1681
+ // src/code-context/adapters/registry.ts
1682
+ import { extname as extname2 } from "pathe";
1683
+
1684
+ // src/code-context/adapters/typescript.ts
1685
+ import { extname } from "pathe";
1686
+ import ts from "typescript";
1687
+ var TS_JS_EXTENSIONS = [
1688
+ ".ts",
1689
+ ".tsx",
1690
+ ".js",
1691
+ ".jsx",
1692
+ ".mts",
1693
+ ".cts",
1694
+ ".mjs",
1695
+ ".cjs"
1696
+ ];
1697
+ function scriptKindForPath(filePath) {
1698
+ const ext = extname(filePath).toLowerCase();
1699
+ switch (ext) {
1700
+ case ".tsx":
1701
+ return ts.ScriptKind.TSX;
1702
+ case ".jsx":
1703
+ return ts.ScriptKind.JSX;
1704
+ case ".js":
1705
+ case ".mjs":
1706
+ case ".cjs":
1707
+ return ts.ScriptKind.JS;
1708
+ case ".cts":
1709
+ case ".mts":
1710
+ case ".ts":
1711
+ default:
1712
+ return ts.ScriptKind.TS;
1713
+ }
1714
+ }
1715
+ function lineOf(sf, pos) {
1716
+ return sf.getLineAndCharacterOfPosition(pos).line + 1;
1717
+ }
1718
+ function hasExportModifier(node) {
1719
+ const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : void 0;
1720
+ return Boolean(mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
1721
+ }
1722
+ function isTopLevel(node, sf) {
1723
+ let p = node.parent;
1724
+ while (p && p !== sf) {
1725
+ if (ts.isFunctionDeclaration(p) || ts.isFunctionExpression(p) || ts.isArrowFunction(p) || ts.isMethodDeclaration(p) || ts.isConstructorDeclaration(p) || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p)) {
1726
+ return false;
1727
+ }
1728
+ if (ts.isClassDeclaration(p) || ts.isClassExpression(p)) {
1729
+ return false;
1730
+ }
1731
+ p = p.parent;
1732
+ }
1733
+ return true;
1734
+ }
1735
+ function extractFromSource(filePath, source, repoRelativePath) {
1736
+ const sf = ts.createSourceFile(
1737
+ filePath,
1738
+ source,
1739
+ ts.ScriptTarget.Latest,
1740
+ /*setParentNodes*/
1741
+ true,
1742
+ scriptKindForPath(filePath)
1743
+ );
1744
+ const symbols = [];
1745
+ const imports = [];
1746
+ const exports = [];
1747
+ const references = [];
1748
+ const pushSymbol = (name, node, kind, exported) => {
1749
+ if (!name) return;
1750
+ symbols.push({
1751
+ name,
1752
+ kind,
1753
+ filePath: repoRelativePath,
1754
+ startLine: lineOf(sf, node.getStart(sf, false)),
1755
+ endLine: lineOf(sf, node.getEnd()),
1756
+ exported
1757
+ });
1758
+ if (exported) {
1759
+ exports.push({ name, kind });
1760
+ }
1761
+ };
1762
+ const visit = (node) => {
1763
+ if (ts.isImportDeclaration(node)) {
1764
+ const spec = node.moduleSpecifier;
1765
+ if (ts.isStringLiteral(spec)) {
1766
+ const names = [];
1767
+ const clause = node.importClause;
1768
+ if (clause?.name) names.push(clause.name.text);
1769
+ if (clause?.namedBindings) {
1770
+ if (ts.isNamespaceImport(clause.namedBindings)) {
1771
+ names.push(clause.namedBindings.name.text);
1772
+ } else if (ts.isNamedImports(clause.namedBindings)) {
1773
+ for (const el of clause.namedBindings.elements) {
1774
+ names.push(el.name.text);
1775
+ }
1776
+ }
1777
+ }
1778
+ imports.push({ from: spec.text, names: names.length ? names : void 0 });
1779
+ }
1780
+ }
1781
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
1782
+ imports.push({ from: node.moduleSpecifier.text, names: ["*"] });
1783
+ }
1784
+ if (ts.isFunctionDeclaration(node) && node.name && isTopLevel(node, sf)) {
1785
+ pushSymbol(node.name.text, node, "function", hasExportModifier(node));
1786
+ }
1787
+ if (ts.isClassDeclaration(node) && node.name && isTopLevel(node, sf)) {
1788
+ pushSymbol(node.name.text, node, "class", hasExportModifier(node));
1789
+ }
1790
+ if (ts.isInterfaceDeclaration(node) && isTopLevel(node, sf)) {
1791
+ pushSymbol(node.name.text, node, "interface", hasExportModifier(node));
1792
+ }
1793
+ if (ts.isTypeAliasDeclaration(node) && isTopLevel(node, sf)) {
1794
+ pushSymbol(node.name.text, node, "type", hasExportModifier(node));
1795
+ }
1796
+ if (ts.isEnumDeclaration(node) && isTopLevel(node, sf)) {
1797
+ pushSymbol(node.name.text, node, "type", hasExportModifier(node));
1798
+ }
1799
+ if (ts.isVariableStatement(node) && isTopLevel(node, sf)) {
1800
+ const exported = hasExportModifier(node);
1801
+ for (const decl of node.declarationList.declarations) {
1802
+ if (!ts.isIdentifier(decl.name)) continue;
1803
+ const name = decl.name.text;
1804
+ const init = decl.initializer;
1805
+ const isFn = init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init));
1806
+ if (exported || isFn) {
1807
+ pushSymbol(name, decl, isFn ? "function" : "const", exported);
1808
+ }
1809
+ }
1810
+ }
1811
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
1812
+ exports.push({ name: "default", kind: "export" });
1813
+ }
1814
+ if (ts.isIdentifier(node) && node.parent && !ts.isImportSpecifier(node.parent)) {
1815
+ const parent = node.parent;
1816
+ if (ts.isCallExpression(parent) || ts.isPropertyAccessExpression(parent) || ts.isTypeReferenceNode(parent)) {
1817
+ references.push({ name: node.text, line: lineOf(sf, node.getStart(sf)) });
1818
+ }
1819
+ }
1820
+ ts.forEachChild(node, visit);
1821
+ };
1822
+ visit(sf);
1823
+ return {
1824
+ ok: true,
1825
+ language: "typescript",
1826
+ symbols,
1827
+ imports,
1828
+ exports,
1829
+ references: references.slice(0, 200)
1830
+ // bound noise
1831
+ };
1832
+ }
1833
+ var typescriptAdapter = {
1834
+ id: "typescript",
1835
+ extensions: [...TS_JS_EXTENSIONS],
1836
+ extract(input) {
1837
+ try {
1838
+ return extractFromSource(input.filePath, input.source, input.filePath);
1839
+ } catch (err) {
1840
+ return {
1841
+ ok: false,
1842
+ language: "typescript",
1843
+ symbols: [],
1844
+ imports: [],
1845
+ exports: [],
1846
+ errors: [err instanceof Error ? err.message : String(err)]
1847
+ };
1848
+ }
1849
+ }
1850
+ };
1851
+
1852
+ // src/code-context/adapters/registry.ts
1853
+ var AdapterRegistry = class {
1854
+ byExt = /* @__PURE__ */ new Map();
1855
+ constructor(adapters) {
1856
+ const list = adapters ?? [typescriptAdapter];
1857
+ for (const a of list) {
1858
+ for (const ext of a.extensions) {
1859
+ this.byExt.set(ext.toLowerCase(), a);
1860
+ }
1861
+ }
1862
+ }
1863
+ getByPath(filePath) {
1864
+ const ext = extname2(filePath).toLowerCase();
1865
+ return this.byExt.get(ext);
1866
+ }
1867
+ hasExtension(ext) {
1868
+ return this.byExt.has(ext.toLowerCase());
1869
+ }
1870
+ };
1871
+
1872
+ // src/code-context/adapters/fallback.ts
1873
+ var fallbackAdapter = {
1874
+ id: "fallback",
1875
+ extensions: [],
1876
+ extract(input) {
1877
+ const lines = input.source.split(/\r?\n/);
1878
+ const headEnd = Math.min(lines.length, 20);
1599
1879
  return {
1600
1880
  ok: true,
1601
1881
  language: "text",
@@ -1657,75 +1937,125 @@ function hardFailure(gaps, message, summary) {
1657
1937
  gaps
1658
1938
  };
1659
1939
  }
1940
+ function toRepoPath(projectRoot, input) {
1941
+ const abs = isAbsolute2(input) ? input : join6(projectRoot, input);
1942
+ let rel = relative2(projectRoot, abs).replaceAll("\\", "/");
1943
+ while (rel.startsWith("./")) rel = rel.slice(2);
1944
+ return rel;
1945
+ }
1946
+ function pathKept(rel, fileArgs, dirArgs) {
1947
+ if (fileArgs.includes(rel)) return true;
1948
+ return dirArgs.some((dir) => rel.startsWith(`${dir}/`));
1949
+ }
1950
+ function symbolKept(symbol, names) {
1951
+ return names.some((name) => symbol === name || symbol.endsWith(`.${name}`));
1952
+ }
1660
1953
  async function generateCodeContext(request, deps) {
1661
- const caps = mergeCaps(request.caps);
1662
1954
  const projectRoot = request.projectRoot;
1663
1955
  const gaps = [];
1664
- if (!deps?.adapters?.length && !isTypescriptAdapterAvailable()) {
1665
- return hardFailure(
1666
- [
1667
- {
1668
- code: "adapter_unavailable",
1669
- message: "TypeScript language adapter could not be loaded; ensure `typescript` is installed with @structured-vibe-coding/core."
1670
- }
1671
- ],
1672
- "Language adapter unavailable"
1673
- );
1674
- }
1675
- const registry = new AdapterRegistry(deps?.adapters);
1676
1956
  const { plan, gaps: focusGaps } = await resolveFocus(request);
1677
1957
  gaps.push(...focusGaps);
1678
- const hasFocus = plan.seedPaths.length > 0 || plan.candidatePaths.length > 0 || plan.seedSymbols.length > 0 || plan.keywords.length > 0 || Boolean(request.paths?.length) || Boolean(request.query) || Boolean(request.symbols?.length);
1679
- if (!plan.seedPaths.length && !plan.candidatePaths.length && !plan.seedSymbols.length && !request.paths?.length && !request.query && !request.symbols?.length) {
1680
- if (!plan.keywords.length) {
1681
- gaps.push({
1682
- code: "no_focus",
1683
- message: "No focus resolved. Pass --path, --symbol, and/or --query, or run with an active change."
1684
- });
1685
- return hardFailure(gaps, "No focus resolved", {
1686
- focusSymbols: plan.seedSymbols,
1687
- keywords: plan.keywords
1688
- });
1689
- }
1690
- }
1691
- if (!plan.candidatePaths.length && !plan.seedPaths.length) {
1692
- if (!hasFocus) {
1693
- gaps.push({
1694
- code: "no_focus",
1695
- message: "No focus resolved. Pass --path, --symbol, and/or --query, or run with an active change."
1696
- });
1697
- return hardFailure(gaps, "No focus resolved", {
1698
- focusSymbols: plan.seedSymbols,
1699
- keywords: plan.keywords
1700
- });
1701
- }
1958
+ const hasExplicit = Boolean(request.paths?.length) || Boolean(request.query?.trim()) || Boolean(request.symbols?.length) || Boolean(request.changeId) || plan.seedPaths.length > 0 || plan.candidatePaths.length > 0 || plan.keywords.length > 0;
1959
+ if (!hasExplicit) {
1702
1960
  gaps.push({
1703
1961
  code: "no_focus",
1704
- message: "Focus signals present but no candidate source files resolved. Pass --path to a file or directory."
1962
+ message: "No focus resolved. Pass --path, --symbol, and/or --query, or run with an active change."
1705
1963
  });
1706
- return hardFailure(gaps, "No candidate files", {
1964
+ return hardFailure(gaps, "No focus resolved", {
1707
1965
  focusSymbols: plan.seedSymbols,
1708
- keywords: plan.keywords,
1709
- focusPaths: plan.seedPaths
1966
+ keywords: plan.keywords
1710
1967
  });
1711
1968
  }
1712
- let candidates = [...plan.candidatePaths];
1713
- if (!candidates.length) candidates = [...plan.seedPaths];
1714
- let truncated = false;
1715
- if (candidates.length > caps.maxFiles) {
1716
- candidates = candidates.slice(0, caps.maxFiles);
1717
- truncated = true;
1718
- gaps.push({
1719
- code: "cap_truncated",
1720
- message: `Candidate files capped at maxFiles=${caps.maxFiles}.`
1969
+ let searchConfig;
1970
+ try {
1971
+ searchConfig = await loadSearchConfig(projectRoot);
1972
+ } catch {
1973
+ return hardFailure(
1974
+ [{ code: "parse_error", message: "search.yaml is invalid" }],
1975
+ "search.yaml is invalid"
1976
+ );
1977
+ }
1978
+ const config = await loadConfig(projectRoot);
1979
+ let changeTitle = plan.changeId;
1980
+ let stage;
1981
+ if (request.changeId) {
1982
+ const mp = join6(changePath(projectRoot, config, request.changeId), "meta.yaml");
1983
+ if (await pathExists(mp)) {
1984
+ const meta = await readYaml(mp);
1985
+ changeTitle = meta.title ?? request.changeId;
1986
+ stage = meta.stage;
1987
+ }
1988
+ }
1989
+ const runtime = {
1990
+ embedder: deps?.embedder,
1991
+ embedderFactory: deps?.embedderFactory
1992
+ };
1993
+ await createBuiltinProvider(projectRoot, config, runtime).index();
1994
+ const budget = request.caps?.maxTokensApprox ?? budgetForStage(searchConfig, stage);
1995
+ const k = request.caps?.maxSlices ?? searchConfig.k;
1996
+ let query = request.query?.trim() ? request.query : "";
1997
+ if (!query && request.symbols?.length) query = request.symbols.join(" ");
1998
+ if (!query && changeTitle && request.changeId) query = changeTitle;
1999
+ const activeChange = await resolveActiveDirectory(projectRoot, config);
2000
+ const cacheRequest = {
2001
+ projectRoot,
2002
+ query: query.trim() ? query : "",
2003
+ channel: "context",
2004
+ scope: "code",
2005
+ k,
2006
+ budget,
2007
+ embeddings: searchConfig.embeddings,
2008
+ activeChange,
2009
+ filterPaths: request.paths ?? [],
2010
+ filterSymbols: request.symbols ?? [],
2011
+ scopes: ["code"]
2012
+ };
2013
+ try {
2014
+ const cached = readPackedCache(cacheRequest);
2015
+ if (cached) return finishContext(cached.hits, cached.text, cached.warning);
2016
+ } catch {
2017
+ }
2018
+ let hits;
2019
+ let mode = "bm25";
2020
+ let hybridScopes = "";
2021
+ if (query.trim()) {
2022
+ const embedder = await resolveEmbedder(projectRoot, searchConfig.embeddings, runtime);
2023
+ const embedded = await embedSearchQuery(embedder, searchConfig.embeddings, query);
2024
+ const ranked = await rankScope(projectRoot, query, "code", k, {
2025
+ embeddings: searchConfig.embeddings,
2026
+ queryVec: embedded.queryVec,
2027
+ failure: embedded.failure
1721
2028
  });
2029
+ hits = ranked.hits;
2030
+ if (ranked.hybrid) hybridScopes = "code";
2031
+ if (ranked.hits.some((hit) => hit.rankedBy === "rrf")) mode = "rrf";
2032
+ if (ranked.warning) {
2033
+ gaps.push({ code: "embeddings_unavailable", message: EMBEDDINGS_UNAVAILABLE });
2034
+ }
2035
+ } else {
2036
+ hits = await listScopeHits(projectRoot, "code");
1722
2037
  }
1723
- const graph = new GraphLite();
1724
- let filesAnalyzed = 0;
1725
- let symbolsExtracted = 0;
1726
- for (const rel of candidates) {
1727
- const kind = classifyPath(rel);
1728
- if (kind === "secret") {
2038
+ const fileArgs = [];
2039
+ const dirArgs = [];
2040
+ for (const input of request.paths ?? []) {
2041
+ const rel = toRepoPath(projectRoot, input);
2042
+ let info = null;
2043
+ try {
2044
+ info = await stat(join6(projectRoot, rel));
2045
+ } catch {
2046
+ info = null;
2047
+ }
2048
+ if (!info) {
2049
+ gaps.push({ code: "path_missing", message: `Path not found: ${rel}`, path: rel });
2050
+ continue;
2051
+ }
2052
+ if (info.isDirectory()) {
2053
+ dirArgs.push(rel);
2054
+ continue;
2055
+ }
2056
+ fileArgs.push(rel);
2057
+ const ext = extname3(rel).toLowerCase();
2058
+ if (isSecretPath(rel)) {
1729
2059
  gaps.push({
1730
2060
  code: "path_denied_secret",
1731
2061
  message: `Skipped potential secret path: ${rel}`,
@@ -1733,169 +2063,150 @@ async function generateCodeContext(request, deps) {
1733
2063
  });
1734
2064
  continue;
1735
2065
  }
1736
- if (kind === "ignored") {
1737
- gaps.push({
1738
- code: "path_ignored",
1739
- message: `Skipped ignored path: ${rel}`,
1740
- path: rel
1741
- });
2066
+ if (isIgnoredPath(rel)) {
2067
+ gaps.push({ code: "path_ignored", message: `Skipped ignored path: ${rel}`, path: rel });
1742
2068
  continue;
1743
2069
  }
1744
- const abs = join6(projectRoot, rel);
1745
- if (!await pathExists(abs)) {
2070
+ if (ext === ".go") {
1746
2071
  gaps.push({
1747
- code: "path_missing",
1748
- message: `Path not found: ${rel}`,
2072
+ code: "unsupported_language",
2073
+ message: `No structure adapter for ${ext}; skipped.`,
1749
2074
  path: rel
1750
2075
  });
1751
- continue;
1752
2076
  }
1753
- const adapter = registry.getByPath(rel);
1754
- if (!adapter) {
1755
- gaps.push({
1756
- code: "unsupported_language",
1757
- message: `No structure adapter for ${extname4(rel) || rel}; skipped.`,
1758
- path: rel
1759
- });
1760
- continue;
1761
- }
1762
- let source;
1763
- try {
1764
- source = await readText(abs);
1765
- } catch (err) {
1766
- gaps.push({
1767
- code: "parse_error",
1768
- message: `Could not read ${rel}`,
1769
- path: rel,
1770
- detail: err instanceof Error ? err.message : String(err)
1771
- });
1772
- continue;
1773
- }
1774
- let extract;
1775
- try {
1776
- extract = await adapter.extract({
1777
- filePath: normalize5(rel).replace(/\\/g, "/"),
1778
- source,
1779
- projectRoot
1780
- });
1781
- } catch (err) {
1782
- gaps.push({
1783
- code: "parse_error",
1784
- message: `Parse failed for ${rel} (continuing with other files).`,
1785
- path: rel,
1786
- detail: err instanceof Error ? err.message : String(err)
1787
- });
1788
- continue;
1789
- }
1790
- if (!extract.ok) {
2077
+ }
2078
+ if (request.paths?.length) {
2079
+ hits = hits.filter((hit) => pathKept(hit.path, fileArgs, dirArgs));
2080
+ }
2081
+ const names = request.symbols ?? [];
2082
+ if (names.length) {
2083
+ hits = hits.filter((hit) => symbolKept(hit.symbol, names));
2084
+ hits.sort((a, b) => {
2085
+ const aExact = names.some((name) => a.symbol === name) ? 0 : 1;
2086
+ const bExact = names.some((name) => b.symbol === name) ? 0 : 1;
2087
+ return aExact - bExact;
2088
+ });
2089
+ }
2090
+ if (!query.trim()) hits = hits.slice(0, k);
2091
+ const indexed = new Set((await listScopeHits(projectRoot, "code")).map((hit) => hit.path));
2092
+ for (const rel of fileArgs) {
2093
+ const ext = extname3(rel).toLowerCase();
2094
+ if (ext === ".go") continue;
2095
+ if (isSecretPath(rel) || isIgnoredPath(rel)) continue;
2096
+ if (!indexed.has(rel) && ext) {
1791
2097
  gaps.push({
1792
2098
  code: "parse_error",
1793
2099
  message: `Parse failed for ${rel} (continuing with other files).`,
1794
- path: rel,
1795
- detail: extract.errors?.join("; ")
1796
- });
1797
- if (!extract.symbols.length) continue;
1798
- }
1799
- extract.symbols = extract.symbols.map((s) => ({
1800
- ...s,
1801
- filePath: normalize5(rel).replace(/\\/g, "/")
1802
- }));
1803
- if (!extract.symbols.length && extract.ok) {
1804
- gaps.push({
1805
- code: "empty_extract",
1806
- message: `No symbols extracted from ${rel}.`,
1807
2100
  path: rel
1808
2101
  });
1809
2102
  }
1810
- graph.add(normalize5(rel).replace(/\\/g, "/"), source, extract);
1811
- filesAnalyzed++;
1812
- symbolsExtracted += extract.symbols.length;
1813
2103
  }
1814
- const ranked = rankSymbols(graph, plan, caps);
1815
- const sliceResult = emitSlices(ranked, graph, caps, {
1816
- includeNeighbors: request.includeNeighbors
1817
- });
1818
- gaps.push(...sliceResult.gaps);
1819
- truncated = truncated || sliceResult.truncated;
2104
+ const packed = packHits({ preamble: "", hits, budget });
2105
+ try {
2106
+ writePackedCache(cacheRequest, packed, hits, mode, hybridScopes);
2107
+ } catch {
2108
+ }
2109
+ const truncated = hits.length > packed.hits.length || packed.hits.some((hit) => hit.form === "signature");
2110
+ if (truncated) {
2111
+ gaps.push({
2112
+ code: "cap_truncated",
2113
+ message: "Packed under the token budget. Lower hits are signatures."
2114
+ });
2115
+ }
2116
+ const slices = packed.hits.map((hit) => ({
2117
+ id: `${hit.path}:${hit.startLine}-${hit.endLine}:${hit.symbol}`,
2118
+ filePath: hit.path,
2119
+ startLine: hit.startLine,
2120
+ endLine: hit.endLine,
2121
+ symbolName: hit.symbol,
2122
+ reason: hit.form === "full" ? "keyword full" : "keyword signature",
2123
+ body: hit.form === "full" ? hit.body : hit.signature
2124
+ }));
1820
2125
  const summary = {
1821
2126
  focusPaths: plan.seedPaths,
1822
2127
  focusSymbols: plan.seedSymbols,
1823
2128
  keywords: plan.keywords,
1824
- filesAnalyzed,
1825
- symbolsExtracted,
1826
- slicesEmitted: sliceResult.slices.length,
2129
+ filesAnalyzed: new Set(hits.map((hit) => hit.path)).size,
2130
+ symbolsExtracted: hits.length,
2131
+ slicesEmitted: slices.length,
1827
2132
  truncated
1828
2133
  };
1829
- let changeTitle = plan.changeId;
1830
- if (plan.changeId) {
1831
- try {
1832
- const { readYaml: readYaml2 } = await import("./fs-J6DIV6CX.js");
1833
- const metaPath = join6(
1834
- projectRoot,
1835
- // best-effort: changes/<id>/meta.yaml via relative walk
1836
- // Prefer using change path from resolve — re-read lightly
1837
- "changes",
1838
- plan.changeId,
1839
- "meta.yaml"
1840
- );
1841
- const { loadConfig: loadConfig2 } = await import("./config-X5VDBVR4.js");
1842
- const { changePath: changePath2 } = await import("./paths-64GA32IZ.js");
1843
- const config = await loadConfig2(projectRoot);
1844
- const mp = join6(changePath2(projectRoot, config, plan.changeId), "meta.yaml");
1845
- if (await pathExists(mp)) {
1846
- const meta = await readYaml2(mp);
1847
- changeTitle = meta.title ?? plan.changeId;
1848
- } else if (await pathExists(metaPath)) {
1849
- const meta = await readYaml2(metaPath);
1850
- changeTitle = meta.title ?? plan.changeId;
1851
- }
1852
- } catch {
1853
- }
1854
- }
1855
2134
  const formatted = formatMarkdown({
1856
2135
  plan,
1857
2136
  summary,
1858
- slices: sliceResult.slices,
2137
+ slices,
1859
2138
  gaps,
1860
2139
  changeTitle,
1861
- maxOutputLines: caps.maxOutputLines,
1862
- maxTokensApprox: caps.maxTokensApprox
1863
- });
1864
- if (formatted.truncated && !summary.truncated) {
1865
- summary.truncated = true;
1866
- if (!gaps.some((g) => g.code === "cap_truncated")) {
1867
- gaps.push({
1868
- code: "cap_truncated",
1869
- message: "Output truncated at configured caps (maxOutputLines/maxTokensApprox)."
1870
- });
1871
- }
1872
- }
1873
- const finalFormat = formatMarkdown({
1874
- plan,
1875
- summary,
1876
- slices: sliceResult.slices,
1877
- gaps,
1878
- changeTitle,
1879
- maxOutputLines: caps.maxOutputLines,
1880
- maxTokensApprox: caps.maxTokensApprox
2140
+ packedText: packed.text
1881
2141
  });
1882
2142
  let artifactPath;
1883
2143
  if (request.writeArtifactTo) {
1884
- await writeText(request.writeArtifactTo, finalFormat.markdown);
2144
+ await writeText(request.writeArtifactTo, formatted.markdown);
1885
2145
  artifactPath = request.writeArtifactTo;
1886
2146
  }
1887
2147
  return {
1888
2148
  ok: true,
1889
- markdown: finalFormat.markdown,
2149
+ markdown: formatted.markdown,
1890
2150
  summary,
1891
- slices: sliceResult.slices,
2151
+ slices,
1892
2152
  gaps,
1893
2153
  artifactPath
1894
2154
  };
2155
+ async function finishContext(packedHits, packedText, warning) {
2156
+ if (warning) {
2157
+ gaps.push({ code: "embeddings_unavailable", message: EMBEDDINGS_UNAVAILABLE });
2158
+ }
2159
+ const truncated2 = packedHits.some((hit) => hit.form === "signature");
2160
+ if (truncated2) {
2161
+ gaps.push({
2162
+ code: "cap_truncated",
2163
+ message: "Packed under the token budget. Lower hits are signatures."
2164
+ });
2165
+ }
2166
+ const slices2 = packedHits.map((hit) => ({
2167
+ id: `${hit.path}:${hit.startLine}-${hit.endLine}:${hit.symbol}`,
2168
+ filePath: hit.path,
2169
+ startLine: hit.startLine,
2170
+ endLine: hit.endLine,
2171
+ symbolName: hit.symbol,
2172
+ reason: hit.form === "full" ? "keyword full" : "keyword signature",
2173
+ body: hit.form === "full" ? hit.body : hit.signature
2174
+ }));
2175
+ const summary2 = {
2176
+ focusPaths: plan.seedPaths,
2177
+ focusSymbols: plan.seedSymbols,
2178
+ keywords: plan.keywords,
2179
+ filesAnalyzed: new Set(packedHits.map((hit) => hit.path)).size,
2180
+ symbolsExtracted: packedHits.length,
2181
+ slicesEmitted: slices2.length,
2182
+ truncated: truncated2
2183
+ };
2184
+ const formatted2 = formatMarkdown({
2185
+ plan,
2186
+ summary: summary2,
2187
+ slices: slices2,
2188
+ gaps,
2189
+ changeTitle,
2190
+ packedText
2191
+ });
2192
+ let artifactPath2;
2193
+ if (request.writeArtifactTo) {
2194
+ await writeText(request.writeArtifactTo, formatted2.markdown);
2195
+ artifactPath2 = request.writeArtifactTo;
2196
+ }
2197
+ return {
2198
+ ok: true,
2199
+ markdown: formatted2.markdown,
2200
+ summary: summary2,
2201
+ slices: slices2,
2202
+ gaps,
2203
+ artifactPath: artifactPath2
2204
+ };
2205
+ }
1895
2206
  }
1896
2207
 
1897
2208
  // src/refine.ts
1898
- import { join as join7 } from "pathe";
2209
+ import { join as join7, relative as relative3 } from "pathe";
1899
2210
  function stageArtifactRefs(ctx, stageId) {
1900
2211
  const stage = getStage(ctx.workflow, ctx.meta, stageId);
1901
2212
  if (!stage) return [];
@@ -1906,10 +2217,10 @@ function stageArtifactRefs(ctx, stageId) {
1906
2217
  absPath: join7(ctx.path, a.path)
1907
2218
  }));
1908
2219
  }
1909
- function listMarkdown(refs) {
2220
+ function listMarkdown(refs, displayDir) {
1910
2221
  if (!refs.length) return "_None._";
1911
2222
  return refs.map(
1912
- (r) => `- \`${r.path}\` (stage \`${r.stageId}\`${r.required ? ", required" : ""}) \u2192 \`${r.absPath}\``
2223
+ (r) => `- \`${r.path}\` (stage \`${r.stageId}\`${r.required ? ", required" : ""}) \u2192 \`${displayDir}/${r.path}\``
1913
2224
  ).join("\n");
1914
2225
  }
1915
2226
  async function buildRefinePlan(opts) {
@@ -1949,8 +2260,13 @@ async function buildRefinePlan(opts) {
1949
2260
  const briefPath = join7(ctx.path, "refine-brief.md");
1950
2261
  const reportPath = join7(ctx.path, "quality-report.md");
1951
2262
  const openItemsPath = join7(ctx.path, "open-items.md");
2263
+ const paths = guidancePaths(opts.config);
2264
+ const briefRel = `${ctx.displayPath}/refine-brief.md`;
2265
+ const reportRel = `${ctx.displayPath}/quality-report.md`;
2266
+ const openItemsRel = `${ctx.displayPath}/open-items.md`;
1952
2267
  const briefMarkdown = buildRefineBriefMarkdown({
1953
2268
  ctx,
2269
+ config: opts.config,
1954
2270
  focusStageId,
1955
2271
  focusStageTitle: focusStage.title ?? focusStageId,
1956
2272
  mode,
@@ -1958,19 +2274,19 @@ async function buildRefinePlan(opts) {
1958
2274
  focusArtifacts,
1959
2275
  priorArtifacts,
1960
2276
  laterArtifacts,
1961
- constitutionPath,
2277
+ constitutionPath: paths.constitution,
1962
2278
  constitutionExists,
1963
- reportPath,
1964
- openItemsPath
2279
+ reportPath: reportRel,
2280
+ openItemsPath: openItemsRel
1965
2281
  });
1966
2282
  const kickoff = [
1967
2283
  `SDD: ${mode} stage \`${focusStageId}\`.`,
1968
2284
  `Title: ${ctx.meta.title}`,
1969
2285
  `Change: ${ctx.id}`,
1970
- `Read first: ${briefPath}`,
2286
+ `Read first: ${briefRel}`,
1971
2287
  `Also: .sdd/protocol.md, .sdd/active-context.md`,
1972
- constitutionExists ? `Constitution (READ-ONLY): ${constitutionPath}` : `No constitution.md found (optional).`,
1973
- mode === "analyze" ? `Mode ANALYZE: write findings to ${reportPath} only \u2014 do not edit pack artifacts.` : `Mode REFINE: improve focus-stage artifacts; impact-scan prior pack files (fix mechanical inconsistencies; highlight judgment calls). Never edit constitution. Do not run sdd next.`,
2288
+ constitutionExists ? `Constitution (READ-ONLY): ${paths.constitution}` : `No constitution.md found (optional).`,
2289
+ mode === "analyze" ? `Mode ANALYZE: write findings to ${reportRel} only \u2014 do not edit pack artifacts.` : `Mode REFINE: improve focus-stage artifacts; impact-scan prior pack files (fix mechanical inconsistencies; highlight judgment calls). Never edit constitution. Do not run sdd next.`,
1974
2290
  focusOnly ? `focusOnly=true: edit only focus-stage files (still read prior + constitution).` : `Prior artifacts may be auto-fixed for clear contradictions/term drift; scope changes \u2192 highlight + open-items (human accept).`
1975
2291
  ].join(" ");
1976
2292
  return {
@@ -1997,6 +2313,7 @@ async function buildRefinePlan(opts) {
1997
2313
  function buildRefineBriefMarkdown(opts) {
1998
2314
  const {
1999
2315
  ctx,
2316
+ config,
2000
2317
  focusStageId,
2001
2318
  focusStageTitle,
2002
2319
  mode,
@@ -2009,6 +2326,7 @@ function buildRefineBriefMarkdown(opts) {
2009
2326
  reportPath,
2010
2327
  openItemsPath
2011
2328
  } = opts;
2329
+ const paths = guidancePaths(config);
2012
2330
  return `# Refine brief (auto-generated)
2013
2331
 
2014
2332
  > Change: **${ctx.meta.title}** \xB7 \`${ctx.id}\`
@@ -2021,9 +2339,10 @@ function buildRefineBriefMarkdown(opts) {
2021
2339
  You are the **SDD refine** agent (one agent, mode \`${mode}\`).
2022
2340
 
2023
2341
  1. Work by **stage**, not hard-coded filenames. Focus stage resolves to the files below.
2024
- 2. **Never edit** \`memory/constitution.md\` (or any constitution path). Read it as constraints only.
2025
- 3. **Do not** run \`sdd next\`, \`sdd complete\`, or change \`meta.yaml\` stage/status.
2026
- 4. Process is **never blocked** by findings \u2014 report and improve docs only.
2342
+ 2. **Never edit** \`${paths.constitution}\` (or any constitution path). Read it as constraints only.
2343
+ 3. Stage-scoped refine brief lives at \`${paths.changes}/<id>/refine-brief.md\`.
2344
+ 4. **Do not** run \`sdd next\`, \`sdd complete\`, or change \`meta.yaml\` stage/status.
2345
+ 5. Process is **never blocked** by findings \u2014 report and improve docs only.
2027
2346
 
2028
2347
  ## Constitution (READ-ONLY)
2029
2348
 
@@ -2031,11 +2350,11 @@ ${constitutionExists ? `- Path: \`${constitutionPath}\` \u2014 **read only**. Co
2031
2350
 
2032
2351
  ## Focus stage artifacts (primary ${mode === "analyze" ? "review" : "edit"} target)
2033
2352
 
2034
- ${listMarkdown(focusArtifacts)}
2353
+ ${listMarkdown(focusArtifacts, ctx.displayPath)}
2035
2354
 
2036
2355
  ## Prior stage artifacts (pack trail \u2014 always read)
2037
2356
 
2038
- ${listMarkdown(priorArtifacts)}
2357
+ ${listMarkdown(priorArtifacts, ctx.displayPath)}
2039
2358
 
2040
2359
  ${focusOnly ? `**focusOnly:** Do **not** edit prior files. Still read them for consistency. Highlight conflicts for the human.` : `**Auto prior impact (required):** After refining the focus stage, use search (\`rg\` / grep) across the change pack for terms, claims, and names you changed. For each hit on a **prior** artifact:
2041
2360
  - **Fix** mechanical inconsistencies (contradictions, renamed terms, outdated references that clearly disagree with the refined focus).
@@ -2044,7 +2363,7 @@ ${focusOnly ? `**focusOnly:** Do **not** edit prior files. Still read them for c
2044
2363
 
2045
2364
  ## Later stage artifacts (usually leave alone)
2046
2365
 
2047
- ${listMarkdown(laterArtifacts)}
2366
+ ${listMarkdown(laterArtifacts, ctx.displayPath)}
2048
2367
 
2049
2368
  Do **not** invent content for later stages that were not materialized yet. If a later file exists and contradicts the refined focus, **highlight** only (unless the human asked to align the whole pack).
2050
2369
 
@@ -2078,11 +2397,12 @@ Do **not** invent content for later stages that were not materialized yet. If a
2078
2397
  async function writeRefineBrief(plan, projectRoot) {
2079
2398
  await writeText(plan.briefPath, plan.briefMarkdown);
2080
2399
  const pointer = join7(sddRoot(projectRoot), "refine-brief.md");
2400
+ const briefRel = relative3(projectRoot, plan.briefPath);
2081
2401
  await writeText(
2082
2402
  pointer,
2083
2403
  `# Refine brief pointer
2084
2404
 
2085
- Active refine brief: \`${plan.briefPath}\`
2405
+ Active refine brief: \`${briefRel}\`
2086
2406
 
2087
2407
  Run: \`sdd refine${plan.focusStageId ? ` ${plan.focusStageId}` : ""}\`
2088
2408
 
@@ -2090,79 +2410,1644 @@ Run: \`sdd refine${plan.focusStageId ? ` ${plan.focusStageId}` : ""}\`
2090
2410
  );
2091
2411
  return plan.briefPath;
2092
2412
  }
2413
+
2414
+ // src/role/handoff.ts
2415
+ import { join as join10 } from "pathe";
2416
+ import { parse as parseYaml3, stringify as stringifyYaml2 } from "yaml";
2417
+
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";
2422
+
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
+ };
2441
+
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");
2462
+ }
2463
+ function ok(stdout = "") {
2464
+ return { exitCode: 0, stdout };
2465
+ }
2466
+ function fail(stdout) {
2467
+ return { exitCode: 1, stdout };
2468
+ }
2469
+ function asText(value) {
2470
+ return typeof value === "string" ? value.trim() : "";
2471
+ }
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;
2480
+ try {
2481
+ data = parseYaml2(rest.slice(0, close.index));
2482
+ } catch {
2483
+ return null;
2484
+ }
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
+ }
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
2507
+ },
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;
2526
+ try {
2527
+ return parseRoleDocument(canonical, await readText(path));
2528
+ } catch {
2529
+ return null;
2530
+ }
2531
+ }
2532
+ async function writeRole(root, role) {
2533
+ await writeText(roleFilePath(root, role.name), renderRole(role));
2534
+ }
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;
2636
+ }
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");
2643
+ }
2644
+ async function listConversationRoles(root) {
2645
+ return listDirs(join8(root, ".sdd", "role-runs", "conversation"));
2646
+ }
2647
+ async function readAllowFile(root) {
2648
+ const path = allowFilePath(root);
2649
+ if (!await pathExists(path)) return null;
2650
+ try {
2651
+ const data = JSON.parse(await readFile(path, "utf8"));
2652
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
2653
+ return data;
2654
+ } catch {
2655
+ return null;
2656
+ }
2657
+ }
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 };
2676
+ }
2677
+ function fail2(stdout, runId) {
2678
+ return runId ? { exitCode: 1, stdout, runId } : { exitCode: 1, stdout };
2679
+ }
2680
+ function linesOf(...parts) {
2681
+ return parts.filter((part) => part.length > 0).join("\n");
2682
+ }
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`;
2688
+ }
2689
+ function mentionsX(task) {
2690
+ return /@X|x\.com|twitter\.com/.test(task);
2691
+ }
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");
2696
+ }
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");
2701
+ }
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
+ }
2711
+ }
2712
+ return parts[parts.length - 1] !== "accepted.md";
2713
+ }
2714
+ function canonicalJson(value) {
2715
+ return JSON.stringify(sortKeys(value));
2716
+ }
2717
+ function sortKeys(value) {
2718
+ if (Array.isArray(value)) return value.map(sortKeys);
2719
+ if (value && typeof value === "object") {
2720
+ const rec = value;
2721
+ const out = {};
2722
+ for (const key of Object.keys(rec).sort()) out[key] = sortKeys(rec[key]);
2723
+ return out;
2724
+ }
2725
+ return value;
2726
+ }
2727
+ function sameInputs(left, right) {
2728
+ return canonicalJson(left) === canonicalJson(right);
2729
+ }
2730
+ function sameCiteSet(changed, cites) {
2731
+ const left = [...new Set(changed)].sort();
2732
+ const right = [...new Set(cites)].sort();
2733
+ return left.length === right.length && left.every((cite, index) => cite === right[index]);
2734
+ }
2735
+ function parseSection(markdown, sectionId) {
2736
+ const rows = markdown.split(/\r?\n/);
2737
+ let start = -1;
2738
+ for (let i = 0; i < rows.length; i++) {
2739
+ const row = rows[i] ?? "";
2740
+ if (row.startsWith("## ") && row.slice(3).trim() === sectionId) {
2741
+ start = i;
2742
+ break;
2743
+ }
2744
+ }
2745
+ if (start < 0) return null;
2746
+ let end = rows.length;
2747
+ for (let i = start + 1; i < rows.length; i++) {
2748
+ if ((rows[i] ?? "").startsWith("## ")) {
2749
+ end = i;
2750
+ break;
2751
+ }
2752
+ }
2753
+ const body = rows.slice(start + 1, end);
2754
+ let decision = "";
2755
+ let accepted = false;
2756
+ const checks = [];
2757
+ for (const row of body) {
2758
+ const trimmed = row.trim();
2759
+ if (!decision && trimmed.startsWith("decision: "))
2760
+ decision = trimmed.slice("decision: ".length).trim();
2761
+ if (trimmed === "accepted: yes") accepted = true;
2762
+ if (trimmed.startsWith("- check: ")) {
2763
+ const id = trimmed.slice("- check: ".length).trim();
2764
+ if (id) checks.push(id);
2765
+ }
2766
+ }
2767
+ if (!decision || !accepted) return null;
2768
+ return { id: sectionId, decision, checks, body: body.join("\n") };
2769
+ }
2770
+ function parseReply(raw) {
2771
+ let value;
2772
+ try {
2773
+ value = JSON.parse(raw);
2774
+ } catch {
2775
+ return null;
2776
+ }
2777
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2778
+ const rec = value;
2779
+ if (typeof rec.text !== "string") return null;
2780
+ const gaps = [];
2781
+ if (rec.gaps !== void 0) {
2782
+ if (!Array.isArray(rec.gaps)) return null;
2783
+ for (const gap of rec.gaps) {
2784
+ if (!gap || typeof gap !== "object" || Array.isArray(gap)) return null;
2785
+ const item = gap;
2786
+ if (typeof item.cite !== "string" || typeof item.text !== "string") return null;
2787
+ gaps.push({ cite: item.cite, text: item.text });
2788
+ }
2789
+ }
2790
+ let changed = null;
2791
+ if (rec.changed !== void 0) {
2792
+ if (!Array.isArray(rec.changed) || rec.changed.some((cite) => typeof cite !== "string"))
2793
+ return null;
2794
+ changed = rec.changed;
2795
+ }
2796
+ let action = null;
2797
+ if (rec.action !== void 0) {
2798
+ if (!rec.action || typeof rec.action !== "object" || Array.isArray(rec.action)) return null;
2799
+ const item = rec.action;
2800
+ if (typeof item.name !== "string" || !item.name) return null;
2801
+ if (item.words !== void 0 && typeof item.words !== "string") return null;
2802
+ if (item.inputs !== void 0 && (!item.inputs || typeof item.inputs !== "object" || Array.isArray(item.inputs))) {
2803
+ return null;
2804
+ }
2805
+ action = {
2806
+ name: item.name,
2807
+ words: typeof item.words === "string" ? item.words : "",
2808
+ inputs: item.inputs && typeof item.inputs === "object" && !Array.isArray(item.inputs) ? item.inputs : {}
2809
+ };
2810
+ }
2811
+ return { text: rec.text, gaps, changed, action };
2812
+ }
2813
+ async function appendJson(file, value) {
2814
+ await ensureDir(join9(file, ".."));
2815
+ await appendFile(file, `${JSON.stringify(value)}
2816
+ `, "utf8");
2817
+ }
2818
+ async function readJsonLines(file) {
2819
+ const raw = await readText(file);
2820
+ const lines = [];
2821
+ for (const line of raw.split(/\r?\n/)) {
2822
+ if (!line.trim()) continue;
2823
+ try {
2824
+ lines.push(JSON.parse(line));
2825
+ } catch {
2826
+ }
2827
+ }
2828
+ return lines;
2829
+ }
2830
+ async function timeoutMinutes(root) {
2831
+ const path = join9(root, ".sdd", "role.yaml");
2832
+ if (!await pathExists(path)) return 10;
2833
+ let data;
2834
+ try {
2835
+ data = await readYaml(path);
2836
+ } catch {
2837
+ return "invalid";
2838
+ }
2839
+ if (data == null) return 10;
2840
+ if (typeof data !== "object" || Array.isArray(data)) return "invalid";
2841
+ const rec = data;
2842
+ if (!("approval_timeout_minutes" in rec) || rec.approval_timeout_minutes == null) return 10;
2843
+ const minutes = rec.approval_timeout_minutes;
2844
+ if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes < 0) return "invalid";
2845
+ return minutes;
2846
+ }
2847
+ function citeOk(cite, section) {
2848
+ return section.checks.includes(cite) || cite === `${section.id}:${section.decision}`;
2849
+ }
2850
+ function authorPrompt(role, task, kind, section) {
2851
+ const rows = [
2852
+ `Job: ${role.job}`,
2853
+ `Description: ${role.description}`,
2854
+ `Task: ${task}`,
2855
+ `Kind: ${kind}`
2856
+ ];
2857
+ if (section) {
2858
+ rows.push(
2859
+ `Section: ${section.id}`,
2860
+ `Decision: ${section.decision}`,
2861
+ "Check ids:",
2862
+ ...section.checks
2863
+ );
2864
+ rows.push("Section text:", section.body);
2865
+ }
2866
+ return rows.join("\n");
2867
+ }
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
+ async function acceptedFile(root, changeId) {
2889
+ if (!await pathExists(configPath(root))) return join9(root, "changes", changeId, "accepted.md");
2890
+ const config = await loadConfig(root);
2891
+ return join9(changesDir(root, config), changeId, "accepted.md");
2892
+ }
2893
+ async function callModel(model, file, roleId, prompt, pass) {
2894
+ let raw;
2895
+ try {
2896
+ raw = await model.complete({ roleId, prompt });
2897
+ } catch {
2898
+ await appendJson(file, { type: "bad_call", roleId });
2899
+ return null;
2900
+ }
2901
+ const parsed = parseReply(raw);
2902
+ if (!parsed) {
2903
+ await appendJson(file, { type: "bad_call", roleId });
2904
+ return null;
2905
+ }
2906
+ await appendJson(file, { type: "model", pass, roleId, text: parsed.text });
2907
+ return parsed;
2908
+ }
2909
+ async function readRule(root, roleName) {
2910
+ const data = await readAllowFile(root);
2911
+ if (!data) return null;
2912
+ const entry = data[roleName];
2913
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
2914
+ const rec = entry;
2915
+ const name = typeof rec.tool === "string" ? rec.tool : typeof rec.name === "string" ? rec.name : "";
2916
+ if (!name) return null;
2917
+ if (!rec.inputs || typeof rec.inputs !== "object" || Array.isArray(rec.inputs)) return null;
2918
+ return { name, inputs: rec.inputs };
2919
+ }
2920
+ async function saveRule(root, roleName, rule) {
2921
+ const path = allowFilePath(root);
2922
+ const existing = await readAllowFile(root) ?? {};
2923
+ existing[roleName] = { tool: rule.name, inputs: rule.inputs };
2924
+ await writeText(path, `${JSON.stringify(existing, null, 2)}
2925
+ `);
2926
+ }
2927
+ async function applyAction(root, file, roleName, action, mode) {
2928
+ if (FORBIDDEN.has(action.name) || !UNAVAILABLE.has(action.name) && action.name !== "write_file") {
2929
+ await appendJson(file, { type: "rejected" });
2930
+ return { exitCode: 1, stdout: ROLE_TEXT.cannot, applied: false };
2931
+ }
2932
+ if (UNAVAILABLE.has(action.name)) {
2933
+ await appendJson(file, { type: "unavailable" });
2934
+ return { exitCode: 0, stdout: ROLE_TEXT.unavailable, applied: false };
2935
+ }
2936
+ const path = action.inputs.path;
2937
+ const contents = action.inputs.contents;
2938
+ if (typeof path !== "string" || typeof contents !== "string" || !isSafeProductPath(path)) {
2939
+ await appendJson(file, { type: "rejected" });
2940
+ return { exitCode: 1, stdout: ROLE_TEXT.cannot, applied: false };
2941
+ }
2942
+ await writeText(join9(root, ...path.split(/[/\\]/)), contents);
2943
+ if (mode === "always")
2944
+ await saveRule(root, roleName, { name: action.name, inputs: action.inputs });
2945
+ await appendJson(file, { type: "applied", name: "write_file" });
2946
+ return { exitCode: 0, stdout: "", applied: true };
2947
+ }
2948
+ async function finishRun(root, file, role, final, minutes, now, runId) {
2949
+ const action = final.action;
2950
+ if (!action) return ok2(final.text, runId);
2951
+ if (FORBIDDEN.has(action.name) || !UNAVAILABLE.has(action.name) && action.name !== "write_file") {
2952
+ await appendJson(file, { type: "rejected" });
2953
+ return fail2(linesOf(final.text, ROLE_TEXT.cannot), runId);
2954
+ }
2955
+ const rule = await readRule(root, role.name);
2956
+ if (!role.askFirst && rule && rule.name === action.name && sameInputs(rule.inputs, action.inputs)) {
2957
+ const applied = await applyAction(root, file, role.name, action, "always");
2958
+ const stdout = applied.applied ? final.text : linesOf(final.text, applied.stdout);
2959
+ return applied.exitCode === 0 ? ok2(stdout, runId) : fail2(stdout, runId);
2960
+ }
2961
+ const expiresAt = new Date(now.getTime() + minutes * 6e4).toISOString();
2962
+ await appendJson(file, {
2963
+ type: "pending",
2964
+ action: { name: action.name, words: action.words, inputs: action.inputs },
2965
+ expiresAt
2966
+ });
2967
+ if (role.askFirst) return ok2(linesOf(action.words, ROLE_TEXT.askFirst), runId);
2968
+ return ok2(linesOf(action.words, ROLE_TEXT.choice), runId);
2969
+ }
2970
+ async function settleRoleReply(root, file, role, reply, now, runId) {
2971
+ const minutes = await timeoutMinutes(root);
2972
+ if (minutes === "invalid") return fail2(ROLE_TEXT.badYaml, runId);
2973
+ return finishRun(
2974
+ root,
2975
+ file,
2976
+ role,
2977
+ { text: reply.text, gaps: [], changed: null, action: reply.action },
2978
+ minutes,
2979
+ now,
2980
+ runId
2981
+ );
2982
+ }
2983
+ async function latestRunFile(root, name) {
2984
+ const dir = conversationDir(root, name);
2985
+ const files = (await listFiles(dir)).filter((file) => file.endsWith(".jsonl")).sort();
2986
+ const last = files[files.length - 1];
2987
+ return last ? join9(dir, last) : null;
2988
+ }
2989
+ async function runRole(root, input) {
2990
+ const name = canonicalRoleName(input.name);
2991
+ if (!name) return fail2(ROLE_TEXT.badName);
2992
+ if (input.stop) {
2993
+ if (!await readRole(root, name)) return fail2(ROLE_TEXT.notFound);
2994
+ const latest = await latestRunFile(root, name);
2995
+ if (!latest) return fail2(ROLE_TEXT.runNotFound);
2996
+ await appendJson(latest, { type: "stopped" });
2997
+ return ok2();
2998
+ }
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);
3002
+ const task = input.task ?? "";
3003
+ let withName = "";
3004
+ if (input.withRole !== void 0 && input.withRole.trim() !== "") {
3005
+ const canonical = canonicalRoleName(input.withRole);
3006
+ if (!canonical) return fail2(ROLE_TEXT.badName);
3007
+ if (canonical === name) return fail2(ROLE_TEXT.self);
3008
+ withName = canonical;
3009
+ }
3010
+ if (mentionsX(task)) return fail2(ROLE_TEXT.x);
3011
+ const kind = input.kind?.trim() || "reading";
3012
+ let section;
3013
+ if (kind !== "reading") {
3014
+ if (!DRAFT_KINDS.has(kind)) return fail2(ROLE_TEXT.needsSection);
3015
+ const changeId = input.change?.trim() ?? "";
3016
+ const sectionId = input.section?.trim() ?? "";
3017
+ if (!safeChangeId(changeId) || !sectionId) return fail2(ROLE_TEXT.needsSection);
3018
+ const acceptedPath = await acceptedFile(root, changeId);
3019
+ if (!await pathExists(acceptedPath)) return fail2(ROLE_TEXT.needsSection);
3020
+ const parsed = parseSection(await readText(acceptedPath), sectionId);
3021
+ if (!parsed) return fail2(ROLE_TEXT.needsSection);
3022
+ section = parsed;
3023
+ }
3024
+ let critic = null;
3025
+ if (withName && section) {
3026
+ critic = await readRole(root, withName);
3027
+ if (!critic) return fail2(ROLE_TEXT.notFound);
3028
+ }
3029
+ const minutes = await timeoutMinutes(root);
3030
+ if (minutes === "invalid") return fail2(ROLE_TEXT.badYaml);
3031
+ if (!input.model) return fail2(ROLE_TEXT.noModel);
3032
+ const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3033
+ const runId = runIdFrom(now);
3034
+ const file = join9(conversationDir(root, name), `${runId}.jsonl`);
3035
+ if (section) {
3036
+ await appendJson(file, {
3037
+ type: "checks",
3038
+ ids: section.checks,
3039
+ section: section.id,
3040
+ decision: section.decision
3041
+ });
3042
+ }
3043
+ const draft = await callModel(
3044
+ input.model,
3045
+ file,
3046
+ role.name,
3047
+ authorPrompt(role, task, kind, section),
3048
+ 1
3049
+ );
3050
+ if (!draft) return fail2("", runId);
3051
+ let final = draft;
3052
+ if (critic && section) {
3053
+ const accepted = section;
3054
+ const critique = await callModel(
3055
+ input.model,
3056
+ file,
3057
+ critic.name,
3058
+ critiquePrompt(critic, accepted, draft.text),
3059
+ 2
3060
+ );
3061
+ if (!critique) return fail2(draft.text, runId);
3062
+ if (critique.gaps.some((gap) => !citeOk(gap.cite, accepted))) {
3063
+ await appendJson(file, { type: "bad_call", roleId: critic.name });
3064
+ return fail2(draft.text, runId);
3065
+ }
3066
+ if (critique.gaps.length > 0) {
3067
+ const revision = await callModel(
3068
+ input.model,
3069
+ file,
3070
+ role.name,
3071
+ revisionPrompt(role, draft.text, critique.gaps),
3072
+ 3
3073
+ );
3074
+ if (!revision) return fail2(draft.text, runId);
3075
+ const cites = critique.gaps.map((gap) => gap.cite);
3076
+ if (revision.changed === null || !sameCiteSet(revision.changed, cites)) {
3077
+ await appendJson(file, { type: "bad_call", roleId: role.name });
3078
+ return fail2(draft.text, runId);
3079
+ }
3080
+ final = revision;
3081
+ }
3082
+ }
3083
+ return finishRun(root, file, role, final, minutes, now, runId);
3084
+ }
3085
+ function openPending(lines) {
3086
+ let pending = null;
3087
+ for (const line of lines) {
3088
+ if (!line || typeof line !== "object" || Array.isArray(line)) continue;
3089
+ const rec = line;
3090
+ if (rec.type === "pending") {
3091
+ const action = rec.action;
3092
+ if (!action || typeof action !== "object" || Array.isArray(action)) {
3093
+ pending = null;
3094
+ continue;
3095
+ }
3096
+ const item = action;
3097
+ if (typeof item.name !== "string") {
3098
+ pending = null;
3099
+ continue;
3100
+ }
3101
+ pending = {
3102
+ name: item.name,
3103
+ words: typeof item.words === "string" ? item.words : "",
3104
+ inputs: item.inputs && typeof item.inputs === "object" && !Array.isArray(item.inputs) ? item.inputs : {},
3105
+ expiresAt: typeof rec.expiresAt === "string" ? rec.expiresAt : ""
3106
+ };
3107
+ continue;
3108
+ }
3109
+ if (typeof rec.type === "string" && CLOSED.has(rec.type)) pending = null;
3110
+ }
3111
+ return pending;
3112
+ }
3113
+ async function findRun(root, runId) {
3114
+ if (!safeRunId(runId)) return null;
3115
+ const hits = [];
3116
+ for (const roleName of await listConversationRoles(root)) {
3117
+ const file = join9(conversationDir(root, roleName), `${runId}.jsonl`);
3118
+ if (await pathExists(file)) hits.push({ file, roleName });
3119
+ }
3120
+ if (hits.length === 1) return hits[0];
3121
+ const pendingHits = [];
3122
+ for (const hit of hits) {
3123
+ if (openPending(await readJsonLines(hit.file))) pendingHits.push(hit);
3124
+ }
3125
+ return pendingHits.length === 1 ? pendingHits[0] : null;
3126
+ }
3127
+ async function allowRole(root, input) {
3128
+ const found = await findRun(root, input.runId);
3129
+ if (!found) return fail2(ROLE_TEXT.runNotFound);
3130
+ const pending = openPending(await readJsonLines(found.file));
3131
+ if (!pending) return fail2(ROLE_TEXT.runNotFound);
3132
+ const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3133
+ const expires = Date.parse(pending.expiresAt);
3134
+ if (!Number.isFinite(expires) || now.getTime() >= expires) {
3135
+ await appendJson(found.file, { type: "expired" });
3136
+ return fail2(ROLE_TEXT.expired);
3137
+ }
3138
+ if (input.mode === "deny") {
3139
+ await appendJson(found.file, { type: "denied" });
3140
+ return ok2("", input.runId);
3141
+ }
3142
+ 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);
3145
+ 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);
3147
+ }
3148
+
3149
+ // src/role/handoff.ts
3150
+ var ID = /^[a-z][a-z0-9-]{0,39}$/;
3151
+ var ROLE_YAML = ["role.yaml"];
3152
+ function ok3(stdout = "", runId) {
3153
+ return runId ? { exitCode: 0, stdout, runId } : { exitCode: 0, stdout };
3154
+ }
3155
+ function fail3(stdout, runId) {
3156
+ return runId ? { exitCode: 1, stdout, runId } : { exitCode: 1, stdout };
3157
+ }
3158
+ function mentionsX2(task) {
3159
+ return /@X|x\.com|twitter\.com/.test(task);
3160
+ }
3161
+ function safeChangeId2(id) {
3162
+ if (!id || id.length > 200 || id === "." || id === "..") return false;
3163
+ return !id.includes("/") && !id.includes("\\") && !id.includes("\0");
3164
+ }
3165
+ function yamlPath(root) {
3166
+ return join10(root, ".sdd", ...ROLE_YAML);
3167
+ }
3168
+ function planPath(root, changeId, sectionId) {
3169
+ return join10(root, ".sdd", "role-runs", "plans", changeId, `${sectionId}.json`);
3170
+ }
3171
+ async function loadYaml(root) {
3172
+ const path = yamlPath(root);
3173
+ if (!await pathExists(path)) return "missing";
3174
+ let data;
3175
+ try {
3176
+ data = parseYaml3(await readText(path));
3177
+ } catch {
3178
+ return "invalid";
3179
+ }
3180
+ if (data == null) return {};
3181
+ if (typeof data !== "object" || Array.isArray(data)) return "invalid";
3182
+ const rec = data;
3183
+ if ("approval_timeout_minutes" in rec && rec.approval_timeout_minutes != null) {
3184
+ const minutes = rec.approval_timeout_minutes;
3185
+ if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes < 0) return "invalid";
3186
+ }
3187
+ if ("role_messaging" in rec && rec.role_messaging != null && typeof rec.role_messaging !== "boolean") {
3188
+ return "invalid";
3189
+ }
3190
+ return rec;
3191
+ }
3192
+ async function messagingOn(root) {
3193
+ const loaded = await loadYaml(root);
3194
+ if (loaded === "invalid") return "invalid";
3195
+ if (loaded === "missing") return true;
3196
+ return loaded.role_messaging !== false;
3197
+ }
3198
+ async function setRoleMessaging(root, enabled) {
3199
+ const loaded = await loadYaml(root);
3200
+ if (loaded === "invalid") return fail3(ROLE_TEXT.badYaml);
3201
+ const raw = loaded === "missing" ? {} : loaded;
3202
+ 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.");
3205
+ }
3206
+ function field(body, prefix) {
3207
+ const found = [];
3208
+ for (const row of body.split(/\r?\n/)) {
3209
+ const trimmed = row.trim();
3210
+ if (!trimmed.startsWith(prefix)) continue;
3211
+ const value = trimmed.slice(prefix.length).trim();
3212
+ if (value) found.push(value);
3213
+ }
3214
+ return found;
3215
+ }
3216
+ function unique(items) {
3217
+ const seen = /* @__PURE__ */ new Set();
3218
+ const found = [];
3219
+ for (const item of items) {
3220
+ if (seen.has(item)) continue;
3221
+ seen.add(item);
3222
+ found.push(item);
3223
+ }
3224
+ return found;
3225
+ }
3226
+ function factsFrom(markdown) {
3227
+ const ids = [];
3228
+ for (const row of markdown.split(/\r?\n/)) {
3229
+ if (row.startsWith("## ")) {
3230
+ const id = row.slice(3).trim();
3231
+ if (id) ids.push(id);
3232
+ }
3233
+ }
3234
+ const facts = [];
3235
+ for (const id of ids) {
3236
+ const section = parseSection(markdown, id);
3237
+ if (!section) continue;
3238
+ const services = field(section.body, "service: ").filter((item) => ID.test(item));
3239
+ const modules = field(section.body, "module: ").filter((item) => ID.test(item));
3240
+ const owners = field(section.body, "owner: ").filter(
3241
+ (item) => canonicalRoleName(item) === item
3242
+ );
3243
+ const leads = field(section.body, "lead: ").filter((item) => canonicalRoleName(item) === item);
3244
+ const connection = connectionOf(section.body);
3245
+ facts.push({
3246
+ id: section.id,
3247
+ decision: section.decision,
3248
+ body: section.body,
3249
+ services,
3250
+ modules,
3251
+ owners,
3252
+ leads,
3253
+ connection,
3254
+ product: field(section.body, "scope: ").includes("product")
3255
+ });
3256
+ }
3257
+ return facts;
3258
+ }
3259
+ function connectionOf(body) {
3260
+ for (const value of field(body, "connection: ")) {
3261
+ const parts = value.split(/\s+/);
3262
+ if (parts.length === 2 && parts[0] && parts[1] && ID.test(parts[0]) && ID.test(parts[1])) {
3263
+ return [parts[0], parts[1]];
3264
+ }
3265
+ }
3266
+ return null;
3267
+ }
3268
+ async function acceptedFacts(root, changeId, sectionId) {
3269
+ if (!safeChangeId2(changeId) || !sectionId) return null;
3270
+ const path = await acceptedFile(root, changeId);
3271
+ if (!await pathExists(path)) return null;
3272
+ const markdown = await readText(path);
3273
+ const all = factsFrom(markdown);
3274
+ const current = all.find((item) => item.id === sectionId);
3275
+ if (!current) return null;
3276
+ return { markdown, current, all };
3277
+ }
3278
+ function handoffRecord(from, to, changeId, section) {
3279
+ return {
3280
+ type: "handoff",
3281
+ from,
3282
+ to,
3283
+ change: changeId,
3284
+ section: section.id,
3285
+ decision: section.decision
3286
+ };
3287
+ }
3288
+ function receiverPrompt(role, task, section) {
3289
+ return [
3290
+ `Job: ${role.job}`,
3291
+ `Description: ${role.description}`,
3292
+ `Task: ${task}`,
3293
+ "Kind: handoff",
3294
+ `Section: ${section.id}`,
3295
+ `Decision: ${section.decision}`,
3296
+ "Section text:",
3297
+ section.body
3298
+ ].join("\n");
3299
+ }
3300
+ async function handoffRole(root, input) {
3301
+ const from = canonicalRoleName(input.from);
3302
+ 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.");
3305
+ const sender = await readRole(root, from);
3306
+ const receiver = await readRole(root, to);
3307
+ if (!sender || !receiver) return fail3(ROLE_TEXT.notFound);
3308
+ const task = input.task ?? "";
3309
+ if (mentionsX2(task)) return fail3(ROLE_TEXT.x);
3310
+ const loaded = await acceptedFacts(root, input.change?.trim() ?? "", input.section?.trim() ?? "");
3311
+ if (!loaded) return fail3(ROLE_TEXT.needsSection);
3312
+ if (loaded.current.owners.length > 0 && !loaded.current.owners.includes(to)) {
3313
+ return fail3("One owner handles this section.");
3314
+ }
3315
+ 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);
3319
+ const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3320
+ const runId = runIdFrom(now);
3321
+ const senderFile = join10(conversationDir(root, from), `${runId}.jsonl`);
3322
+ const receiverFile = join10(conversationDir(root, to), `${runId}.jsonl`);
3323
+ const record = handoffRecord(from, to, input.change?.trim() ?? "", loaded.current);
3324
+ await appendJson(senderFile, record);
3325
+ await appendJson(receiverFile, record);
3326
+ let raw;
3327
+ try {
3328
+ raw = await input.model.complete({
3329
+ roleId: to,
3330
+ prompt: receiverPrompt(receiver, task, loaded.current)
3331
+ });
3332
+ } catch {
3333
+ await appendJson(receiverFile, { type: "bad_call", roleId: to });
3334
+ return fail3("", runId);
3335
+ }
3336
+ const parsed = parseReply(raw);
3337
+ if (!parsed) {
3338
+ await appendJson(receiverFile, { type: "bad_call", roleId: to });
3339
+ return fail3("", runId);
3340
+ }
3341
+ await appendJson(receiverFile, { type: "model", pass: 1, roleId: to, text: parsed.text });
3342
+ const settled = await settleRoleReply(root, receiverFile, receiver, parsed, now, runId);
3343
+ await appendJson(senderFile, {
3344
+ type: "handoff_reply",
3345
+ from: to,
3346
+ to: from,
3347
+ change: record.change,
3348
+ section: record.section,
3349
+ decision: record.decision,
3350
+ summary: parsed.text
3351
+ });
3352
+ return settled;
3353
+ }
3354
+ async function showRoleGroup(root, changeId) {
3355
+ const path = safeChangeId2(changeId) ? await acceptedFile(root, changeId) : "";
3356
+ if (!path || !await pathExists(path)) return ok3("No roles in this group.");
3357
+ const seen = /* @__PURE__ */ new Set();
3358
+ const owners = [];
3359
+ for (const section of factsFrom(await readText(path))) {
3360
+ for (const owner of section.owners) {
3361
+ if (seen.has(owner)) continue;
3362
+ seen.add(owner);
3363
+ owners.push(owner);
3364
+ }
3365
+ }
3366
+ return ok3(owners.length > 0 ? owners.join("\n") : "No roles in this group.");
3367
+ }
3368
+ function distinctServices(all) {
3369
+ const services = /* @__PURE__ */ new Map();
3370
+ for (const section of all) {
3371
+ for (const service of section.services) {
3372
+ if (!services.has(service)) services.set(service, section);
3373
+ }
3374
+ }
3375
+ return services;
3376
+ }
3377
+ function nodeLine(node) {
3378
+ const after = node.after.length > 0 ? node.after.join(",") : "none";
3379
+ return `${node.scope} ${node.owner} ${node.decision} after ${after} artifact ${node.artifact} boundary ${node.boundary}`;
3380
+ }
3381
+ function planLines(nodes) {
3382
+ const lines = [];
3383
+ for (const node of nodes) {
3384
+ lines.push(nodeLine(node));
3385
+ lines.push(...planLines(node.children));
3386
+ }
3387
+ return lines;
3388
+ }
3389
+ function artifactsOf(nodes) {
3390
+ const artifacts = [];
3391
+ for (const node of nodes) {
3392
+ artifacts.push(node.artifact);
3393
+ artifacts.push(...artifactsOf(node.children));
3394
+ }
3395
+ return artifacts;
3396
+ }
3397
+ async function readPlan(root, changeId, sectionId) {
3398
+ const path = planPath(root, changeId, sectionId);
3399
+ if (!await pathExists(path)) return null;
3400
+ try {
3401
+ const data = JSON.parse(await readText(path));
3402
+ if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.results)) return null;
3403
+ return data;
3404
+ } catch {
3405
+ return null;
3406
+ }
3407
+ }
3408
+ async function writePlan(root, changeId, sectionId, plan) {
3409
+ await writeText(planPath(root, changeId, sectionId), `${JSON.stringify(plan, null, 2)}
3410
+ `);
3411
+ }
3412
+ function contextOf(all) {
3413
+ const services = /* @__PURE__ */ new Set();
3414
+ const modules = /* @__PURE__ */ new Set();
3415
+ const decisions = /* @__PURE__ */ new Set();
3416
+ for (const section of all) {
3417
+ for (const service of section.services) services.add(service);
3418
+ for (const moduleId of section.modules) modules.add(moduleId);
3419
+ decisions.add(section.decision);
3420
+ }
3421
+ return { services, modules, decisions };
3422
+ }
3423
+ async function parsePlan(root, raw, ctx) {
3424
+ let value;
3425
+ try {
3426
+ value = JSON.parse(raw);
3427
+ } catch {
3428
+ return "invalid";
3429
+ }
3430
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid";
3431
+ const rec = value;
3432
+ if (typeof rec.text !== "string" || !Array.isArray(rec.plan)) return "invalid";
3433
+ const seen = /* @__PURE__ */ new Set();
3434
+ const nodes = await parseNodes(root, rec.plan, ctx, seen, true);
3435
+ return nodes;
3436
+ }
3437
+ async function parseNodes(root, value, ctx, seen, roots) {
3438
+ if (!Array.isArray(value)) return "invalid";
3439
+ const nodes = [];
3440
+ for (const item of value) {
3441
+ const parsed = await parseNode(root, item, ctx, seen);
3442
+ if (parsed === "invalid" || parsed === "deep") return parsed;
3443
+ if (!roots && parsed.children.length > 0) return "deep";
3444
+ for (const child of parsed.children) {
3445
+ if (child.children.length > 0) return "deep";
3446
+ }
3447
+ nodes.push(parsed);
3448
+ }
3449
+ return nodes;
3450
+ }
3451
+ async function parseNode(root, value, ctx, seen) {
3452
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid";
3453
+ const rec = value;
3454
+ const scope = typeof rec.scope === "string" ? rec.scope : "";
3455
+ const owner = typeof rec.owner === "string" ? rec.owner : "";
3456
+ const decision = typeof rec.decision === "string" ? rec.decision : "";
3457
+ const artifact = typeof rec.artifact === "string" ? rec.artifact : "";
3458
+ const boundary = rec.boundary;
3459
+ if (!ctx.services.has(scope) && !ctx.modules.has(scope)) return "invalid";
3460
+ if (!await readRole(root, owner)) return "invalid";
3461
+ if (!ctx.decisions.has(decision) || seen.has(decision)) return "invalid";
3462
+ if (!Array.isArray(rec.after) || rec.after.some((id) => typeof id !== "string")) return "invalid";
3463
+ if (rec.after.some((id) => !ctx.decisions.has(id))) return "invalid";
3464
+ if (!isSafeProductPath(artifact)) return "invalid";
3465
+ if (boundary !== "ask" && boundary !== "stop") return "invalid";
3466
+ seen.add(decision);
3467
+ let children = [];
3468
+ if (rec.children !== void 0) {
3469
+ const parsed = await parseNodes(root, rec.children, ctx, seen, false);
3470
+ if (parsed === "invalid" || parsed === "deep") return parsed;
3471
+ children = parsed;
3472
+ }
3473
+ return {
3474
+ scope,
3475
+ owner,
3476
+ decision,
3477
+ after: rec.after,
3478
+ artifact,
3479
+ boundary,
3480
+ children
3481
+ };
3482
+ }
3483
+ function leadPrompt(task, services) {
3484
+ const rows = [`Task: ${task}`];
3485
+ for (const [service, section] of services) {
3486
+ const moduleId = section.modules[0] ?? "none";
3487
+ const owner = section.owners[0] ?? "none";
3488
+ rows.push(`${service} module ${moduleId} owner ${owner} decision ${section.decision}`);
3489
+ }
3490
+ return rows.join("\n");
3491
+ }
3492
+ async function connectionResult(root, changeId, section, now) {
3493
+ const messaging = await messagingOn(root);
3494
+ if (messaging === "invalid") return fail3(ROLE_TEXT.badYaml);
3495
+ if (!messaging) return ok3("Messaging between roles is off.");
3496
+ const owners = unique(section.owners);
3497
+ const runId = runIdFrom(now);
3498
+ for (let i = 0; i < owners.length - 1; i++) {
3499
+ const from = owners[i] ?? "";
3500
+ const to = owners[i + 1] ?? "";
3501
+ 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);
3504
+ }
3505
+ const lines = ["Connection hands off. Lead is absent.", ...owners];
3506
+ return ok3(lines.join("\n"));
3507
+ }
3508
+ function freshPlan(task, nodes) {
3509
+ return { status: "pending", rebuilds: 0, stalled: false, task, nodes, results: [] };
3510
+ }
3511
+ async function planNetwork(root, input) {
3512
+ const changeId = input.change?.trim() ?? "";
3513
+ const sectionId = input.section?.trim() ?? "";
3514
+ const loaded = await acceptedFacts(root, changeId, sectionId);
3515
+ if (!loaded) return fail3(ROLE_TEXT.needsSection);
3516
+ const task = input.task ?? "";
3517
+ const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3518
+ const connection = loaded.all.find(
3519
+ (item) => item.connection && (item.id === loaded.current.id || loaded.current.services.some((service) => item.connection?.includes(service)))
3520
+ );
3521
+ if (connection?.connection) return connectionResult(root, changeId, connection, now);
3522
+ const services = distinctServices(loaded.all);
3523
+ if (services.size === 0) return ok3("One scope. No lead.");
3524
+ if (services.size === 1) {
3525
+ const entry = [...services.entries()][0];
3526
+ if (!entry) return ok3("One scope. No lead.");
3527
+ const [name, section] = entry;
3528
+ 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}.`);
3532
+ }
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);
3536
+ 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);
3540
+ return storeLeadPlan(
3541
+ root,
3542
+ changeId,
3543
+ sectionId,
3544
+ task,
3545
+ lead,
3546
+ services,
3547
+ loaded.all,
3548
+ input.model,
3549
+ null
3550
+ );
3551
+ }
3552
+ async function storeLeadPlan(root, changeId, sectionId, task, lead, services, all, model, previous) {
3553
+ let raw;
3554
+ try {
3555
+ raw = await model.complete({ roleId: lead.name, prompt: leadPrompt(task, services) });
3556
+ } catch {
3557
+ return fail3("");
3558
+ }
3559
+ 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.");
3563
+ if (previous && sameArtifacts(previous.nodes, parsed)) {
3564
+ previous.stalled = true;
3565
+ previous.rebuilds += 1;
3566
+ await writePlan(root, changeId, sectionId, previous);
3567
+ return ok3("Stopped. The plan made no progress.");
3568
+ }
3569
+ const next = previous ? {
3570
+ ...previous,
3571
+ status: "pending",
3572
+ stalled: false,
3573
+ rebuilds: previous.rebuilds + 1,
3574
+ nodes: parsed,
3575
+ results: []
3576
+ } : freshPlan(task, parsed);
3577
+ await writePlan(root, changeId, sectionId, next);
3578
+ return ok3(["Accept this plan before anyone writes.", ...planLines(parsed)].join("\n"));
3579
+ }
3580
+ function planFailure(parsed) {
3581
+ return parsed === "deep" ? "A plan is deeper than 2." : "The plan is not valid.";
3582
+ }
3583
+ function sameArtifacts(left, right) {
3584
+ const a = artifactsOf(left).slice().sort();
3585
+ const b = artifactsOf(right).slice().sort();
3586
+ return a.length === b.length && a.every((item, index) => item === b[index]);
3587
+ }
3588
+ async function acceptNetwork(root, input) {
3589
+ const changeId = input.change?.trim() ?? "";
3590
+ const sectionId = input.section?.trim() ?? "";
3591
+ if (!safeChangeId2(changeId) || !sectionId) return fail3("No plan is stored.");
3592
+ 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.");
3595
+ plan.status = "accepted";
3596
+ await writePlan(root, changeId, sectionId, plan);
3597
+ return ok3("Plan accepted.");
3598
+ }
3599
+ function walk(nodes) {
3600
+ const flat = [];
3601
+ for (const node of nodes) {
3602
+ flat.push(node);
3603
+ flat.push(...walk(node.children));
3604
+ }
3605
+ return flat;
3606
+ }
3607
+ function ready(node, results) {
3608
+ if (results.some((item) => item.decision === node.decision)) return false;
3609
+ const afterOk = node.after.every((id) => results.some((item) => item.decision === id));
3610
+ const childrenOk = node.children.every(
3611
+ (child) => results.some((item) => item.decision === child.decision)
3612
+ );
3613
+ return afterOk && childrenOk;
3614
+ }
3615
+ async function runNetwork(root, input) {
3616
+ const changeId = input.change?.trim() ?? "";
3617
+ const sectionId = input.section?.trim() ?? "";
3618
+ if (!safeChangeId2(changeId) || !sectionId) return fail3("No plan is stored.");
3619
+ 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);
3625
+ const minutes = await timeoutMinutes(root);
3626
+ if (minutes === "invalid") return fail3(ROLE_TEXT.badYaml);
3627
+ if (!input.model) return fail3(ROLE_TEXT.noModel);
3628
+ const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
3629
+ const summaries = [];
3630
+ const called = /* @__PURE__ */ new Set();
3631
+ let index = 0;
3632
+ let progressed = true;
3633
+ let missingOwner = false;
3634
+ while (progressed && !missingOwner) {
3635
+ progressed = false;
3636
+ for (const node of walk(plan.nodes)) {
3637
+ if (!ready(node, plan.results) || called.has(node.decision)) continue;
3638
+ called.add(node.decision);
3639
+ const owner = await readRole(root, node.owner);
3640
+ if (!owner) {
3641
+ missingOwner = true;
3642
+ break;
3643
+ }
3644
+ const when = new Date(now.getTime() + index * 1e3);
3645
+ index += 1;
3646
+ const runId = runIdFrom(when);
3647
+ const file = join10(conversationDir(root, owner.name), `${runId}.jsonl`);
3648
+ let raw;
3649
+ try {
3650
+ raw = await input.model.complete({
3651
+ roleId: owner.name,
3652
+ prompt: [
3653
+ `Job: ${owner.job}`,
3654
+ `Description: ${owner.description}`,
3655
+ `Task: ${plan.task}`,
3656
+ `Artifact: ${node.artifact}`,
3657
+ `Boundary: ${node.boundary}`
3658
+ ].join("\n")
3659
+ });
3660
+ } catch {
3661
+ continue;
3662
+ }
3663
+ const parsed = parseReply(raw);
3664
+ if (!parsed) continue;
3665
+ await appendJson(file, { type: "model", pass: 1, roleId: owner.name, text: parsed.text });
3666
+ if (parsed.action) {
3667
+ const expiresAt = new Date(when.getTime() + minutes * 6e4).toISOString();
3668
+ await appendJson(file, {
3669
+ type: "pending",
3670
+ action: {
3671
+ name: parsed.action.name,
3672
+ words: parsed.action.words,
3673
+ inputs: parsed.action.inputs
3674
+ },
3675
+ expiresAt
3676
+ });
3677
+ }
3678
+ plan.results.push({
3679
+ decision: node.decision,
3680
+ artifact: node.artifact,
3681
+ summary: parsed.text
3682
+ });
3683
+ summaries.push(`${owner.name}: ${parsed.text}`);
3684
+ progressed = true;
3685
+ }
3686
+ }
3687
+ const all = walk(plan.nodes);
3688
+ const waiting = all.some((node) => !plan.results.some((item) => item.decision === node.decision));
3689
+ plan.status = waiting ? "open" : "done";
3690
+ await writePlan(root, changeId, sectionId, plan);
3691
+ if (missingOwner) return fail3(ROLE_TEXT.notFound);
3692
+ const lines = [...summaries];
3693
+ if (waiting) {
3694
+ lines.push("A branch is waiting for its prerequisites.");
3695
+ lines.push("The parent stays open.");
3696
+ } else {
3697
+ lines.push("The parent is done.");
3698
+ }
3699
+ return ok3(lines.join("\n"));
3700
+ }
3701
+ async function rebuildNetwork(root, input) {
3702
+ const changeId = input.change?.trim() ?? "";
3703
+ const sectionId = input.section?.trim() ?? "";
3704
+ if (!safeChangeId2(changeId) || !sectionId) return fail3("No plan is stored.");
3705
+ const plan = await readPlan(root, changeId, sectionId);
3706
+ if (!plan) return fail3("No plan is stored.");
3707
+ const allowed = plan.rebuilds === 0 || plan.rebuilds === 1 && !plan.stalled && plan.status === "accepted";
3708
+ if (!allowed) return fail3("A second rebuild does not start.");
3709
+ const loaded = await acceptedFacts(root, changeId, sectionId);
3710
+ if (!loaded) return fail3(ROLE_TEXT.needsSection);
3711
+ if (!input.model) return fail3(ROLE_TEXT.noModel);
3712
+ 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.");
3716
+ const lead = await readRole(root, picked.name);
3717
+ if (!lead) return fail3(ROLE_TEXT.notFound);
3718
+ const before = JSON.stringify(plan);
3719
+ const result = await storeLeadPlan(
3720
+ root,
3721
+ changeId,
3722
+ sectionId,
3723
+ plan.task,
3724
+ lead,
3725
+ services,
3726
+ loaded.all,
3727
+ input.model,
3728
+ plan
3729
+ );
3730
+ if (result.exitCode !== 0) {
3731
+ const current = await readPlan(root, changeId, sectionId);
3732
+ if (current && JSON.stringify(current) !== before) {
3733
+ await writePlan(root, changeId, sectionId, JSON.parse(before));
3734
+ }
3735
+ }
3736
+ return result;
3737
+ }
3738
+ function pickLead(all, services) {
3739
+ const modules = /* @__PURE__ */ new Set();
3740
+ let missingModule = false;
3741
+ for (const section of services.values()) {
3742
+ const moduleId2 = section.modules[0];
3743
+ if (!moduleId2) missingModule = true;
3744
+ else modules.add(moduleId2);
3745
+ }
3746
+ const moduleId = [...modules][0];
3747
+ 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 };
3751
+ }
3752
+ 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 };
3756
+ }
3757
+ return { kind: "none" };
3758
+ }
3759
+
3760
+ // src/stories.ts
3761
+ import { join as join11 } from "pathe";
3762
+ var STORY_ID = /^S-\d+$/i;
3763
+ var EPIC_KEY = /^[A-Za-z0-9][A-Za-z0-9-]{0,39}$/;
3764
+ function validBranchName(name) {
3765
+ if (!name || name === "." || name === ".." || name.includes(" ") || name.includes("..")) {
3766
+ return false;
3767
+ }
3768
+ if (name.startsWith("/") || name.endsWith("/") || name.endsWith(".lock") || name.includes("@{")) {
3769
+ return false;
3770
+ }
3771
+ if (/[\\~^:?*[]/.test(name)) return false;
3772
+ return name.split("/").every((part) => part.length > 0 && part !== "." && part !== "..");
3773
+ }
3774
+ function parseStoryDraft(input) {
3775
+ const epic = input.epic?.trim() ?? "";
3776
+ const storyRaw = input.story?.trim() ?? "";
3777
+ const dependsRaw = (input.depends ?? []).map((item) => item.trim()).filter((item) => item.length > 0);
3778
+ if ((storyRaw || dependsRaw.length > 0) && !epic) {
3779
+ return { ok: false, stdout: "A story needs an epic." };
3780
+ }
3781
+ if (epic && !EPIC_KEY.test(epic)) return { ok: false, stdout: "An epic key is not valid." };
3782
+ if (dependsRaw.length > 0 && !storyRaw) return { ok: false, stdout: "A story id is not valid." };
3783
+ if (storyRaw && !STORY_ID.test(storyRaw))
3784
+ return { ok: false, stdout: "A story id is not valid." };
3785
+ const depends = [];
3786
+ for (const dep of dependsRaw) {
3787
+ if (!STORY_ID.test(dep)) return { ok: false, stdout: "A story id is not valid." };
3788
+ const id = dep.toUpperCase();
3789
+ if (!depends.includes(id)) depends.push(id);
3790
+ }
3791
+ return {
3792
+ ok: true,
3793
+ draft: {
3794
+ epic: epic || void 0,
3795
+ story: storyRaw ? storyRaw.toUpperCase() : void 0,
3796
+ depends
3797
+ }
3798
+ };
3799
+ }
3800
+ async function metasIn(dir) {
3801
+ const out = [];
3802
+ for (const id of await listDirs(dir)) {
3803
+ const path = join11(dir, id, "meta.yaml");
3804
+ if (!await pathExists(path)) continue;
3805
+ try {
3806
+ out.push(ChangeMetaSchema.parse(await readYaml(path)));
3807
+ } catch {
3808
+ }
3809
+ }
3810
+ return out;
3811
+ }
3812
+ async function loadEpicStories(projectRoot, config, epic) {
3813
+ const metas = [
3814
+ ...await metasIn(changesDir(projectRoot, config)),
3815
+ ...await metasIn(archiveDir(projectRoot, config))
3816
+ ];
3817
+ const nodes = [];
3818
+ for (const meta of metas) {
3819
+ if (meta.epic !== epic || !meta.story) continue;
3820
+ nodes.push({
3821
+ changeId: meta.id,
3822
+ title: meta.title,
3823
+ story: meta.story.toUpperCase(),
3824
+ epic: meta.epic,
3825
+ depends: (meta.depends ?? []).map((item) => item.toUpperCase()),
3826
+ status: meta.status
3827
+ });
3828
+ }
3829
+ return nodes;
3830
+ }
3831
+ async function storyIdTaken(projectRoot, config, epic, story) {
3832
+ const nodes = await loadEpicStories(projectRoot, config, epic);
3833
+ return nodes.some((node) => node.story === story.toUpperCase());
3834
+ }
3835
+ function byStoryId(left, right) {
3836
+ if (left.story < right.story) return -1;
3837
+ if (left.story > right.story) return 1;
3838
+ return 0;
3839
+ }
3840
+ function formatEpicGraph(nodes) {
3841
+ const ordered = [...nodes].sort(byStoryId);
3842
+ const byStory = /* @__PURE__ */ new Map();
3843
+ for (const node of ordered) {
3844
+ if (byStory.has(node.story))
3845
+ return { exitCode: 1, stdout: `Story ${node.story} already exists.` };
3846
+ byStory.set(node.story, node);
3847
+ }
3848
+ for (const node of ordered) {
3849
+ for (const dep of [...node.depends].sort()) {
3850
+ if (!byStory.has(dep)) {
3851
+ return {
3852
+ exitCode: 1,
3853
+ stdout: `${node.story} depends on ${dep}, which is not in this epic.`
3854
+ };
3855
+ }
3856
+ }
3857
+ }
3858
+ const wave = /* @__PURE__ */ new Map();
3859
+ const stack = [];
3860
+ const visit = (id) => {
3861
+ if (wave.has(id)) return null;
3862
+ if (stack.includes(id)) {
3863
+ const at = stack.indexOf(id);
3864
+ return `Story cycle: ${[...stack.slice(at), id].join(" -> ")}`;
3865
+ }
3866
+ stack.push(id);
3867
+ const node = byStory.get(id);
3868
+ if (!node) return null;
3869
+ let deepest = 0;
3870
+ for (const dep of [...node.depends].sort()) {
3871
+ const err = visit(dep);
3872
+ if (err) return err;
3873
+ deepest = Math.max(deepest, wave.get(dep) ?? 0);
3874
+ }
3875
+ stack.pop();
3876
+ wave.set(id, deepest + 1);
3877
+ return null;
3878
+ };
3879
+ for (const node of ordered) {
3880
+ const err = visit(node.story);
3881
+ if (err) return { exitCode: 1, stdout: err };
3882
+ }
3883
+ const groups = /* @__PURE__ */ new Map();
3884
+ for (const node of ordered) {
3885
+ const number = wave.get(node.story) ?? 1;
3886
+ const list = groups.get(number) ?? [];
3887
+ list.push(node);
3888
+ groups.set(number, list);
3889
+ }
3890
+ const lines = [];
3891
+ const parallel = [];
3892
+ for (const number of [...groups.keys()].sort((left, right) => left - right)) {
3893
+ const group = groups.get(number) ?? [];
3894
+ lines.push(`Wave ${number}`);
3895
+ for (const node of group) {
3896
+ const deps = [...node.depends].sort();
3897
+ lines.push(
3898
+ deps.length > 0 ? `${node.story} ${node.title} after ${deps.join(", ")}` : `${node.story} ${node.title}`
3899
+ );
3900
+ }
3901
+ if (group.length > 1) parallel.push(group.map((node) => node.story).join(", "));
3902
+ }
3903
+ if (parallel.length === 0) lines.push("Parallel: none");
3904
+ for (const line of parallel) lines.push(`Parallel: ${line}`);
3905
+ const serial = ordered.filter((node) => node.depends.length > 0);
3906
+ if (serial.length === 0) lines.push("Serial: none");
3907
+ for (const node of serial) {
3908
+ lines.push(`Serial: ${node.story} after ${[...node.depends].sort().join(", ")}`);
3909
+ }
3910
+ return { exitCode: 0, stdout: lines.join("\n") };
3911
+ }
3912
+ function branchBlock(nodes, story) {
3913
+ const graph = formatEpicGraph(nodes);
3914
+ if (graph.exitCode !== 0) return graph.stdout;
3915
+ const node = nodes.find((item) => item.story === story);
3916
+ if (!node) return null;
3917
+ const waiting = [...node.depends].sort().filter((dep) => {
3918
+ const other = nodes.find((item) => item.story === dep);
3919
+ return !other || other.status !== "completed";
3920
+ });
3921
+ if (waiting.length === 0) return null;
3922
+ return `${story} waits on ${waiting.join(", ")}.`;
3923
+ }
2093
3924
  export {
2094
3925
  AGENT_INTEGRATIONS,
2095
3926
  AGENT_TARGET_OPTIONS,
2096
3927
  ALL_AGENT_TARGETS,
2097
3928
  AdapterRegistry,
3929
+ AgentInstallPreflightError,
2098
3930
  ArtifactDefSchema,
2099
3931
  ChangeMetaSchema,
3932
+ ChangeMetricsSchema,
2100
3933
  ConfigSchema,
2101
3934
  DEFAULT_CODE_CONTEXT_CAPS,
2102
3935
  DEFAULT_INIT_INTEGRATION,
3936
+ EMBEDDINGS_UNAVAILABLE,
3937
+ EMBEDDING_MODEL,
2103
3938
  GateSchema,
2104
3939
  McpConfigSchema,
2105
3940
  McpInvokeSchema,
2106
3941
  McpSourceSchema,
2107
3942
  McpWhenSchema,
2108
3943
  PROTOCOL_MD,
3944
+ PhaseMetricSchema,
3945
+ RRF_K,
2109
3946
  SDD_AGENT_ROLES,
2110
3947
  SDD_CORE_VERSION,
2111
3948
  SDD_DIR,
3949
+ SUMMARY_TOKEN_CAP,
2112
3950
  SddError,
3951
+ SearchConfigError,
2113
3952
  StageSchema,
3953
+ TokenUsageMetricSchema,
2114
3954
  VerifyCommandSchema,
2115
3955
  WorkflowSchema,
3956
+ acceptNetwork,
2116
3957
  activePointerPath,
2117
3958
  activeStages,
2118
3959
  addMcpSource,
2119
3960
  advanceStage,
2120
3961
  agentHostPaths,
2121
3962
  agentKickoffMessage,
3963
+ allowRole,
2122
3964
  approveGate,
2123
3965
  archiveDir,
3966
+ assertAgentInstallable,
3967
+ assertSessionLogAllowed,
3968
+ branchBlock,
3969
+ budgetForStage,
2124
3970
  buildAgentPrompt,
2125
3971
  buildContext,
2126
3972
  buildRefinePlan,
3973
+ buildRetrievedInner,
2127
3974
  callMcpSourceTool,
2128
3975
  canLeaveStage,
2129
3976
  changeDirName,
2130
3977
  changeMetaPath,
2131
3978
  changePath,
2132
3979
  changesDir,
3980
+ changesPathRel,
3981
+ chunkCode,
3982
+ chunkMarkdown,
2133
3983
  classifyPath,
2134
3984
  clearActiveIf,
3985
+ clearRegisteredSearchProviders,
3986
+ compareChangesByFirstPhase,
2135
3987
  completeChange,
2136
3988
  configPath,
3989
+ contentHash,
2137
3990
  copyDir,
2138
3991
  copyDirSkipExisting,
3992
+ costFor,
3993
+ countTokens,
3994
+ createBuiltinProvider,
2139
3995
  createChange,
3996
+ createFastembedEmbedder,
3997
+ createRole,
2140
3998
  defaultConfig,
2141
3999
  defaultMcpConfig,
2142
4000
  defaultMcpYamlPath,
2143
4001
  defaultMemoryDir,
4002
+ defaultPricesYamlPath,
4003
+ defaultSearchYamlPath,
2144
4004
  defaultTemplatesDir,
2145
4005
  defaultWorkflowsDir,
2146
4006
  defaultsRoot,
4007
+ deleteRole,
4008
+ describeFsError,
4009
+ describeKept,
4010
+ displayArchivePath,
4011
+ displayChangePath,
2147
4012
  domainsDir,
4013
+ duplicateRole,
4014
+ editRole,
4015
+ embedSearchQuery,
4016
+ emptyChangeMetrics,
2148
4017
  ensureDir,
2149
4018
  ensureMcpConfig,
2150
4019
  errorMessage,
4020
+ executeSearch,
2151
4021
  explainMatch,
4022
+ extractiveSummary,
2152
4023
  fallbackAdapter,
2153
4024
  fallbackIncompleteStageId,
4025
+ fallbackWarning,
4026
+ fastembedImportCount,
2154
4027
  findFeaturesBacklogPath,
2155
4028
  firstActiveStageId,
4029
+ formatDuration,
4030
+ formatEpicGraph,
2156
4031
  formatJsonSummary,
2157
4032
  formatMcpContextForHandoff,
2158
4033
  formatStatus,
4034
+ ftsTokens,
4035
+ fuseByRank,
4036
+ fuseHits,
2159
4037
  gatherMcpContext,
2160
4038
  gatherMcpContextForChange,
2161
4039
  generateCodeContext,
4040
+ generatedState,
2162
4041
  getActiveChangeId,
4042
+ getHeadSha,
2163
4043
  getIntegration,
2164
4044
  getStage,
4045
+ guidancePaths,
4046
+ handoffRole,
4047
+ hideRole,
4048
+ importSessionMetrics,
2165
4049
  incompletePriorStageErrors,
4050
+ indexDbPath,
2166
4051
  inferComplexity,
2167
4052
  initProject,
2168
4053
  installAgentIntegration,
@@ -2170,67 +4055,121 @@ export {
2170
4055
  integrationKeyFor,
2171
4056
  interpolate,
2172
4057
  interpolateArgs,
4058
+ isHardDenied,
2173
4059
  isIgnoredPath,
2174
4060
  isInitialized,
4061
+ isPrunedDir,
2175
4062
  isSddError,
2176
4063
  isSecretPath,
2177
4064
  isStageSkipped,
2178
4065
  isSubstantiveArtifactContent,
4066
+ keywordHits,
2179
4067
  listBacklogFeatures,
2180
4068
  listChanges,
2181
4069
  listDirs,
2182
4070
  listFiles,
2183
4071
  listMatchingSourceIds,
2184
4072
  listMcpSourceTools,
4073
+ listScopeHits,
2185
4074
  listWorkflowNames,
2186
4075
  loadChangeMeta,
2187
4076
  loadConfig,
4077
+ loadEpicStories,
4078
+ loadExcluder,
4079
+ loadGeneratedManifest,
2188
4080
  loadInstalledAgent,
2189
4081
  loadMcpConfig,
4082
+ loadPrices,
4083
+ loadSearchConfig,
2190
4084
  loadWorkflow,
4085
+ markGenerated,
2191
4086
  matchMcpSources,
2192
4087
  mcpConfigPath,
2193
4088
  mcpTimeoutMs,
2194
4089
  memoryDir,
4090
+ memoryPathRel,
2195
4091
  mergeCaps,
4092
+ mergeConfigDefaults,
4093
+ mergeRankedHits,
2196
4094
  nextStageId,
2197
4095
  nowIso,
2198
4096
  optionForTarget,
4097
+ packHits,
2199
4098
  parseAgentTargets,
2200
4099
  parseFeaturesBacklog,
2201
4100
  parseIntegration,
4101
+ parseStoryDraft,
2202
4102
  pathExists,
4103
+ phaseTiming,
4104
+ pinRole,
4105
+ planAgentIntegration,
4106
+ planNetwork,
2203
4107
  promoteGreenfieldToMemory,
4108
+ rankScope,
4109
+ readChangeMetrics,
4110
+ readHandoffCodePoints,
2204
4111
  readText,
2205
4112
  readYaml,
4113
+ rebuildNetwork,
2206
4114
  recommendWorkflow,
4115
+ reconcileGeneratedTree,
2207
4116
  refreshActiveAgentContext,
4117
+ refreshConfiguredGuidance,
4118
+ registerSearchProvider,
2208
4119
  removeMcpSource,
2209
4120
  removeOtherAgentHosts,
4121
+ removeOtherAgentHostsDetailed,
2210
4122
  removePath,
4123
+ renderAgentsMd,
4124
+ renderBlock,
4125
+ renderChangeReport,
4126
+ renderMemoryIndex,
4127
+ renderProtocolMd,
4128
+ renderSddReadme,
2211
4129
  renderThinAgent,
4130
+ repairRequiredIgnores,
4131
+ reportAllChanges,
4132
+ reportChange,
4133
+ resolveActiveDirectory,
2212
4134
  resolveChangeId,
4135
+ resolveEmbedder,
2213
4136
  resolveFocus,
4137
+ resolveSearchProvider,
2214
4138
  resolveStages,
4139
+ rrf,
2215
4140
  runLocalVerify,
4141
+ runNetwork,
4142
+ runRole,
2216
4143
  saveChangeMeta,
2217
4144
  saveConfig,
4145
+ saveGeneratedManifest,
2218
4146
  saveMcpConfig,
4147
+ scopeForPath,
2219
4148
  sddRoot,
4149
+ searchConfigPath,
4150
+ searchPreamble,
2220
4151
  setActiveChange,
4152
+ setRoleMessaging,
2221
4153
  shouldAutoSkip,
4154
+ showRoleGroup,
4155
+ showRoles,
2222
4156
  skipStage,
2223
4157
  slugify,
2224
4158
  startFeatureFromBacklog,
2225
4159
  startGreenfield,
4160
+ startPhase,
4161
+ storyIdTaken,
2226
4162
  switchWorkflow,
4163
+ syncChangeHead,
2227
4164
  templatesDir,
2228
4165
  toProcessEnv,
2229
4166
  todayIsoDate,
2230
4167
  tokenizeKeywords,
4168
+ transitionPhase,
2231
4169
  typescriptAdapter,
2232
4170
  uniqueChangeDirName,
2233
4171
  updateFeatureStatus,
4172
+ validBranchName,
2234
4173
  withMcpSource,
2235
4174
  workflowsDir,
2236
4175
  writeAgentHandoff,