@structured-vibe-coding/core 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/defaults/prices.yaml +9 -0
  2. package/defaults/search.yaml +18 -0
  3. package/defaults/teams/research/roles/researcher.md +29 -0
  4. package/defaults/teams/research/team.yaml +7 -0
  5. package/defaults/teams/software/roles/architect.md +31 -0
  6. package/defaults/teams/software/roles/dev-lead.md +32 -0
  7. package/defaults/teams/software/roles/developer.md +30 -0
  8. package/defaults/teams/software/roles/product-manager.md +32 -0
  9. package/defaults/teams/software/roles/product-owner.md +30 -0
  10. package/defaults/teams/software/roles/qa.md +35 -0
  11. package/defaults/teams/software/routing.yaml +28 -0
  12. package/defaults/teams/software/team.yaml +24 -0
  13. package/defaults/teams/solo/roles/developer.md +28 -0
  14. package/defaults/teams/solo/team.yaml +7 -0
  15. package/defaults/templates/plan.md +17 -0
  16. package/defaults/templates/review.md +11 -0
  17. package/defaults/templates/story.md +13 -0
  18. package/defaults/templates/task.md +11 -0
  19. package/defaults/workflows/team-software.yaml +153 -0
  20. package/dist/{agents-RWYBLZQE.js → agents-PSIOQEYH.js} +18 -7
  21. package/dist/{change-context-W4HDO2IK.js → change-context-EZJMSGSP.js} +2 -4
  22. package/dist/chunk-26YG6A4M.js +1210 -0
  23. package/dist/chunk-2FJBFYVJ.js +1576 -0
  24. package/dist/{chunk-KYWETNFU.js → chunk-4BRO7BOJ.js} +2 -4
  25. package/dist/{chunk-74OGSVJZ.js → chunk-H55RFAEI.js} +2 -2
  26. package/dist/chunk-HG7TPW5V.js +567 -0
  27. package/dist/{chunk-EPFURATC.js → chunk-O5SP6X5R.js} +89 -0
  28. package/dist/chunk-TGRV6WGS.js +3436 -0
  29. package/dist/{gather-BZDW4EN4.js → gather-7TSVKQO2.js} +3 -4
  30. package/dist/{greenfield-Z425JOMP.js → greenfield-QFA5WVHE.js} +5 -6
  31. package/dist/index.d.ts +1832 -44
  32. package/dist/index.js +5172 -992
  33. package/dist/{sources-PF45EUH2.js → sources-3TXUPU6V.js} +2 -3
  34. package/package.json +11 -3
  35. package/vendor/tree-sitter/NOTICE +29 -0
  36. package/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  37. package/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  38. package/vendor/tree-sitter/tree-sitter-rust.wasm +0 -0
  39. package/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  40. package/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  41. package/dist/chunk-67AUAGNV.js +0 -150
  42. package/dist/chunk-7JLDL2OK.js +0 -771
  43. package/dist/chunk-7MJYPJMR.js +0 -51
  44. package/dist/chunk-A752UVPF.js +0 -317
  45. package/dist/chunk-P3N67UEZ.js +0 -538
  46. package/dist/chunk-R5WQN4HA.js +0 -139
  47. package/dist/chunk-WHDG6OTG.js +0 -46
  48. package/dist/config-X5VDBVR4.js +0 -16
  49. package/dist/fs-J6DIV6CX.js +0 -28
  50. package/dist/paths-64GA32IZ.js +0 -28
