@structured-vibe-coding/core 0.12.1 → 0.13.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.
- package/README.md +1 -0
- package/defaults/memory/index.md +3 -0
- package/defaults/templates/features.md +42 -0
- package/defaults/templates/greenfield-architecture.md +39 -0
- package/defaults/templates/requirements.md +29 -0
- package/defaults/templates/vision.md +34 -0
- package/defaults/workflows/greenfield.yaml +92 -0
- package/dist/{agents-R4RMNXOS.js → agents-WL3PNTJL.js} +5 -2
- package/dist/change-context-W4HDO2IK.js +23 -0
- package/dist/{chunk-KZHNYGD6.js → chunk-67AUAGNV.js} +1 -38
- package/dist/chunk-A752UVPF.js +317 -0
- package/dist/chunk-C725DWSB.js +122 -0
- package/dist/chunk-OJFMJHNL.js +42 -0
- package/dist/{chunk-24S2YWWL.js → chunk-S7SHVKG4.js} +22 -435
- package/dist/chunk-SM734TFW.js +784 -0
- package/dist/{config-4QZMRSL3.js → config-ZYEEIRXJ.js} +2 -1
- package/dist/greenfield-NTLW6XBL.js +23 -0
- package/dist/index.d.ts +66 -1
- package/dist/index.js +103 -537
- package/package.json +1 -1
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import {
|
|
2
|
+
activeStages,
|
|
3
|
+
buildContext,
|
|
4
|
+
changeDirName,
|
|
5
|
+
clearActiveIf,
|
|
6
|
+
firstActiveStageId,
|
|
7
|
+
getStage,
|
|
8
|
+
isStageSkipped,
|
|
9
|
+
loadWorkflow,
|
|
10
|
+
nextStageId,
|
|
11
|
+
nowIso,
|
|
12
|
+
saveChangeMeta,
|
|
13
|
+
setActiveChange,
|
|
14
|
+
shouldAutoSkip,
|
|
15
|
+
uniqueChangeDirName
|
|
16
|
+
} from "./chunk-A752UVPF.js";
|
|
17
|
+
import {
|
|
18
|
+
ChangeMetaSchema
|
|
19
|
+
} from "./chunk-67AUAGNV.js";
|
|
20
|
+
import {
|
|
21
|
+
archiveDir,
|
|
22
|
+
changeMetaPath,
|
|
23
|
+
changePath,
|
|
24
|
+
changesDir,
|
|
25
|
+
memoryDir,
|
|
26
|
+
templatesDir
|
|
27
|
+
} from "./chunk-7MJYPJMR.js";
|
|
28
|
+
import {
|
|
29
|
+
copyDir,
|
|
30
|
+
ensureDir,
|
|
31
|
+
listDirs,
|
|
32
|
+
pathExists,
|
|
33
|
+
readText,
|
|
34
|
+
readYaml,
|
|
35
|
+
removePath,
|
|
36
|
+
writeText,
|
|
37
|
+
writeYaml
|
|
38
|
+
} from "./chunk-EPFURATC.js";
|
|
39
|
+
|
|
40
|
+
// src/greenfield.ts
|
|
41
|
+
import { join as join3 } from "pathe";
|
|
42
|
+
|
|
43
|
+
// src/change.ts
|
|
44
|
+
import { join as join2 } from "pathe";
|
|
45
|
+
|
|
46
|
+
// src/stage-gates.ts
|
|
47
|
+
import { join } from "pathe";
|
|
48
|
+
|
|
49
|
+
// src/artifacts.ts
|
|
50
|
+
function isSubstantiveArtifactContent(content) {
|
|
51
|
+
const stripped = content.replace(/<!--[\s\S]*?-->/g, "").replace(/^#{1,6}\s+.*$/gm, "").replace(/^>\s.*$/gm, "").replace(/^\s*[-*+]\s*$/gm, "").replace(/^\s*[-*+]\s*\.\.\.\s*$/gm, "").replace(/^\s*[-*+]\s*TBD\s*$/gim, "").replace(/^\s*[-*+]\s*TODO\s*$/gim, "").replace(/^\s*\|.*\|\s*$/gm, "").replace(/`+/g, "").trim();
|
|
52
|
+
if (stripped.length < 48) return false;
|
|
53
|
+
const meaningful = stripped.replace(/[-–—_.\s]/g, "").replace(/\b(TBD|TODO|N\/A|none|n\/a)\b/gi, "");
|
|
54
|
+
return meaningful.length >= 32;
|
|
55
|
+
}
|
|
56
|
+
function incompleteArtifactReason(path, content) {
|
|
57
|
+
if (content == null) {
|
|
58
|
+
return `Missing required artifact: ${path}`;
|
|
59
|
+
}
|
|
60
|
+
if (!isSubstantiveArtifactContent(content)) {
|
|
61
|
+
return [
|
|
62
|
+
`Required artifact still incomplete (looks like empty template/stub): ${path}`,
|
|
63
|
+
``,
|
|
64
|
+
`Open that file and replace the template with a few real sentences, for example:`,
|
|
65
|
+
` - What is broken or missing?`,
|
|
66
|
+
` - What will you change?`,
|
|
67
|
+
` - How will you know it worked?`,
|
|
68
|
+
``,
|
|
69
|
+
`Then run: sdd next`,
|
|
70
|
+
`Optional stages only: sdd skip <stage> -r "not needed"`
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/stage-gates.ts
|
|
77
|
+
async function checkRequiredArtifacts(changePath2, stageId, artifacts) {
|
|
78
|
+
const errors = [];
|
|
79
|
+
for (const artifact of artifacts) {
|
|
80
|
+
if (!artifact.required) continue;
|
|
81
|
+
if (artifact.path.endsWith("/")) {
|
|
82
|
+
const target2 = join(changePath2, artifact.path);
|
|
83
|
+
if (!await pathExists(target2)) {
|
|
84
|
+
errors.push(`Missing required directory: ${artifact.path} (stage ${stageId})`);
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const target = join(changePath2, artifact.path);
|
|
89
|
+
if (!await pathExists(target)) {
|
|
90
|
+
errors.push(incompleteArtifactReason(artifact.path, null));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const content = await readText(target);
|
|
94
|
+
const reason = incompleteArtifactReason(artifact.path, content);
|
|
95
|
+
if (reason) {
|
|
96
|
+
errors.push(`${reason} (stage ${stageId})`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return errors;
|
|
100
|
+
}
|
|
101
|
+
async function incompletePriorStageErrors(ctx) {
|
|
102
|
+
const errors = [];
|
|
103
|
+
const stages = activeStages(ctx.workflow, ctx.meta);
|
|
104
|
+
const currentIdx = stages.findIndex((s) => s.id === ctx.meta.stage);
|
|
105
|
+
if (currentIdx <= 0) return errors;
|
|
106
|
+
for (let i = 0; i < currentIdx; i++) {
|
|
107
|
+
const stage = stages[i];
|
|
108
|
+
if (isStageSkipped(ctx.meta, stage.id) || shouldAutoSkip(stage, ctx.meta)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const required = stage.artifacts.filter((a) => a.required);
|
|
112
|
+
if (!required.length && stage.optional) continue;
|
|
113
|
+
const stageErrors = await checkRequiredArtifacts(
|
|
114
|
+
ctx.path,
|
|
115
|
+
stage.id,
|
|
116
|
+
stage.artifacts
|
|
117
|
+
);
|
|
118
|
+
if (stageErrors.length) {
|
|
119
|
+
errors.push(
|
|
120
|
+
`Prior stage "${stage.id}" is not complete \u2014 finish it or \`sdd skip ${stage.id} -r "\u2026"\` if optional.`
|
|
121
|
+
);
|
|
122
|
+
errors.push(...stageErrors);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return errors;
|
|
126
|
+
}
|
|
127
|
+
async function canLeaveStage(ctx, config) {
|
|
128
|
+
const stage = getStage(ctx.workflow, ctx.meta, ctx.meta.stage);
|
|
129
|
+
const errors = [];
|
|
130
|
+
const warnings = [];
|
|
131
|
+
if (!stage) {
|
|
132
|
+
return { ok: false, errors: [`Unknown stage: ${ctx.meta.stage}`], warnings };
|
|
133
|
+
}
|
|
134
|
+
errors.push(...await incompletePriorStageErrors(ctx));
|
|
135
|
+
errors.push(
|
|
136
|
+
...await checkRequiredArtifacts(ctx.path, stage.id, stage.artifacts)
|
|
137
|
+
);
|
|
138
|
+
const declared = stage.gate?.type ?? "soft";
|
|
139
|
+
let effectiveGate = declared;
|
|
140
|
+
if (config.policy.gates === "hard" && declared === "soft") {
|
|
141
|
+
effectiveGate = "hard";
|
|
142
|
+
}
|
|
143
|
+
const gateState = ctx.meta.gates?.[stage.id];
|
|
144
|
+
if (effectiveGate === "hard") {
|
|
145
|
+
if (!gateState || gateState.status !== "approved" && gateState.status !== "waived") {
|
|
146
|
+
errors.push(
|
|
147
|
+
`Hard gate on stage "${stage.id}" is not approved. Run \`sdd gate approve\` or complete the checklist.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} else if (effectiveGate === "soft" && stage.gate?.checklist?.length) {
|
|
151
|
+
if (!gateState || gateState.status === "pending") {
|
|
152
|
+
warnings.push(
|
|
153
|
+
`Soft gate checklist not signed off on "${stage.id}" (proceeding allowed).`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const requiredCmds = stage.verify?.commands?.filter((c) => c.required) ?? [];
|
|
158
|
+
if (requiredCmds.length) {
|
|
159
|
+
const verifyOk = ctx.meta.verify_results?.[stage.id]?.ok === true;
|
|
160
|
+
const humanOverride = gateState?.status === "waived" || gateState?.status === "approved";
|
|
161
|
+
if (!verifyOk && !humanOverride) {
|
|
162
|
+
errors.push(
|
|
163
|
+
`Required local verify commands have not passed for "${stage.id}". Run \`sdd verify\` (or \`sdd gate approve/waive\` with a note).`
|
|
164
|
+
);
|
|
165
|
+
} else if (!verifyOk && humanOverride) {
|
|
166
|
+
warnings.push(
|
|
167
|
+
`Stage "${stage.id}" gate signed off without a successful required verify run.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (stage.optional || stage.kind === "clarify" || stage.kind === "brainstorm") {
|
|
172
|
+
warnings.push(
|
|
173
|
+
`Stage "${stage.id}" is optional (${stage.kind ?? "optional"}). Skip with: sdd skip ${stage.id} -r "not needed"`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
177
|
+
}
|
|
178
|
+
async function fallbackIncompleteStageId(ctx) {
|
|
179
|
+
const stages = activeStages(ctx.workflow, ctx.meta);
|
|
180
|
+
for (const stage of stages) {
|
|
181
|
+
if (stage.id === ctx.meta.stage) break;
|
|
182
|
+
if (isStageSkipped(ctx.meta, stage.id) || shouldAutoSkip(stage, ctx.meta)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const required = stage.artifacts.filter((a) => a.required);
|
|
186
|
+
if (!required.length && stage.optional) continue;
|
|
187
|
+
const errs = await checkRequiredArtifacts(ctx.path, stage.id, stage.artifacts);
|
|
188
|
+
if (errs.length) return stage.id;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
async function approveGate(projectRoot, config, changeId, stageId, note, status = "approved") {
|
|
193
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
194
|
+
const id = stageId ?? ctx.meta.stage;
|
|
195
|
+
ctx.meta.gates = {
|
|
196
|
+
...ctx.meta.gates,
|
|
197
|
+
[id]: { status, note, at: nowIso() }
|
|
198
|
+
};
|
|
199
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
200
|
+
return buildContext(projectRoot, config, changeId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/change.ts
|
|
204
|
+
async function existingChangeIds(projectRoot, config) {
|
|
205
|
+
const open = await listDirs(changesDir(projectRoot, config));
|
|
206
|
+
const archived = await listDirs(archiveDir(projectRoot, config));
|
|
207
|
+
return /* @__PURE__ */ new Set([...open, ...archived]);
|
|
208
|
+
}
|
|
209
|
+
async function createChange(input) {
|
|
210
|
+
const { projectRoot, config, title, workflowName } = input;
|
|
211
|
+
const workflow = await loadWorkflow(projectRoot, workflowName);
|
|
212
|
+
if (!workflow.stages.length) {
|
|
213
|
+
throw new Error(`Workflow ${workflowName} has no stages`);
|
|
214
|
+
}
|
|
215
|
+
const baseId = changeDirName(title);
|
|
216
|
+
const id = uniqueChangeDirName(baseId, await existingChangeIds(projectRoot, config));
|
|
217
|
+
const dir = changePath(projectRoot, config, id);
|
|
218
|
+
await ensureDir(dir);
|
|
219
|
+
let meta = ChangeMetaSchema.parse({
|
|
220
|
+
id,
|
|
221
|
+
title,
|
|
222
|
+
workflow: workflowName,
|
|
223
|
+
created: nowIso(),
|
|
224
|
+
updated: nowIso(),
|
|
225
|
+
status: "in_progress",
|
|
226
|
+
stage: workflow.stages[0].id,
|
|
227
|
+
domain: input.domain,
|
|
228
|
+
branch: input.branch,
|
|
229
|
+
flags: input.flags ?? {},
|
|
230
|
+
overrides: {
|
|
231
|
+
skip_stages: [],
|
|
232
|
+
gates: {},
|
|
233
|
+
extra_stages: []
|
|
234
|
+
},
|
|
235
|
+
skipped: [],
|
|
236
|
+
gates: {}
|
|
237
|
+
});
|
|
238
|
+
const startId = firstActiveStageId(workflow, meta) ?? workflow.stages[0].id;
|
|
239
|
+
meta = { ...meta, stage: startId };
|
|
240
|
+
const firstStage = getStage(workflow, meta, startId);
|
|
241
|
+
if (!firstStage) {
|
|
242
|
+
throw new Error(`Workflow ${workflowName} has no active stages`);
|
|
243
|
+
}
|
|
244
|
+
await writeYaml(changeMetaPath(projectRoot, config, id), meta);
|
|
245
|
+
await materializeStageArtifacts(projectRoot, config, id, workflow, meta, firstStage);
|
|
246
|
+
await setActiveChange(projectRoot, config, id);
|
|
247
|
+
try {
|
|
248
|
+
const { refreshActiveAgentContext } = await import("./agents-WL3PNTJL.js");
|
|
249
|
+
await refreshActiveAgentContext(projectRoot);
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
return buildContext(projectRoot, config, id);
|
|
253
|
+
}
|
|
254
|
+
async function materializeStageArtifacts(projectRoot, config, changeId, workflow, meta, stage) {
|
|
255
|
+
const created = [];
|
|
256
|
+
const dir = changePath(projectRoot, config, changeId);
|
|
257
|
+
const tdir = templatesDir(projectRoot);
|
|
258
|
+
for (const artifact of stage.artifacts) {
|
|
259
|
+
const target = join2(dir, artifact.path);
|
|
260
|
+
if (await pathExists(target)) continue;
|
|
261
|
+
if (artifact.path.endsWith("/")) {
|
|
262
|
+
await ensureDir(target);
|
|
263
|
+
created.push(artifact.path);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
let content = defaultArtifactContent(artifact.id, meta, stage, workflow);
|
|
267
|
+
if (artifact.template) {
|
|
268
|
+
const templatePath = join2(tdir, artifact.template.replace(/^templates\//, ""));
|
|
269
|
+
const candidates = [
|
|
270
|
+
join2(tdir, artifact.template),
|
|
271
|
+
templatePath,
|
|
272
|
+
join2(tdir, `${artifact.id}.md`)
|
|
273
|
+
];
|
|
274
|
+
for (const c of candidates) {
|
|
275
|
+
if (await pathExists(c)) {
|
|
276
|
+
content = interpolate(await readText(c), meta, stage, workflow, {
|
|
277
|
+
idea: String(meta.flags?.idea ?? meta.title ?? "")
|
|
278
|
+
});
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
const fallback = join2(tdir, `${artifact.id}.md`);
|
|
284
|
+
if (await pathExists(fallback)) {
|
|
285
|
+
content = interpolate(await readText(fallback), meta, stage, workflow, {
|
|
286
|
+
idea: String(meta.flags?.idea ?? meta.title ?? "")
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await writeText(target, content);
|
|
291
|
+
created.push(artifact.path);
|
|
292
|
+
}
|
|
293
|
+
return created;
|
|
294
|
+
}
|
|
295
|
+
function interpolate(template, meta, stage, workflow, extra) {
|
|
296
|
+
const idea = extra?.idea || String(meta.flags?.idea ?? "") || meta.title.replace(/^Greenfield:\s*/i, "");
|
|
297
|
+
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);
|
|
298
|
+
}
|
|
299
|
+
function defaultArtifactContent(artifactId, meta, stage, workflow) {
|
|
300
|
+
return `# ${artifactId}
|
|
301
|
+
|
|
302
|
+
> Change: **${meta.title}** (\`${meta.id}\`)
|
|
303
|
+
> Workflow: \`${workflow.name}\` \xB7 Stage: \`${stage.id}\`
|
|
304
|
+
|
|
305
|
+
## Summary
|
|
306
|
+
|
|
307
|
+
<!-- Write the intent / content for this artifact -->
|
|
308
|
+
|
|
309
|
+
## Notes
|
|
310
|
+
|
|
311
|
+
-
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
async function advanceStage(projectRoot, config, changeId, opts) {
|
|
315
|
+
let ctx = await buildContext(projectRoot, config, changeId);
|
|
316
|
+
const warnings = [];
|
|
317
|
+
if (ctx.meta.status === "completed") {
|
|
318
|
+
throw new Error("Change is already completed.");
|
|
319
|
+
}
|
|
320
|
+
if (!opts?.force) {
|
|
321
|
+
const fallback = await fallbackIncompleteStageId(ctx);
|
|
322
|
+
if (fallback && fallback !== ctx.meta.stage) {
|
|
323
|
+
const from2 = ctx.meta.stage;
|
|
324
|
+
ctx.meta.stage = fallback;
|
|
325
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
326
|
+
const stage2 = getStage(ctx.workflow, ctx.meta, fallback);
|
|
327
|
+
const artifactsCreated2 = await materializeStageArtifacts(
|
|
328
|
+
projectRoot,
|
|
329
|
+
config,
|
|
330
|
+
changeId,
|
|
331
|
+
ctx.workflow,
|
|
332
|
+
ctx.meta,
|
|
333
|
+
stage2
|
|
334
|
+
);
|
|
335
|
+
try {
|
|
336
|
+
const { refreshActiveAgentContext } = await import("./agents-WL3PNTJL.js");
|
|
337
|
+
await refreshActiveAgentContext(projectRoot);
|
|
338
|
+
} catch {
|
|
339
|
+
}
|
|
340
|
+
const updated2 = await buildContext(projectRoot, config, changeId);
|
|
341
|
+
return {
|
|
342
|
+
ctx: updated2,
|
|
343
|
+
from: from2,
|
|
344
|
+
to: fallback,
|
|
345
|
+
completed: false,
|
|
346
|
+
artifactsCreated: artifactsCreated2,
|
|
347
|
+
warnings: [
|
|
348
|
+
...warnings,
|
|
349
|
+
`Fell back to incomplete stage "${fallback}" (required work not done). Finish it before advancing.`
|
|
350
|
+
]
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const check = await canLeaveStage(ctx, config);
|
|
355
|
+
warnings.push(...check.warnings);
|
|
356
|
+
if (!check.ok && !opts?.force) {
|
|
357
|
+
throw new Error(check.errors.join("\n"));
|
|
358
|
+
}
|
|
359
|
+
const from = ctx.meta.stage;
|
|
360
|
+
const next = nextStageId(ctx.workflow, ctx.meta);
|
|
361
|
+
if (!next) {
|
|
362
|
+
return {
|
|
363
|
+
ctx,
|
|
364
|
+
from,
|
|
365
|
+
to: null,
|
|
366
|
+
completed: false,
|
|
367
|
+
artifactsCreated: [],
|
|
368
|
+
warnings: [...warnings, "No further stages. Run `sdd complete` when ready."]
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
ctx.meta.stage = next;
|
|
372
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
373
|
+
const stage = getStage(ctx.workflow, ctx.meta, next);
|
|
374
|
+
const artifactsCreated = await materializeStageArtifacts(
|
|
375
|
+
projectRoot,
|
|
376
|
+
config,
|
|
377
|
+
changeId,
|
|
378
|
+
ctx.workflow,
|
|
379
|
+
ctx.meta,
|
|
380
|
+
stage
|
|
381
|
+
);
|
|
382
|
+
try {
|
|
383
|
+
const { refreshActiveAgentContext } = await import("./agents-WL3PNTJL.js");
|
|
384
|
+
await refreshActiveAgentContext(projectRoot);
|
|
385
|
+
} catch {
|
|
386
|
+
}
|
|
387
|
+
const updated = await buildContext(projectRoot, config, changeId);
|
|
388
|
+
return {
|
|
389
|
+
ctx: updated,
|
|
390
|
+
from,
|
|
391
|
+
to: next,
|
|
392
|
+
completed: false,
|
|
393
|
+
artifactsCreated,
|
|
394
|
+
warnings
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
async function skipStage(projectRoot, config, changeId, stageId, reason) {
|
|
398
|
+
if (!config.per_change.allow_skip) {
|
|
399
|
+
throw new Error("Skipping stages is disabled in .sdd/config.yaml (per_change.allow_skip).");
|
|
400
|
+
}
|
|
401
|
+
if (config.per_change.require_reason_on_skip && !reason.trim()) {
|
|
402
|
+
throw new Error("A reason is required to skip a stage.");
|
|
403
|
+
}
|
|
404
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
405
|
+
const stage = getStage(ctx.workflow, ctx.meta, stageId);
|
|
406
|
+
if (!stage) {
|
|
407
|
+
throw new Error(`Stage "${stageId}" not found in workflow ${ctx.meta.workflow}`);
|
|
408
|
+
}
|
|
409
|
+
if (!stage.skippable) {
|
|
410
|
+
throw new Error(`Stage "${stageId}" is not skippable.`);
|
|
411
|
+
}
|
|
412
|
+
const skip_stages = new Set(ctx.meta.overrides.skip_stages ?? []);
|
|
413
|
+
skip_stages.add(stageId);
|
|
414
|
+
ctx.meta.overrides.skip_stages = [...skip_stages];
|
|
415
|
+
ctx.meta.skipped = [
|
|
416
|
+
...ctx.meta.skipped ?? [],
|
|
417
|
+
{ stage: stageId, reason, at: nowIso() }
|
|
418
|
+
];
|
|
419
|
+
if (ctx.meta.stage === stageId) {
|
|
420
|
+
const next = nextStageId(ctx.workflow, ctx.meta);
|
|
421
|
+
if (next) {
|
|
422
|
+
ctx.meta.stage = next;
|
|
423
|
+
const nextStage = getStage(ctx.workflow, ctx.meta, next);
|
|
424
|
+
if (nextStage) {
|
|
425
|
+
await materializeStageArtifacts(
|
|
426
|
+
projectRoot,
|
|
427
|
+
config,
|
|
428
|
+
changeId,
|
|
429
|
+
ctx.workflow,
|
|
430
|
+
ctx.meta,
|
|
431
|
+
nextStage
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
437
|
+
return buildContext(projectRoot, config, changeId);
|
|
438
|
+
}
|
|
439
|
+
async function switchWorkflow(projectRoot, config, changeId, workflowName, reason) {
|
|
440
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
441
|
+
const workflow = await loadWorkflow(projectRoot, workflowName);
|
|
442
|
+
if (config.allowed_workflows?.length && !config.allowed_workflows.includes(workflowName)) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Workflow "${workflowName}" is not in allowed_workflows: ${config.allowed_workflows.join(", ")}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
ctx.meta.workflow = workflowName;
|
|
448
|
+
const hasStage = workflow.stages.some((s) => s.id === ctx.meta.stage);
|
|
449
|
+
if (!hasStage) {
|
|
450
|
+
ctx.meta.stage = workflow.stages[0].id;
|
|
451
|
+
}
|
|
452
|
+
if (reason) {
|
|
453
|
+
ctx.meta.flags = {
|
|
454
|
+
...ctx.meta.flags,
|
|
455
|
+
workflow_switch_reason: reason
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
459
|
+
const stage = getStage(workflow, ctx.meta, ctx.meta.stage);
|
|
460
|
+
if (stage) {
|
|
461
|
+
await materializeStageArtifacts(projectRoot, config, changeId, workflow, ctx.meta, stage);
|
|
462
|
+
}
|
|
463
|
+
return buildContext(projectRoot, config, changeId);
|
|
464
|
+
}
|
|
465
|
+
async function completeChange(projectRoot, config, changeId) {
|
|
466
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
467
|
+
const next = nextStageId(ctx.workflow, ctx.meta);
|
|
468
|
+
if (next) {
|
|
469
|
+
const active = activeStages(ctx.workflow, ctx.meta);
|
|
470
|
+
const last = active[active.length - 1];
|
|
471
|
+
if (last && ctx.meta.stage !== last.id) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`Cannot complete: still on stage "${ctx.meta.stage}". Finish remaining stages or skip them. Next: ${next}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const check = await canLeaveStage(ctx, config);
|
|
478
|
+
if (!check.ok) {
|
|
479
|
+
throw new Error(check.errors.join("\n"));
|
|
480
|
+
}
|
|
481
|
+
ctx.meta.status = "completed";
|
|
482
|
+
ctx.meta.completed_at = nowIso();
|
|
483
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
484
|
+
let promoted;
|
|
485
|
+
if (ctx.meta.workflow === "greenfield" || ctx.meta.flags?.greenfield === true) {
|
|
486
|
+
try {
|
|
487
|
+
const { promoteGreenfieldToMemory: promoteGreenfieldToMemory2 } = await import("./greenfield-NTLW6XBL.js");
|
|
488
|
+
promoted = await promoteGreenfieldToMemory2(projectRoot, config, changeId);
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
await clearActiveIf(projectRoot, config, changeId);
|
|
493
|
+
let archivedTo = null;
|
|
494
|
+
const shouldArchive = config.persistence.archive_on_complete && ctx.workflow.on_complete?.archive !== false;
|
|
495
|
+
if (shouldArchive) {
|
|
496
|
+
const dest = join2(archiveDir(projectRoot, config), changeId);
|
|
497
|
+
if (await pathExists(dest)) {
|
|
498
|
+
throw new Error(`Archive already exists for change: ${changeId}`);
|
|
499
|
+
}
|
|
500
|
+
await ensureDir(archiveDir(projectRoot, config));
|
|
501
|
+
await copyDir(ctx.path, dest);
|
|
502
|
+
await removePath(ctx.path);
|
|
503
|
+
archivedTo = dest;
|
|
504
|
+
}
|
|
505
|
+
if (archivedTo) {
|
|
506
|
+
const meta = ChangeMetaSchema.parse(await readYaml(join2(archivedTo, "meta.yaml")));
|
|
507
|
+
return {
|
|
508
|
+
archivedTo,
|
|
509
|
+
promoted,
|
|
510
|
+
ctx: {
|
|
511
|
+
id: changeId,
|
|
512
|
+
path: archivedTo,
|
|
513
|
+
meta,
|
|
514
|
+
workflow: ctx.workflow,
|
|
515
|
+
stages: ctx.stages,
|
|
516
|
+
active: ctx.active
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
archivedTo,
|
|
522
|
+
promoted,
|
|
523
|
+
ctx: await buildContext(projectRoot, config, changeId)
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/greenfield.ts
|
|
528
|
+
var FEATURE_HEADER = /^##\s+(F-\d+)\s*:\s*(.+?)\s*$/i;
|
|
529
|
+
async function startGreenfield(input) {
|
|
530
|
+
const idea = input.idea.trim();
|
|
531
|
+
if (!idea || idea.length < 3) {
|
|
532
|
+
throw new Error('Provide a one-line product idea, e.g. sdd greenfield "Team expense tracker for remote startups"');
|
|
533
|
+
}
|
|
534
|
+
const title = idea.length > 80 ? `Greenfield: ${idea.slice(0, 77)}\u2026` : `Greenfield: ${idea}`;
|
|
535
|
+
const ctx = await createChange({
|
|
536
|
+
projectRoot: input.projectRoot,
|
|
537
|
+
config: input.config,
|
|
538
|
+
title,
|
|
539
|
+
workflowName: "greenfield",
|
|
540
|
+
flags: { greenfield: true, idea }
|
|
541
|
+
});
|
|
542
|
+
const visionPath = join3(ctx.path, "vision.md");
|
|
543
|
+
if (await pathExists(visionPath)) {
|
|
544
|
+
let body = await readText(visionPath);
|
|
545
|
+
if (body.includes("{{idea}}")) {
|
|
546
|
+
body = body.replaceAll("{{idea}}", idea);
|
|
547
|
+
await writeText(visionPath, body);
|
|
548
|
+
} else if (!body.includes(idea)) {
|
|
549
|
+
body = body.replace(
|
|
550
|
+
/## One-liner\n+(\s*[-–—]\s*\n)?/,
|
|
551
|
+
`## One-liner
|
|
552
|
+
|
|
553
|
+
${idea}
|
|
554
|
+
|
|
555
|
+
`
|
|
556
|
+
);
|
|
557
|
+
if (!body.includes(idea)) {
|
|
558
|
+
body = `${body.trim()}
|
|
559
|
+
|
|
560
|
+
## One-liner
|
|
561
|
+
|
|
562
|
+
${idea}
|
|
563
|
+
`;
|
|
564
|
+
}
|
|
565
|
+
await writeText(visionPath, body);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return { ctx, idea, visionPath };
|
|
569
|
+
}
|
|
570
|
+
function parseFeaturesBacklog(markdown, sourcePath) {
|
|
571
|
+
const lines = markdown.split(/\r?\n/);
|
|
572
|
+
const features = [];
|
|
573
|
+
let current = null;
|
|
574
|
+
const flush = () => {
|
|
575
|
+
if (current) features.push(current);
|
|
576
|
+
current = null;
|
|
577
|
+
};
|
|
578
|
+
for (const line of lines) {
|
|
579
|
+
const h = line.match(FEATURE_HEADER);
|
|
580
|
+
if (h) {
|
|
581
|
+
flush();
|
|
582
|
+
current = {
|
|
583
|
+
id: h[1].toUpperCase(),
|
|
584
|
+
name: h[2].trim(),
|
|
585
|
+
status: "planned",
|
|
586
|
+
priority: "should",
|
|
587
|
+
workflow: "feature",
|
|
588
|
+
summary: "",
|
|
589
|
+
requirements: "",
|
|
590
|
+
notes: "",
|
|
591
|
+
sourcePath
|
|
592
|
+
};
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (!current) continue;
|
|
596
|
+
const field = line.match(
|
|
597
|
+
/^\s*[-*]\s*\*\*(Status|Priority|Workflow|Summary|Requirements|Notes):\*\*\s*(.*)$/i
|
|
598
|
+
);
|
|
599
|
+
if (field) {
|
|
600
|
+
const key = field[1].toLowerCase();
|
|
601
|
+
const val = field[2].trim();
|
|
602
|
+
if (key === "status") current.status = val || current.status;
|
|
603
|
+
else if (key === "priority") current.priority = val || current.priority;
|
|
604
|
+
else if (key === "workflow") current.workflow = val || current.workflow;
|
|
605
|
+
else if (key === "summary") current.summary = val;
|
|
606
|
+
else if (key === "requirements") current.requirements = val;
|
|
607
|
+
else if (key === "notes") current.notes = val;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
flush();
|
|
611
|
+
return features;
|
|
612
|
+
}
|
|
613
|
+
async function findFeaturesBacklogPath(projectRoot, config) {
|
|
614
|
+
const mem = join3(memoryDir(projectRoot, config), "features.md");
|
|
615
|
+
if (await pathExists(mem)) return mem;
|
|
616
|
+
const { getActiveChangeId: getActiveChangeId2 } = await import("./change-context-W4HDO2IK.js");
|
|
617
|
+
const active = await getActiveChangeId2(projectRoot, config);
|
|
618
|
+
if (active) {
|
|
619
|
+
const p = join3(changePath(projectRoot, config, active), "features.md");
|
|
620
|
+
if (await pathExists(p)) return p;
|
|
621
|
+
}
|
|
622
|
+
const ids = (await listDirs(changesDir(projectRoot, config))).sort().reverse();
|
|
623
|
+
for (const id of ids) {
|
|
624
|
+
try {
|
|
625
|
+
const p = join3(changePath(projectRoot, config, id), "features.md");
|
|
626
|
+
if (await pathExists(p)) {
|
|
627
|
+
const body = await readText(p);
|
|
628
|
+
if (parseFeaturesBacklog(body, p).length) return p;
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
async function listBacklogFeatures(projectRoot, config) {
|
|
636
|
+
const path = await findFeaturesBacklogPath(projectRoot, config);
|
|
637
|
+
if (!path) {
|
|
638
|
+
throw new Error(
|
|
639
|
+
'No feature backlog found. Run `sdd greenfield "your idea"` and complete the features stage (or add memory/features.md).'
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
const features = parseFeaturesBacklog(await readText(path), path);
|
|
643
|
+
if (!features.length) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`No F-NNN features parsed in ${path}. Use headings like: ## F-001: Short name`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return { path, features };
|
|
649
|
+
}
|
|
650
|
+
async function startFeatureFromBacklog(input) {
|
|
651
|
+
const id = input.featureId.trim().toUpperCase();
|
|
652
|
+
if (!/^F-\d+$/i.test(id)) {
|
|
653
|
+
throw new Error(`Feature id must look like F-001 (got "${input.featureId}")`);
|
|
654
|
+
}
|
|
655
|
+
const { path, features } = await listBacklogFeatures(
|
|
656
|
+
input.projectRoot,
|
|
657
|
+
input.config
|
|
658
|
+
);
|
|
659
|
+
const feature = features.find((f) => f.id === id);
|
|
660
|
+
if (!feature) {
|
|
661
|
+
const known = features.map((f) => f.id).join(", ");
|
|
662
|
+
throw new Error(`Feature ${id} not found in ${path}. Known: ${known}`);
|
|
663
|
+
}
|
|
664
|
+
const workflowName = input.workflowName?.trim() || feature.workflow?.trim() || "feature";
|
|
665
|
+
const title = feature.name ? `${feature.id}: ${feature.name}` : feature.id;
|
|
666
|
+
const ctx = await createChange({
|
|
667
|
+
projectRoot: input.projectRoot,
|
|
668
|
+
config: input.config,
|
|
669
|
+
title,
|
|
670
|
+
workflowName,
|
|
671
|
+
flags: {
|
|
672
|
+
from_backlog: feature.id,
|
|
673
|
+
greenfield_feature: true
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
const stage = ctx.workflow.stages.find((s) => s.id === ctx.meta.stage);
|
|
677
|
+
const art = stage?.artifacts.find((a) => a.required) ?? stage?.artifacts[0];
|
|
678
|
+
if (art) {
|
|
679
|
+
const artPath = join3(ctx.path, art.path);
|
|
680
|
+
if (await pathExists(artPath)) {
|
|
681
|
+
let body = await readText(artPath);
|
|
682
|
+
if (!isSubstantiveArtifactContent(body)) {
|
|
683
|
+
body = [
|
|
684
|
+
`# ${feature.name || feature.id}`,
|
|
685
|
+
``,
|
|
686
|
+
`> From backlog **${feature.id}** \xB7 priority: ${feature.priority}`,
|
|
687
|
+
``,
|
|
688
|
+
`## Summary`,
|
|
689
|
+
``,
|
|
690
|
+
feature.summary || feature.name || "(fill in)",
|
|
691
|
+
``,
|
|
692
|
+
`## Requirements`,
|
|
693
|
+
``,
|
|
694
|
+
feature.requirements || "-",
|
|
695
|
+
``,
|
|
696
|
+
`## Notes`,
|
|
697
|
+
``,
|
|
698
|
+
feature.notes || "-",
|
|
699
|
+
``,
|
|
700
|
+
`## Success`,
|
|
701
|
+
``,
|
|
702
|
+
`-`,
|
|
703
|
+
``
|
|
704
|
+
].join("\n");
|
|
705
|
+
await writeText(artPath, body);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
await updateFeatureStatus(path, feature.id, "in_progress");
|
|
710
|
+
return { ctx, feature };
|
|
711
|
+
}
|
|
712
|
+
async function updateFeatureStatus(backlogPath, featureId, status) {
|
|
713
|
+
const id = featureId.toUpperCase();
|
|
714
|
+
let md = await readText(backlogPath);
|
|
715
|
+
const re = new RegExp(
|
|
716
|
+
`(##\\s+${id}\\s*:[\\s\\S]*?[-*]\\s*\\*\\*Status:\\*\\*\\s*)([^\\n]+)`,
|
|
717
|
+
"i"
|
|
718
|
+
);
|
|
719
|
+
if (!re.test(md)) return;
|
|
720
|
+
md = md.replace(re, `$1${status}`);
|
|
721
|
+
await writeText(backlogPath, md);
|
|
722
|
+
}
|
|
723
|
+
async function promoteGreenfieldToMemory(projectRoot, config, changeId) {
|
|
724
|
+
const dir = changePath(projectRoot, config, changeId);
|
|
725
|
+
const mem = memoryDir(projectRoot, config);
|
|
726
|
+
await ensureDir(mem);
|
|
727
|
+
const map = [
|
|
728
|
+
{ from: "vision.md", to: "product.md" },
|
|
729
|
+
{ from: "requirements.md", to: "requirements.md" },
|
|
730
|
+
{ from: "features.md", to: "features.md" },
|
|
731
|
+
{ from: "architecture.md", to: "architecture.md" }
|
|
732
|
+
];
|
|
733
|
+
const written = [];
|
|
734
|
+
for (const { from, to } of map) {
|
|
735
|
+
const src = join3(dir, from);
|
|
736
|
+
if (!await pathExists(src)) continue;
|
|
737
|
+
const body = await readText(src);
|
|
738
|
+
if (!isSubstantiveArtifactContent(body)) continue;
|
|
739
|
+
const dest = join3(mem, to);
|
|
740
|
+
await writeText(
|
|
741
|
+
dest,
|
|
742
|
+
body.includes("Promoted from greenfield") ? body : `${body.trim()}
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
_Promoted from greenfield change \`${changeId}\`._
|
|
747
|
+
`
|
|
748
|
+
);
|
|
749
|
+
written.push(join3(config.memory_path, to));
|
|
750
|
+
}
|
|
751
|
+
const indexPath = join3(mem, "index.md");
|
|
752
|
+
if (await pathExists(indexPath)) {
|
|
753
|
+
let index = await readText(indexPath);
|
|
754
|
+
if (!index.includes("features.md")) {
|
|
755
|
+
index = index.replace(
|
|
756
|
+
/\| Architecture \|/,
|
|
757
|
+
`| Feature backlog | [features.md](features.md) |
|
|
758
|
+
| Architecture |`
|
|
759
|
+
);
|
|
760
|
+
await writeText(indexPath, index);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return written;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export {
|
|
767
|
+
isSubstantiveArtifactContent,
|
|
768
|
+
incompletePriorStageErrors,
|
|
769
|
+
canLeaveStage,
|
|
770
|
+
fallbackIncompleteStageId,
|
|
771
|
+
approveGate,
|
|
772
|
+
startGreenfield,
|
|
773
|
+
parseFeaturesBacklog,
|
|
774
|
+
findFeaturesBacklogPath,
|
|
775
|
+
listBacklogFeatures,
|
|
776
|
+
startFeatureFromBacklog,
|
|
777
|
+
updateFeatureStatus,
|
|
778
|
+
promoteGreenfieldToMemory,
|
|
779
|
+
createChange,
|
|
780
|
+
advanceStage,
|
|
781
|
+
skipStage,
|
|
782
|
+
switchWorkflow,
|
|
783
|
+
completeChange
|
|
784
|
+
};
|