@uoyo/mvtt 2.0.0-beta.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +299 -64
- package/README.zh-CN.md +419 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +14 -6
- package/dist/cli.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +28 -16
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +71 -41
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/uninstall.d.ts.map +1 -1
- package/dist/commands/uninstall.js +75 -30
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +34 -21
- package/dist/commands/update.js.map +1 -1
- package/dist/fs/install-manifest.d.ts +4 -1
- package/dist/fs/install-manifest.d.ts.map +1 -1
- package/dist/fs/install-manifest.js +13 -1
- package/dist/fs/install-manifest.js.map +1 -1
- package/dist/fs/materialize.d.ts +2 -0
- package/dist/fs/materialize.d.ts.map +1 -1
- package/dist/fs/materialize.js +39 -9
- package/dist/fs/materialize.js.map +1 -1
- package/dist/fs/registry-merge.d.ts.map +1 -1
- package/dist/fs/registry-merge.js +72 -29
- package/dist/fs/registry-merge.js.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/scripts/epic-update.cjs +7713 -0
- package/dist/scripts/plan-update.cjs +491 -275
- package/dist/scripts/session-update.cjs +320 -199
- package/dist/types/platform.d.ts +12 -0
- package/dist/types/platform.d.ts.map +1 -0
- package/dist/types/platform.js +36 -0
- package/dist/types/platform.js.map +1 -0
- package/dist/types/registry.d.ts +3 -24
- package/dist/types/registry.d.ts.map +1 -1
- package/dist/util/bilingual.d.ts +10 -0
- package/dist/util/bilingual.d.ts.map +1 -0
- package/dist/util/bilingual.js +14 -0
- package/dist/util/bilingual.js.map +1 -0
- package/dist/util/cancel.d.ts +2 -0
- package/dist/util/cancel.d.ts.map +1 -0
- package/dist/util/cancel.js +6 -0
- package/dist/util/cancel.js.map +1 -0
- package/dist/util/color.d.ts +9 -6
- package/dist/util/color.d.ts.map +1 -1
- package/dist/util/color.js +10 -10
- package/dist/util/color.js.map +1 -1
- package/dist/util/spinner.d.ts +8 -0
- package/dist/util/spinner.d.ts.map +1 -0
- package/dist/util/spinner.js +17 -0
- package/dist/util/spinner.js.map +1 -0
- package/install-manifest.yaml +4 -0
- package/package.json +4 -3
- package/registry.yaml +33 -159
- package/sources/defaults/config.yaml +8 -13
- package/sources/defaults/project-context.yaml +2 -5
- package/sources/defaults/session.yaml +14 -2
- package/sources/knowledge/core/manifest.yaml +1 -4
- package/sources/scripts/epic-update.js +512 -0
- package/sources/scripts/plan-update.js +614 -353
- package/sources/scripts/session-update.js +102 -2
- package/sources/sections/activation-load-config.md +3 -3
- package/sources/sections/activation-load-context.md +42 -13
- package/sources/sections/activation-preflight.md +1 -1
- package/sources/sections/footer-next-steps.md +3 -2
- package/sources/sections/language-constraint.md +26 -0
- package/sources/sections/output-format-constraint.md +14 -14
- package/sources/sections/project-context-profile.md +29 -29
- package/sources/sections/session-update.md +41 -1
- package/sources/skills/mvt-analyze/business.md +46 -8
- package/sources/skills/mvt-analyze/manifest.yaml +6 -2
- package/sources/skills/mvt-analyze-code/business.md +18 -17
- package/sources/skills/mvt-analyze-code/manifest.yaml +4 -7
- package/sources/skills/mvt-bug-detect/manifest.yaml +3 -0
- package/sources/skills/mvt-check-context/business.md +13 -6
- package/sources/skills/mvt-check-context/manifest.yaml +2 -4
- package/sources/skills/mvt-cleanup/business.md +17 -2
- package/sources/skills/mvt-cleanup/manifest.yaml +1 -1
- package/sources/skills/mvt-config/business.md +5 -5
- package/sources/skills/mvt-config/manifest.yaml +6 -6
- package/sources/skills/mvt-create-skill/business.md +3 -15
- package/sources/skills/mvt-create-skill/manifest.yaml +1 -6
- package/sources/skills/mvt-decompose/business.md +94 -0
- package/sources/skills/mvt-decompose/manifest.yaml +121 -0
- package/sources/skills/mvt-design/manifest.yaml +1 -1
- package/sources/skills/mvt-fix/business.md +21 -6
- package/sources/skills/mvt-fix/manifest.yaml +2 -2
- package/sources/skills/mvt-help/business.md +11 -9
- package/sources/skills/mvt-help/manifest.yaml +2 -4
- package/sources/skills/mvt-implement/business.md +51 -8
- package/sources/skills/mvt-implement/manifest.yaml +1 -1
- package/sources/skills/mvt-init/business.md +23 -13
- package/sources/skills/mvt-init/manifest.yaml +2 -3
- package/sources/skills/mvt-manage-context/business.md +41 -14
- package/sources/skills/mvt-manage-context/manifest.yaml +3 -7
- package/sources/skills/mvt-plan-dev/business.md +17 -9
- package/sources/skills/mvt-plan-dev/manifest.yaml +1 -1
- package/sources/skills/mvt-quick-dev/business.md +22 -7
- package/sources/skills/mvt-quick-dev/manifest.yaml +1 -2
- package/sources/skills/mvt-refactor/business.md +32 -17
- package/sources/skills/mvt-refactor/manifest.yaml +1 -6
- package/sources/skills/mvt-resume/business.md +32 -12
- package/sources/skills/mvt-resume/manifest.yaml +6 -3
- package/sources/skills/mvt-review/business.md +24 -9
- package/sources/skills/mvt-review/manifest.yaml +1 -1
- package/sources/skills/mvt-status/business.md +37 -9
- package/sources/skills/mvt-status/manifest.yaml +5 -2
- package/sources/skills/mvt-sync-context/business.md +30 -16
- package/sources/skills/mvt-sync-context/manifest.yaml +1 -1
- package/sources/skills/mvt-template/business.md +1 -1
- package/sources/skills/mvt-template/manifest.yaml +2 -4
- package/sources/skills/mvt-test/business.md +30 -15
- package/sources/skills/mvt-test/manifest.yaml +1 -1
- package/sources/skills/mvt-update-plan/business.md +41 -12
- package/sources/skills/mvt-update-plan/manifest.yaml +8 -8
- package/sources/templates/decompose-output/body.md +13 -0
- package/sources/templates/decompose-output/manifest.yaml +11 -0
- package/sources/sections/output-language-constraint.md +0 -11
|
@@ -1,353 +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
|
|
9
|
-
* the full plan validator, and writes back atomically.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* --
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
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();
|