@structured-vibe-coding/core 0.2.2 → 0.3.1
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/dist/agents-AIH2DNH3.js +16 -0
- package/dist/chunk-ANNG5SIL.js +1282 -0
- package/dist/index.d.ts +44 -1
- package/dist/index.js +118 -972
- package/package.json +1 -1
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
// src/agents.ts
|
|
2
|
+
import { join as join4 } from "pathe";
|
|
3
|
+
|
|
4
|
+
// src/fs.ts
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { dirname } from "pathe";
|
|
7
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
8
|
+
async function pathExists(path) {
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(path);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function ensureDir(path) {
|
|
17
|
+
await fs.mkdir(path, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
async function readText(path) {
|
|
20
|
+
return fs.readFile(path, "utf8");
|
|
21
|
+
}
|
|
22
|
+
async function writeText(path, content) {
|
|
23
|
+
await ensureDir(dirname(path));
|
|
24
|
+
await fs.writeFile(path, content, "utf8");
|
|
25
|
+
}
|
|
26
|
+
async function readYaml(path) {
|
|
27
|
+
const raw = await readText(path);
|
|
28
|
+
return parseYaml(raw);
|
|
29
|
+
}
|
|
30
|
+
async function writeYaml(path, data) {
|
|
31
|
+
const content = stringifyYaml(data, {
|
|
32
|
+
lineWidth: 100,
|
|
33
|
+
defaultStringType: "PLAIN",
|
|
34
|
+
defaultKeyType: "PLAIN"
|
|
35
|
+
});
|
|
36
|
+
await writeText(path, content.endsWith("\n") ? content : `${content}
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
39
|
+
async function listDirs(path) {
|
|
40
|
+
if (!await pathExists(path)) return [];
|
|
41
|
+
const entries = await fs.readdir(path, { withFileTypes: true });
|
|
42
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
43
|
+
}
|
|
44
|
+
async function listFiles(path) {
|
|
45
|
+
if (!await pathExists(path)) return [];
|
|
46
|
+
const entries = await fs.readdir(path, { withFileTypes: true });
|
|
47
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
48
|
+
}
|
|
49
|
+
async function copyDir(src, dest) {
|
|
50
|
+
await ensureDir(dest);
|
|
51
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const from = `${src}/${entry.name}`;
|
|
54
|
+
const to = `${dest}/${entry.name}`;
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
await copyDir(from, to);
|
|
57
|
+
} else {
|
|
58
|
+
await ensureDir(dirname(to));
|
|
59
|
+
await fs.copyFile(from, to);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function removePath(path) {
|
|
64
|
+
await fs.rm(path, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/schemas.ts
|
|
68
|
+
import { z } from "zod";
|
|
69
|
+
var GateSchema = z.object({
|
|
70
|
+
type: z.enum(["none", "soft", "hard"]).default("soft"),
|
|
71
|
+
mode: z.enum(["none", "human_approval", "checklist"]).default("checklist"),
|
|
72
|
+
checklist: z.array(z.string()).default([]),
|
|
73
|
+
overridable: z.boolean().default(true)
|
|
74
|
+
});
|
|
75
|
+
var ArtifactDefSchema = z.object({
|
|
76
|
+
id: z.string(),
|
|
77
|
+
path: z.string(),
|
|
78
|
+
template: z.string().optional(),
|
|
79
|
+
required: z.boolean().default(true)
|
|
80
|
+
});
|
|
81
|
+
var VerifyCommandSchema = z.object({
|
|
82
|
+
name: z.string(),
|
|
83
|
+
run: z.string(),
|
|
84
|
+
required: z.boolean().default(false)
|
|
85
|
+
});
|
|
86
|
+
var StageSchema = z.object({
|
|
87
|
+
id: z.string(),
|
|
88
|
+
title: z.string().optional(),
|
|
89
|
+
summary: z.string().optional(),
|
|
90
|
+
artifacts: z.array(ArtifactDefSchema).default([]),
|
|
91
|
+
gate: GateSchema.default({}),
|
|
92
|
+
skippable: z.boolean().default(true),
|
|
93
|
+
skip_when: z.object({
|
|
94
|
+
flags: z.array(z.string()).optional()
|
|
95
|
+
}).optional(),
|
|
96
|
+
verify: z.object({
|
|
97
|
+
commands: z.array(VerifyCommandSchema).default([]),
|
|
98
|
+
evidence_dir: z.string().optional()
|
|
99
|
+
}).optional(),
|
|
100
|
+
agent_context: z.object({
|
|
101
|
+
include: z.array(z.string()).default([]),
|
|
102
|
+
instructions: z.string().optional()
|
|
103
|
+
}).optional()
|
|
104
|
+
});
|
|
105
|
+
var WorkflowSchema = z.object({
|
|
106
|
+
name: z.string(),
|
|
107
|
+
description: z.string().optional(),
|
|
108
|
+
version: z.number().default(1),
|
|
109
|
+
stages: z.array(StageSchema).min(1),
|
|
110
|
+
recommendation: z.object({
|
|
111
|
+
when: z.object({
|
|
112
|
+
labels: z.array(z.string()).optional(),
|
|
113
|
+
keywords: z.array(z.string()).optional(),
|
|
114
|
+
max_complexity: z.enum(["low", "medium", "high"]).optional(),
|
|
115
|
+
min_complexity: z.enum(["low", "medium", "high"]).optional()
|
|
116
|
+
}).optional(),
|
|
117
|
+
priority: z.number().default(0)
|
|
118
|
+
}).optional(),
|
|
119
|
+
on_complete: z.object({
|
|
120
|
+
archive: z.boolean().default(true),
|
|
121
|
+
domain_sync: z.enum(["never", "recommend", "require"]).default("never")
|
|
122
|
+
}).optional()
|
|
123
|
+
});
|
|
124
|
+
var ConfigSchema = z.object({
|
|
125
|
+
version: z.number().default(1),
|
|
126
|
+
memory_path: z.string().default("memory"),
|
|
127
|
+
changes_path: z.string().default("changes"),
|
|
128
|
+
archive_path: z.string().default("archive"),
|
|
129
|
+
domains_path: z.string().default("domains"),
|
|
130
|
+
default_workflow: z.string().default("recommend"),
|
|
131
|
+
allowed_workflows: z.array(z.string()).optional(),
|
|
132
|
+
per_change: z.object({
|
|
133
|
+
allow_skip: z.boolean().default(true),
|
|
134
|
+
allow_gate_override: z.enum(["true", "false", "soft_only"]).default("soft_only"),
|
|
135
|
+
allow_custom_stages: z.boolean().default(true),
|
|
136
|
+
require_reason_on_skip: z.boolean().default(true)
|
|
137
|
+
}).default({}),
|
|
138
|
+
persistence: z.object({
|
|
139
|
+
default: z.enum(["change_only", "domain_sync"]).default("change_only"),
|
|
140
|
+
archive_on_complete: z.boolean().default(true),
|
|
141
|
+
domain_sync: z.object({
|
|
142
|
+
mode: z.enum(["never", "recommend", "require"]).default("never"),
|
|
143
|
+
anchored_domains: z.array(z.string()).default([])
|
|
144
|
+
}).default({})
|
|
145
|
+
}).default({}),
|
|
146
|
+
policy: z.object({
|
|
147
|
+
gates: z.enum(["soft", "hard"]).default("soft")
|
|
148
|
+
}).default({})
|
|
149
|
+
});
|
|
150
|
+
var ChangeMetaSchema = z.object({
|
|
151
|
+
id: z.string(),
|
|
152
|
+
title: z.string(),
|
|
153
|
+
workflow: z.string(),
|
|
154
|
+
created: z.string(),
|
|
155
|
+
updated: z.string().optional(),
|
|
156
|
+
status: z.enum(["in_progress", "blocked", "completed", "abandoned"]).default("in_progress"),
|
|
157
|
+
stage: z.string(),
|
|
158
|
+
branch: z.string().optional(),
|
|
159
|
+
domain: z.string().optional(),
|
|
160
|
+
flags: z.record(z.union([z.boolean(), z.string(), z.number()])).default({}),
|
|
161
|
+
overrides: z.object({
|
|
162
|
+
skip_stages: z.array(z.string()).default([]),
|
|
163
|
+
gates: z.record(z.enum(["none", "soft", "hard"])).default({}),
|
|
164
|
+
extra_stages: z.array(StageSchema).default([])
|
|
165
|
+
}).default({}),
|
|
166
|
+
skipped: z.array(
|
|
167
|
+
z.object({
|
|
168
|
+
stage: z.string(),
|
|
169
|
+
reason: z.string(),
|
|
170
|
+
at: z.string()
|
|
171
|
+
})
|
|
172
|
+
).default([]),
|
|
173
|
+
gates: z.record(
|
|
174
|
+
z.object({
|
|
175
|
+
status: z.enum(["pending", "approved", "failed", "waived"]),
|
|
176
|
+
note: z.string().optional(),
|
|
177
|
+
at: z.string().optional()
|
|
178
|
+
})
|
|
179
|
+
).default({}),
|
|
180
|
+
/** Results from `sdd verify` keyed by stage id */
|
|
181
|
+
verify_results: z.record(
|
|
182
|
+
z.object({
|
|
183
|
+
ok: z.boolean(),
|
|
184
|
+
at: z.string(),
|
|
185
|
+
results: z.array(
|
|
186
|
+
z.object({
|
|
187
|
+
name: z.string(),
|
|
188
|
+
exitCode: z.number().nullable()
|
|
189
|
+
})
|
|
190
|
+
).default([])
|
|
191
|
+
})
|
|
192
|
+
).default({}),
|
|
193
|
+
pr: z.object({
|
|
194
|
+
number: z.number().optional(),
|
|
195
|
+
url: z.string().optional()
|
|
196
|
+
}).optional(),
|
|
197
|
+
completed_at: z.string().optional()
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// src/paths.ts
|
|
201
|
+
import { join } from "pathe";
|
|
202
|
+
var SDD_DIR = ".sdd";
|
|
203
|
+
function sddRoot(projectRoot) {
|
|
204
|
+
return join(projectRoot, SDD_DIR);
|
|
205
|
+
}
|
|
206
|
+
function configPath(projectRoot) {
|
|
207
|
+
return join(sddRoot(projectRoot), "config.yaml");
|
|
208
|
+
}
|
|
209
|
+
function workflowsDir(projectRoot) {
|
|
210
|
+
return join(sddRoot(projectRoot), "workflows");
|
|
211
|
+
}
|
|
212
|
+
function templatesDir(projectRoot) {
|
|
213
|
+
return join(sddRoot(projectRoot), "templates");
|
|
214
|
+
}
|
|
215
|
+
function memoryDir(projectRoot, config) {
|
|
216
|
+
return join(projectRoot, config.memory_path);
|
|
217
|
+
}
|
|
218
|
+
function changesDir(projectRoot, config) {
|
|
219
|
+
return join(projectRoot, config.changes_path);
|
|
220
|
+
}
|
|
221
|
+
function archiveDir(projectRoot, config) {
|
|
222
|
+
return join(projectRoot, config.archive_path);
|
|
223
|
+
}
|
|
224
|
+
function domainsDir(projectRoot, config) {
|
|
225
|
+
return join(projectRoot, config.domains_path);
|
|
226
|
+
}
|
|
227
|
+
function changePath(projectRoot, config, changeId) {
|
|
228
|
+
return join(changesDir(projectRoot, config), changeId);
|
|
229
|
+
}
|
|
230
|
+
function changeMetaPath(projectRoot, config, changeId) {
|
|
231
|
+
return join(changePath(projectRoot, config, changeId), "meta.yaml");
|
|
232
|
+
}
|
|
233
|
+
function activePointerPath(projectRoot, config) {
|
|
234
|
+
return join(changesDir(projectRoot, config), ".active");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/config.ts
|
|
238
|
+
async function isInitialized(projectRoot) {
|
|
239
|
+
return pathExists(configPath(projectRoot));
|
|
240
|
+
}
|
|
241
|
+
async function loadConfig(projectRoot) {
|
|
242
|
+
const path = configPath(projectRoot);
|
|
243
|
+
if (!await pathExists(path)) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`SDD is not initialized in ${projectRoot}. Run \`sdd init\` first.`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const raw = await readYaml(path);
|
|
249
|
+
return ConfigSchema.parse(raw);
|
|
250
|
+
}
|
|
251
|
+
async function saveConfig(projectRoot, config) {
|
|
252
|
+
await ensureDir(sddRoot(projectRoot));
|
|
253
|
+
await writeYaml(configPath(projectRoot), ConfigSchema.parse(config));
|
|
254
|
+
}
|
|
255
|
+
function defaultConfig() {
|
|
256
|
+
return ConfigSchema.parse({});
|
|
257
|
+
}
|
|
258
|
+
function projectPaths(projectRoot, config) {
|
|
259
|
+
return {
|
|
260
|
+
sdd: sddRoot(projectRoot),
|
|
261
|
+
config: configPath(projectRoot),
|
|
262
|
+
workflows: workflowsDir(projectRoot),
|
|
263
|
+
templates: templatesDir(projectRoot),
|
|
264
|
+
memory: memoryDir(projectRoot, config)
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/change.ts
|
|
269
|
+
import { join as join3 } from "pathe";
|
|
270
|
+
|
|
271
|
+
// src/slug.ts
|
|
272
|
+
function slugify(input) {
|
|
273
|
+
return input.toLowerCase().trim().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
|
|
274
|
+
}
|
|
275
|
+
function todayIsoDate(d = /* @__PURE__ */ new Date()) {
|
|
276
|
+
return d.toISOString().slice(0, 10);
|
|
277
|
+
}
|
|
278
|
+
function nowIso(d = /* @__PURE__ */ new Date()) {
|
|
279
|
+
return d.toISOString();
|
|
280
|
+
}
|
|
281
|
+
function changeDirName(title, d = /* @__PURE__ */ new Date()) {
|
|
282
|
+
const slug = slugify(title) || "change";
|
|
283
|
+
return `${todayIsoDate(d)}-${slug}`;
|
|
284
|
+
}
|
|
285
|
+
function uniqueChangeDirName(base, taken) {
|
|
286
|
+
if (!taken.has(base)) return base;
|
|
287
|
+
let n = 2;
|
|
288
|
+
while (taken.has(`${base}-${n}`)) n += 1;
|
|
289
|
+
return `${base}-${n}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/workflow.ts
|
|
293
|
+
import { join as join2 } from "pathe";
|
|
294
|
+
async function listWorkflowNames(projectRoot) {
|
|
295
|
+
const dir = workflowsDir(projectRoot);
|
|
296
|
+
const files = await listFiles(dir);
|
|
297
|
+
return files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).map((f) => f.replace(/\.ya?ml$/, "")).sort();
|
|
298
|
+
}
|
|
299
|
+
async function loadWorkflow(projectRoot, name) {
|
|
300
|
+
const dir = workflowsDir(projectRoot);
|
|
301
|
+
const candidates = [`${name}.yaml`, `${name}.yml`];
|
|
302
|
+
for (const file of candidates) {
|
|
303
|
+
const path = join2(dir, file);
|
|
304
|
+
if (await pathExists(path)) {
|
|
305
|
+
const raw = await readYaml(path);
|
|
306
|
+
const workflow = WorkflowSchema.parse(raw);
|
|
307
|
+
if (workflow.name !== name) {
|
|
308
|
+
}
|
|
309
|
+
return workflow;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
throw new Error(
|
|
313
|
+
`Workflow "${name}" not found in ${dir}. Available: ${(await listWorkflowNames(projectRoot)).join(", ") || "(none)"}`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
function resolveStages(workflow, meta) {
|
|
317
|
+
const base = [...workflow.stages];
|
|
318
|
+
const extra = meta.overrides?.extra_stages ?? [];
|
|
319
|
+
const stages = [...base, ...extra];
|
|
320
|
+
return stages.map((stage) => {
|
|
321
|
+
const gateOverride = meta.overrides?.gates?.[stage.id];
|
|
322
|
+
if (!gateOverride) return stage;
|
|
323
|
+
return {
|
|
324
|
+
...stage,
|
|
325
|
+
gate: {
|
|
326
|
+
...stage.gate,
|
|
327
|
+
type: gateOverride
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function isStageSkipped(meta, stageId) {
|
|
333
|
+
if (meta.overrides?.skip_stages?.includes(stageId)) return true;
|
|
334
|
+
if (meta.skipped?.some((s) => s.stage === stageId)) return true;
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
function shouldAutoSkip(stage, meta) {
|
|
338
|
+
const flags = stage.skip_when?.flags ?? [];
|
|
339
|
+
for (const flag of flags) {
|
|
340
|
+
if (meta.flags?.[flag] === true || meta.flags?.[flag] === "true") {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
function activeStages(workflow, meta) {
|
|
347
|
+
return resolveStages(workflow, meta).filter((s) => {
|
|
348
|
+
if (isStageSkipped(meta, s.id)) return false;
|
|
349
|
+
if (shouldAutoSkip(s, meta)) return false;
|
|
350
|
+
return true;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function getStage(workflow, meta, stageId) {
|
|
354
|
+
return resolveStages(workflow, meta).find((s) => s.id === stageId);
|
|
355
|
+
}
|
|
356
|
+
function nextStageId(workflow, meta) {
|
|
357
|
+
const all = resolveStages(workflow, meta);
|
|
358
|
+
const activeIds = new Set(activeStages(workflow, meta).map((s) => s.id));
|
|
359
|
+
let start = all.findIndex((s) => s.id === meta.stage);
|
|
360
|
+
if (start === -1) start = -1;
|
|
361
|
+
for (let i = start + 1; i < all.length; i++) {
|
|
362
|
+
const id = all[i].id;
|
|
363
|
+
if (activeIds.has(id)) return id;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
function previousStageId(workflow, meta) {
|
|
368
|
+
const all = resolveStages(workflow, meta);
|
|
369
|
+
const activeIds = new Set(activeStages(workflow, meta).map((s) => s.id));
|
|
370
|
+
const start = all.findIndex((s) => s.id === meta.stage);
|
|
371
|
+
if (start <= 0) return null;
|
|
372
|
+
for (let i = start - 1; i >= 0; i--) {
|
|
373
|
+
const id = all[i].id;
|
|
374
|
+
if (activeIds.has(id)) return id;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
function firstActiveStageId(workflow, meta) {
|
|
379
|
+
return activeStages(workflow, meta)[0]?.id ?? null;
|
|
380
|
+
}
|
|
381
|
+
function inferComplexity(title, hints) {
|
|
382
|
+
const text = `${title} ${(hints?.keywords ?? []).join(" ")}`.toLowerCase();
|
|
383
|
+
const hotfixHints = ["typo", "hotfix", "nit", "rename", "bump", "chore", "readme", "docs"];
|
|
384
|
+
const highHints = [
|
|
385
|
+
"architecture",
|
|
386
|
+
"migration",
|
|
387
|
+
"auth",
|
|
388
|
+
"payment",
|
|
389
|
+
"billing",
|
|
390
|
+
"redesign",
|
|
391
|
+
"platform",
|
|
392
|
+
"arb",
|
|
393
|
+
"epic"
|
|
394
|
+
];
|
|
395
|
+
if (hotfixHints.some((h) => text.includes(h))) return "low";
|
|
396
|
+
if (highHints.some((h) => text.includes(h))) return "high";
|
|
397
|
+
if (text.length > 80) return "medium";
|
|
398
|
+
return "medium";
|
|
399
|
+
}
|
|
400
|
+
async function recommendWorkflow(projectRoot, title, config, opts) {
|
|
401
|
+
const available = await listWorkflowNames(projectRoot);
|
|
402
|
+
const allowed = config.allowed_workflows?.length ? available.filter((n) => config.allowed_workflows.includes(n)) : available;
|
|
403
|
+
if (opts?.preferred) {
|
|
404
|
+
if (!available.includes(opts.preferred)) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`Workflow "${opts.preferred}" is not available. Available: ${available.join(", ") || "(none)"}`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (config.allowed_workflows?.length && !config.allowed_workflows.includes(opts.preferred)) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Workflow "${opts.preferred}" is not in allowed_workflows: ${config.allowed_workflows.join(", ")}`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
name: opts.preferred,
|
|
416
|
+
reason: "Explicitly selected",
|
|
417
|
+
alternatives: allowed.filter((n) => n !== opts.preferred)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (config.default_workflow !== "recommend" && allowed.includes(config.default_workflow)) {
|
|
421
|
+
return {
|
|
422
|
+
name: config.default_workflow,
|
|
423
|
+
reason: `Repo default_workflow=${config.default_workflow}`,
|
|
424
|
+
alternatives: allowed.filter((n) => n !== config.default_workflow)
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const complexity = opts?.complexity ?? inferComplexity(title);
|
|
428
|
+
const scored = [];
|
|
429
|
+
for (const name of allowed) {
|
|
430
|
+
try {
|
|
431
|
+
const wf = await loadWorkflow(projectRoot, name);
|
|
432
|
+
let score = wf.recommendation?.priority ?? 0;
|
|
433
|
+
let reason = `workflow priority ${score}`;
|
|
434
|
+
const when = wf.recommendation?.when;
|
|
435
|
+
if (complexity === "low" && name === "hotfix") {
|
|
436
|
+
score += 50;
|
|
437
|
+
reason = "Low complexity \u2192 hotfix";
|
|
438
|
+
} else if (complexity === "low" && name === "patch") {
|
|
439
|
+
score += 40;
|
|
440
|
+
reason = "Low complexity \u2192 patch";
|
|
441
|
+
} else if (complexity === "medium" && name === "feature") {
|
|
442
|
+
score += 45;
|
|
443
|
+
reason = "Medium complexity \u2192 feature";
|
|
444
|
+
} else if (complexity === "medium" && name === "patch") {
|
|
445
|
+
score += 30;
|
|
446
|
+
reason = "Medium complexity \u2192 patch";
|
|
447
|
+
} else if (complexity === "high" && name === "enterprise-feature") {
|
|
448
|
+
score += 50;
|
|
449
|
+
reason = "High complexity \u2192 enterprise-feature";
|
|
450
|
+
} else if (complexity === "high" && name === "feature") {
|
|
451
|
+
score += 35;
|
|
452
|
+
reason = "High complexity \u2192 feature";
|
|
453
|
+
}
|
|
454
|
+
const keywords = when?.keywords ?? [];
|
|
455
|
+
const titleLower = title.toLowerCase();
|
|
456
|
+
if (keywords.some((k) => titleLower.includes(k.toLowerCase()))) {
|
|
457
|
+
score += 20;
|
|
458
|
+
reason = `Keyword match for ${name}`;
|
|
459
|
+
}
|
|
460
|
+
scored.push({ name, score, reason });
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
scored.sort((a, b) => b.score - a.score);
|
|
465
|
+
const best = scored[0];
|
|
466
|
+
if (!best) {
|
|
467
|
+
throw new Error("No workflows available. Run `sdd init` or add files under .sdd/workflows/");
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
name: best.name,
|
|
471
|
+
reason: best.reason,
|
|
472
|
+
alternatives: scored.slice(1).map((s) => s.name)
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/change.ts
|
|
477
|
+
async function existingChangeIds(projectRoot, config) {
|
|
478
|
+
const open = await listDirs(changesDir(projectRoot, config));
|
|
479
|
+
const archived = await listDirs(archiveDir(projectRoot, config));
|
|
480
|
+
return /* @__PURE__ */ new Set([...open, ...archived]);
|
|
481
|
+
}
|
|
482
|
+
async function createChange(input) {
|
|
483
|
+
const { projectRoot, config, title, workflowName } = input;
|
|
484
|
+
const workflow = await loadWorkflow(projectRoot, workflowName);
|
|
485
|
+
if (!workflow.stages.length) {
|
|
486
|
+
throw new Error(`Workflow ${workflowName} has no stages`);
|
|
487
|
+
}
|
|
488
|
+
const baseId = changeDirName(title);
|
|
489
|
+
const id = uniqueChangeDirName(baseId, await existingChangeIds(projectRoot, config));
|
|
490
|
+
const dir = changePath(projectRoot, config, id);
|
|
491
|
+
await ensureDir(dir);
|
|
492
|
+
let meta = ChangeMetaSchema.parse({
|
|
493
|
+
id,
|
|
494
|
+
title,
|
|
495
|
+
workflow: workflowName,
|
|
496
|
+
created: nowIso(),
|
|
497
|
+
updated: nowIso(),
|
|
498
|
+
status: "in_progress",
|
|
499
|
+
stage: workflow.stages[0].id,
|
|
500
|
+
domain: input.domain,
|
|
501
|
+
branch: input.branch,
|
|
502
|
+
flags: input.flags ?? {},
|
|
503
|
+
overrides: {
|
|
504
|
+
skip_stages: [],
|
|
505
|
+
gates: {},
|
|
506
|
+
extra_stages: []
|
|
507
|
+
},
|
|
508
|
+
skipped: [],
|
|
509
|
+
gates: {}
|
|
510
|
+
});
|
|
511
|
+
const startId = firstActiveStageId(workflow, meta) ?? workflow.stages[0].id;
|
|
512
|
+
meta = { ...meta, stage: startId };
|
|
513
|
+
const firstStage = getStage(workflow, meta, startId);
|
|
514
|
+
if (!firstStage) {
|
|
515
|
+
throw new Error(`Workflow ${workflowName} has no active stages`);
|
|
516
|
+
}
|
|
517
|
+
await writeYaml(changeMetaPath(projectRoot, config, id), meta);
|
|
518
|
+
await materializeStageArtifacts(projectRoot, config, id, workflow, meta, firstStage);
|
|
519
|
+
await setActiveChange(projectRoot, config, id);
|
|
520
|
+
try {
|
|
521
|
+
const { refreshActiveAgentContext: refreshActiveAgentContext2 } = await import("./agents-AIH2DNH3.js");
|
|
522
|
+
await refreshActiveAgentContext2(projectRoot);
|
|
523
|
+
} catch {
|
|
524
|
+
}
|
|
525
|
+
return buildContext(projectRoot, config, id);
|
|
526
|
+
}
|
|
527
|
+
async function materializeStageArtifacts(projectRoot, config, changeId, workflow, meta, stage) {
|
|
528
|
+
const created = [];
|
|
529
|
+
const dir = changePath(projectRoot, config, changeId);
|
|
530
|
+
const tdir = templatesDir(projectRoot);
|
|
531
|
+
for (const artifact of stage.artifacts) {
|
|
532
|
+
const target = join3(dir, artifact.path);
|
|
533
|
+
if (await pathExists(target)) continue;
|
|
534
|
+
if (artifact.path.endsWith("/")) {
|
|
535
|
+
await ensureDir(target);
|
|
536
|
+
created.push(artifact.path);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
let content = defaultArtifactContent(artifact.id, meta, stage, workflow);
|
|
540
|
+
if (artifact.template) {
|
|
541
|
+
const templatePath = join3(tdir, artifact.template.replace(/^templates\//, ""));
|
|
542
|
+
const candidates = [
|
|
543
|
+
join3(tdir, artifact.template),
|
|
544
|
+
templatePath,
|
|
545
|
+
join3(tdir, `${artifact.id}.md`)
|
|
546
|
+
];
|
|
547
|
+
for (const c of candidates) {
|
|
548
|
+
if (await pathExists(c)) {
|
|
549
|
+
const raw = await readText(c);
|
|
550
|
+
content = interpolate(raw, meta, stage, workflow);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
const fallback = join3(tdir, `${artifact.id}.md`);
|
|
556
|
+
if (await pathExists(fallback)) {
|
|
557
|
+
content = interpolate(await readText(fallback), meta, stage, workflow);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
await writeText(target, content);
|
|
561
|
+
created.push(artifact.path);
|
|
562
|
+
}
|
|
563
|
+
return created;
|
|
564
|
+
}
|
|
565
|
+
function interpolate(template, meta, stage, workflow) {
|
|
566
|
+
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));
|
|
567
|
+
}
|
|
568
|
+
function defaultArtifactContent(artifactId, meta, stage, workflow) {
|
|
569
|
+
return `# ${artifactId}
|
|
570
|
+
|
|
571
|
+
> Change: **${meta.title}** (\`${meta.id}\`)
|
|
572
|
+
> Workflow: \`${workflow.name}\` \xB7 Stage: \`${stage.id}\`
|
|
573
|
+
|
|
574
|
+
## Summary
|
|
575
|
+
|
|
576
|
+
<!-- Write the intent / content for this artifact -->
|
|
577
|
+
|
|
578
|
+
## Notes
|
|
579
|
+
|
|
580
|
+
-
|
|
581
|
+
`;
|
|
582
|
+
}
|
|
583
|
+
async function loadChangeMeta(projectRoot, config, changeId) {
|
|
584
|
+
const path = changeMetaPath(projectRoot, config, changeId);
|
|
585
|
+
if (!await pathExists(path)) {
|
|
586
|
+
throw new Error(`Change not found: ${changeId}`);
|
|
587
|
+
}
|
|
588
|
+
return ChangeMetaSchema.parse(await readYaml(path));
|
|
589
|
+
}
|
|
590
|
+
async function saveChangeMeta(projectRoot, config, meta) {
|
|
591
|
+
meta.updated = nowIso();
|
|
592
|
+
await writeYaml(changeMetaPath(projectRoot, config, meta.id), ChangeMetaSchema.parse(meta));
|
|
593
|
+
}
|
|
594
|
+
async function buildContext(projectRoot, config, changeId) {
|
|
595
|
+
const meta = await loadChangeMeta(projectRoot, config, changeId);
|
|
596
|
+
const workflow = await loadWorkflow(projectRoot, meta.workflow);
|
|
597
|
+
return {
|
|
598
|
+
id: changeId,
|
|
599
|
+
path: changePath(projectRoot, config, changeId),
|
|
600
|
+
meta,
|
|
601
|
+
workflow,
|
|
602
|
+
stages: resolveStages(workflow, meta),
|
|
603
|
+
active: activeStages(workflow, meta)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
async function listChanges(projectRoot, config) {
|
|
607
|
+
return listDirs(changesDir(projectRoot, config));
|
|
608
|
+
}
|
|
609
|
+
async function getActiveChangeId(projectRoot, config) {
|
|
610
|
+
const pointer = activePointerPath(projectRoot, config);
|
|
611
|
+
if (await pathExists(pointer)) {
|
|
612
|
+
const id = (await readText(pointer)).trim();
|
|
613
|
+
if (id && await pathExists(changePath(projectRoot, config, id))) {
|
|
614
|
+
return id;
|
|
615
|
+
}
|
|
616
|
+
if (id) {
|
|
617
|
+
await writeText(pointer, "");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const all = await listChanges(projectRoot, config);
|
|
621
|
+
const inProgress = [];
|
|
622
|
+
for (const id of all) {
|
|
623
|
+
try {
|
|
624
|
+
const meta = await loadChangeMeta(projectRoot, config, id);
|
|
625
|
+
if (meta.status === "in_progress" || meta.status === "blocked") {
|
|
626
|
+
inProgress.push({ id, updated: meta.updated ?? meta.created });
|
|
627
|
+
}
|
|
628
|
+
} catch {
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!inProgress.length) return null;
|
|
632
|
+
inProgress.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
633
|
+
return inProgress[0].id;
|
|
634
|
+
}
|
|
635
|
+
async function clearActiveIf(projectRoot, config, changeId) {
|
|
636
|
+
const pointer = activePointerPath(projectRoot, config);
|
|
637
|
+
if (!await pathExists(pointer)) return;
|
|
638
|
+
const id = (await readText(pointer)).trim();
|
|
639
|
+
if (id === changeId) {
|
|
640
|
+
await writeText(pointer, "");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function setActiveChange(projectRoot, config, changeId) {
|
|
644
|
+
await ensureDir(changesDir(projectRoot, config));
|
|
645
|
+
await writeText(activePointerPath(projectRoot, config), `${changeId}
|
|
646
|
+
`);
|
|
647
|
+
}
|
|
648
|
+
async function resolveChangeId(projectRoot, config, changeId) {
|
|
649
|
+
if (changeId) return changeId;
|
|
650
|
+
const active = await getActiveChangeId(projectRoot, config);
|
|
651
|
+
if (!active) {
|
|
652
|
+
throw new Error("No active change. Pass --change <id> or run `sdd new`.");
|
|
653
|
+
}
|
|
654
|
+
return active;
|
|
655
|
+
}
|
|
656
|
+
async function canLeaveStage(ctx, config) {
|
|
657
|
+
const stage = getStage(ctx.workflow, ctx.meta, ctx.meta.stage);
|
|
658
|
+
const errors = [];
|
|
659
|
+
const warnings = [];
|
|
660
|
+
if (!stage) {
|
|
661
|
+
return { ok: false, errors: [`Unknown stage: ${ctx.meta.stage}`], warnings };
|
|
662
|
+
}
|
|
663
|
+
for (const artifact of stage.artifacts) {
|
|
664
|
+
if (!artifact.required) continue;
|
|
665
|
+
const target = join3(ctx.path, artifact.path);
|
|
666
|
+
if (!await pathExists(target)) {
|
|
667
|
+
errors.push(`Missing required artifact: ${artifact.path}`);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const declared = stage.gate?.type ?? "soft";
|
|
672
|
+
let effectiveGate = declared;
|
|
673
|
+
if (config.policy.gates === "hard" && declared === "soft") {
|
|
674
|
+
effectiveGate = "hard";
|
|
675
|
+
}
|
|
676
|
+
const gateState = ctx.meta.gates?.[stage.id];
|
|
677
|
+
if (effectiveGate === "hard") {
|
|
678
|
+
if (!gateState || gateState.status !== "approved" && gateState.status !== "waived") {
|
|
679
|
+
errors.push(
|
|
680
|
+
`Hard gate on stage "${stage.id}" is not approved. Run \`sdd gate approve\` or complete the checklist.`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
} else if (effectiveGate === "soft" && stage.gate?.checklist?.length) {
|
|
684
|
+
if (!gateState || gateState.status === "pending") {
|
|
685
|
+
warnings.push(
|
|
686
|
+
`Soft gate checklist not signed off on "${stage.id}" (proceeding allowed).`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const requiredCmds = stage.verify?.commands?.filter((c) => c.required) ?? [];
|
|
691
|
+
if (requiredCmds.length) {
|
|
692
|
+
const verifyOk = ctx.meta.verify_results?.[stage.id]?.ok === true;
|
|
693
|
+
const humanOverride = gateState?.status === "waived" || gateState?.status === "approved";
|
|
694
|
+
if (!verifyOk && !humanOverride) {
|
|
695
|
+
errors.push(
|
|
696
|
+
`Required local verify commands have not passed for "${stage.id}". Run \`sdd verify\` (or \`sdd gate approve/waive\` with a note).`
|
|
697
|
+
);
|
|
698
|
+
} else if (!verifyOk && humanOverride) {
|
|
699
|
+
warnings.push(
|
|
700
|
+
`Stage "${stage.id}" gate signed off without a successful required verify run.`
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
705
|
+
}
|
|
706
|
+
async function advanceStage(projectRoot, config, changeId, opts) {
|
|
707
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
708
|
+
const warnings = [];
|
|
709
|
+
if (ctx.meta.status === "completed") {
|
|
710
|
+
throw new Error("Change is already completed.");
|
|
711
|
+
}
|
|
712
|
+
const check = await canLeaveStage(ctx, config);
|
|
713
|
+
warnings.push(...check.warnings);
|
|
714
|
+
if (!check.ok && !opts?.force) {
|
|
715
|
+
throw new Error(check.errors.join("\n"));
|
|
716
|
+
}
|
|
717
|
+
const from = ctx.meta.stage;
|
|
718
|
+
const next = nextStageId(ctx.workflow, ctx.meta);
|
|
719
|
+
if (!next) {
|
|
720
|
+
return {
|
|
721
|
+
ctx,
|
|
722
|
+
from,
|
|
723
|
+
to: null,
|
|
724
|
+
completed: false,
|
|
725
|
+
artifactsCreated: [],
|
|
726
|
+
warnings: [...warnings, "No further stages. Run `sdd complete` when ready."]
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
ctx.meta.stage = next;
|
|
730
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
731
|
+
const stage = getStage(ctx.workflow, ctx.meta, next);
|
|
732
|
+
const artifactsCreated = await materializeStageArtifacts(
|
|
733
|
+
projectRoot,
|
|
734
|
+
config,
|
|
735
|
+
changeId,
|
|
736
|
+
ctx.workflow,
|
|
737
|
+
ctx.meta,
|
|
738
|
+
stage
|
|
739
|
+
);
|
|
740
|
+
try {
|
|
741
|
+
const { refreshActiveAgentContext: refreshActiveAgentContext2 } = await import("./agents-AIH2DNH3.js");
|
|
742
|
+
await refreshActiveAgentContext2(projectRoot);
|
|
743
|
+
} catch {
|
|
744
|
+
}
|
|
745
|
+
const updated = await buildContext(projectRoot, config, changeId);
|
|
746
|
+
return {
|
|
747
|
+
ctx: updated,
|
|
748
|
+
from,
|
|
749
|
+
to: next,
|
|
750
|
+
completed: false,
|
|
751
|
+
artifactsCreated,
|
|
752
|
+
warnings
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
async function skipStage(projectRoot, config, changeId, stageId, reason) {
|
|
756
|
+
if (!config.per_change.allow_skip) {
|
|
757
|
+
throw new Error("Skipping stages is disabled in .sdd/config.yaml (per_change.allow_skip).");
|
|
758
|
+
}
|
|
759
|
+
if (config.per_change.require_reason_on_skip && !reason.trim()) {
|
|
760
|
+
throw new Error("A reason is required to skip a stage.");
|
|
761
|
+
}
|
|
762
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
763
|
+
const stage = getStage(ctx.workflow, ctx.meta, stageId);
|
|
764
|
+
if (!stage) {
|
|
765
|
+
throw new Error(`Stage "${stageId}" not found in workflow ${ctx.meta.workflow}`);
|
|
766
|
+
}
|
|
767
|
+
if (!stage.skippable) {
|
|
768
|
+
throw new Error(`Stage "${stageId}" is not skippable.`);
|
|
769
|
+
}
|
|
770
|
+
const skip_stages = new Set(ctx.meta.overrides.skip_stages ?? []);
|
|
771
|
+
skip_stages.add(stageId);
|
|
772
|
+
ctx.meta.overrides.skip_stages = [...skip_stages];
|
|
773
|
+
ctx.meta.skipped = [
|
|
774
|
+
...ctx.meta.skipped ?? [],
|
|
775
|
+
{ stage: stageId, reason, at: nowIso() }
|
|
776
|
+
];
|
|
777
|
+
if (ctx.meta.stage === stageId) {
|
|
778
|
+
const next = nextStageId(ctx.workflow, ctx.meta);
|
|
779
|
+
if (next) {
|
|
780
|
+
ctx.meta.stage = next;
|
|
781
|
+
const nextStage = getStage(ctx.workflow, ctx.meta, next);
|
|
782
|
+
if (nextStage) {
|
|
783
|
+
await materializeStageArtifacts(
|
|
784
|
+
projectRoot,
|
|
785
|
+
config,
|
|
786
|
+
changeId,
|
|
787
|
+
ctx.workflow,
|
|
788
|
+
ctx.meta,
|
|
789
|
+
nextStage
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
795
|
+
return buildContext(projectRoot, config, changeId);
|
|
796
|
+
}
|
|
797
|
+
async function switchWorkflow(projectRoot, config, changeId, workflowName, reason) {
|
|
798
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
799
|
+
const workflow = await loadWorkflow(projectRoot, workflowName);
|
|
800
|
+
if (config.allowed_workflows?.length && !config.allowed_workflows.includes(workflowName)) {
|
|
801
|
+
throw new Error(
|
|
802
|
+
`Workflow "${workflowName}" is not in allowed_workflows: ${config.allowed_workflows.join(", ")}`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
ctx.meta.workflow = workflowName;
|
|
806
|
+
const hasStage = workflow.stages.some((s) => s.id === ctx.meta.stage);
|
|
807
|
+
if (!hasStage) {
|
|
808
|
+
ctx.meta.stage = workflow.stages[0].id;
|
|
809
|
+
}
|
|
810
|
+
if (reason) {
|
|
811
|
+
ctx.meta.flags = {
|
|
812
|
+
...ctx.meta.flags,
|
|
813
|
+
workflow_switch_reason: reason
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
817
|
+
const stage = getStage(workflow, ctx.meta, ctx.meta.stage);
|
|
818
|
+
if (stage) {
|
|
819
|
+
await materializeStageArtifacts(projectRoot, config, changeId, workflow, ctx.meta, stage);
|
|
820
|
+
}
|
|
821
|
+
return buildContext(projectRoot, config, changeId);
|
|
822
|
+
}
|
|
823
|
+
async function approveGate(projectRoot, config, changeId, stageId, note, status = "approved") {
|
|
824
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
825
|
+
const id = stageId ?? ctx.meta.stage;
|
|
826
|
+
ctx.meta.gates = {
|
|
827
|
+
...ctx.meta.gates,
|
|
828
|
+
[id]: { status, note, at: nowIso() }
|
|
829
|
+
};
|
|
830
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
831
|
+
return buildContext(projectRoot, config, changeId);
|
|
832
|
+
}
|
|
833
|
+
async function completeChange(projectRoot, config, changeId) {
|
|
834
|
+
const ctx = await buildContext(projectRoot, config, changeId);
|
|
835
|
+
const next = nextStageId(ctx.workflow, ctx.meta);
|
|
836
|
+
if (next) {
|
|
837
|
+
const active = activeStages(ctx.workflow, ctx.meta);
|
|
838
|
+
const last = active[active.length - 1];
|
|
839
|
+
if (last && ctx.meta.stage !== last.id) {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`Cannot complete: still on stage "${ctx.meta.stage}". Finish remaining stages or skip them. Next: ${next}`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const check = await canLeaveStage(ctx, config);
|
|
846
|
+
if (!check.ok) {
|
|
847
|
+
throw new Error(check.errors.join("\n"));
|
|
848
|
+
}
|
|
849
|
+
ctx.meta.status = "completed";
|
|
850
|
+
ctx.meta.completed_at = nowIso();
|
|
851
|
+
await saveChangeMeta(projectRoot, config, ctx.meta);
|
|
852
|
+
await clearActiveIf(projectRoot, config, changeId);
|
|
853
|
+
let archivedTo = null;
|
|
854
|
+
const shouldArchive = config.persistence.archive_on_complete && ctx.workflow.on_complete?.archive !== false;
|
|
855
|
+
if (shouldArchive) {
|
|
856
|
+
const dest = join3(archiveDir(projectRoot, config), changeId);
|
|
857
|
+
if (await pathExists(dest)) {
|
|
858
|
+
throw new Error(`Archive already exists for change: ${changeId}`);
|
|
859
|
+
}
|
|
860
|
+
await ensureDir(archiveDir(projectRoot, config));
|
|
861
|
+
await copyDir(ctx.path, dest);
|
|
862
|
+
await removePath(ctx.path);
|
|
863
|
+
archivedTo = dest;
|
|
864
|
+
}
|
|
865
|
+
if (archivedTo) {
|
|
866
|
+
const meta = ChangeMetaSchema.parse(await readYaml(join3(archivedTo, "meta.yaml")));
|
|
867
|
+
return {
|
|
868
|
+
archivedTo,
|
|
869
|
+
ctx: {
|
|
870
|
+
id: changeId,
|
|
871
|
+
path: archivedTo,
|
|
872
|
+
meta,
|
|
873
|
+
workflow: ctx.workflow,
|
|
874
|
+
stages: ctx.stages,
|
|
875
|
+
active: ctx.active
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return { archivedTo, ctx: await buildContext(projectRoot, config, changeId) };
|
|
880
|
+
}
|
|
881
|
+
function formatStatus(ctx) {
|
|
882
|
+
const lines = [];
|
|
883
|
+
lines.push(`Change: ${ctx.meta.title}`);
|
|
884
|
+
lines.push(`ID: ${ctx.id}`);
|
|
885
|
+
lines.push(`Status: ${ctx.meta.status}`);
|
|
886
|
+
lines.push(`Workflow: ${ctx.meta.workflow}`);
|
|
887
|
+
lines.push(`Path: ${ctx.path}`);
|
|
888
|
+
lines.push("");
|
|
889
|
+
lines.push("Stages:");
|
|
890
|
+
for (const stage of ctx.stages) {
|
|
891
|
+
const skipped = isStageSkipped(ctx.meta, stage.id) || shouldAutoSkip(stage, ctx.meta);
|
|
892
|
+
let mark = " ";
|
|
893
|
+
if (skipped) mark = "\xB7";
|
|
894
|
+
else if (stage.id === ctx.meta.stage) mark = "\u25CF";
|
|
895
|
+
else {
|
|
896
|
+
const order = ctx.active.map((s) => s.id);
|
|
897
|
+
const cur = order.indexOf(ctx.meta.stage);
|
|
898
|
+
const idx = order.indexOf(stage.id);
|
|
899
|
+
if (idx >= 0 && cur >= 0 && idx < cur) mark = "\u2713";
|
|
900
|
+
}
|
|
901
|
+
const gate = ctx.meta.gates?.[stage.id];
|
|
902
|
+
const gateInfo = gate ? ` [${gate.status}]` : "";
|
|
903
|
+
const skipInfo = skipped ? " (skipped)" : "";
|
|
904
|
+
lines.push(` [${mark}] ${stage.id}${stage.title ? ` \u2014 ${stage.title}` : ""}${gateInfo}${skipInfo}`);
|
|
905
|
+
}
|
|
906
|
+
if (ctx.meta.overrides?.skip_stages?.length) {
|
|
907
|
+
lines.push("");
|
|
908
|
+
lines.push(`Overrides: skip=${ctx.meta.overrides.skip_stages.join(", ")}`);
|
|
909
|
+
}
|
|
910
|
+
return lines.join("\n");
|
|
911
|
+
}
|
|
912
|
+
async function buildAgentPrompt(ctx, config, projectRoot) {
|
|
913
|
+
const stage = getStage(ctx.workflow, ctx.meta, ctx.meta.stage);
|
|
914
|
+
const parts = [];
|
|
915
|
+
parts.push(`# SDD Agent Handoff`);
|
|
916
|
+
parts.push("");
|
|
917
|
+
parts.push(`You are implementing a change under Structured Vibe Coding (local SDD).`);
|
|
918
|
+
parts.push("");
|
|
919
|
+
parts.push(`## Change`);
|
|
920
|
+
parts.push(`- Title: ${ctx.meta.title}`);
|
|
921
|
+
parts.push(`- ID: ${ctx.id}`);
|
|
922
|
+
parts.push(`- Workflow: ${ctx.meta.workflow}`);
|
|
923
|
+
parts.push(`- Current stage: ${ctx.meta.stage}${stage?.title ? ` (${stage.title})` : ""}`);
|
|
924
|
+
parts.push(`- Change path: ${ctx.path}`);
|
|
925
|
+
parts.push("");
|
|
926
|
+
if (stage?.summary) {
|
|
927
|
+
parts.push(`## Stage goal`);
|
|
928
|
+
parts.push(stage.summary);
|
|
929
|
+
parts.push("");
|
|
930
|
+
}
|
|
931
|
+
if (stage?.agent_context?.instructions) {
|
|
932
|
+
parts.push(`## Instructions`);
|
|
933
|
+
parts.push(stage.agent_context.instructions);
|
|
934
|
+
parts.push("");
|
|
935
|
+
}
|
|
936
|
+
parts.push(`## Constraints`);
|
|
937
|
+
parts.push(`- Follow artifacts in the change directory as source of truth.`);
|
|
938
|
+
parts.push(`- Do not skip ARB/decision constraints if present.`);
|
|
939
|
+
parts.push(`- Local development only \u2014 verify on this machine.`);
|
|
940
|
+
parts.push(`- Prefer small, reviewable commits.`);
|
|
941
|
+
parts.push("");
|
|
942
|
+
parts.push(`## Artifacts to read`);
|
|
943
|
+
const memoryHint = join3(projectRoot, config.memory_path);
|
|
944
|
+
parts.push(`- Memory: ${memoryHint}/`);
|
|
945
|
+
for (const s of ctx.stages) {
|
|
946
|
+
for (const a of s.artifacts) {
|
|
947
|
+
parts.push(`- ${join3(ctx.path, a.path)}`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
parts.push("");
|
|
951
|
+
parts.push(`## Current stage artifacts`);
|
|
952
|
+
if (stage) {
|
|
953
|
+
for (const a of stage.artifacts) {
|
|
954
|
+
parts.push(`- ${a.path}${a.required ? " (required)" : ""}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
parts.push("");
|
|
958
|
+
parts.push(`When done with this stage, the human will run \`sdd next\` or \`sdd verify\` / \`sdd complete\`.`);
|
|
959
|
+
return parts.join("\n");
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/agents.ts
|
|
963
|
+
var ALL_AGENT_TARGETS = ["copilot", "claude-code", "intellij"];
|
|
964
|
+
var SDD_AGENT_ROLES = [
|
|
965
|
+
{
|
|
966
|
+
id: "sdd",
|
|
967
|
+
description: "Default SDD agent. Use for any Spec-Driven Development work on the active change pack.",
|
|
968
|
+
roleLine: "Router: inspect stage in active-context; plan or implement accordingly.",
|
|
969
|
+
roleRules: [
|
|
970
|
+
"If stage is intent/feature/design/tasks/stories/research: act as planner (artifacts, little or no product code).",
|
|
971
|
+
"If stage is implement: act as implementer (scoped code only).",
|
|
972
|
+
"If stage is local_verify: act as reviewer (gaps vs acceptance; fixes only)."
|
|
973
|
+
]
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
id: "sdd-planner",
|
|
977
|
+
description: "SDD planner. Use when filling intent, design, research, stories, or tasks \u2014 not large code changes.",
|
|
978
|
+
roleLine: "Planner: produce/update stage markdown only.",
|
|
979
|
+
roleRules: [
|
|
980
|
+
"Do not make large product code changes unless the user explicitly overrides.",
|
|
981
|
+
"Keep artifacts short and actionable."
|
|
982
|
+
]
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
id: "sdd-implementer",
|
|
986
|
+
description: "SDD implementer. Use when coding the active change (implement stage).",
|
|
987
|
+
roleLine: "Implementer: code only for the active change pack.",
|
|
988
|
+
roleRules: [
|
|
989
|
+
"Follow tasks.md / acceptance; honor arb-decision and design constraints.",
|
|
990
|
+
"Do not expand scope beyond the change title/intent."
|
|
991
|
+
]
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
id: "sdd-reviewer",
|
|
995
|
+
description: "SDD reviewer. Use before verify/complete or to check work against the change pack.",
|
|
996
|
+
roleLine: "Reviewer: compare work to acceptance and stage artifacts.",
|
|
997
|
+
roleRules: [
|
|
998
|
+
"List gaps; suggest minimal fixes only.",
|
|
999
|
+
"Remind user to run sdd verify before sdd complete."
|
|
1000
|
+
]
|
|
1001
|
+
}
|
|
1002
|
+
];
|
|
1003
|
+
async function installAgentIntegrations(opts) {
|
|
1004
|
+
const targets = opts.targets?.length ? opts.targets : ALL_AGENT_TARGETS;
|
|
1005
|
+
const created = [];
|
|
1006
|
+
const skipped = [];
|
|
1007
|
+
const root = opts.projectRoot;
|
|
1008
|
+
const force = opts.force ?? false;
|
|
1009
|
+
const write = async (rel, content) => {
|
|
1010
|
+
const full = join4(root, rel);
|
|
1011
|
+
if (await pathExists(full) && !force) {
|
|
1012
|
+
skipped.push(rel);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
await writeText(full, content);
|
|
1016
|
+
created.push(rel);
|
|
1017
|
+
};
|
|
1018
|
+
await write(join4(".sdd", "protocol.md"), PROTOCOL_MD);
|
|
1019
|
+
await refreshActiveAgentContext(root);
|
|
1020
|
+
if (targets.includes("copilot") || targets.includes("claude-code")) {
|
|
1021
|
+
for (const role of SDD_AGENT_ROLES) {
|
|
1022
|
+
const body = renderThinAgent(role);
|
|
1023
|
+
if (targets.includes("claude-code")) {
|
|
1024
|
+
await write(`.claude/agents/${role.id}.md`, body);
|
|
1025
|
+
}
|
|
1026
|
+
if (targets.includes("copilot")) {
|
|
1027
|
+
await write(`.github/agents/${role.id}.agent.md`, body);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (targets.includes("intellij")) {
|
|
1032
|
+
await write(".idea/sdd-agent-notes.md", INTELLIJ_NOTES);
|
|
1033
|
+
}
|
|
1034
|
+
if (targets.includes("copilot") || targets.includes("claude-code") || targets.includes("intellij")) {
|
|
1035
|
+
await write("AGENTS.md", AGENTS_MD_THIN);
|
|
1036
|
+
}
|
|
1037
|
+
await write(
|
|
1038
|
+
join4(".sdd", "agents.json"),
|
|
1039
|
+
JSON.stringify(
|
|
1040
|
+
{
|
|
1041
|
+
version: 2,
|
|
1042
|
+
mode: "agents-only",
|
|
1043
|
+
protocol: ".sdd/protocol.md",
|
|
1044
|
+
activeContext: ".sdd/active-context.md",
|
|
1045
|
+
roles: SDD_AGENT_ROLES.map((r) => r.id),
|
|
1046
|
+
installed: targets,
|
|
1047
|
+
updated: (/* @__PURE__ */ new Date()).toISOString()
|
|
1048
|
+
},
|
|
1049
|
+
null,
|
|
1050
|
+
2
|
|
1051
|
+
) + "\n"
|
|
1052
|
+
);
|
|
1053
|
+
return { created, skipped, targets };
|
|
1054
|
+
}
|
|
1055
|
+
function renderThinAgent(role) {
|
|
1056
|
+
const rules = role.roleRules.map((r) => `- ${r}`).join("\n");
|
|
1057
|
+
return `---
|
|
1058
|
+
name: ${role.id}
|
|
1059
|
+
description: ${role.description}
|
|
1060
|
+
---
|
|
1061
|
+
|
|
1062
|
+
# ${role.id}
|
|
1063
|
+
|
|
1064
|
+
${role.roleLine}
|
|
1065
|
+
|
|
1066
|
+
## Required reads (in order)
|
|
1067
|
+
|
|
1068
|
+
1. \`.sdd/active-context.md\` \u2014 current change and stage
|
|
1069
|
+
2. \`.sdd/protocol.md\` \u2014 SDD rules for this repo (single playbook)
|
|
1070
|
+
3. Active change files under \`changes/<id>/\` as listed in active-context
|
|
1071
|
+
|
|
1072
|
+
## Role rules
|
|
1073
|
+
|
|
1074
|
+
${rules}
|
|
1075
|
+
|
|
1076
|
+
## Done
|
|
1077
|
+
|
|
1078
|
+
Remind the human: \`sdd verify\` then \`sdd next\` / \`sdd complete\` as appropriate.
|
|
1079
|
+
Do not claim the change is complete without local verification when the workflow requires it.
|
|
1080
|
+
`;
|
|
1081
|
+
}
|
|
1082
|
+
async function refreshActiveAgentContext(projectRoot) {
|
|
1083
|
+
const markerDir = sddRoot(projectRoot);
|
|
1084
|
+
if (!await pathExists(join4(markerDir, "config.yaml"))) {
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
const protocolPath = join4(markerDir, "protocol.md");
|
|
1088
|
+
if (!await pathExists(protocolPath)) {
|
|
1089
|
+
await writeText(protocolPath, PROTOCOL_MD);
|
|
1090
|
+
}
|
|
1091
|
+
const config = await loadConfig(projectRoot);
|
|
1092
|
+
const activeId = await getActiveChangeId(projectRoot, config);
|
|
1093
|
+
const outPath = join4(markerDir, "active-context.md");
|
|
1094
|
+
if (!activeId) {
|
|
1095
|
+
const empty = `# SDD active context
|
|
1096
|
+
|
|
1097
|
+
_No active change._ Run \`sdd new "\u2026"\` or **SDD: New Change** in the IDE.
|
|
1098
|
+
|
|
1099
|
+
Then re-run \`sdd agents refresh\` (or advance a stage) to update this file.
|
|
1100
|
+
|
|
1101
|
+
Protocol: \`.sdd/protocol.md\`
|
|
1102
|
+
`;
|
|
1103
|
+
await writeText(outPath, empty);
|
|
1104
|
+
return outPath;
|
|
1105
|
+
}
|
|
1106
|
+
const ctx = await buildContext(projectRoot, config, activeId);
|
|
1107
|
+
const handoff = await buildAgentPrompt(ctx, config, projectRoot);
|
|
1108
|
+
const status = formatStatus(ctx);
|
|
1109
|
+
const body = `# SDD active context
|
|
1110
|
+
|
|
1111
|
+
> Auto-generated for coding agents.
|
|
1112
|
+
> Change: **${ctx.meta.title}** \xB7 \`${ctx.id}\` \xB7 stage **${ctx.meta.stage}**
|
|
1113
|
+
> Protocol: \`.sdd/protocol.md\`
|
|
1114
|
+
|
|
1115
|
+
## Status
|
|
1116
|
+
|
|
1117
|
+
\`\`\`
|
|
1118
|
+
${status}
|
|
1119
|
+
\`\`\`
|
|
1120
|
+
|
|
1121
|
+
${handoff}
|
|
1122
|
+
`;
|
|
1123
|
+
await writeText(outPath, body);
|
|
1124
|
+
return outPath;
|
|
1125
|
+
}
|
|
1126
|
+
var PROTOCOL_MD = `# SDD protocol
|
|
1127
|
+
|
|
1128
|
+
This repo uses **Structured Vibe Coding** (\`sdd\`). Agents must follow this file plus \`.sdd/active-context.md\`. Do not invent a parallel process.
|
|
1129
|
+
|
|
1130
|
+
## Source of truth
|
|
1131
|
+
|
|
1132
|
+
| What | Where |
|
|
1133
|
+
|------|--------|
|
|
1134
|
+
| Process state | \`changes/<id>/meta.yaml\` (workflow, stage, gates) |
|
|
1135
|
+
| Current task snapshot | \`.sdd/active-context.md\` |
|
|
1136
|
+
| Stable product/tech rules | \`memory/*.md\` |
|
|
1137
|
+
| Stage artifacts | files in \`changes/<id>/\` for the active change |
|
|
1138
|
+
|
|
1139
|
+
## Required read order
|
|
1140
|
+
|
|
1141
|
+
1. \`.sdd/active-context.md\`
|
|
1142
|
+
2. This file (\`.sdd/protocol.md\`)
|
|
1143
|
+
3. \`meta.yaml\` + artifacts for the **current stage** (and prior stages if needed)
|
|
1144
|
+
4. \`memory/\` when architecture or conventions matter
|
|
1145
|
+
|
|
1146
|
+
## Stage behavior
|
|
1147
|
+
|
|
1148
|
+
| Stage kind | Agent focus |
|
|
1149
|
+
|------------|-------------|
|
|
1150
|
+
| intent / feature / stories | Clarify scope; write short artifacts |
|
|
1151
|
+
| design / lld / db / research / hl_arb | Specs and decisions; minimal product code |
|
|
1152
|
+
| tasks | Break work into implementable checklist |
|
|
1153
|
+
| implement | Code only for this change; follow tasks/acceptance |
|
|
1154
|
+
| local_verify | Check acceptance; fix only; prepare for complete |
|
|
1155
|
+
|
|
1156
|
+
## Hard rules
|
|
1157
|
+
|
|
1158
|
+
1. **Do not skip hard gates.** If blocked, tell the human to run \`sdd gate approve\` (or fix the gate).
|
|
1159
|
+
2. **Stay in scope** of the active change title/intent. No drive-by refactors or new features.
|
|
1160
|
+
3. **Honor constraints** in \`arb-decision.md\`, design, and memory non-negotiables.
|
|
1161
|
+
4. **Local verify** is part of done when the workflow has a verify stage: \`sdd verify\`.
|
|
1162
|
+
5. Prefer small, reviewable diffs.
|
|
1163
|
+
|
|
1164
|
+
## Commands (human / shell)
|
|
1165
|
+
|
|
1166
|
+
\`\`\`bash
|
|
1167
|
+
sdd status
|
|
1168
|
+
sdd next
|
|
1169
|
+
sdd agent # print handoff
|
|
1170
|
+
sdd agents refresh # refresh active-context.md
|
|
1171
|
+
sdd verify
|
|
1172
|
+
sdd complete
|
|
1173
|
+
\`\`\`
|
|
1174
|
+
|
|
1175
|
+
## Out of scope for agents
|
|
1176
|
+
|
|
1177
|
+
- Replacing CI/CD or team process tools
|
|
1178
|
+
- Deleting \`.sdd\` / archive history
|
|
1179
|
+
- Claiming complete without verify when required
|
|
1180
|
+
`;
|
|
1181
|
+
var AGENTS_MD_THIN = `# Agents
|
|
1182
|
+
|
|
1183
|
+
This repo uses **SDD agents only** (no skills).
|
|
1184
|
+
|
|
1185
|
+
| Read first | |
|
|
1186
|
+
|------------|--|
|
|
1187
|
+
| Live task | \`.sdd/active-context.md\` |
|
|
1188
|
+
| Playbook | \`.sdd/protocol.md\` |
|
|
1189
|
+
|
|
1190
|
+
| Host | Agents |
|
|
1191
|
+
|------|--------|
|
|
1192
|
+
| Claude Code | \`.claude/agents/\` (\`sdd\`, \`sdd-planner\`, \`sdd-implementer\`, \`sdd-reviewer\`) |
|
|
1193
|
+
| GitHub Copilot | \`.github/agents/*.agent.md\` (same roles) |
|
|
1194
|
+
|
|
1195
|
+
Refresh context: \`sdd agents refresh\`.
|
|
1196
|
+
`;
|
|
1197
|
+
var INTELLIJ_NOTES = `# SDD + IntelliJ
|
|
1198
|
+
|
|
1199
|
+
Agents only. Playbook: \`.sdd/protocol.md\`. Live: \`.sdd/active-context.md\`.
|
|
1200
|
+
|
|
1201
|
+
1. Install \`sdd\` on PATH: \`npm i -g @structured-vibe-coding/cli\`
|
|
1202
|
+
2. Tools \u2192 SDD \u2192 Initialize / Install Agent Integrations
|
|
1203
|
+
3. GitHub Copilot uses \`.github/agents/*\` + active-context (same as VS Code)
|
|
1204
|
+
4. After stage changes: **Refresh Agent Context**
|
|
1205
|
+
`;
|
|
1206
|
+
|
|
1207
|
+
export {
|
|
1208
|
+
GateSchema,
|
|
1209
|
+
ArtifactDefSchema,
|
|
1210
|
+
VerifyCommandSchema,
|
|
1211
|
+
StageSchema,
|
|
1212
|
+
WorkflowSchema,
|
|
1213
|
+
ConfigSchema,
|
|
1214
|
+
ChangeMetaSchema,
|
|
1215
|
+
SDD_DIR,
|
|
1216
|
+
sddRoot,
|
|
1217
|
+
configPath,
|
|
1218
|
+
workflowsDir,
|
|
1219
|
+
templatesDir,
|
|
1220
|
+
memoryDir,
|
|
1221
|
+
changesDir,
|
|
1222
|
+
archiveDir,
|
|
1223
|
+
domainsDir,
|
|
1224
|
+
changePath,
|
|
1225
|
+
changeMetaPath,
|
|
1226
|
+
activePointerPath,
|
|
1227
|
+
pathExists,
|
|
1228
|
+
ensureDir,
|
|
1229
|
+
readText,
|
|
1230
|
+
writeText,
|
|
1231
|
+
readYaml,
|
|
1232
|
+
writeYaml,
|
|
1233
|
+
listDirs,
|
|
1234
|
+
listFiles,
|
|
1235
|
+
copyDir,
|
|
1236
|
+
removePath,
|
|
1237
|
+
isInitialized,
|
|
1238
|
+
loadConfig,
|
|
1239
|
+
saveConfig,
|
|
1240
|
+
defaultConfig,
|
|
1241
|
+
projectPaths,
|
|
1242
|
+
listWorkflowNames,
|
|
1243
|
+
loadWorkflow,
|
|
1244
|
+
resolveStages,
|
|
1245
|
+
isStageSkipped,
|
|
1246
|
+
shouldAutoSkip,
|
|
1247
|
+
activeStages,
|
|
1248
|
+
getStage,
|
|
1249
|
+
nextStageId,
|
|
1250
|
+
previousStageId,
|
|
1251
|
+
firstActiveStageId,
|
|
1252
|
+
inferComplexity,
|
|
1253
|
+
recommendWorkflow,
|
|
1254
|
+
slugify,
|
|
1255
|
+
todayIsoDate,
|
|
1256
|
+
nowIso,
|
|
1257
|
+
changeDirName,
|
|
1258
|
+
uniqueChangeDirName,
|
|
1259
|
+
ALL_AGENT_TARGETS,
|
|
1260
|
+
SDD_AGENT_ROLES,
|
|
1261
|
+
installAgentIntegrations,
|
|
1262
|
+
renderThinAgent,
|
|
1263
|
+
refreshActiveAgentContext,
|
|
1264
|
+
PROTOCOL_MD,
|
|
1265
|
+
createChange,
|
|
1266
|
+
loadChangeMeta,
|
|
1267
|
+
saveChangeMeta,
|
|
1268
|
+
buildContext,
|
|
1269
|
+
listChanges,
|
|
1270
|
+
getActiveChangeId,
|
|
1271
|
+
clearActiveIf,
|
|
1272
|
+
setActiveChange,
|
|
1273
|
+
resolveChangeId,
|
|
1274
|
+
canLeaveStage,
|
|
1275
|
+
advanceStage,
|
|
1276
|
+
skipStage,
|
|
1277
|
+
switchWorkflow,
|
|
1278
|
+
approveGate,
|
|
1279
|
+
completeChange,
|
|
1280
|
+
formatStatus,
|
|
1281
|
+
buildAgentPrompt
|
|
1282
|
+
};
|