@bvdm/delano 0.2.4 → 0.2.5

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.
@@ -0,0 +1,918 @@
1
+ const { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const { CliError } = require("./errors");
5
+ const { findDelanoRoot } = require("./runtime");
6
+
7
+ const CLOSED_TASK_STATUSES = new Set(["done", "deferred", "canceled"]);
8
+ const PROGRESSED_TASK_STATUSES = new Set(["in-progress", "done"]);
9
+
10
+ function requireDelanoRoot(startDir = process.cwd()) {
11
+ const root = findDelanoRoot(startDir);
12
+ if (!root) {
13
+ throw new CliError(
14
+ "Could not find a Delano repository. Run this inside a repo with .project/ and .agents/scripts/pm/.",
15
+ 1
16
+ );
17
+ }
18
+ return root;
19
+ }
20
+
21
+ function nowIso() {
22
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
23
+ }
24
+
25
+ function slugify(value) {
26
+ const slug = String(value || "")
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/['"]/g, "")
30
+ .replace(/[^a-z0-9]+/g, "-")
31
+ .replace(/^-+|-+$/g, "");
32
+ return slug || "untitled";
33
+ }
34
+
35
+ function requireSlug(value, label = "slug") {
36
+ const slug = String(value || "").trim();
37
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
38
+ throw new CliError(`${label} must be kebab-case, for example: my-project.`, 1);
39
+ }
40
+ return slug;
41
+ }
42
+
43
+ function requireWorkstreamId(value) {
44
+ const id = String(value || "").trim();
45
+ if (!/^WS-[A-Za-z0-9]+$/.test(id)) {
46
+ throw new CliError("workstream id must use canonical form like WS-A.", 1);
47
+ }
48
+ return id;
49
+ }
50
+
51
+ function requireTaskId(value) {
52
+ const id = String(value || "").trim();
53
+ if (!/^T-[0-9]{3}$/.test(id)) {
54
+ throw new CliError("task id must use canonical form like T-001.", 1);
55
+ }
56
+ return id;
57
+ }
58
+
59
+ function readTemplate(root, templateName) {
60
+ const templatePath = path.join(root, ".project", "templates", templateName);
61
+ if (!existsSync(templatePath)) {
62
+ throw new CliError(`Missing template: .project/templates/${templateName}`, 1);
63
+ }
64
+ return readFileSync(templatePath, "utf8").replace(/\r\n/g, "\n");
65
+ }
66
+
67
+ function renderTemplate(root, templateName, replacements) {
68
+ let text = readTemplate(root, templateName);
69
+ for (const [token, value] of Object.entries(replacements)) {
70
+ text = text.split(token).join(String(value));
71
+ }
72
+ return text;
73
+ }
74
+
75
+ function projectDir(root, slug) {
76
+ return path.join(root, ".project", "projects", slug);
77
+ }
78
+
79
+ function loadProjectState(projectPath) {
80
+ return {
81
+ projectDir: projectPath,
82
+ spec: loadMarkdownFile(path.join(projectPath, "spec.md")),
83
+ plan: loadMarkdownFile(path.join(projectPath, "plan.md")),
84
+ decisions: loadMarkdownFile(path.join(projectPath, "decisions.md")),
85
+ workstreams: loadMarkdownFiles(path.join(projectPath, "workstreams")),
86
+ tasks: loadMarkdownFiles(path.join(projectPath, "tasks"))
87
+ };
88
+ }
89
+
90
+ function loadProject(root, slug) {
91
+ const resolvedSlug = requireSlug(slug, "project slug");
92
+ const resolvedProjectDir = projectDir(root, resolvedSlug);
93
+ if (!existsSync(resolvedProjectDir)) {
94
+ throw new CliError(`Project not found: .project/projects/${resolvedSlug}`, 1);
95
+ }
96
+ return loadProjectState(resolvedProjectDir);
97
+ }
98
+
99
+ function loadMarkdownFiles(directory) {
100
+ if (!existsSync(directory)) {
101
+ return [];
102
+ }
103
+ return readdirSync(directory)
104
+ .filter((file) => file.endsWith(".md"))
105
+ .sort()
106
+ .map((file) => loadMarkdownFile(path.join(directory, file)));
107
+ }
108
+
109
+ function loadMarkdownFile(filePath) {
110
+ if (!existsSync(filePath)) {
111
+ return null;
112
+ }
113
+ const text = readFileSync(filePath, "utf8").replace(/\r\n/g, "\n");
114
+ return {
115
+ path: filePath,
116
+ text,
117
+ frontmatter: parseFrontmatter(text),
118
+ originalText: text
119
+ };
120
+ }
121
+
122
+ function parseFrontmatter(text) {
123
+ const match = text.match(/^---\n([\s\S]*?)\n---\n/);
124
+ if (!match) {
125
+ return {};
126
+ }
127
+ const result = {};
128
+ for (const line of match[1].split("\n")) {
129
+ const index = line.indexOf(":");
130
+ if (index === -1) continue;
131
+ result[line.slice(0, index).trim()] = line.slice(index + 1).trim();
132
+ }
133
+ return result;
134
+ }
135
+
136
+ function setFrontmatter(file, key, value) {
137
+ file.frontmatter[key] = String(value);
138
+ file.text = updateFrontmatterText(file.text, file.frontmatter);
139
+ }
140
+
141
+ function removeFrontmatter(file, key) {
142
+ if (Object.prototype.hasOwnProperty.call(file.frontmatter, key)) {
143
+ delete file.frontmatter[key];
144
+ file.text = updateFrontmatterText(file.text, file.frontmatter);
145
+ }
146
+ }
147
+
148
+ function updateFrontmatterText(text, frontmatter) {
149
+ const match = text.match(/^---\n([\s\S]*?)\n---\n/);
150
+ if (!match) {
151
+ throw new CliError("Cannot update markdown without frontmatter.", 1);
152
+ }
153
+
154
+ const originalLines = match[1].split("\n");
155
+ const seen = new Set();
156
+ const lines = [];
157
+
158
+ for (const line of originalLines) {
159
+ const index = line.indexOf(":");
160
+ if (index === -1) {
161
+ lines.push(line);
162
+ continue;
163
+ }
164
+
165
+ const key = line.slice(0, index).trim();
166
+ if (!Object.prototype.hasOwnProperty.call(frontmatter, key)) {
167
+ continue;
168
+ }
169
+
170
+ seen.add(key);
171
+ lines.push(`${key}: ${frontmatter[key]}`);
172
+ }
173
+
174
+ for (const [key, value] of Object.entries(frontmatter)) {
175
+ if (!seen.has(key)) {
176
+ lines.push(`${key}: ${value}`);
177
+ }
178
+ }
179
+
180
+ return `---\n${lines.join("\n")}\n---\n${text.slice(match[0].length)}`;
181
+ }
182
+
183
+ function appendEvidence(file, timestamp, text) {
184
+ appendSectionEntry(file, "Evidence Log", `- ${timestamp}: ${cleanInlineText(text)}`);
185
+ }
186
+
187
+ function appendUpdate(file, timestamp, text) {
188
+ appendSectionEntry(file, "Updates", `- ${timestamp}: ${cleanInlineText(text)}`);
189
+ }
190
+
191
+ function appendSectionEntry(file, sectionName, entry) {
192
+ const trimmedEntry = String(entry || "").trim();
193
+ if (!trimmedEntry) {
194
+ return;
195
+ }
196
+
197
+ const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
198
+ const headingPattern = new RegExp(`(\\n## ${escaped}\\n)`);
199
+ if (headingPattern.test(file.text)) {
200
+ file.text = file.text.replace(headingPattern, (match) => `${match}\n${trimmedEntry}\n`);
201
+ return;
202
+ }
203
+
204
+ file.text = `${file.text.replace(/\s+$/, "")}\n\n## ${sectionName}\n\n${trimmedEntry}\n`;
205
+ }
206
+
207
+ function cleanInlineText(value) {
208
+ return String(value || "").replace(/\s+/g, " ").trim();
209
+ }
210
+
211
+ function createProjectFromTemplates(root, options) {
212
+ const slug = requireSlug(options.slug, "project slug");
213
+ const targetDir = projectDir(root, slug);
214
+ if (existsSync(targetDir)) {
215
+ throw new CliError(`Project already exists: .project/projects/${slug}`, 1);
216
+ }
217
+
218
+ const timestamp = options.now || nowIso();
219
+ const name = options.name || titleFromSlug(slug);
220
+ const owner = options.owner || "team";
221
+ const lead = options.lead || owner;
222
+ const outcome = options.outcome || "A traceable Delano project is ready for execution.";
223
+ const uncertainty = options.uncertainty || "medium";
224
+ const probeRequired = normalizeBooleanOption(options.probeRequired, false);
225
+ const probeStatus = options.probeStatus || (probeRequired === "true" ? "pending" : "skipped");
226
+ const riskLevel = options.riskLevel || uncertainty;
227
+ const generationNote = "Created from `.project/templates` by `delano project create`.";
228
+ const baseReplacements = {
229
+ "<project-name>": name,
230
+ "<kebab-case>": slug,
231
+ "<person-or-team>": owner,
232
+ "<person>": lead,
233
+ "<ISO8601 UTC>": timestamp,
234
+ "<true|false>": probeRequired,
235
+ "<pending|skipped|completed>": probeStatus,
236
+ "<planned|active|complete|deferred>": "planned",
237
+ "<source or generation notes>": generationNote,
238
+ "<exception, rationale, and owner>": "None recorded.",
239
+ "<user>": "operator",
240
+ "<capability>": "execute this Delano project from local contracts",
241
+ "<outcome>": outcome,
242
+ "<context>": "the project contract is created",
243
+ "<action>": "the project enters execution",
244
+ "<observable result>": "contract state and evidence remain traceable",
245
+ "<assumption to validate>": "Project scope and ownership remain accurate as execution starts.",
246
+ "<question that must be answered before activation or execution>": "None recorded at creation."
247
+ };
248
+
249
+ const spec = renderTemplate(root, "spec.md", {
250
+ ...baseReplacements,
251
+ "<measurable target>": outcome,
252
+ "<low|medium|high>": uncertainty
253
+ });
254
+ const plan = renderTemplate(root, "plan.md", {
255
+ ...baseReplacements,
256
+ "<low|medium|high>": riskLevel
257
+ });
258
+ const decisions = renderTemplate(root, "decisions.md", baseReplacements);
259
+
260
+ mkdirSync(path.join(targetDir, "tasks"), { recursive: true });
261
+ mkdirSync(path.join(targetDir, "workstreams"), { recursive: true });
262
+ mkdirSync(path.join(targetDir, "updates"), { recursive: true });
263
+ writeFileSync(path.join(targetDir, "spec.md"), spec, "utf8");
264
+ writeFileSync(path.join(targetDir, "plan.md"), plan, "utf8");
265
+ writeFileSync(path.join(targetDir, "decisions.md"), decisions, "utf8");
266
+
267
+ return {
268
+ slug,
269
+ projectDir: targetDir,
270
+ files: [
271
+ `.project/projects/${slug}/spec.md`,
272
+ `.project/projects/${slug}/plan.md`,
273
+ `.project/projects/${slug}/decisions.md`
274
+ ]
275
+ };
276
+ }
277
+
278
+ function addWorkstreamFromTemplate(root, options) {
279
+ const project = loadProject(root, options.project);
280
+ const id = requireWorkstreamId(options.id);
281
+ if (findWorkstream(project, id)) {
282
+ throw new CliError(`Workstream already exists: ${id}`, 1);
283
+ }
284
+
285
+ const timestamp = options.now || nowIso();
286
+ const title = options.name || titleFromSlug(id.replace(/^WS-/, "workstream-"));
287
+ const displayName = title.startsWith(`${id} `) ? title : `${id} ${title}`;
288
+ const owner = options.owner || "team";
289
+ const text = renderTemplate(root, "workstream.md", {
290
+ "WS-A API Foundation": displayName,
291
+ "backend-team": owner,
292
+ "id: WS-A": `id: ${id}`,
293
+ "<ISO8601 UTC>": timestamp
294
+ });
295
+ const filename = `${id}-${slugify(displayName.replace(new RegExp(`^${id}\\s+`), ""))}.md`;
296
+ const workstreamDir = path.join(project.projectDir, "workstreams");
297
+ mkdirSync(workstreamDir, { recursive: true });
298
+ const filePath = path.join(workstreamDir, filename);
299
+ if (existsSync(filePath)) {
300
+ throw new CliError(`Workstream file already exists: ${path.relative(project.projectDir, filePath)}`, 1);
301
+ }
302
+ writeFileSync(filePath, text, "utf8");
303
+
304
+ return {
305
+ project,
306
+ id,
307
+ filePath,
308
+ files: [relativeProjectPath(project, filePath)]
309
+ };
310
+ }
311
+
312
+ function addTaskFromTemplate(root, options) {
313
+ const project = loadProject(root, options.project);
314
+ const id = requireTaskId(options.id);
315
+ if (findTask(project, id)) {
316
+ throw new CliError(`Task already exists: ${id}`, 1);
317
+ }
318
+
319
+ const workstreamId = requireWorkstreamId(options.workstream);
320
+ const workstream = findWorkstream(project, workstreamId);
321
+ if (!workstream) {
322
+ throw new CliError(`Workstream not found in project: ${workstreamId}`, 1);
323
+ }
324
+
325
+ const timestamp = options.now || nowIso();
326
+ const name = options.name || titleFromSlug(id.toLowerCase());
327
+ const text = renderTemplate(root, "task.md", {
328
+ "T-001": id,
329
+ "WS-A": workstreamId,
330
+ "<task-title>": name,
331
+ "<ISO8601 UTC>": timestamp,
332
+ "<story_id or none>": options.storyId || "none",
333
+ "<acceptance criteria ids or none>": formatHumanList(options.acceptanceCriteriaIds || []),
334
+ "<date>: <evidence>": `${timestamp}: Created from .project/templates/task.md by \`delano task add\`.`
335
+ });
336
+
337
+ const taskDir = path.join(project.projectDir, "tasks");
338
+ mkdirSync(taskDir, { recursive: true });
339
+ const filePath = path.join(taskDir, `${id}-${slugify(name)}.md`);
340
+ if (existsSync(filePath)) {
341
+ throw new CliError(`Task file already exists: ${path.relative(project.projectDir, filePath)}`, 1);
342
+ }
343
+
344
+ const file = {
345
+ path: filePath,
346
+ text,
347
+ frontmatter: parseFrontmatter(text),
348
+ originalText: ""
349
+ };
350
+ setFrontmatter(file, "depends_on", formatInlineList(options.dependsOn || []));
351
+ setFrontmatter(file, "conflicts_with", formatInlineList(options.conflictsWith || []));
352
+ setFrontmatter(file, "parallel", normalizeBooleanOption(options.parallel, true));
353
+ setFrontmatter(file, "priority", options.priority || "medium");
354
+ setFrontmatter(file, "estimate", options.estimate || "M");
355
+ if (options.storyId) setFrontmatter(file, "story_id", options.storyId);
356
+ if (options.acceptanceCriteriaIds && options.acceptanceCriteriaIds.length > 0) {
357
+ setFrontmatter(file, "acceptance_criteria_ids", formatInlineList(options.acceptanceCriteriaIds));
358
+ }
359
+ if (options.description) {
360
+ fillSection(file, "Description", cleanParagraph(options.description));
361
+ }
362
+ if (options.acceptanceCriteria && options.acceptanceCriteria.length > 0) {
363
+ fillSection(file, "Acceptance Criteria", options.acceptanceCriteria.map((criterion) => `- [ ] ${criterion}`).join("\n"));
364
+ }
365
+
366
+ writeFileSync(filePath, file.text, "utf8");
367
+
368
+ const changes = [];
369
+ if (["done", "deferred"].includes(workstream.frontmatter.status || "")) {
370
+ promoteWorkstreamToActive(project, workstream, timestamp, changes);
371
+ promoteProjectToActive(project, timestamp, changes);
372
+ writeChangedProjectFiles(project);
373
+ }
374
+
375
+ return {
376
+ project,
377
+ id,
378
+ filePath,
379
+ files: [relativeProjectPath(project, filePath)],
380
+ changes
381
+ };
382
+ }
383
+
384
+ function addUpdateFromTemplate(root, options) {
385
+ const project = loadProject(root, options.project);
386
+ const timestamp = options.now || nowIso();
387
+ const status = options.status || "in-progress";
388
+ const message = cleanInlineText(options.message);
389
+ if (!message) {
390
+ throw new CliError("delano update add requires --message.", 1);
391
+ }
392
+
393
+ const text = fillProgressUpdate(renderTemplate(root, "progress-update.md", {
394
+ "<ISO8601 UTC>": timestamp,
395
+ "<task-id>": options.task || "",
396
+ "<stream-id>": options.stream || "",
397
+ "in-progress|blocked|review": status
398
+ }), {
399
+ status,
400
+ message,
401
+ section: options.section || ""
402
+ });
403
+
404
+ const updateDir = path.join(project.projectDir, "updates");
405
+ mkdirSync(updateDir, { recursive: true });
406
+ const date = timestamp.slice(0, 10);
407
+ const filePath = uniqueFilePath(updateDir, `${date}-${slugify(options.title || message)}.md`);
408
+ writeFileSync(filePath, text, "utf8");
409
+
410
+ return {
411
+ project,
412
+ filePath,
413
+ files: [relativeProjectPath(project, filePath)]
414
+ };
415
+ }
416
+
417
+ function applyTaskAction({ project, task, action, timestamp, evidence, owner, checkBack, reason, message, changes }) {
418
+ const previousStatus = task.frontmatter.status || "";
419
+ if (["start", "close"].includes(action)) {
420
+ assertDependenciesDone(project, task, action);
421
+ assertTaskWorkstreamExists(project, task, action);
422
+ }
423
+
424
+ if (action === "open") {
425
+ setFrontmatter(task, "status", "ready");
426
+ removeFrontmatter(task, "blocked_owner");
427
+ removeFrontmatter(task, "blocked_check_back");
428
+ appendEvidence(task, timestamp, reason || "Task opened with `delano task open`.");
429
+ } else if (action === "start") {
430
+ setFrontmatter(task, "status", "in-progress");
431
+ removeFrontmatter(task, "blocked_owner");
432
+ removeFrontmatter(task, "blocked_check_back");
433
+ appendEvidence(task, timestamp, reason || "Task started with `delano task start`.");
434
+ } else if (action === "close") {
435
+ setFrontmatter(task, "status", "done");
436
+ removeFrontmatter(task, "blocked_owner");
437
+ removeFrontmatter(task, "blocked_check_back");
438
+ appendEvidence(task, timestamp, evidence);
439
+ } else if (action === "block") {
440
+ setFrontmatter(task, "status", "blocked");
441
+ setFrontmatter(task, "blocked_owner", owner);
442
+ setFrontmatter(task, "blocked_check_back", checkBack);
443
+ appendEvidence(task, timestamp, reason || `Blocked; owner=${owner}; check-back=${checkBack}.`);
444
+ } else if (action === "defer") {
445
+ setFrontmatter(task, "status", "deferred");
446
+ removeFrontmatter(task, "blocked_owner");
447
+ removeFrontmatter(task, "blocked_check_back");
448
+ appendEvidence(task, timestamp, reason || "Task deferred with `delano task defer`.");
449
+ } else if (action === "update") {
450
+ appendEvidence(task, timestamp, message || reason);
451
+ } else {
452
+ throw new CliError(`Unsupported task action: ${action}`, 1);
453
+ }
454
+
455
+ setFrontmatter(task, "updated", timestamp);
456
+ changes.push(`${relativeProjectPath(project, task.path)} status -> ${task.frontmatter.status}`);
457
+
458
+ applyTaskRollups({ project, task, action, previousStatus, timestamp, changes });
459
+ }
460
+
461
+ function applyTaskRollups({ project, task, action, previousStatus, timestamp, changes }) {
462
+ const workstream = findWorkstream(project, task.frontmatter.workstream || "");
463
+ const reopenedClosedTask = isClosedTaskStatus(previousStatus) && !isClosedTaskStatus(task.frontmatter.status || "");
464
+
465
+ if (["start", "close"].includes(action)) {
466
+ promoteProjectToActive(project, timestamp, changes);
467
+ promoteWorkstreamToActive(project, workstream, timestamp, changes);
468
+ }
469
+
470
+ if (action === "open" && reopenedClosedTask) {
471
+ promoteProjectToActive(project, timestamp, changes);
472
+ promoteWorkstreamToActive(project, workstream, timestamp, changes);
473
+ }
474
+
475
+ if (["close", "defer"].includes(action)) {
476
+ closeWorkstreamIfDone(project, workstream, timestamp, changes);
477
+ closeProjectIfDone(project, timestamp, changes);
478
+ }
479
+ }
480
+
481
+ function assertTaskWorkstreamExists(project, task, action) {
482
+ const workstreamId = String(task.frontmatter.workstream || "").trim();
483
+ if (!workstreamId) {
484
+ throw new CliError(`Cannot ${action} ${relativeProjectPath(project, task.path)} because it has no workstream frontmatter.`, 1);
485
+ }
486
+ if (!findWorkstream(project, workstreamId)) {
487
+ throw new CliError(`Cannot ${action} ${relativeProjectPath(project, task.path)} because workstream ${workstreamId} does not exist in this project.`, 1);
488
+ }
489
+ }
490
+
491
+ function applyProjectAction(project, options) {
492
+ const timestamp = options.now || nowIso();
493
+ const changes = [];
494
+ const action = options.action;
495
+
496
+ if (action === "start") {
497
+ promoteProjectToActive(project, timestamp, changes);
498
+ appendProjectNote(project, timestamp, options.reason || "Project started with `delano project start`.");
499
+ } else if (action === "close") {
500
+ assertNoOpenTasks(project, "close project");
501
+ setProjectClosed(project, timestamp, changes);
502
+ appendProjectNote(project, timestamp, options.evidence || "Project closed with `delano project close`.");
503
+ } else if (action === "defer") {
504
+ setSpecStatus(project, "deferred", timestamp, changes);
505
+ setPlanStatus(project, "deferred", timestamp, changes);
506
+ for (const workstream of project.workstreams) {
507
+ if (!isClosedWorkstreamStatus(workstream.frontmatter.status || "")) {
508
+ setFrontmatter(workstream, "status", "deferred");
509
+ setFrontmatter(workstream, "updated", timestamp);
510
+ changes.push(`${relativeProjectPath(project, workstream.path)} status -> deferred`);
511
+ }
512
+ }
513
+ for (const task of project.tasks) {
514
+ if (!isClosedTaskStatus(task.frontmatter.status || "")) {
515
+ setFrontmatter(task, "status", "deferred");
516
+ setFrontmatter(task, "updated", timestamp);
517
+ appendEvidence(task, timestamp, options.reason || "Task deferred by `delano project defer`.");
518
+ changes.push(`${relativeProjectPath(project, task.path)} status -> deferred`);
519
+ }
520
+ }
521
+ appendProjectNote(project, timestamp, options.reason || "Project deferred with `delano project defer`.");
522
+ } else if (action === "block") {
523
+ setBlockMetadata(project.spec, options.owner, options.checkBack, timestamp);
524
+ setBlockMetadata(project.plan, options.owner, options.checkBack, timestamp);
525
+ appendProjectNote(project, timestamp, options.reason || `Blocked; owner=${options.owner}; check-back=${options.checkBack}.`);
526
+ changes.push("project block metadata updated");
527
+ } else if (action === "update") {
528
+ touchProject(project, timestamp, changes);
529
+ appendProjectNote(project, timestamp, options.message || options.reason);
530
+ } else {
531
+ throw new CliError(`Unsupported project action: ${action}`, 1);
532
+ }
533
+
534
+ writeChangedProjectFiles(project);
535
+ return changes;
536
+ }
537
+
538
+ function applyWorkstreamAction(project, workstream, options) {
539
+ const timestamp = options.now || nowIso();
540
+ const changes = [];
541
+ const action = options.action;
542
+
543
+ if (action === "start") {
544
+ promoteProjectToActive(project, timestamp, changes);
545
+ promoteWorkstreamToActive(project, workstream, timestamp, changes);
546
+ appendUpdate(workstream, timestamp, options.reason || "Workstream started with `delano workstream start`.");
547
+ } else if (action === "close") {
548
+ assertNoOpenWorkstreamTasks(project, workstream, "close workstream");
549
+ setFrontmatter(workstream, "status", "done");
550
+ setFrontmatter(workstream, "updated", timestamp);
551
+ changes.push(`${relativeProjectPath(project, workstream.path)} status -> done`);
552
+ appendUpdate(workstream, timestamp, options.evidence || "Workstream closed with `delano workstream close`.");
553
+ closeProjectIfDone(project, timestamp, changes);
554
+ } else if (action === "defer") {
555
+ setFrontmatter(workstream, "status", "deferred");
556
+ setFrontmatter(workstream, "updated", timestamp);
557
+ changes.push(`${relativeProjectPath(project, workstream.path)} status -> deferred`);
558
+ for (const task of tasksForWorkstream(project, getWorkstreamId(workstream))) {
559
+ if (!isClosedTaskStatus(task.frontmatter.status || "")) {
560
+ setFrontmatter(task, "status", "deferred");
561
+ setFrontmatter(task, "updated", timestamp);
562
+ appendEvidence(task, timestamp, options.reason || "Task deferred by `delano workstream defer`.");
563
+ changes.push(`${relativeProjectPath(project, task.path)} status -> deferred`);
564
+ }
565
+ }
566
+ appendUpdate(workstream, timestamp, options.reason || "Workstream deferred with `delano workstream defer`.");
567
+ closeProjectIfDone(project, timestamp, changes);
568
+ } else if (action === "block") {
569
+ setBlockMetadata(workstream, options.owner, options.checkBack, timestamp);
570
+ appendUpdate(workstream, timestamp, options.reason || `Blocked; owner=${options.owner}; check-back=${options.checkBack}.`);
571
+ changes.push(`${relativeProjectPath(project, workstream.path)} block metadata updated`);
572
+ } else if (action === "update") {
573
+ setFrontmatter(workstream, "updated", timestamp);
574
+ appendUpdate(workstream, timestamp, options.message || options.reason);
575
+ changes.push(`${relativeProjectPath(project, workstream.path)} updated`);
576
+ } else {
577
+ throw new CliError(`Unsupported workstream action: ${action}`, 1);
578
+ }
579
+
580
+ writeChangedProjectFiles(project);
581
+ return changes;
582
+ }
583
+
584
+ function setBlockMetadata(file, owner, checkBack, timestamp) {
585
+ if (!file) {
586
+ return;
587
+ }
588
+ setFrontmatter(file, "blocked_owner", owner);
589
+ setFrontmatter(file, "blocked_check_back", checkBack);
590
+ setFrontmatter(file, "updated", timestamp);
591
+ }
592
+
593
+ function touchProject(project, timestamp, changes) {
594
+ if (project.spec) {
595
+ setFrontmatter(project.spec, "updated", timestamp);
596
+ changes.push("spec.md updated");
597
+ }
598
+ if (project.plan) {
599
+ setFrontmatter(project.plan, "updated", timestamp);
600
+ changes.push("plan.md updated");
601
+ }
602
+ }
603
+
604
+ function appendProjectNote(project, timestamp, text) {
605
+ if (project.spec) {
606
+ appendSectionEntry(project.spec, "Approval Notes", `- ${timestamp}: ${cleanInlineText(text)}`);
607
+ }
608
+ }
609
+
610
+ function promoteProjectToActive(project, timestamp, changes) {
611
+ if (project.spec && ["planned", "complete", "deferred"].includes(project.spec.frontmatter.status || "")) {
612
+ setSpecStatus(project, "active", timestamp, changes);
613
+ }
614
+ if (project.plan && ["planned", "done", "deferred"].includes(project.plan.frontmatter.status || "")) {
615
+ setPlanStatus(project, "active", timestamp, changes);
616
+ }
617
+ }
618
+
619
+ function promoteWorkstreamToActive(project, workstream, timestamp, changes) {
620
+ if (!workstream || workstream.frontmatter.status === "active") {
621
+ return;
622
+ }
623
+ if (["planned", "done", "deferred"].includes(workstream.frontmatter.status || "")) {
624
+ setFrontmatter(workstream, "status", "active");
625
+ setFrontmatter(workstream, "updated", timestamp);
626
+ changes.push(`${relativeProjectPath(project, workstream.path)} status -> active`);
627
+ }
628
+ }
629
+
630
+ function closeWorkstreamIfDone(project, workstream, timestamp, changes) {
631
+ if (!workstream || workstream.frontmatter.status === "deferred") {
632
+ return;
633
+ }
634
+ const workstreamTasks = tasksForWorkstream(project, getWorkstreamId(workstream));
635
+ if (workstreamTasks.length > 0 && workstreamTasks.every((task) => isClosedTaskStatus(task.frontmatter.status || ""))) {
636
+ setFrontmatter(workstream, "status", "done");
637
+ setFrontmatter(workstream, "updated", timestamp);
638
+ changes.push(`${relativeProjectPath(project, workstream.path)} status -> done`);
639
+ }
640
+ }
641
+
642
+ function closeProjectIfDone(project, timestamp, changes) {
643
+ if (project.tasks.length === 0 || !project.tasks.every((task) => isClosedTaskStatus(task.frontmatter.status || ""))) {
644
+ return;
645
+ }
646
+ setProjectClosed(project, timestamp, changes);
647
+ }
648
+
649
+ function setProjectClosed(project, timestamp, changes) {
650
+ setSpecStatus(project, "complete", timestamp, changes);
651
+ setPlanStatus(project, "done", timestamp, changes);
652
+ }
653
+
654
+ function setSpecStatus(project, status, timestamp, changes) {
655
+ if (project.spec && project.spec.frontmatter.status !== status) {
656
+ setFrontmatter(project.spec, "status", status);
657
+ setFrontmatter(project.spec, "updated", timestamp);
658
+ changes.push(`spec.md status -> ${status}`);
659
+ }
660
+ }
661
+
662
+ function setPlanStatus(project, status, timestamp, changes) {
663
+ if (project.plan && project.plan.frontmatter.status !== status) {
664
+ setFrontmatter(project.plan, "status", status);
665
+ setFrontmatter(project.plan, "updated", timestamp);
666
+ changes.push(`plan.md status -> ${status}`);
667
+ }
668
+ }
669
+
670
+ function assertDependenciesDone(project, task, action) {
671
+ const dependencies = parseInlineList(task.frontmatter.depends_on || "[]");
672
+ for (const dependencyId of dependencies) {
673
+ const dependency = findTask(project, dependencyId);
674
+ if (dependency && dependency.frontmatter.status !== "done") {
675
+ throw new CliError(
676
+ `Cannot ${action} ${task.frontmatter.id || path.basename(task.path)}: dependency ${dependencyId} is ${dependency.frontmatter.status || "missing status"}.`,
677
+ 1
678
+ );
679
+ }
680
+ }
681
+ }
682
+
683
+ function assertNoOpenTasks(project, actionLabel) {
684
+ const openTask = project.tasks.find((task) => !isClosedTaskStatus(task.frontmatter.status || ""));
685
+ if (openTask) {
686
+ throw new CliError(`Cannot ${actionLabel}: ${openTask.frontmatter.id || path.basename(openTask.path)} is ${openTask.frontmatter.status || "open"}.`, 1);
687
+ }
688
+ }
689
+
690
+ function assertNoOpenWorkstreamTasks(project, workstream, actionLabel) {
691
+ const workstreamId = getWorkstreamId(workstream);
692
+ const openTask = tasksForWorkstream(project, workstreamId).find((task) => !isClosedTaskStatus(task.frontmatter.status || ""));
693
+ if (openTask) {
694
+ throw new CliError(`Cannot ${actionLabel}: ${openTask.frontmatter.id || path.basename(openTask.path)} is ${openTask.frontmatter.status || "open"}.`, 1);
695
+ }
696
+ }
697
+
698
+ function findTask(project, taskRef) {
699
+ const normalizedRef = String(taskRef || "").toLowerCase();
700
+ return project.tasks.find((task) => {
701
+ const id = (task.frontmatter.id || "").toLowerCase();
702
+ const basename = path.basename(task.path, ".md").toLowerCase();
703
+ return id === normalizedRef || basename === normalizedRef || basename.startsWith(`${normalizedRef}-`);
704
+ }) || null;
705
+ }
706
+
707
+ function resolveTask(project, taskRef) {
708
+ const normalizedRef = String(taskRef || "").toLowerCase();
709
+ const matches = project.tasks.filter((task) => {
710
+ const id = (task.frontmatter.id || "").toLowerCase();
711
+ const basename = path.basename(task.path, ".md").toLowerCase();
712
+ return id === normalizedRef || basename === normalizedRef || basename.startsWith(`${normalizedRef}-`);
713
+ });
714
+
715
+ if (matches.length === 0) {
716
+ throw new CliError(`Task not found in project: ${taskRef}`, 1);
717
+ }
718
+ if (matches.length > 1) {
719
+ throw new CliError(`Task reference is ambiguous: ${taskRef}`, 1);
720
+ }
721
+ return matches[0];
722
+ }
723
+
724
+ function findWorkstream(project, workstreamRef) {
725
+ const normalizedRef = String(workstreamRef || "").toLowerCase();
726
+ return project.workstreams.find((workstream) => {
727
+ const id = getWorkstreamId(workstream).toLowerCase();
728
+ const name = (workstream.frontmatter.name || "").toLowerCase();
729
+ const basename = path.basename(workstream.path, ".md").toLowerCase();
730
+ return id === normalizedRef || name === normalizedRef || basename === normalizedRef || basename.startsWith(`${normalizedRef}-`);
731
+ }) || null;
732
+ }
733
+
734
+ function resolveWorkstream(project, workstreamRef) {
735
+ const normalizedRef = String(workstreamRef || "").toLowerCase();
736
+ const matches = project.workstreams.filter((workstream) => {
737
+ const id = getWorkstreamId(workstream).toLowerCase();
738
+ const name = (workstream.frontmatter.name || "").toLowerCase();
739
+ const basename = path.basename(workstream.path, ".md").toLowerCase();
740
+ return id === normalizedRef || name === normalizedRef || basename === normalizedRef || basename.startsWith(`${normalizedRef}-`);
741
+ });
742
+
743
+ if (matches.length === 0) {
744
+ throw new CliError(`Workstream not found in project: ${workstreamRef}`, 1);
745
+ }
746
+ if (matches.length > 1) {
747
+ throw new CliError(`Workstream reference is ambiguous: ${workstreamRef}`, 1);
748
+ }
749
+ return matches[0];
750
+ }
751
+
752
+ function getWorkstreamId(workstream) {
753
+ return workstream.frontmatter.id || path.basename(workstream.path, ".md").match(/^(WS-[A-Za-z0-9]+)/)?.[1] || "";
754
+ }
755
+
756
+ function tasksForWorkstream(project, workstreamId) {
757
+ return project.tasks.filter((task) => task.frontmatter.workstream === workstreamId);
758
+ }
759
+
760
+ function writeChangedProjectFiles(project) {
761
+ for (const file of changedProjectFiles(project)) {
762
+ writeFileSync(file.path, file.text, "utf8");
763
+ }
764
+ }
765
+
766
+ function changedProjectFiles(project) {
767
+ return [project.spec, project.plan, project.decisions, ...project.workstreams, ...project.tasks]
768
+ .filter((file) => file && file.text !== file.originalText);
769
+ }
770
+
771
+ function relativeProjectPath(project, filePath) {
772
+ return path.relative(project.projectDir, filePath).replace(/\\/g, "/");
773
+ }
774
+
775
+ function fillSection(file, sectionName, value) {
776
+ const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
777
+ const pattern = new RegExp(`(## ${escaped}\\n)([\\s\\S]*?)(\\n## |$)`);
778
+ file.text = file.text.replace(pattern, (match, heading, _body, nextHeading) => {
779
+ const suffix = nextHeading === "\n## " ? "\n## " : "";
780
+ return `${heading}\n${value.trim()}\n${suffix}`;
781
+ });
782
+ }
783
+
784
+ function fillProgressUpdate(text, options) {
785
+ const section = normalizeUpdateSection(options.section, options.status);
786
+ const heading = {
787
+ completed: "Completed",
788
+ "in-progress": "In Progress",
789
+ blockers: "Blockers",
790
+ next: "Next Actions"
791
+ }[section];
792
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
793
+ const pattern = new RegExp(`(## ${escaped}\\n)-[ \\t]*(?:None / <blocker>)?`);
794
+ return text
795
+ .replace(pattern, (_match, heading) => `${heading}- ${options.message}`)
796
+ .replace(/(## Blockers\n)- None \/ <blocker>/, (_match, heading) => `${heading}- None`);
797
+ }
798
+
799
+ function normalizeUpdateSection(section, status) {
800
+ if (section) {
801
+ const normalized = String(section).trim().toLowerCase();
802
+ if (["completed", "in-progress", "blockers", "next"].includes(normalized)) {
803
+ return normalized;
804
+ }
805
+ throw new CliError("--section must be one of completed, in-progress, blockers, or next.", 1);
806
+ }
807
+ if (status === "blocked") return "blockers";
808
+ if (status === "review" || status === "done") return "completed";
809
+ return "in-progress";
810
+ }
811
+
812
+ function uniqueFilePath(directory, filename) {
813
+ const extension = path.extname(filename);
814
+ const stem = path.basename(filename, extension);
815
+ let candidate = path.join(directory, filename);
816
+ let index = 2;
817
+ while (existsSync(candidate)) {
818
+ candidate = path.join(directory, `${stem}-${index}${extension}`);
819
+ index += 1;
820
+ }
821
+ return candidate;
822
+ }
823
+
824
+ function titleFromSlug(slug) {
825
+ return String(slug || "")
826
+ .split("-")
827
+ .filter(Boolean)
828
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
829
+ .join(" ") || "Untitled";
830
+ }
831
+
832
+ function cleanParagraph(value) {
833
+ return String(value || "").trim().replace(/\r\n/g, "\n");
834
+ }
835
+
836
+ function normalizeBooleanOption(value, defaultValue) {
837
+ if (value === undefined || value === null || value === "") {
838
+ return defaultValue ? "true" : "false";
839
+ }
840
+ const normalized = String(value).trim().toLowerCase();
841
+ if (!["true", "false"].includes(normalized)) {
842
+ throw new CliError("boolean options must be true or false.", 1);
843
+ }
844
+ return normalized;
845
+ }
846
+
847
+ function parseInlineList(raw) {
848
+ const value = String(raw || "").trim();
849
+ if (!value || value === "[]") return [];
850
+ if (value.startsWith("[") && value.endsWith("]")) {
851
+ const inner = value.slice(1, -1).trim();
852
+ if (!inner) return [];
853
+ return inner.split(",").map((item) => item.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
854
+ }
855
+ return [value.replace(/^['"]|['"]$/g, "")].filter(Boolean);
856
+ }
857
+
858
+ function parseCsvList(raw) {
859
+ return String(raw || "")
860
+ .split(",")
861
+ .map((item) => item.trim())
862
+ .filter(Boolean);
863
+ }
864
+
865
+ function formatInlineList(values) {
866
+ const list = Array.isArray(values) ? values : parseCsvList(values);
867
+ return `[${list.map((item) => String(item).trim()).filter(Boolean).join(", ")}]`;
868
+ }
869
+
870
+ function formatHumanList(values) {
871
+ const list = Array.isArray(values) ? values : parseCsvList(values);
872
+ return list.length > 0 ? list.join(", ") : "none";
873
+ }
874
+
875
+ function isClosedTaskStatus(status) {
876
+ return CLOSED_TASK_STATUSES.has(status);
877
+ }
878
+
879
+ function isProgressedTaskStatus(status) {
880
+ return PROGRESSED_TASK_STATUSES.has(status);
881
+ }
882
+
883
+ function isClosedWorkstreamStatus(status) {
884
+ return ["done", "deferred"].includes(status);
885
+ }
886
+
887
+ module.exports = {
888
+ addTaskFromTemplate,
889
+ addUpdateFromTemplate,
890
+ addWorkstreamFromTemplate,
891
+ applyProjectAction,
892
+ applyTaskAction,
893
+ applyWorkstreamAction,
894
+ changedProjectFiles,
895
+ createProjectFromTemplates,
896
+ findTask,
897
+ findWorkstream,
898
+ formatInlineList,
899
+ getWorkstreamId,
900
+ isClosedTaskStatus,
901
+ isProgressedTaskStatus,
902
+ loadProject,
903
+ loadProjectState,
904
+ nowIso,
905
+ parseCsvList,
906
+ parseFrontmatter,
907
+ parseInlineList,
908
+ projectDir,
909
+ relativeProjectPath,
910
+ requireDelanoRoot,
911
+ requireSlug,
912
+ requireTaskId,
913
+ requireWorkstreamId,
914
+ resolveTask,
915
+ resolveWorkstream,
916
+ slugify,
917
+ writeChangedProjectFiles
918
+ };