@uoyo/mvtt 2.0.0-beta.3 → 2.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +299 -64
  2. package/README.zh-CN.md +419 -0
  3. package/dist/commands/install.d.ts.map +1 -1
  4. package/dist/commands/install.js +27 -2
  5. package/dist/commands/install.js.map +1 -1
  6. package/dist/commands/uninstall.d.ts.map +1 -1
  7. package/dist/commands/uninstall.js +19 -7
  8. package/dist/commands/uninstall.js.map +1 -1
  9. package/dist/commands/update.d.ts.map +1 -1
  10. package/dist/commands/update.js +4 -2
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/fs/install-manifest.d.ts +4 -1
  13. package/dist/fs/install-manifest.d.ts.map +1 -1
  14. package/dist/fs/install-manifest.js +13 -1
  15. package/dist/fs/install-manifest.js.map +1 -1
  16. package/dist/fs/materialize.d.ts +2 -0
  17. package/dist/fs/materialize.d.ts.map +1 -1
  18. package/dist/fs/materialize.js +46 -13
  19. package/dist/fs/materialize.js.map +1 -1
  20. package/dist/fs/registry-merge.d.ts +19 -0
  21. package/dist/fs/registry-merge.d.ts.map +1 -0
  22. package/dist/fs/registry-merge.js +220 -0
  23. package/dist/fs/registry-merge.js.map +1 -0
  24. package/dist/scripts/epic-update.cjs +7670 -0
  25. package/dist/scripts/plan-update.cjs +7736 -0
  26. package/dist/scripts/session-update.cjs +84 -6
  27. package/dist/types/platform.d.ts +12 -0
  28. package/dist/types/platform.d.ts.map +1 -0
  29. package/dist/types/platform.js +24 -0
  30. package/dist/types/platform.js.map +1 -0
  31. package/dist/types/registry.d.ts +3 -24
  32. package/dist/types/registry.d.ts.map +1 -1
  33. package/install-manifest.yaml +10 -2
  34. package/package.json +1 -1
  35. package/registry.yaml +72 -198
  36. package/sources/defaults/config.yaml +8 -13
  37. package/sources/defaults/project-context.yaml +2 -5
  38. package/sources/defaults/session.yaml +14 -2
  39. package/sources/knowledge/core/manifest.yaml +1 -4
  40. package/sources/scripts/epic-update.js +512 -0
  41. package/sources/scripts/plan-update.js +614 -0
  42. package/sources/scripts/session-update.js +102 -2
  43. package/sources/sections/activation-load-config.md +1 -1
  44. package/sources/sections/activation-load-context.md +42 -13
  45. package/sources/sections/activation-preflight.md +1 -1
  46. package/sources/sections/footer-next-steps.md +3 -2
  47. package/sources/sections/output-format-constraint.md +14 -0
  48. package/sources/sections/project-context-profile.md +29 -0
  49. package/sources/sections/session-update.md +41 -1
  50. package/sources/skills/mvt-analyze/business.md +46 -8
  51. package/sources/skills/mvt-analyze/manifest.yaml +8 -1
  52. package/sources/skills/mvt-analyze-code/business.md +18 -17
  53. package/sources/skills/mvt-analyze-code/manifest.yaml +9 -6
  54. package/sources/skills/mvt-check-context/business.md +13 -6
  55. package/sources/skills/mvt-check-context/manifest.yaml +0 -5
  56. package/sources/skills/mvt-cleanup/business.md +17 -2
  57. package/sources/skills/mvt-cleanup/manifest.yaml +3 -0
  58. package/sources/skills/mvt-config/business.md +5 -5
  59. package/sources/skills/mvt-config/manifest.yaml +3 -6
  60. package/sources/skills/mvt-create-skill/business.md +2 -14
  61. package/sources/skills/mvt-create-skill/manifest.yaml +2 -4
  62. package/sources/skills/mvt-decompose/business.md +94 -0
  63. package/sources/skills/mvt-decompose/manifest.yaml +121 -0
  64. package/sources/skills/mvt-design/manifest.yaml +3 -0
  65. package/sources/skills/mvt-fix/business.md +21 -6
  66. package/sources/skills/mvt-fix/manifest.yaml +4 -1
  67. package/sources/skills/mvt-help/business.md +11 -9
  68. package/sources/skills/mvt-help/manifest.yaml +0 -5
  69. package/sources/skills/mvt-implement/business.md +57 -10
  70. package/sources/skills/mvt-implement/manifest.yaml +3 -0
  71. package/sources/skills/mvt-init/business.md +23 -13
  72. package/sources/skills/mvt-init/manifest.yaml +4 -2
  73. package/sources/skills/mvt-manage-context/business.md +41 -14
  74. package/sources/skills/mvt-manage-context/manifest.yaml +7 -5
  75. package/sources/skills/mvt-plan-dev/business.md +17 -9
  76. package/sources/skills/mvt-plan-dev/manifest.yaml +3 -0
  77. package/sources/skills/mvt-quick-dev/business.md +22 -7
  78. package/sources/skills/mvt-quick-dev/manifest.yaml +3 -1
  79. package/sources/skills/mvt-refactor/business.md +32 -17
  80. package/sources/skills/mvt-refactor/manifest.yaml +2 -4
  81. package/sources/skills/mvt-resume/business.md +32 -12
  82. package/sources/skills/mvt-resume/manifest.yaml +3 -3
  83. package/sources/skills/mvt-review/business.md +24 -9
  84. package/sources/skills/mvt-review/manifest.yaml +3 -0
  85. package/sources/skills/mvt-status/business.md +37 -9
  86. package/sources/skills/mvt-status/manifest.yaml +2 -2
  87. package/sources/skills/mvt-sync-context/business.md +77 -34
  88. package/sources/skills/mvt-sync-context/manifest.yaml +6 -0
  89. package/sources/skills/mvt-template/business.md +1 -1
  90. package/sources/skills/mvt-template/manifest.yaml +0 -5
  91. package/sources/skills/mvt-test/business.md +30 -15
  92. package/sources/skills/mvt-test/manifest.yaml +3 -0
  93. package/sources/skills/mvt-update-plan/business.md +64 -33
  94. package/sources/skills/mvt-update-plan/manifest.yaml +10 -7
  95. package/sources/templates/decompose-output/body.md +13 -0
  96. package/sources/templates/decompose-output/manifest.yaml +11 -0
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * plan-update.js — Mechanical plan.yaml mutation script
5
+ *
6
+ * Replaces AI-driven plan.yaml mutation with a deterministic Node.js script.
7
+ * /mvt-update-plan calls this instead of hand-editing the plan: it applies a
8
+ * single task status change, recomputes current_tasks via the per-project DAG
9
+ * rules, runs the full plan validator, and writes back atomically.
10
+ *
11
+ * task.project is an array, validated via caller-supplied --projects.
12
+ * current_task (string) -> current_tasks (Record<string, string>).
13
+ * Per-project independent in_progress advancement.
14
+ * resolvedIds = done + skipped (blocked does NOT satisfy depends_on).
15
+ * Cross-project advancement emits project_switch notification.
16
+ * findCycle partitions by project subgraph.
17
+ *
18
+ * NOTE: This source file uses `import from "yaml"`. During the build pipeline,
19
+ * esbuild bundles it into a zero-dependency single file deployed to
20
+ * .ai-agents/scripts/plan-update.cjs.
21
+ *
22
+ * Usage:
23
+ * node .ai-agents/scripts/plan-update.cjs \
24
+ * --plan <path-to-plan.yaml> \
25
+ * --task <task_id> \
26
+ * --status <pending|in_progress|done|blocked|skipped> \
27
+ * [--projects "web,api"] \
28
+ * [--artifacts "<comma,separated,paths>"] \
29
+ * [--notes "<free-form text>"] \
30
+ * [--deliverables-pointer current] \
31
+ * [--mark-deliverable-stale <task_id>[,task_id2,...]]
32
+ *
33
+ * Output:
34
+ * Success (exit 0): one-line JSON on stdout, e.g.
35
+ * {"ok":true,"task":{...},"current_tasks":{"web":"t2"},"plan_status":"in_progress",...}
36
+ * Failure (exit 1): plain-text error message(s) on stderr
37
+ */
38
+
39
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "node:fs";
40
+ import { dirname, join, resolve } from "node:path";
41
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
42
+
43
+ // ── Project Discovery ──────────────────────────────────────────────────────
44
+ // Resolve project root by walking up from a file path until `.ai-agents/` is
45
+ // found. Returns null if no project root can be determined.
46
+ function findProjectRootFromPath(filePath) {
47
+ let dir = resolve(dirname(filePath));
48
+ while (true) {
49
+ if (existsSync(join(dir, ".ai-agents"))) return dir;
50
+ const parent = dirname(dir);
51
+ if (parent === dir) return null;
52
+ dir = parent;
53
+ }
54
+ }
55
+
56
+ // Read the sole project name from `.ai-agents/workspace/project-context.yaml`.
57
+ // Returns the project array (length 1) when the file exists AND has exactly
58
+ // one project with a non-empty name; returns null otherwise (so the caller
59
+ // can fall back to its hardcoded default).
60
+ function loadSoleProject(projectRoot) {
61
+ if (!projectRoot) return null;
62
+ const ctxPath = join(projectRoot, ".ai-agents/workspace/project-context.yaml");
63
+ if (!existsSync(ctxPath)) return null;
64
+ try {
65
+ const ctx = parseYaml(readFileSync(ctxPath, "utf-8"));
66
+ const projects = ctx?.projects;
67
+ if (!Array.isArray(projects) || projects.length !== 1) return null;
68
+ const name = projects[0]?.name;
69
+ if (typeof name !== "string" || name === "") return null;
70
+ return [name];
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ // ── Constants ─────────────────────────────────────────────────────────────
77
+ const VALID_STATUSES = ["pending", "in_progress", "done", "blocked", "skipped"];
78
+ const TERMINAL_STATUSES = ["done", "blocked", "skipped"];
79
+ const PROJECT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
80
+ const VALID_FRESHNESS = ["current", "stale"];
81
+
82
+ const ERRORS = {
83
+ MISSING_PLAN: () => "Missing required argument: --plan",
84
+ MISSING_TASK: () => "Missing required argument: --task",
85
+ MISSING_STATUS: () => "Missing required argument: --status",
86
+ INVALID_STATUS: (val) =>
87
+ `Invalid --status "${val}". Must be one of: ${VALID_STATUSES.join(", ")}.`,
88
+ PLAN_NOT_FOUND: (p) => `Plan not found at ${p}. Run /mvt-plan-dev to create one.`,
89
+ PLAN_PARSE_FAILED: (detail) =>
90
+ `Failed to parse plan.yaml: ${detail}. Fix the file manually; not repairing silently.`,
91
+ TASK_NOT_FOUND: (id, valid) =>
92
+ `Task "${id}" not found. Valid task ids: ${valid.length ? valid.join(", ") : "(none)"}.`,
93
+ VALIDATION_FAILED: (errs) =>
94
+ `Plan validation failed; file not written:\n - ${errs.join("\n - ")}`,
95
+ PLAN_WRITE_FAILED: (detail) => `Failed to write plan.yaml: ${detail}`,
96
+ INVALID_PROJECT_NAME: (name) =>
97
+ `Invalid project name "${name}". Must match ${PROJECT_NAME_RE.source} (no leading underscore).`,
98
+ INVALID_TASK_PROJECT: (taskId, proj, valid) =>
99
+ `Task "${taskId}" has project "${proj}" not in --projects list: ${valid.join(", ")}.`,
100
+ INVALID_FRESHNESS: (taskId, val) =>
101
+ `Task "${taskId}" has invalid deliverables.freshness "${val}". Must be one of: ${VALID_FRESHNESS.join(", ")}.`,
102
+ STALE_TASK_NOT_FOUND: (id, valid) =>
103
+ `--mark-deliverable-stale task "${id}" not found. Valid task ids: ${valid.length ? valid.join(", ") : "(none)"}.`,
104
+ INVALID_DELIVERABLES_POINTER: (val) =>
105
+ `Invalid --deliverables-pointer "${val}". Only "current" is supported.`,
106
+ };
107
+
108
+ // ── CLI Parsing ─────────────────────────────────────────────────────────────
109
+ function parseArgs(argv) {
110
+ const args = {};
111
+ for (let i = 2; i < argv.length; i++) {
112
+ if (argv[i].startsWith("--")) {
113
+ const key = argv[i].slice(2);
114
+ const next = argv[i + 1];
115
+ if (next && !next.startsWith("--")) {
116
+ args[key] = next;
117
+ i++;
118
+ } else {
119
+ args[key] = true;
120
+ }
121
+ }
122
+ }
123
+ return args;
124
+ }
125
+
126
+ function validateArgs(args) {
127
+ if (!args.plan || args.plan === true) return ERRORS.MISSING_PLAN();
128
+ if (!args.task || args.task === true) return ERRORS.MISSING_TASK();
129
+ if (!args.status || args.status === true) return ERRORS.MISSING_STATUS();
130
+ if (!VALID_STATUSES.includes(args.status)) return ERRORS.INVALID_STATUS(args.status);
131
+ return null;
132
+ }
133
+
134
+ // ── Mutation ─────────────────────────────────────────────────────────────────
135
+ function applyUpdate(plan, args, now) {
136
+ const task = plan.tasks.find((t) => t.id === args.task);
137
+
138
+ const oldStatus = task.status;
139
+ task.status = args.status;
140
+
141
+ if (args.artifacts && args.artifacts !== true) {
142
+ const incoming = args.artifacts
143
+ .split(",")
144
+ .map((s) => s.trim())
145
+ .filter(Boolean);
146
+ if (incoming.length) {
147
+ if (!task.artifacts || typeof task.artifacts !== "object") {
148
+ task.artifacts = { files: [] };
149
+ }
150
+ if (!Array.isArray(task.artifacts.files)) {
151
+ task.artifacts.files = [];
152
+ }
153
+ const seen = new Set(task.artifacts.files);
154
+ for (const f of incoming) {
155
+ if (!seen.has(f)) {
156
+ task.artifacts.files.push(f);
157
+ seen.add(f);
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ if (args.notes && args.notes !== true) {
164
+ task.notes = args.notes;
165
+ }
166
+
167
+ // completed_at consistency: set only on first transition to done, else null.
168
+ if (args.status === "done" && !task.completed_at) {
169
+ task.completed_at = now;
170
+ } else if (args.status !== "done") {
171
+ task.completed_at = null;
172
+ }
173
+
174
+ // --deliverables-pointer current
175
+ if (args["deliverables-pointer"] && args["deliverables-pointer"] !== true) {
176
+ if (args["deliverables-pointer"] !== "current") {
177
+ return { error: ERRORS.INVALID_DELIVERABLES_POINTER(args["deliverables-pointer"]) };
178
+ }
179
+ task.deliverables = { freshness: "current" };
180
+ }
181
+
182
+ // --mark-deliverable-stale <task_id>[,task_id2,...]
183
+ // Supports comma-separated list of downstream task ids.
184
+ if (args["mark-deliverable-stale"] && args["mark-deliverable-stale"] !== true) {
185
+ const staleIds = args["mark-deliverable-stale"]
186
+ .split(",")
187
+ .map((s) => s.trim())
188
+ .filter(Boolean);
189
+ for (const staleTaskId of staleIds) {
190
+ const staleTask = plan.tasks.find((t) => t.id === staleTaskId);
191
+ if (staleTask) {
192
+ if (!staleTask.deliverables || typeof staleTask.deliverables !== "object") {
193
+ staleTask.deliverables = { freshness: "stale" };
194
+ } else {
195
+ staleTask.deliverables.freshness = "stale";
196
+ }
197
+ }
198
+ // If task not found, silently skip -- the task may not have deliverables yet
199
+ }
200
+ }
201
+
202
+ plan.updated_at = now;
203
+
204
+ return { id: task.id, title: task.title || "", old_status: oldStatus, new_status: args.status };
205
+ }
206
+
207
+ // -- current_tasks recomputation (per-project independent advancement) --
208
+ function recomputeCurrentTasks(plan, changedTaskId, projectList) {
209
+ let warning = null;
210
+
211
+ const changedTask = plan.tasks.find((t) => t.id === changedTaskId);
212
+ const changedToTerminal =
213
+ changedTask && TERMINAL_STATUSES.includes(changedTask.status);
214
+
215
+ // Capture the set of projects that had active tasks BEFORE this recomputation.
216
+ // plan.current_tasks still holds the old value at this point.
217
+ const priorActiveProjects = new Set(
218
+ Object.keys(plan.current_tasks || {})
219
+ );
220
+
221
+ // resolvedIds = done + skipped (blocked does NOT satisfy depends_on)
222
+ const resolvedIds = new Set(
223
+ plan.tasks
224
+ .filter((t) => t.status === "done" || t.status === "skipped")
225
+ .map((t) => t.id)
226
+ );
227
+
228
+ // Derive effective project list: prefer explicit --projects, else read the
229
+ // sole project from project-context.yaml (single-project workspaces), else
230
+ // fall back to ["default"] for legacy / unconfigured cases.
231
+ const projects = (projectList && projectList.length > 0)
232
+ ? projectList
233
+ : (loadSoleProject(findProjectRootFromPath(args.plan)) || ["default"]);
234
+
235
+ // Build current_tasks: for each project, find the in_progress task
236
+ const currentTasks = {};
237
+
238
+ for (const proj of projects) {
239
+ // Find the in_progress task for this project
240
+ const inProgressForProject = plan.tasks.filter(
241
+ (t) => t.status === "in_progress" && getTaskProjects(t).includes(proj)
242
+ );
243
+
244
+ if (inProgressForProject.length > 0) {
245
+ // Use the first in_progress task for this project
246
+ currentTasks[proj] = inProgressForProject[0].id;
247
+ continue;
248
+ }
249
+
250
+ // No in_progress for this project: try to advance a pending task
251
+ const nextPending = plan.tasks.find(
252
+ (t) =>
253
+ t.status === "pending" &&
254
+ getTaskProjects(t).includes(proj) &&
255
+ (t.depends_on || []).every((d) => resolvedIds.has(d))
256
+ );
257
+ if (nextPending) {
258
+ nextPending.status = "in_progress";
259
+ currentTasks[proj] = nextPending.id;
260
+ }
261
+ }
262
+
263
+ // Detect project_switch: compare projects active AFTER recomputation with
264
+ // projects active BEFORE. New projects = present in currentTasks but absent
265
+ // from priorActiveProjects. This correctly handles:
266
+ // - Single→single cross-project (api done, web advances -> from:[api] to:[web])
267
+ // - Cross-project task done (both web+api were active -> no false switch)
268
+ // - Mixed: cross-project done, downstream in new project (emits correctly)
269
+ let switchNotification = null;
270
+ if (changedToTerminal && changedTask) {
271
+ const newActiveProjects = new Set(Object.keys(currentTasks));
272
+ const newlyActive = [...newActiveProjects].filter((p) => !priorActiveProjects.has(p));
273
+ if (newlyActive.length > 0) {
274
+ switchNotification = {
275
+ project_switch: {
276
+ from: [...priorActiveProjects].filter((p) => !newActiveProjects.has(p)),
277
+ to: newlyActive,
278
+ },
279
+ };
280
+ }
281
+ }
282
+
283
+ // Set plan.status based on overall task states
284
+ const allDone = plan.tasks.every((t) => t.status === "done");
285
+ const anyInProgress = plan.tasks.some((t) => t.status === "in_progress");
286
+ const anyPending = plan.tasks.some((t) => t.status === "pending");
287
+
288
+ if (allDone) {
289
+ plan.status = "done";
290
+ plan.current_tasks = {};
291
+ } else {
292
+ plan.current_tasks = currentTasks;
293
+ if (anyInProgress || Object.keys(currentTasks).length > 0) {
294
+ plan.status = "in_progress";
295
+ } else if (anyPending) {
296
+ plan.status = "in_progress";
297
+ warning =
298
+ "All remaining tasks are blocked by dependencies; resolve a blocker before continuing.";
299
+ }
300
+ }
301
+
302
+ return { warning, project_switch: switchNotification };
303
+ }
304
+
305
+ // Get the project array for a task, defaulting to ["default"] if not set
306
+ function getTaskProjects(task) {
307
+ if (Array.isArray(task.project) && task.project.length > 0) {
308
+ return task.project;
309
+ }
310
+ return ["default"];
311
+ }
312
+
313
+ // Derive the effective project list from tasks' project arrays.
314
+ // Used when --projects is not explicitly provided.
315
+ function deriveProjectList(tasks) {
316
+ const projects = new Set();
317
+ for (const t of tasks) {
318
+ for (const p of getTaskProjects(t)) {
319
+ projects.add(p);
320
+ }
321
+ }
322
+ return [...projects];
323
+ }
324
+
325
+ // ── Validation ────────────────────────────────────────────────
326
+ function validatePlan(plan, projectList) {
327
+ const errors = [];
328
+ const tasks = Array.isArray(plan.tasks) ? plan.tasks : [];
329
+
330
+ // Unique ids
331
+ const ids = tasks.map((t) => t.id);
332
+ const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
333
+ if (dupes.length) {
334
+ errors.push(`Duplicate task ids: ${[...new Set(dupes)].join(", ")}`);
335
+ }
336
+
337
+ const idSet = new Set(ids);
338
+
339
+ // Valid depends_on references
340
+ for (const t of tasks) {
341
+ for (const d of t.depends_on || []) {
342
+ if (!idSet.has(d)) {
343
+ errors.push(`Task "${t.id}" depends_on unknown task "${d}"`);
344
+ }
345
+ }
346
+ }
347
+
348
+ // DAG (no cycles) — per-project subgraph when --projects is provided
349
+ const cycle = findCycle(tasks, projectList);
350
+ if (cycle) {
351
+ errors.push(`Dependency cycle detected: ${cycle.join(" -> ")}`);
352
+ }
353
+
354
+ // Per-project in_progress constraint (use the same effective project list
355
+ // resolution as recomputeCurrentTasks so validation matches runtime behavior).
356
+ const projects = (projectList && projectList.length > 0)
357
+ ? projectList
358
+ : (loadSoleProject(findProjectRootFromPath(args.plan)) || ["default"]);
359
+ for (const proj of projects) {
360
+ const inProgressForProject = tasks.filter(
361
+ (t) => t.status === "in_progress" && getTaskProjects(t).includes(proj)
362
+ );
363
+ if (inProgressForProject.length > 1) {
364
+ errors.push(
365
+ `More than one task is in_progress for project "${proj}": ${inProgressForProject.map((t) => t.id).join(", ")}`
366
+ );
367
+ }
368
+ }
369
+
370
+ // Task project validation against --projects
371
+ if (projectList && projectList.length > 0) {
372
+ for (const t of tasks) {
373
+ if (Array.isArray(t.project)) {
374
+ for (const p of t.project) {
375
+ if (!projectList.includes(p)) {
376
+ errors.push(ERRORS.INVALID_TASK_PROJECT(t.id, p, projectList));
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ // Project naming constraint (no leading underscore)
384
+ if (projectList && projectList.length > 0) {
385
+ for (const p of projectList) {
386
+ if (!PROJECT_NAME_RE.test(p)) {
387
+ errors.push(ERRORS.INVALID_PROJECT_NAME(p));
388
+ }
389
+ }
390
+ }
391
+
392
+ // Acceptance required
393
+ for (const t of tasks) {
394
+ if (!Array.isArray(t.acceptance) || t.acceptance.length === 0) {
395
+ errors.push(`Task "${t.id}" has no acceptance criteria`);
396
+ }
397
+ }
398
+
399
+ // completed_at consistency
400
+ for (const t of tasks) {
401
+ if (t.status !== "done" && t.completed_at != null) {
402
+ errors.push(`Task "${t.id}" is not done but has completed_at set`);
403
+ }
404
+ }
405
+
406
+ // deliverables.freshness enum validation (stale never blocks a write)
407
+ for (const t of tasks) {
408
+ if (t.deliverables && typeof t.deliverables === "object") {
409
+ if (!VALID_FRESHNESS.includes(t.deliverables.freshness)) {
410
+ errors.push(ERRORS.INVALID_FRESHNESS(t.id, t.deliverables.freshness));
411
+ }
412
+ }
413
+ }
414
+
415
+ // current_tasks validity
416
+ if (plan.status === "done") {
417
+ if (plan.current_tasks && Object.keys(plan.current_tasks).length > 0) {
418
+ errors.push("plan.status is done but current_tasks is not empty");
419
+ }
420
+ } else if (plan.current_tasks && typeof plan.current_tasks === "object") {
421
+ for (const [proj, taskId] of Object.entries(plan.current_tasks)) {
422
+ const ct = tasks.find((t) => t.id === taskId);
423
+ if (!ct) {
424
+ errors.push(`current_tasks["${proj}"] = "${taskId}" does not reference a task`);
425
+ } else if (ct.status !== "pending" && ct.status !== "in_progress") {
426
+ errors.push(
427
+ `current_tasks["${proj}"] = "${taskId}" has status "${ct.status}" (must be pending or in_progress)`
428
+ );
429
+ }
430
+ }
431
+ }
432
+
433
+ return errors;
434
+ }
435
+
436
+ // Returns an array describing a cycle path, or null if the graph is a DAG.
437
+ // when projectList is provided, partition tasks by project into
438
+ // subgraphs; cross-project depends_on included in both subgraphs.
439
+ function findCycle(tasks, projectList) {
440
+ if (!projectList || projectList.length <= 1) {
441
+ // Single project or no project list: check the whole graph
442
+ return findCycleInSubgraph(tasks, tasks.map((t) => t.id));
443
+ }
444
+
445
+ // Per-project subgraph: each task belongs to every project in its project array.
446
+ // Cross-project depends_on are included in both subgraphs (ADR-8):
447
+ // if task A[web] depends on B[api], B is added to the web subgraph so
448
+ // that cross-project cycles like A[web] -> B[api] -> A[web] are detected.
449
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
450
+
451
+ for (const proj of projectList) {
452
+ const idSet = new Set(
453
+ tasks
454
+ .filter((t) => getTaskProjects(t).includes(proj))
455
+ .map((t) => t.id)
456
+ );
457
+ // Expand: pull in cross-project depends_on targets (and their transitive deps)
458
+ // so that cross-project cycles are visible within each subgraph.
459
+ const queue = [...idSet];
460
+ for (const id of queue) {
461
+ for (const dep of (taskMap.get(id)?.depends_on || [])) {
462
+ if (!idSet.has(dep)) {
463
+ idSet.add(dep);
464
+ queue.push(dep);
465
+ }
466
+ }
467
+ }
468
+ const cycle = findCycleInSubgraph(tasks, [...idSet]);
469
+ if (cycle) return cycle;
470
+ }
471
+ return null;
472
+ }
473
+
474
+ function findCycleInSubgraph(tasks, taskIds) {
475
+ const idSet = new Set(taskIds);
476
+ const adj = new Map();
477
+ for (const t of tasks) {
478
+ if (!idSet.has(t.id)) continue;
479
+ // Only include depends_on that are in this subgraph
480
+ adj.set(t.id, (t.depends_on || []).filter((d) => idSet.has(d)));
481
+ }
482
+
483
+ const WHITE = 0, GRAY = 1, BLACK = 2;
484
+ const color = new Map(taskIds.map((id) => [id, WHITE]));
485
+ const stack = [];
486
+
487
+ function dfs(node) {
488
+ color.set(node, GRAY);
489
+ stack.push(node);
490
+ for (const dep of adj.get(node) || []) {
491
+ if (!color.has(dep)) continue;
492
+ if (color.get(dep) === GRAY) {
493
+ const start = stack.indexOf(dep);
494
+ return [...stack.slice(start), dep];
495
+ }
496
+ if (color.get(dep) === WHITE) {
497
+ const found = dfs(dep);
498
+ if (found) return found;
499
+ }
500
+ }
501
+ stack.pop();
502
+ color.set(node, BLACK);
503
+ return null;
504
+ }
505
+
506
+ for (const id of taskIds) {
507
+ if (color.get(id) === WHITE) {
508
+ const found = dfs(id);
509
+ if (found) return found;
510
+ }
511
+ }
512
+ return null;
513
+ }
514
+
515
+ // ── Main ──────────────────────────────────────────────────────────────────────
516
+ function main() {
517
+ const args = parseArgs(process.argv);
518
+
519
+ const argErr = validateArgs(args);
520
+ if (argErr) {
521
+ process.stderr.write(argErr + "\n");
522
+ process.exit(1);
523
+ }
524
+
525
+ if (!existsSync(args.plan)) {
526
+ process.stderr.write(ERRORS.PLAN_NOT_FOUND(args.plan) + "\n");
527
+ process.exit(1);
528
+ }
529
+
530
+ let plan;
531
+ try {
532
+ plan = parseYaml(readFileSync(args.plan, "utf-8"));
533
+ } catch (e) {
534
+ process.stderr.write(ERRORS.PLAN_PARSE_FAILED(e.message) + "\n");
535
+ process.exit(1);
536
+ }
537
+
538
+ if (!plan || !Array.isArray(plan.tasks)) {
539
+ process.stderr.write(ERRORS.PLAN_PARSE_FAILED("missing tasks[]") + "\n");
540
+ process.exit(1);
541
+ }
542
+
543
+ if (!plan.tasks.some((t) => t.id === args.task)) {
544
+ process.stderr.write(
545
+ ERRORS.TASK_NOT_FOUND(args.task, plan.tasks.map((t) => t.id)) + "\n"
546
+ );
547
+ process.exit(1);
548
+ }
549
+
550
+ // Parse --projects; if not provided, derive from tasks
551
+ let projectList = null;
552
+ if (args.projects && args.projects !== true) {
553
+ projectList = args.projects.split(",").map((s) => s.trim()).filter(Boolean);
554
+ } else {
555
+ // Derive project list from task.project arrays so that validation
556
+ // and recomputation work correctly even without explicit --projects.
557
+ projectList = deriveProjectList(plan.tasks);
558
+ }
559
+
560
+ // Migrate old current_task (string) to current_tasks (Record) if needed.
561
+ // Also remove legacy current_task field even when null (YAML `current_task: null`).
562
+ if (plan.current_task != null && (!plan.current_tasks || typeof plan.current_tasks !== "object")) {
563
+ plan.current_tasks = { default: plan.current_task };
564
+ }
565
+ if ("current_task" in plan) {
566
+ delete plan.current_task;
567
+ }
568
+ if (!plan.current_tasks) {
569
+ plan.current_tasks = {};
570
+ }
571
+
572
+ const now = new Date().toISOString();
573
+
574
+ const taskChange = applyUpdate(plan, args, now);
575
+ if (taskChange.error) {
576
+ process.stderr.write(taskChange.error + "\n");
577
+ process.exit(1);
578
+ }
579
+ const { warning, project_switch: switchNotif } = recomputeCurrentTasks(plan, args.task, projectList);
580
+
581
+ const validationErrors = validatePlan(plan, projectList);
582
+ if (validationErrors.length) {
583
+ process.stderr.write(ERRORS.VALIDATION_FAILED(validationErrors) + "\n");
584
+ process.exit(1);
585
+ }
586
+
587
+ const tmpPath = args.plan + ".tmp";
588
+ try {
589
+ writeFileSync(tmpPath, stringifyYaml(plan, { lineWidth: 200 }), "utf-8");
590
+ renameSync(tmpPath, args.plan);
591
+ } catch (e) {
592
+ try {
593
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
594
+ } catch {
595
+ // best effort
596
+ }
597
+ process.stderr.write(ERRORS.PLAN_WRITE_FAILED(e.message) + "\n");
598
+ process.exit(1);
599
+ }
600
+
601
+ const doneCount = plan.tasks.filter((t) => t.status === "done").length;
602
+ const result = {
603
+ ok: true,
604
+ task: taskChange,
605
+ current_tasks: plan.current_tasks,
606
+ plan_status: plan.status,
607
+ progress: { done: doneCount, total: plan.tasks.length },
608
+ ...(warning ? { warning } : {}),
609
+ ...(switchNotif ? switchNotif : {}),
610
+ };
611
+ process.stdout.write(JSON.stringify(result) + "\n");
612
+ }
613
+
614
+ main();