@@ -0,0 +1,1576 @@
1
+ import {
2
+ WORK_TYPES,
3
+ getHeadSha,
4
+ gitUserName,
5
+ loadConfig,
6
+ readTeamConfig
7
+ } from "./chunk-TGRV6WGS.js";
8
+ import {
9
+ ChangeMetaSchema,
10
+ activeStages,
11
+ buildContext,
12
+ changeDirName,
13
+ clearActiveIf,
14
+ firstActiveStageId,
15
+ getActiveChangeId,
16
+ getStage,
17
+ isStageSkipped,
18
+ loadWorkflow,
19
+ nextStageId,
20
+ nowIso,
21
+ saveChangeMeta,
22
+ setActiveChange,
23
+ shouldAutoSkip,
24
+ uniqueChangeDirName
25
+ } from "./chunk-HG7TPW5V.js";
26
+ import {
27
+ archiveDir,
28
+ changeMetaPath,
29
+ changePath,
30
+ changesDir,
31
+ copyDir,
32
+ displayArchivePath,
33
+ ensureDir,
34
+ listDirs,
35
+ listFiles,
36
+ memoryDir,
37
+ pathExists,
38
+ readText,
39
+ readYaml,
40
+ removePath,
41
+ templatesDir,
42
+ writeText,
43
+ writeYaml
44
+ } from "./chunk-O5SP6X5R.js";
45
+
46
+ // src/greenfield.ts
47
+ import { join as join6 } from "pathe";
48
+
49
+ // src/artifacts.ts
50
+ var PLACEHOLDER_WORD = /^(?:TBD|TODO|N\/A|NA|none|tbd|todo|n\/a|\.{3}|…|-|—|–|_|\*)$/i;
51
+ function isPlaceholderCell(cell) {
52
+ const text = cell.trim();
53
+ if (!text) return true;
54
+ if (/^\d+$/.test(text)) return true;
55
+ if (PLACEHOLDER_WORD.test(text)) return true;
56
+ if (/^[-–—_.:]+$/.test(text)) return true;
57
+ return false;
58
+ }
59
+ function isTableRow(line) {
60
+ const trimmed = line.trim();
61
+ return trimmed.includes("|") && /^\|.*\|$/.test(trimmed);
62
+ }
63
+ function isSeparatorRow(line) {
64
+ if (!isTableRow(line)) return false;
65
+ const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
66
+ return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
67
+ }
68
+ function tableRowToText(line) {
69
+ const cells = line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
70
+ return cells.filter((cell) => !isPlaceholderCell(cell)).join(" ");
71
+ }
72
+ function isInstructionalLine(line) {
73
+ const text = line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").replace(/^\[[ xX]\]\s*/, "").replace(/\*\*/g, "").trim();
74
+ if (!text) return true;
75
+ if (PLACEHOLDER_WORD.test(text)) return true;
76
+ if (/^[-–—_.*\s]+$/.test(text)) return true;
77
+ if (/^\{\{[^}]+\}\}$/.test(text)) return true;
78
+ if (/^<[^>\n]+>$/.test(text)) return true;
79
+ if (/^<!--/.test(text)) return true;
80
+ if (/^[^:]{1,48}:\s*$/.test(text)) return true;
81
+ if (/^(?:Q|A):\s*$/i.test(text)) return true;
82
+ if (/^(?:Approve(?: with conditions)?|Reject|Promote to feature change|Abandon|Needs more research)$/i.test(
83
+ text
84
+ )) {
85
+ return true;
86
+ }
87
+ if (/^No blocking unknowns remain\b/i.test(text)) return true;
88
+ if (/\?\s*$/.test(text) && text.length < 140) return true;
89
+ if (/^(?:As a|I want|So that)\b/i.test(text) && /…|\.\.\./.test(text)) return true;
90
+ if (/^sdd (?:skip|backlog)\b/i.test(text)) return true;
91
+ if (/^Optional stage\b/i.test(text)) return true;
92
+ if (/^skip if nothing is ambiguous\b/i.test(text)) return true;
93
+ if (/^Copy the block below\b/i.test(text)) return true;
94
+ if (/^Keep Status\b/i.test(text)) return true;
95
+ if (/^Each feature should fit\b/i.test(text)) return true;
96
+ if (/^Greenfield —/i.test(text)) return true;
97
+ if (/^MVP only\b/i.test(text)) return true;
98
+ if (/^US-\d+:/i.test(text) && /…|\.\.\./.test(text)) return true;
99
+ if (/^F-\d+:\s*</i.test(text)) return true;
100
+ if (/^(?:Status|Priority|Workflow|Summary|Requirements|Notes):\s*$/i.test(text)) return true;
101
+ if (/^(?:Status|Priority|Workflow):\s*(?:planned|must|should|could|feature|hotfix|patch|spike)\s*$/i.test(
102
+ text
103
+ )) {
104
+ return true;
105
+ }
106
+ if (/^Summary:\s*One or two sentences\b/i.test(text)) return true;
107
+ if (/^Requirements:\s*R-\d+\s*$/i.test(text)) return true;
108
+ if (/^One or two sentences\b/i.test(text)) return true;
109
+ if (/^R-\d+$/i.test(text)) return true;
110
+ if (/^(?:planned|must|should|feature)$/i.test(text)) return true;
111
+ if (/^How to write a feature$/i.test(text)) return true;
112
+ if (/^paste local commands$/i.test(text)) return true;
113
+ return false;
114
+ }
115
+ function isSubstantiveArtifactContent(content) {
116
+ const withoutComments = content.replace(/<!--[\s\S]*?-->/g, "");
117
+ const rawLines = withoutComments.split(/\r?\n/);
118
+ const lines = [];
119
+ for (let i = 0; i < rawLines.length; i++) {
120
+ const line = rawLines[i]?.replace(/`+/g, "") ?? "";
121
+ const trimmed = line.trim();
122
+ if (!trimmed) continue;
123
+ if (/^#{1,6}\s+/.test(trimmed)) continue;
124
+ if (/^>\s/.test(trimmed)) continue;
125
+ if (isTableRow(trimmed)) {
126
+ const next = rawLines[i + 1]?.replace(/`+/g, "").trim() ?? "";
127
+ if (isSeparatorRow(trimmed) || isSeparatorRow(next)) continue;
128
+ const kept = tableRowToText(trimmed);
129
+ if (kept) lines.push(kept);
130
+ continue;
131
+ }
132
+ if (isInstructionalLine(line)) continue;
133
+ lines.push(line);
134
+ }
135
+ const stripped = lines.join("\n").trim();
136
+ if (stripped.length < 48) return false;
137
+ const meaningful = stripped.replace(/[-–—_.\s|#*`]/g, "").replace(/\{\{[^}]*\}\}/g, "").replace(/\b(TBD|TODO|N\/A|none|n\/a)\b/gi, "");
138
+ return meaningful.length >= 32;
139
+ }
140
+ function incompleteArtifactReason(path, content) {
141
+ if (content == null) {
142
+ return `Missing required artifact: ${path}`;
143
+ }
144
+ if (!isSubstantiveArtifactContent(content)) {
145
+ return [
146
+ `Required artifact still incomplete (looks like empty template/stub): ${path}`,
147
+ ``,
148
+ `Open that file and replace the template with a few real sentences, for example:`,
149
+ ` - What is broken or missing?`,
150
+ ` - What will you change?`,
151
+ ` - How will you know it worked?`,
152
+ ``,
153
+ `Then run: sdd next`,
154
+ `Optional stages only: sdd skip <stage> -r "not needed"`
155
+ ].join("\n");
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // src/change.ts
161
+ import { join as join5 } from "pathe";
162
+
163
+ // src/metrics.ts
164
+ import { createReadStream } from "fs";
165
+ import { stat } from "fs/promises";
166
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
167
+ import { createInterface } from "readline";
168
+ import { z } from "zod";
169
+ var UtcTimestampSchema = z.string().datetime();
170
+ var PhaseMetricSchema = z.object({
171
+ phase: z.string().min(1),
172
+ startedAt: UtcTimestampSchema,
173
+ endedAt: UtcTimestampSchema.optional()
174
+ }).refine((phase) => !phase.endedAt || Date.parse(phase.endedAt) >= Date.parse(phase.startedAt));
175
+ var TokenUsageMetricSchema = z.object({
176
+ provider: z.enum(["claude-code", "codex", "grok"]),
177
+ model: z.string().min(1).nullable().optional(),
178
+ requestId: z.string().min(1),
179
+ phase: z.string().min(1),
180
+ startedAt: UtcTimestampSchema,
181
+ inputTokens: z.number().int().nonnegative().nullable(),
182
+ cacheReadTokens: z.number().int().nonnegative().nullable(),
183
+ cacheWriteTokens: z.number().int().nonnegative().nullable(),
184
+ outputTokens: z.number().int().nonnegative().nullable(),
185
+ measured: z.literal(true)
186
+ });
187
+ var DecisionMetricSchema = z.object({
188
+ workType: z.enum(WORK_TYPES),
189
+ kind: z.enum(["reason", "decide"]),
190
+ provider: z.enum(["command", "ollama"]),
191
+ model: z.string().min(1).nullable(),
192
+ startedAt: UtcTimestampSchema,
193
+ inputTokens: z.number().int().nonnegative().nullable(),
194
+ cacheReadTokens: z.number().int().nonnegative().nullable(),
195
+ cacheWriteTokens: z.number().int().nonnegative().nullable(),
196
+ outputTokens: z.number().int().nonnegative().nullable()
197
+ });
198
+ var ChangeMetricsSchema = z.object({
199
+ version: z.literal(1),
200
+ phases: z.array(PhaseMetricSchema),
201
+ requests: z.array(TokenUsageMetricSchema),
202
+ decisions: z.array(DecisionMetricSchema).optional()
203
+ });
204
+ var MODEL_ID = /^[A-Za-z0-9._:/-]+$/;
205
+ function metricsPath(projectRoot, config, changeId) {
206
+ return `${changePath(projectRoot, config, changeId)}/metrics.json`;
207
+ }
208
+ function emptyChangeMetrics() {
209
+ return { version: 1, phases: [], requests: [] };
210
+ }
211
+ async function readChangeMetrics(projectRoot, config, changeId) {
212
+ const path = metricsPath(projectRoot, config, changeId);
213
+ if (!await pathExists(path)) return emptyChangeMetrics();
214
+ return ChangeMetricsSchema.parse(JSON.parse(await readText(path)));
215
+ }
216
+ async function writeChangeMetrics(projectRoot, config, changeId, metrics) {
217
+ const path = metricsPath(projectRoot, config, changeId);
218
+ const validated = ChangeMetricsSchema.parse(metrics);
219
+ await writeText(path, `${JSON.stringify(validated, null, 2)}
220
+ `);
221
+ }
222
+ async function recordDecisionMetric(projectRoot, config, changeId, draft) {
223
+ if (!config.metrics.enabled) return;
224
+ const metrics = await readChangeMetrics(projectRoot, config, changeId);
225
+ const decisions = [...metrics.decisions ?? [], DecisionMetricSchema.parse(draft)];
226
+ await writeChangeMetrics(projectRoot, config, changeId, { ...metrics, decisions });
227
+ }
228
+ async function startPhase(projectRoot, config, changeId, phase, startedAt = (/* @__PURE__ */ new Date()).toISOString()) {
229
+ if (!config.metrics.enabled) return;
230
+ const metrics = await readChangeMetrics(projectRoot, config, changeId);
231
+ const current = metrics.phases.at(-1);
232
+ if (current && !current.endedAt) {
233
+ throw new Error(`Cannot start phase "${phase}" while "${current.phase}" is still open.`);
234
+ }
235
+ metrics.phases.push({ phase, startedAt });
236
+ await writeChangeMetrics(projectRoot, config, changeId, metrics);
237
+ }
238
+ async function transitionPhase(projectRoot, config, changeId, fromPhase, toPhase, at = (/* @__PURE__ */ new Date()).toISOString(), fromStartedAt = at) {
239
+ if (!config.metrics.enabled || fromPhase === toPhase) return;
240
+ const metrics = await readChangeMetrics(projectRoot, config, changeId);
241
+ const current = metrics.phases.at(-1);
242
+ if (current && !current.endedAt) {
243
+ if (current.phase !== fromPhase) {
244
+ if (current.phase === toPhase) return;
245
+ throw new Error(
246
+ `Cannot leave phase "${fromPhase}" while metrics phase "${current.phase}" is open.`
247
+ );
248
+ }
249
+ current.endedAt = at;
250
+ } else {
251
+ metrics.phases.push({ phase: fromPhase, startedAt: fromStartedAt, endedAt: at });
252
+ }
253
+ if (toPhase) metrics.phases.push({ phase: toPhase, startedAt: at });
254
+ await writeChangeMetrics(projectRoot, config, changeId, metrics);
255
+ }
256
+ function asObject(value) {
257
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
258
+ }
259
+ function tokenCount(value) {
260
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
261
+ }
262
+ function isoTimestamp(value) {
263
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) return null;
264
+ return new Date(value).toISOString();
265
+ }
266
+ function stringId(...values) {
267
+ const value = values.find((item) => typeof item === "string" && item.trim().length > 0);
268
+ return typeof value === "string" ? value : null;
269
+ }
270
+ function parseClaudeUsage(record) {
271
+ const message = asObject(record.message);
272
+ const usage = asObject(message?.usage);
273
+ const requestId = stringId(record.requestId, message?.id);
274
+ const startedAt = isoTimestamp(record.timestamp ?? message?.timestamp);
275
+ if (!usage || !requestId || !startedAt) return null;
276
+ return {
277
+ provider: "claude-code",
278
+ requestId,
279
+ startedAt,
280
+ inputTokens: tokenCount(usage.input_tokens),
281
+ cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
282
+ cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
283
+ outputTokens: tokenCount(usage.output_tokens)
284
+ };
285
+ }
286
+ function parseCodexUsage(record, state) {
287
+ const payload = asObject(record.payload);
288
+ const payloadType = payload?.type;
289
+ const timestamp = isoTimestamp(record.timestamp);
290
+ if (record.type === "event_msg" && (payloadType === "task_started" || payloadType === "turn_started")) {
291
+ if (timestamp) state.turnStartedAt = timestamp;
292
+ state.messageId = void 0;
293
+ return null;
294
+ }
295
+ if (record.type === "response_item" && payloadType === "message" && payload?.role === "assistant") {
296
+ state.messageId = stringId(payload.id, record.id) ?? void 0;
297
+ return null;
298
+ }
299
+ if (record.type !== "event_msg" || payloadType !== "token_count") return null;
300
+ const info = asObject(payload?.info);
301
+ const usage = asObject(info?.last_token_usage);
302
+ const totalInput = tokenCount(usage?.input_tokens);
303
+ const cachedInput = tokenCount(usage?.cached_input_tokens);
304
+ const requestId = stringId(payload?.request_id, info?.request_id, state.messageId);
305
+ const startedAt = state.turnStartedAt;
306
+ if (!usage || !requestId || !startedAt) return null;
307
+ state.turnStartedAt = void 0;
308
+ state.messageId = void 0;
309
+ return {
310
+ provider: "codex",
311
+ requestId,
312
+ startedAt,
313
+ inputTokens: totalInput === null ? null : Math.max(0, totalInput - (cachedInput ?? 0)),
314
+ cacheReadTokens: cachedInput,
315
+ cacheWriteTokens: null,
316
+ outputTokens: tokenCount(usage.output_tokens)
317
+ };
318
+ }
319
+ function phaseAt(metrics, timestamp) {
320
+ const requestTime = Date.parse(timestamp);
321
+ return metrics.phases.find((phase) => {
322
+ const start = Date.parse(phase.startedAt);
323
+ const end = phase.endedAt ? Date.parse(phase.endedAt) : Number.POSITIVE_INFINITY;
324
+ return requestTime >= start && requestTime < end;
325
+ });
326
+ }
327
+ function toForwardSlashes(value) {
328
+ let out = "";
329
+ for (const ch of value) out += ch === "\\" ? "/" : ch;
330
+ return out;
331
+ }
332
+ function stripTrailingSlashes(value) {
333
+ let end = value.length;
334
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
335
+ return value.slice(0, end);
336
+ }
337
+ function stripLeadingDotSlash(value) {
338
+ let out = value;
339
+ while (out.startsWith("./")) out = out.slice(2);
340
+ return out;
341
+ }
342
+ function prefixLooksAbsolute(prefix) {
343
+ if (prefix.startsWith("/") || prefix.startsWith("\\")) return true;
344
+ const first = prefix.charCodeAt(0);
345
+ const letter = first >= 65 && first <= 90 || first >= 97 && first <= 122;
346
+ return letter && prefix.charCodeAt(1) === 58;
347
+ }
348
+ function normalizeExcludePrefix(prefix) {
349
+ if (isAbsolute(prefix) || prefixLooksAbsolute(prefix)) {
350
+ throw new Error("metrics.exclude prefix is invalid.");
351
+ }
352
+ const slashed = stripLeadingDotSlash(toForwardSlashes(prefix));
353
+ if (prefixLooksAbsolute(slashed)) throw new Error("metrics.exclude prefix is invalid.");
354
+ const trimmed = stripTrailingSlashes(slashed);
355
+ if (!trimmed) throw new Error("metrics.exclude prefix is invalid.");
356
+ for (const segment of trimmed.split("/")) {
357
+ if (segment === "" || segment === "." || segment === "..") {
358
+ throw new Error("metrics.exclude prefix is invalid.");
359
+ }
360
+ }
361
+ return trimmed;
362
+ }
363
+ function assertSessionLogAllowed(projectRoot, prefixes, sessionLogPath) {
364
+ const normalized = prefixes.map(normalizeExcludePrefix);
365
+ const root = resolve(projectRoot);
366
+ const absLog = resolve(root, sessionLogPath);
367
+ const rel = relative(root, absLog);
368
+ if (rel.startsWith("..") || isAbsolute(rel)) return;
369
+ const relPosix = rel.split(sep).join("/");
370
+ for (const prefix of normalized) {
371
+ if (relPosix === prefix || relPosix.startsWith(`${prefix}/`)) {
372
+ throw new Error(`Refusing to import a session log under excluded prefix "${prefix}".`);
373
+ }
374
+ }
375
+ }
376
+ function recordParsed(metrics, requestIds, result, parsed) {
377
+ const phase = phaseAt(metrics, parsed.startedAt);
378
+ if (!phase) {
379
+ result.unmatched += 1;
380
+ return;
381
+ }
382
+ const key = `${parsed.provider}\0${parsed.requestId}`;
383
+ if (requestIds.has(key)) {
384
+ result.duplicates += 1;
385
+ return;
386
+ }
387
+ requestIds.add(key);
388
+ const request = {
389
+ provider: parsed.provider,
390
+ requestId: parsed.requestId,
391
+ phase: phase.phase,
392
+ startedAt: parsed.startedAt,
393
+ inputTokens: parsed.inputTokens,
394
+ cacheReadTokens: parsed.cacheReadTokens,
395
+ cacheWriteTokens: parsed.cacheWriteTokens,
396
+ outputTokens: parsed.outputTokens,
397
+ measured: true
398
+ };
399
+ if (parsed.model) request.model = parsed.model;
400
+ metrics.requests.push(request);
401
+ result.imported += 1;
402
+ }
403
+ async function readTurnStarts(directory) {
404
+ const starts = /* @__PURE__ */ new Map();
405
+ const path = join(directory, "events.jsonl");
406
+ if (!await pathExists(path)) return starts;
407
+ const lines = createInterface({
408
+ input: createReadStream(path),
409
+ crlfDelay: Number.POSITIVE_INFINITY
410
+ });
411
+ for await (const line of lines) {
412
+ if (!line.trim()) continue;
413
+ let record = null;
414
+ try {
415
+ record = asObject(JSON.parse(line));
416
+ } catch {
417
+ continue;
418
+ }
419
+ if (!record || record.type !== "turn_started") continue;
420
+ const turnNumber = tokenCount(record.turn_number);
421
+ const timestamp = isoTimestamp(record.ts);
422
+ if (turnNumber === null || turnNumber < 1 || !timestamp || starts.has(turnNumber)) continue;
423
+ starts.set(turnNumber, timestamp);
424
+ }
425
+ return starts;
426
+ }
427
+ function grokBucket(value) {
428
+ const bucket = asObject(value);
429
+ if (!bucket) return null;
430
+ const input = tokenCount(bucket.inputTokens);
431
+ const cacheRead = tokenCount(bucket.cachedReadTokens);
432
+ const cacheWrite = tokenCount(bucket.cacheCreationTokens);
433
+ const output = tokenCount(bucket.outputTokens);
434
+ if (input === null || cacheRead === null || cacheWrite === null || output === null) return null;
435
+ return { input, cacheRead, cacheWrite, output };
436
+ }
437
+ async function importGrokUsage(filePath, metrics, requestIds, providers, result) {
438
+ let parsed;
439
+ try {
440
+ parsed = JSON.parse(await readText(filePath));
441
+ } catch {
442
+ throw new Error("usage.json is not a Grok usage file.");
443
+ }
444
+ const document = asObject(parsed);
445
+ const sessionId = document?.sessionId;
446
+ const turns = document?.turns;
447
+ if (!document || typeof sessionId !== "string" || sessionId.length === 0 || !Array.isArray(turns)) {
448
+ throw new Error("usage.json is not a Grok usage file.");
449
+ }
450
+ providers.add("grok");
451
+ const session = asObject(document.session);
452
+ if (session?.usageIsIncomplete === true) result.incomplete = true;
453
+ const turnStarts = await readTurnStarts(dirname(filePath));
454
+ for (const turnValue of turns) {
455
+ const turn = asObject(turnValue);
456
+ if (!turn) {
457
+ result.ignored += 1;
458
+ continue;
459
+ }
460
+ if (turn.usageIsIncomplete === true) result.incomplete = true;
461
+ const turnNumber = tokenCount(turn.turnNumber);
462
+ const modelUsage = asObject(turn.modelUsage);
463
+ const entries = modelUsage ? Object.entries(modelUsage) : [];
464
+ if (turnNumber === null || turnNumber < 1) {
465
+ result.ignored += 1;
466
+ continue;
467
+ }
468
+ const startedAt = turnStarts.get(turnNumber) ?? isoTimestamp(turn.endedAt);
469
+ if (!startedAt) {
470
+ result.ignored += entries.length;
471
+ continue;
472
+ }
473
+ for (const [model, bucketValue] of entries) {
474
+ if (!MODEL_ID.test(model)) {
475
+ result.ignored += 1;
476
+ continue;
477
+ }
478
+ const bucket = grokBucket(bucketValue);
479
+ if (!bucket) {
480
+ result.ignored += 1;
481
+ continue;
482
+ }
483
+ recordParsed(metrics, requestIds, result, {
484
+ provider: "grok",
485
+ model,
486
+ requestId: `${sessionId}:${turnNumber}:${model}`,
487
+ startedAt,
488
+ inputTokens: Math.max(0, bucket.input - bucket.cacheRead - bucket.cacheWrite),
489
+ cacheReadTokens: bucket.cacheRead,
490
+ cacheWriteTokens: bucket.cacheWrite,
491
+ outputTokens: bucket.output
492
+ });
493
+ }
494
+ }
495
+ }
496
+ async function importJsonlUsage(sessionLogPath, metrics, requestIds, providers, result) {
497
+ const codexState = {};
498
+ const lines = createInterface({
499
+ input: createReadStream(sessionLogPath),
500
+ crlfDelay: Number.POSITIVE_INFINITY
501
+ });
502
+ for await (const line of lines) {
503
+ if (!line.trim()) continue;
504
+ let record = null;
505
+ try {
506
+ record = asObject(JSON.parse(line));
507
+ } catch {
508
+ result.ignored += 1;
509
+ continue;
510
+ }
511
+ if (!record) {
512
+ result.ignored += 1;
513
+ continue;
514
+ }
515
+ const message = asObject(record.message);
516
+ const claudeCandidate = record.type === "assistant" && Boolean(asObject(message?.usage));
517
+ const codexCandidate = record.type === "event_msg" && asObject(record.payload)?.type === "token_count";
518
+ const parsed = claudeCandidate ? parseClaudeUsage(record) : parseCodexUsage(record, codexState);
519
+ if (claudeCandidate) providers.add("claude-code");
520
+ else if (codexCandidate) providers.add("codex");
521
+ if (!parsed) {
522
+ if (claudeCandidate || codexCandidate) result.ignored += 1;
523
+ continue;
524
+ }
525
+ recordParsed(metrics, requestIds, result, parsed);
526
+ }
527
+ }
528
+ async function grokUsagePath(sessionLogPath) {
529
+ const info = await stat(sessionLogPath);
530
+ if (info.isDirectory()) {
531
+ const filePath = join(sessionLogPath, "usage.json");
532
+ if (!await pathExists(filePath)) throw new Error("Grok usage.json is missing.");
533
+ return filePath;
534
+ }
535
+ return basename(sessionLogPath) === "usage.json" ? sessionLogPath : null;
536
+ }
537
+ async function importSessionMetrics(projectRoot, config, changeId, sessionLogPath) {
538
+ if (!config.metrics.enabled) throw new Error("Metrics are disabled in .sdd/config.yaml.");
539
+ assertSessionLogAllowed(projectRoot, config.metrics.exclude, sessionLogPath);
540
+ const metrics = await readChangeMetrics(projectRoot, config, changeId);
541
+ const requestIds = new Set(
542
+ metrics.requests.map((request) => `${request.provider}\0${request.requestId}`)
543
+ );
544
+ const providers = /* @__PURE__ */ new Set();
545
+ const result = {
546
+ imported: 0,
547
+ duplicates: 0,
548
+ ignored: 0,
549
+ unmatched: 0,
550
+ providers: [],
551
+ incomplete: false
552
+ };
553
+ const grokPath = await grokUsagePath(sessionLogPath);
554
+ if (grokPath) await importGrokUsage(grokPath, metrics, requestIds, providers, result);
555
+ else await importJsonlUsage(sessionLogPath, metrics, requestIds, providers, result);
556
+ result.providers = [...providers].sort();
557
+ if (result.imported) await writeChangeMetrics(projectRoot, config, changeId, metrics);
558
+ return result;
559
+ }
560
+
561
+ // src/stage-gates.ts
562
+ import { join as join3 } from "pathe";
563
+
564
+ // src/role/signoff.ts
565
+ import { appendFile } from "fs/promises";
566
+ import { join as join2 } from "pathe";
567
+ function ok(stdout) {
568
+ return { exitCode: 0, stdout };
569
+ }
570
+ function fail(stdout) {
571
+ return { exitCode: 1, stdout };
572
+ }
573
+ function oneLine(note, empty, broken) {
574
+ const raw = note ?? "";
575
+ if (raw.includes("\n") || raw.includes("\r")) return fail(broken);
576
+ const words = raw.trim();
577
+ if (!words) return fail(empty);
578
+ return words;
579
+ }
580
+ function safeStageId(stage) {
581
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(stage);
582
+ }
583
+ function signoffFileName(stage, sha) {
584
+ return `reviews/${stage}-${sha}.md`;
585
+ }
586
+ function renderSignoff(record) {
587
+ return [
588
+ "---",
589
+ `kind: ${record.kind}`,
590
+ `sha: ${record.sha}`,
591
+ `reviewer: ${record.reviewer}`,
592
+ `verdict: ${record.verdict}`,
593
+ "---",
594
+ record.evidence,
595
+ ""
596
+ ].join("\n");
597
+ }
598
+ function parseSignoff(raw) {
599
+ const lines = raw.split("\n");
600
+ if (lines[0] !== "---") return null;
601
+ const end = lines.indexOf("---", 1);
602
+ if (end <= 1) return null;
603
+ const fields = /* @__PURE__ */ new Map();
604
+ for (const line of lines.slice(1, end)) {
605
+ const split = line.indexOf(":");
606
+ if (split <= 0) return null;
607
+ fields.set(line.slice(0, split).trim(), line.slice(split + 1).trim());
608
+ }
609
+ const kind = fields.get("kind") ?? "";
610
+ const sha = fields.get("sha") ?? "";
611
+ const reviewer = fields.get("reviewer") ?? "";
612
+ const verdict = fields.get("verdict") ?? "";
613
+ const evidence = lines.slice(end + 1).join("\n").trim();
614
+ if (!kind || !sha || !reviewer || !verdict || !evidence) return null;
615
+ return { kind, sha, reviewer, verdict, evidence };
616
+ }
617
+ async function headSha(root) {
618
+ try {
619
+ const sha = await getHeadSha(root);
620
+ if (!sha) return fail("Cannot sign off: Git HEAD is unavailable in this project.");
621
+ return sha;
622
+ } catch (error) {
623
+ const message = error instanceof Error ? error.message : "Git HEAD is unavailable.";
624
+ return fail(message);
625
+ }
626
+ }
627
+ async function openChange(root, change) {
628
+ const config = await loadConfig(root);
629
+ const requested = change?.trim() ?? "";
630
+ const changeId = requested || await getActiveChangeId(root, config);
631
+ if (!changeId) return fail("No active change.");
632
+ try {
633
+ const ctx = await buildContext(root, config, changeId);
634
+ return { path: ctx.path, stage: ctx.meta.stage };
635
+ } catch (error) {
636
+ const message = error instanceof Error ? error.message : "Change not found.";
637
+ return fail(message);
638
+ }
639
+ }
640
+ function isFailure(value) {
641
+ return "exitCode" in value;
642
+ }
643
+ async function recordSignoff(root, input) {
644
+ const words = oneLine(input.note, "A sign-off needs a note.", "A sign-off note is one line.");
645
+ if (typeof words !== "string") return words;
646
+ const opened = await openChange(root, input.change);
647
+ if (isFailure(opened)) return opened;
648
+ const stage = (input.stage?.trim() || opened.stage).trim();
649
+ if (!safeStageId(stage)) return fail("A stage id is not valid.");
650
+ const team = await readTeamConfig(root);
651
+ if ("error" in team) return fail(team.error);
652
+ const sha = await headSha(root);
653
+ if (typeof sha !== "string") return sha;
654
+ const gitUser = gitUserName(root);
655
+ if (!gitUser) return fail("Git user.name is not set.");
656
+ if (!team.team.humans.some((human) => human.name === gitUser)) {
657
+ return fail("Git user.name is not a human approver.");
658
+ }
659
+ const file = join2(opened.path, signoffFileName(stage, sha));
660
+ const existing = await pathExists(file) ? parseSignoff(await readText(file)) : null;
661
+ const same = existing !== null && existing.kind === stage && existing.sha === sha && existing.reviewer === gitUser && existing.verdict === "accept";
662
+ if (!same) {
663
+ await writeText(
664
+ file,
665
+ renderSignoff({ kind: stage, sha, reviewer: gitUser, verdict: "accept", evidence: words })
666
+ );
667
+ }
668
+ await appendFile(
669
+ join2(opened.path, "decisions.md"),
670
+ `${nowIso()} sign-off ${stage} ${sha} ${gitUser} accept ${words}
671
+ `
672
+ );
673
+ return ok(`Signed ${stage} at ${sha}.`);
674
+ }
675
+ async function recordDecisionRequest(root, input) {
676
+ const words = oneLine(input.note, "A decision needs a note.", "A decision note is one line.");
677
+ if (typeof words !== "string") return words;
678
+ const opened = await openChange(root, input.change);
679
+ if (isFailure(opened)) return opened;
680
+ await appendFile(join2(opened.path, "decisions.md"), `${nowIso()} decision-needed ${words}
681
+ `);
682
+ return ok("Decision needed.");
683
+ }
684
+ async function checkHumanSignoff(root, changePath2, stageId) {
685
+ if (!safeStageId(stageId)) return { ok: false, message: "A stage id is not valid." };
686
+ const team = await readTeamConfig(root);
687
+ if ("error" in team) return { ok: false, message: team.error };
688
+ let sha;
689
+ try {
690
+ sha = await getHeadSha(root);
691
+ } catch (error) {
692
+ const message = error instanceof Error ? error.message : "Git HEAD is unavailable.";
693
+ return { ok: false, message };
694
+ }
695
+ if (!sha) {
696
+ return {
697
+ ok: false,
698
+ message: `Cannot leave stage "${stageId}": Git HEAD is unavailable, so a human sign-off cannot be checked.`
699
+ };
700
+ }
701
+ const file = join2(changePath2, signoffFileName(stageId, sha));
702
+ if (!await pathExists(file)) {
703
+ return {
704
+ ok: false,
705
+ message: `Hard gate on stage "${stageId}" needs a human sign-off for this commit. Run \`sdd team signoff\`.`
706
+ };
707
+ }
708
+ const parsed = parseSignoff(await readText(file));
709
+ const listed = parsed !== null && parsed.kind === stageId && parsed.sha === sha && parsed.verdict === "accept" && team.team.humans.some((human) => human.name === parsed.reviewer);
710
+ if (!listed) {
711
+ return { ok: false, message: `Sign-off for stage "${stageId}" does not match this commit.` };
712
+ }
713
+ return { ok: true };
714
+ }
715
+
716
+ // src/stage-gates.ts
717
+ async function checkRequiredArtifacts(changePath2, stageId, artifacts) {
718
+ const errors = [];
719
+ for (const artifact of artifacts) {
720
+ if (!artifact.required) continue;
721
+ if (artifact.path.endsWith("/")) {
722
+ const target2 = join3(changePath2, artifact.path);
723
+ if (!await pathExists(target2)) {
724
+ errors.push(`Missing required directory: ${artifact.path} (stage ${stageId})`);
725
+ }
726
+ continue;
727
+ }
728
+ const target = join3(changePath2, artifact.path);
729
+ if (!await pathExists(target)) {
730
+ errors.push(incompleteArtifactReason(artifact.path, null));
731
+ continue;
732
+ }
733
+ const content = await readText(target);
734
+ const reason = incompleteArtifactReason(artifact.path, content);
735
+ if (reason) {
736
+ errors.push(`${reason} (stage ${stageId})`);
737
+ }
738
+ }
739
+ return errors;
740
+ }
741
+ async function incompletePriorStageErrors(ctx) {
742
+ const errors = [];
743
+ const stages = activeStages(ctx.workflow, ctx.meta);
744
+ const currentIdx = stages.findIndex((s) => s.id === ctx.meta.stage);
745
+ if (currentIdx <= 0) return errors;
746
+ for (let i = 0; i < currentIdx; i++) {
747
+ const stage = stages[i];
748
+ if (isStageSkipped(ctx.meta, stage.id) || shouldAutoSkip(stage, ctx.meta)) {
749
+ continue;
750
+ }
751
+ const required = stage.artifacts.filter((a) => a.required);
752
+ if (!required.length && stage.optional) continue;
753
+ const stageErrors = await checkRequiredArtifacts(ctx.path, stage.id, stage.artifacts);
754
+ if (stageErrors.length) {
755
+ errors.push(
756
+ `Prior stage "${stage.id}" is not complete \u2014 finish it or \`sdd skip ${stage.id} -r "\u2026"\` if optional.`
757
+ );
758
+ errors.push(...stageErrors);
759
+ }
760
+ }
761
+ return errors;
762
+ }
763
+ async function canLeaveStage(ctx, config, projectRoot) {
764
+ const stage = getStage(ctx.workflow, ctx.meta, ctx.meta.stage);
765
+ const errors = [];
766
+ const warnings = [];
767
+ if (!stage) {
768
+ return { ok: false, errors: [`Unknown stage: ${ctx.meta.stage}`], warnings };
769
+ }
770
+ errors.push(...await incompletePriorStageErrors(ctx));
771
+ errors.push(...await checkRequiredArtifacts(ctx.path, stage.id, stage.artifacts));
772
+ const declared = stage.gate?.type ?? "soft";
773
+ let effectiveGate = declared;
774
+ if (config.policy.gates === "hard" && declared === "soft") {
775
+ effectiveGate = "hard";
776
+ }
777
+ const gateState = ctx.meta.gates?.[stage.id];
778
+ const humanGate = effectiveGate === "hard" && (stage.gate?.approvers?.includes("human") ?? false);
779
+ if (humanGate) {
780
+ if (gateState?.status === "failed") {
781
+ errors.push(
782
+ `Hard gate on stage "${stage.id}" is not approved. Run \`sdd gate approve\` or complete the checklist.`
783
+ );
784
+ } else if (gateState?.status !== "waived") {
785
+ if (!projectRoot) {
786
+ errors.push(
787
+ `Cannot leave stage "${stage.id}": the project root is required to check a human sign-off.`
788
+ );
789
+ } else {
790
+ const signed = await checkHumanSignoff(projectRoot, ctx.path, stage.id);
791
+ if (!signed.ok) errors.push(signed.message);
792
+ }
793
+ }
794
+ } else if (effectiveGate === "hard") {
795
+ if (!gateState || gateState.status !== "approved" && gateState.status !== "waived") {
796
+ errors.push(
797
+ `Hard gate on stage "${stage.id}" is not approved. Run \`sdd gate approve\` or complete the checklist.`
798
+ );
799
+ }
800
+ } else if (effectiveGate === "soft" && stage.gate?.checklist?.length) {
801
+ if (!gateState || gateState.status === "pending") {
802
+ warnings.push(`Soft gate checklist not signed off on "${stage.id}" (proceeding allowed).`);
803
+ }
804
+ }
805
+ const requiredCmds = stage.verify?.commands?.filter((c) => c.required) ?? [];
806
+ if (requiredCmds.length) {
807
+ const lastRun = ctx.meta.verify_results?.[stage.id];
808
+ const verifyOk = requiredCmds.every(
809
+ (c) => lastRun?.results?.some((r) => r.name === c.name && r.exitCode === 0)
810
+ );
811
+ const humanOverride = gateState?.status === "waived" || gateState?.status === "approved";
812
+ if (!verifyOk && !humanOverride) {
813
+ errors.push(
814
+ `Required local verify commands have not passed for "${stage.id}". Run \`sdd verify\` (or \`sdd gate approve/waive\` with a note).`
815
+ );
816
+ } else if (!verifyOk && humanOverride) {
817
+ warnings.push(
818
+ `Stage "${stage.id}" gate signed off without a successful required verify run.`
819
+ );
820
+ }
821
+ }
822
+ if (stage.optional || stage.kind === "clarify" || stage.kind === "brainstorm") {
823
+ warnings.push(
824
+ `Stage "${stage.id}" is optional (${stage.kind ?? "optional"}). Skip with: sdd skip ${stage.id} -r "not needed"`
825
+ );
826
+ }
827
+ return { ok: errors.length === 0, errors, warnings };
828
+ }
829
+ async function fallbackIncompleteStageId(ctx) {
830
+ const stages = activeStages(ctx.workflow, ctx.meta);
831
+ for (const stage of stages) {
832
+ if (stage.id === ctx.meta.stage) break;
833
+ if (isStageSkipped(ctx.meta, stage.id) || shouldAutoSkip(stage, ctx.meta)) {
834
+ continue;
835
+ }
836
+ const required = stage.artifacts.filter((a) => a.required);
837
+ if (!required.length && stage.optional) continue;
838
+ const errs = await checkRequiredArtifacts(ctx.path, stage.id, stage.artifacts);
839
+ if (errs.length) return stage.id;
840
+ }
841
+ return null;
842
+ }
843
+ async function approveGate(projectRoot, config, changeId, stageId, note, status = "approved") {
844
+ const ctx = await buildContext(projectRoot, config, changeId);
845
+ const id = stageId ?? ctx.meta.stage;
846
+ ctx.meta.gates = {
847
+ ...ctx.meta.gates,
848
+ [id]: { status, note, at: nowIso() }
849
+ };
850
+ await saveChangeMeta(projectRoot, config, ctx.meta);
851
+ return buildContext(projectRoot, config, changeId);
852
+ }
853
+
854
+ // src/stage-items.ts
855
+ import { join as join4 } from "pathe";
856
+ import { z as z2 } from "zod";
857
+ var itemSchema = z2.object({
858
+ id: z2.string().trim().min(1),
859
+ stage: z2.string().trim().min(1),
860
+ source: z2.string().trim().min(1),
861
+ status: z2.enum(["open", "done"])
862
+ }).strict();
863
+ var itemsSchema = z2.array(itemSchema);
864
+ function itemSourcePattern(pattern) {
865
+ if (pattern.includes("..") || pattern.startsWith("/") || pattern.includes("\\")) return null;
866
+ const slash = pattern.lastIndexOf("/");
867
+ if (slash <= 0) return null;
868
+ const dir = pattern.slice(0, slash);
869
+ const file = pattern.slice(slash + 1);
870
+ if (!/^[A-Za-z0-9._-]+$/.test(dir) || dir.includes("*")) return null;
871
+ const star = file.indexOf("*");
872
+ if (star < 0 || file.indexOf("*", star + 1) !== -1) return null;
873
+ return { dir, prefix: file.slice(0, star), suffix: file.slice(star + 1) };
874
+ }
875
+ function matches(name, prefix, suffix) {
876
+ if (!name.startsWith(prefix) || !name.endsWith(suffix)) return false;
877
+ return name.length > prefix.length + suffix.length;
878
+ }
879
+ function itemId(name) {
880
+ const dot = name.lastIndexOf(".");
881
+ if (dot <= 0) return name;
882
+ return name.slice(0, dot);
883
+ }
884
+ async function syncStageItems(changeDir, stage) {
885
+ if (!stage.items_from) return [];
886
+ const pattern = itemSourcePattern(stage.items_from);
887
+ if (!pattern) throw new Error("items_from is invalid");
888
+ const names = (await listFiles(join4(changeDir, pattern.dir))).filter((name) => matches(name, pattern.prefix, pattern.suffix)).sort();
889
+ const itemsPath = join4(changeDir, "items.yaml");
890
+ let existing = [];
891
+ if (await pathExists(itemsPath)) {
892
+ let raw;
893
+ try {
894
+ raw = await readYaml(itemsPath);
895
+ } catch {
896
+ throw new Error("items.yaml is invalid");
897
+ }
898
+ const parsed = itemsSchema.safeParse(raw);
899
+ if (!parsed.success) throw new Error("items.yaml is invalid");
900
+ existing = parsed.data;
901
+ }
902
+ const others = existing.filter((item) => item.stage !== stage.id);
903
+ const mine = new Map(
904
+ existing.filter((item) => item.stage === stage.id).map((item) => [item.id, item])
905
+ );
906
+ const seen = /* @__PURE__ */ new Set();
907
+ const next = [];
908
+ for (const name of names) {
909
+ const id = itemId(name);
910
+ if (seen.has(id)) continue;
911
+ seen.add(id);
912
+ const source = `${pattern.dir}/${name}`;
913
+ const prev = mine.get(id);
914
+ if (prev) next.push({ ...prev, source });
915
+ else next.push({ id, stage: stage.id, source, status: "open" });
916
+ }
917
+ const items = [...others, ...next].sort(
918
+ (a, b) => a.stage === b.stage ? a.id.localeCompare(b.id) : a.stage.localeCompare(b.stage)
919
+ );
920
+ await writeYaml(itemsPath, items);
921
+ return next.map((item) => item.id);
922
+ }
923
+
924
+ // src/change.ts
925
+ async function existingChangeIds(projectRoot, config) {
926
+ const open = await listDirs(changesDir(projectRoot, config));
927
+ const archived = await listDirs(archiveDir(projectRoot, config));
928
+ return /* @__PURE__ */ new Set([...open, ...archived]);
929
+ }
930
+ async function createChange(input) {
931
+ const { projectRoot, config, title, workflowName } = input;
932
+ const workflow = await loadWorkflow(projectRoot, workflowName);
933
+ if (!workflow.stages.length) {
934
+ throw new Error(`Workflow ${workflowName} has no stages`);
935
+ }
936
+ const baseId = changeDirName(title);
937
+ const id = uniqueChangeDirName(baseId, await existingChangeIds(projectRoot, config));
938
+ const dir = changePath(projectRoot, config, id);
939
+ await ensureDir(dir);
940
+ let meta = ChangeMetaSchema.parse({
941
+ id,
942
+ title,
943
+ workflow: workflowName,
944
+ created: nowIso(),
945
+ updated: nowIso(),
946
+ status: "in_progress",
947
+ stage: workflow.stages[0].id,
948
+ domain: input.domain,
949
+ branch: input.branch,
950
+ epic: input.epic,
951
+ story: input.story,
952
+ depends: input.depends ?? [],
953
+ flags: input.flags ?? {},
954
+ overrides: {
955
+ skip_stages: [],
956
+ gates: {},
957
+ extra_stages: []
958
+ },
959
+ skipped: [],
960
+ gates: {}
961
+ });
962
+ const startId = firstActiveStageId(workflow, meta) ?? workflow.stages[0].id;
963
+ meta = { ...meta, stage: startId };
964
+ const firstStage = getStage(workflow, meta, startId);
965
+ if (!firstStage) {
966
+ throw new Error(`Workflow ${workflowName} has no active stages`);
967
+ }
968
+ await writeYaml(changeMetaPath(projectRoot, config, id), meta);
969
+ await startPhase(projectRoot, config, id, startId, meta.created);
970
+ await materializeStageArtifacts(projectRoot, config, id, workflow, meta, firstStage);
971
+ await setActiveChange(projectRoot, config, id);
972
+ try {
973
+ const { refreshActiveAgentContext } = await import("./agents-PSIOQEYH.js");
974
+ await refreshActiveAgentContext(projectRoot);
975
+ } catch {
976
+ }
977
+ return buildContext(projectRoot, config, id);
978
+ }
979
+ async function materializeStageArtifacts(projectRoot, config, changeId, workflow, meta, stage) {
980
+ const created = [];
981
+ const dir = changePath(projectRoot, config, changeId);
982
+ const tdir = templatesDir(projectRoot);
983
+ for (const artifact of stage.artifacts) {
984
+ const target = join5(dir, artifact.path);
985
+ if (await pathExists(target)) continue;
986
+ if (artifact.path.endsWith("/")) {
987
+ await ensureDir(target);
988
+ created.push(artifact.path);
989
+ continue;
990
+ }
991
+ let content = defaultArtifactContent(artifact.id, meta, stage, workflow);
992
+ if (artifact.template) {
993
+ const templatePath = join5(tdir, artifact.template.replace(/^templates\//, ""));
994
+ const candidates = [
995
+ join5(tdir, artifact.template),
996
+ templatePath,
997
+ join5(tdir, `${artifact.id}.md`)
998
+ ];
999
+ for (const c of candidates) {
1000
+ if (await pathExists(c)) {
1001
+ content = interpolate(await readText(c), meta, stage, workflow, {
1002
+ idea: String(meta.flags?.idea ?? meta.title ?? "")
1003
+ });
1004
+ break;
1005
+ }
1006
+ }
1007
+ } else {
1008
+ const fallback = join5(tdir, `${artifact.id}.md`);
1009
+ if (await pathExists(fallback)) {
1010
+ content = interpolate(await readText(fallback), meta, stage, workflow, {
1011
+ idea: String(meta.flags?.idea ?? meta.title ?? "")
1012
+ });
1013
+ }
1014
+ }
1015
+ await writeText(target, content);
1016
+ created.push(artifact.path);
1017
+ }
1018
+ if (stage.items_from) {
1019
+ await syncStageItems(dir, stage);
1020
+ created.push("items.yaml");
1021
+ }
1022
+ return created;
1023
+ }
1024
+ function interpolate(template, meta, stage, workflow, extra) {
1025
+ const idea = extra?.idea || String(meta.flags?.idea ?? "") || meta.title.replace(/^Greenfield:\s*/i, "");
1026
+ return template.replaceAll("{{title}}", meta.title).replaceAll("{{id}}", meta.id).replaceAll("{{workflow}}", workflow.name).replaceAll("{{stage}}", stage.id).replaceAll("{{stage_title}}", stage.title ?? stage.id).replaceAll("{{date}}", meta.created.slice(0, 10)).replaceAll("{{idea}}", idea);
1027
+ }
1028
+ function defaultArtifactContent(artifactId, meta, stage, workflow) {
1029
+ return `# ${artifactId}
1030
+
1031
+ > Change: **${meta.title}** (\`${meta.id}\`)
1032
+ > Workflow: \`${workflow.name}\` \xB7 Stage: \`${stage.id}\`
1033
+
1034
+ ## Summary
1035
+
1036
+ <!-- Write the intent / content for this artifact -->
1037
+
1038
+ ## Notes
1039
+
1040
+ -
1041
+ `;
1042
+ }
1043
+ async function advanceStage(projectRoot, config, changeId, opts) {
1044
+ const ctx = await buildContext(projectRoot, config, changeId);
1045
+ const warnings = [];
1046
+ if (ctx.meta.status === "completed") {
1047
+ throw new Error("Change is already completed.");
1048
+ }
1049
+ if (!opts?.force) {
1050
+ const fallback = await fallbackIncompleteStageId(ctx);
1051
+ if (fallback && fallback !== ctx.meta.stage) {
1052
+ const from2 = ctx.meta.stage;
1053
+ const at2 = nowIso();
1054
+ await transitionPhase(
1055
+ projectRoot,
1056
+ config,
1057
+ changeId,
1058
+ from2,
1059
+ fallback,
1060
+ at2,
1061
+ ctx.meta.updated ?? ctx.meta.created
1062
+ );
1063
+ ctx.meta.stage = fallback;
1064
+ await saveChangeMeta(projectRoot, config, ctx.meta);
1065
+ const stage2 = getStage(ctx.workflow, ctx.meta, fallback);
1066
+ const artifactsCreated2 = await materializeStageArtifacts(
1067
+ projectRoot,
1068
+ config,
1069
+ changeId,
1070
+ ctx.workflow,
1071
+ ctx.meta,
1072
+ stage2
1073
+ );
1074
+ try {
1075
+ const { refreshActiveAgentContext } = await import("./agents-PSIOQEYH.js");
1076
+ await refreshActiveAgentContext(projectRoot);
1077
+ } catch {
1078
+ }
1079
+ const updated2 = await buildContext(projectRoot, config, changeId);
1080
+ return {
1081
+ ctx: updated2,
1082
+ from: from2,
1083
+ to: fallback,
1084
+ completed: false,
1085
+ artifactsCreated: artifactsCreated2,
1086
+ warnings: [
1087
+ ...warnings,
1088
+ `Fell back to incomplete stage "${fallback}" (required work not done). Finish it before advancing.`
1089
+ ]
1090
+ };
1091
+ }
1092
+ }
1093
+ const check = await canLeaveStage(ctx, config, projectRoot);
1094
+ warnings.push(...check.warnings);
1095
+ if (!check.ok && !opts?.force) {
1096
+ throw new Error(check.errors.join("\n"));
1097
+ }
1098
+ const from = ctx.meta.stage;
1099
+ const next = nextStageId(ctx.workflow, ctx.meta);
1100
+ if (!next) {
1101
+ return {
1102
+ ctx,
1103
+ from,
1104
+ to: null,
1105
+ completed: false,
1106
+ artifactsCreated: [],
1107
+ warnings: [...warnings, "No further stages. Run `sdd complete` when ready."]
1108
+ };
1109
+ }
1110
+ const at = nowIso();
1111
+ await transitionPhase(
1112
+ projectRoot,
1113
+ config,
1114
+ changeId,
1115
+ from,
1116
+ next,
1117
+ at,
1118
+ ctx.meta.updated ?? ctx.meta.created
1119
+ );
1120
+ ctx.meta.stage = next;
1121
+ await saveChangeMeta(projectRoot, config, ctx.meta);
1122
+ const stage = getStage(ctx.workflow, ctx.meta, next);
1123
+ const artifactsCreated = await materializeStageArtifacts(
1124
+ projectRoot,
1125
+ config,
1126
+ changeId,
1127
+ ctx.workflow,
1128
+ ctx.meta,
1129
+ stage
1130
+ );
1131
+ try {
1132
+ const { refreshActiveAgentContext } = await import("./agents-PSIOQEYH.js");
1133
+ await refreshActiveAgentContext(projectRoot);
1134
+ } catch {
1135
+ }
1136
+ const updated = await buildContext(projectRoot, config, changeId);
1137
+ return {
1138
+ ctx: updated,
1139
+ from,
1140
+ to: next,
1141
+ completed: false,
1142
+ artifactsCreated,
1143
+ warnings
1144
+ };
1145
+ }
1146
+ async function skipStage(projectRoot, config, changeId, stageId, reason) {
1147
+ if (!config.per_change.allow_skip) {
1148
+ throw new Error("Skipping stages is disabled in .sdd/config.yaml (per_change.allow_skip).");
1149
+ }
1150
+ if (config.per_change.require_reason_on_skip && !reason.trim()) {
1151
+ throw new Error("A reason is required to skip a stage.");
1152
+ }
1153
+ const ctx = await buildContext(projectRoot, config, changeId);
1154
+ const stage = getStage(ctx.workflow, ctx.meta, stageId);
1155
+ if (!stage) {
1156
+ throw new Error(`Stage "${stageId}" not found in workflow ${ctx.meta.workflow}`);
1157
+ }
1158
+ if (!stage.skippable) {
1159
+ throw new Error(`Stage "${stageId}" is not skippable.`);
1160
+ }
1161
+ const skip_stages = new Set(ctx.meta.overrides.skip_stages ?? []);
1162
+ skip_stages.add(stageId);
1163
+ ctx.meta.overrides.skip_stages = [...skip_stages];
1164
+ ctx.meta.skipped = [...ctx.meta.skipped ?? [], { stage: stageId, reason, at: nowIso() }];
1165
+ if (ctx.meta.stage === stageId) {
1166
+ const next = nextStageId(ctx.workflow, ctx.meta);
1167
+ if (next) {
1168
+ const at = nowIso();
1169
+ await transitionPhase(
1170
+ projectRoot,
1171
+ config,
1172
+ changeId,
1173
+ stageId,
1174
+ next,
1175
+ at,
1176
+ ctx.meta.updated ?? ctx.meta.created
1177
+ );
1178
+ ctx.meta.stage = next;
1179
+ const nextStage = getStage(ctx.workflow, ctx.meta, next);
1180
+ if (nextStage) {
1181
+ await materializeStageArtifacts(
1182
+ projectRoot,
1183
+ config,
1184
+ changeId,
1185
+ ctx.workflow,
1186
+ ctx.meta,
1187
+ nextStage
1188
+ );
1189
+ }
1190
+ }
1191
+ }
1192
+ await saveChangeMeta(projectRoot, config, ctx.meta);
1193
+ return buildContext(projectRoot, config, changeId);
1194
+ }
1195
+ async function switchWorkflow(projectRoot, config, changeId, workflowName, reason) {
1196
+ const ctx = await buildContext(projectRoot, config, changeId);
1197
+ const workflow = await loadWorkflow(projectRoot, workflowName);
1198
+ if (config.allowed_workflows?.length && !config.allowed_workflows.includes(workflowName)) {
1199
+ throw new Error(
1200
+ `Workflow "${workflowName}" is not in allowed_workflows: ${config.allowed_workflows.join(", ")}`
1201
+ );
1202
+ }
1203
+ const previousStage = ctx.meta.stage;
1204
+ ctx.meta.workflow = workflowName;
1205
+ const hasStage = workflow.stages.some((s) => s.id === ctx.meta.stage);
1206
+ if (!hasStage) {
1207
+ ctx.meta.stage = workflow.stages[0].id;
1208
+ }
1209
+ if (ctx.meta.stage !== previousStage) {
1210
+ const at = nowIso();
1211
+ await transitionPhase(
1212
+ projectRoot,
1213
+ config,
1214
+ changeId,
1215
+ previousStage,
1216
+ ctx.meta.stage,
1217
+ at,
1218
+ ctx.meta.updated ?? ctx.meta.created
1219
+ );
1220
+ }
1221
+ if (reason) {
1222
+ ctx.meta.flags = {
1223
+ ...ctx.meta.flags,
1224
+ workflow_switch_reason: reason
1225
+ };
1226
+ }
1227
+ await saveChangeMeta(projectRoot, config, ctx.meta);
1228
+ const stage = getStage(workflow, ctx.meta, ctx.meta.stage);
1229
+ if (stage) {
1230
+ await materializeStageArtifacts(projectRoot, config, changeId, workflow, ctx.meta, stage);
1231
+ }
1232
+ return buildContext(projectRoot, config, changeId);
1233
+ }
1234
+ async function completeChange(projectRoot, config, changeId) {
1235
+ const ctx = await buildContext(projectRoot, config, changeId);
1236
+ const next = nextStageId(ctx.workflow, ctx.meta);
1237
+ if (next) {
1238
+ const active = activeStages(ctx.workflow, ctx.meta);
1239
+ const last = active[active.length - 1];
1240
+ if (last && ctx.meta.stage !== last.id) {
1241
+ throw new Error(
1242
+ `Cannot complete: still on stage "${ctx.meta.stage}". Finish remaining stages or skip them. Next: ${next}`
1243
+ );
1244
+ }
1245
+ }
1246
+ const check = await canLeaveStage(ctx, config, projectRoot);
1247
+ if (!check.ok) {
1248
+ throw new Error(check.errors.join("\n"));
1249
+ }
1250
+ await transitionPhase(
1251
+ projectRoot,
1252
+ config,
1253
+ changeId,
1254
+ ctx.meta.stage,
1255
+ null,
1256
+ nowIso(),
1257
+ ctx.meta.updated ?? ctx.meta.created
1258
+ );
1259
+ ctx.meta.status = "completed";
1260
+ ctx.meta.completed_at = nowIso();
1261
+ await saveChangeMeta(projectRoot, config, ctx.meta);
1262
+ let promoted;
1263
+ if (ctx.meta.workflow === "greenfield" || ctx.meta.flags?.greenfield === true) {
1264
+ try {
1265
+ const { promoteGreenfieldToMemory: promoteGreenfieldToMemory2 } = await import("./greenfield-QFA5WVHE.js");
1266
+ promoted = await promoteGreenfieldToMemory2(projectRoot, config, changeId);
1267
+ } catch {
1268
+ }
1269
+ }
1270
+ await clearActiveIf(projectRoot, config, changeId);
1271
+ let archivedTo = null;
1272
+ const shouldArchive = config.persistence.archive_on_complete && ctx.workflow.on_complete?.archive !== false;
1273
+ if (shouldArchive) {
1274
+ const dest = join5(archiveDir(projectRoot, config), changeId);
1275
+ if (await pathExists(dest)) {
1276
+ throw new Error(`Archive already exists for change: ${changeId}`);
1277
+ }
1278
+ await ensureDir(archiveDir(projectRoot, config));
1279
+ await copyDir(ctx.path, dest);
1280
+ await removePath(ctx.path);
1281
+ archivedTo = dest;
1282
+ }
1283
+ if (archivedTo) {
1284
+ const meta = ChangeMetaSchema.parse(await readYaml(join5(archivedTo, "meta.yaml")));
1285
+ return {
1286
+ archivedTo,
1287
+ promoted,
1288
+ ctx: {
1289
+ id: changeId,
1290
+ path: archivedTo,
1291
+ displayPath: displayArchivePath(config, changeId),
1292
+ meta,
1293
+ workflow: ctx.workflow,
1294
+ stages: ctx.stages,
1295
+ active: ctx.active
1296
+ }
1297
+ };
1298
+ }
1299
+ return {
1300
+ archivedTo,
1301
+ promoted,
1302
+ ctx: await buildContext(projectRoot, config, changeId)
1303
+ };
1304
+ }
1305
+
1306
+ // src/greenfield.ts
1307
+ var FEATURE_HEADER = /^##\s+(F-\d+)\s*:\s*(.+?)\s*$/i;
1308
+ async function startGreenfield(input) {
1309
+ const idea = input.idea.trim();
1310
+ if (!idea || idea.length < 3) {
1311
+ throw new Error(
1312
+ 'Provide a one-line product idea, e.g. sdd greenfield "Team expense tracker for remote startups"'
1313
+ );
1314
+ }
1315
+ const title = idea.length > 80 ? `Greenfield: ${idea.slice(0, 77)}\u2026` : `Greenfield: ${idea}`;
1316
+ const ctx = await createChange({
1317
+ projectRoot: input.projectRoot,
1318
+ config: input.config,
1319
+ title,
1320
+ workflowName: "greenfield",
1321
+ flags: { greenfield: true, idea }
1322
+ });
1323
+ const visionPath = join6(ctx.path, "vision.md");
1324
+ if (await pathExists(visionPath)) {
1325
+ let body = await readText(visionPath);
1326
+ if (body.includes("{{idea}}")) {
1327
+ body = body.replaceAll("{{idea}}", idea);
1328
+ await writeText(visionPath, body);
1329
+ } else if (!body.includes(idea)) {
1330
+ body = body.replace(/## One-liner\n+(\s*[-–—]\s*\n)?/, `## One-liner
1331
+
1332
+ ${idea}
1333
+
1334
+ `);
1335
+ if (!body.includes(idea)) {
1336
+ body = `${body.trim()}
1337
+
1338
+ ## One-liner
1339
+
1340
+ ${idea}
1341
+ `;
1342
+ }
1343
+ await writeText(visionPath, body);
1344
+ }
1345
+ }
1346
+ return { ctx, idea, visionPath };
1347
+ }
1348
+ function parseFeaturesBacklog(markdown, sourcePath) {
1349
+ const lines = markdown.split(/\r?\n/);
1350
+ const features = [];
1351
+ let current = null;
1352
+ const flush = () => {
1353
+ if (current) features.push(current);
1354
+ current = null;
1355
+ };
1356
+ for (const line of lines) {
1357
+ const h = line.match(FEATURE_HEADER);
1358
+ if (h) {
1359
+ flush();
1360
+ current = {
1361
+ id: h[1].toUpperCase(),
1362
+ name: h[2].trim(),
1363
+ status: "planned",
1364
+ priority: "should",
1365
+ workflow: "feature",
1366
+ summary: "",
1367
+ requirements: "",
1368
+ notes: "",
1369
+ sourcePath
1370
+ };
1371
+ continue;
1372
+ }
1373
+ if (!current) continue;
1374
+ const field = line.match(
1375
+ /^\s*[-*]\s*\*\*(Status|Priority|Workflow|Summary|Requirements|Notes):\*\*\s*(.*)$/i
1376
+ );
1377
+ if (field) {
1378
+ const key = field[1].toLowerCase();
1379
+ const val = field[2].trim();
1380
+ if (key === "status") current.status = val || current.status;
1381
+ else if (key === "priority") current.priority = val || current.priority;
1382
+ else if (key === "workflow") current.workflow = val || current.workflow;
1383
+ else if (key === "summary") current.summary = val;
1384
+ else if (key === "requirements") current.requirements = val;
1385
+ else if (key === "notes") current.notes = val;
1386
+ }
1387
+ }
1388
+ flush();
1389
+ return features;
1390
+ }
1391
+ async function findFeaturesBacklogPath(projectRoot, config) {
1392
+ const mem = join6(memoryDir(projectRoot, config), "features.md");
1393
+ if (await pathExists(mem)) return mem;
1394
+ const { getActiveChangeId: getActiveChangeId2 } = await import("./change-context-EZJMSGSP.js");
1395
+ const active = await getActiveChangeId2(projectRoot, config);
1396
+ if (active) {
1397
+ const p = join6(changePath(projectRoot, config, active), "features.md");
1398
+ if (await pathExists(p)) return p;
1399
+ }
1400
+ const ids = (await listDirs(changesDir(projectRoot, config))).sort().reverse();
1401
+ for (const id of ids) {
1402
+ try {
1403
+ const p = join6(changePath(projectRoot, config, id), "features.md");
1404
+ if (await pathExists(p)) {
1405
+ const body = await readText(p);
1406
+ if (parseFeaturesBacklog(body, p).length) return p;
1407
+ }
1408
+ } catch {
1409
+ }
1410
+ }
1411
+ return null;
1412
+ }
1413
+ async function listBacklogFeatures(projectRoot, config) {
1414
+ const path = await findFeaturesBacklogPath(projectRoot, config);
1415
+ if (!path) {
1416
+ throw new Error(
1417
+ [
1418
+ "No feature backlog found.",
1419
+ "Options:",
1420
+ ' 1. sdd greenfield "your idea" \u2192 fill features.md \u2192 sdd next \u2026 \u2192 sdd complete',
1421
+ " 2. Or add memory/features.md with ## F-001: Name blocks",
1422
+ " 3. If a greenfield pack is mid-flight, finish the features stage first (features.md must exist and list F-NNN items)"
1423
+ ].join("\n")
1424
+ );
1425
+ }
1426
+ const features = parseFeaturesBacklog(await readText(path), path);
1427
+ if (!features.length) {
1428
+ throw new Error(`No F-NNN features parsed in ${path}. Use headings like: ## F-001: Short name`);
1429
+ }
1430
+ return { path, features };
1431
+ }
1432
+ async function startFeatureFromBacklog(input) {
1433
+ const id = input.featureId.trim().toUpperCase();
1434
+ if (!/^F-\d+$/i.test(id)) {
1435
+ throw new Error(`Feature id must look like F-001 (got "${input.featureId}")`);
1436
+ }
1437
+ const { path, features } = await listBacklogFeatures(input.projectRoot, input.config);
1438
+ const feature = features.find((f) => f.id === id);
1439
+ if (!feature) {
1440
+ const known = features.map((f) => f.id).join(", ");
1441
+ throw new Error(`Feature ${id} not found in ${path}. Known: ${known}`);
1442
+ }
1443
+ const workflowName = input.workflowName?.trim() || feature.workflow?.trim() || "feature";
1444
+ const title = feature.name ? `${feature.id}: ${feature.name}` : feature.id;
1445
+ const ctx = await createChange({
1446
+ projectRoot: input.projectRoot,
1447
+ config: input.config,
1448
+ title,
1449
+ workflowName,
1450
+ flags: {
1451
+ from_backlog: feature.id,
1452
+ greenfield_feature: true
1453
+ }
1454
+ });
1455
+ const stage = ctx.workflow.stages.find((s) => s.id === ctx.meta.stage);
1456
+ const art = stage?.artifacts.find((a) => a.required) ?? stage?.artifacts[0];
1457
+ if (art) {
1458
+ const artPath = join6(ctx.path, art.path);
1459
+ if (await pathExists(artPath)) {
1460
+ let body = await readText(artPath);
1461
+ if (!isSubstantiveArtifactContent(body)) {
1462
+ body = [
1463
+ `# ${feature.name || feature.id}`,
1464
+ ``,
1465
+ `> From backlog **${feature.id}** \xB7 priority: ${feature.priority}`,
1466
+ ``,
1467
+ `## Summary`,
1468
+ ``,
1469
+ feature.summary || feature.name || "(fill in)",
1470
+ ``,
1471
+ `## Requirements`,
1472
+ ``,
1473
+ feature.requirements || "-",
1474
+ ``,
1475
+ `## Notes`,
1476
+ ``,
1477
+ feature.notes || "-",
1478
+ ``,
1479
+ `## Success`,
1480
+ ``,
1481
+ `-`,
1482
+ ``
1483
+ ].join("\n");
1484
+ await writeText(artPath, body);
1485
+ }
1486
+ }
1487
+ }
1488
+ await updateFeatureStatus(path, feature.id, "in_progress");
1489
+ return { ctx, feature };
1490
+ }
1491
+ async function updateFeatureStatus(backlogPath, featureId, status) {
1492
+ const id = featureId.toUpperCase();
1493
+ let md = await readText(backlogPath);
1494
+ const re = new RegExp(
1495
+ `(##\\s+${id}\\s*:[\\s\\S]*?[-*]\\s*\\*\\*Status:\\*\\*\\s*)([^\\n]+)`,
1496
+ "i"
1497
+ );
1498
+ if (!re.test(md)) return;
1499
+ md = md.replace(re, `$1${status}`);
1500
+ await writeText(backlogPath, md);
1501
+ }
1502
+ async function promoteGreenfieldToMemory(projectRoot, config, changeId) {
1503
+ const dir = changePath(projectRoot, config, changeId);
1504
+ const mem = memoryDir(projectRoot, config);
1505
+ await ensureDir(mem);
1506
+ const map = [
1507
+ { from: "vision.md", to: "product.md" },
1508
+ { from: "requirements.md", to: "requirements.md" },
1509
+ { from: "features.md", to: "features.md" },
1510
+ { from: "architecture.md", to: "architecture.md" }
1511
+ ];
1512
+ const written = [];
1513
+ for (const { from, to } of map) {
1514
+ const src = join6(dir, from);
1515
+ if (!await pathExists(src)) continue;
1516
+ const body = await readText(src);
1517
+ if (!isSubstantiveArtifactContent(body)) continue;
1518
+ const dest = join6(mem, to);
1519
+ await writeText(
1520
+ dest,
1521
+ body.includes("Promoted from greenfield") ? body : `${body.trim()}
1522
+
1523
+ ---
1524
+
1525
+ _Promoted from greenfield change \`${changeId}\`._
1526
+ `
1527
+ );
1528
+ written.push(join6(config.memory_path, to));
1529
+ }
1530
+ const indexPath = join6(mem, "index.md");
1531
+ if (await pathExists(indexPath)) {
1532
+ let index = await readText(indexPath);
1533
+ if (!index.includes("features.md")) {
1534
+ index = index.replace(
1535
+ /\| Architecture \|/,
1536
+ `| Feature backlog | [features.md](features.md) |
1537
+ | Architecture |`
1538
+ );
1539
+ await writeText(indexPath, index);
1540
+ }
1541
+ }
1542
+ return written;
1543
+ }
1544
+
1545
+ export {
1546
+ PhaseMetricSchema,
1547
+ TokenUsageMetricSchema,
1548
+ DecisionMetricSchema,
1549
+ ChangeMetricsSchema,
1550
+ emptyChangeMetrics,
1551
+ readChangeMetrics,
1552
+ recordDecisionMetric,
1553
+ startPhase,
1554
+ transitionPhase,
1555
+ assertSessionLogAllowed,
1556
+ importSessionMetrics,
1557
+ isSubstantiveArtifactContent,
1558
+ recordSignoff,
1559
+ recordDecisionRequest,
1560
+ incompletePriorStageErrors,
1561
+ canLeaveStage,
1562
+ fallbackIncompleteStageId,
1563
+ approveGate,
1564
+ startGreenfield,
1565
+ parseFeaturesBacklog,
1566
+ findFeaturesBacklogPath,
1567
+ listBacklogFeatures,
1568
+ startFeatureFromBacklog,
1569
+ updateFeatureStatus,
1570
+ promoteGreenfieldToMemory,
1571
+ createChange,
1572
+ advanceStage,
1573
+ skipStage,
1574
+ switchWorkflow,
1575
+ completeChange
1576
+ };