@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.
- package/README.md +299 -64
- package/README.zh-CN.md +419 -0
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +27 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/uninstall.d.ts.map +1 -1
- package/dist/commands/uninstall.js +19 -7
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +4 -2
- 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 +46 -13
- package/dist/fs/materialize.js.map +1 -1
- package/dist/fs/registry-merge.d.ts +19 -0
- package/dist/fs/registry-merge.d.ts.map +1 -0
- package/dist/fs/registry-merge.js +220 -0
- package/dist/fs/registry-merge.js.map +1 -0
- package/dist/scripts/epic-update.cjs +7670 -0
- package/dist/scripts/plan-update.cjs +7736 -0
- package/dist/scripts/session-update.cjs +84 -6
- package/dist/types/platform.d.ts +12 -0
- package/dist/types/platform.d.ts.map +1 -0
- package/dist/types/platform.js +24 -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/install-manifest.yaml +10 -2
- package/package.json +1 -1
- package/registry.yaml +72 -198
- 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 -0
- package/sources/scripts/session-update.js +102 -2
- package/sources/sections/activation-load-config.md +1 -1
- 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/output-format-constraint.md +14 -0
- package/sources/sections/project-context-profile.md +29 -0
- package/sources/sections/session-update.md +41 -1
- package/sources/skills/mvt-analyze/business.md +46 -8
- package/sources/skills/mvt-analyze/manifest.yaml +8 -1
- package/sources/skills/mvt-analyze-code/business.md +18 -17
- package/sources/skills/mvt-analyze-code/manifest.yaml +9 -6
- package/sources/skills/mvt-check-context/business.md +13 -6
- package/sources/skills/mvt-check-context/manifest.yaml +0 -5
- package/sources/skills/mvt-cleanup/business.md +17 -2
- package/sources/skills/mvt-cleanup/manifest.yaml +3 -0
- package/sources/skills/mvt-config/business.md +5 -5
- package/sources/skills/mvt-config/manifest.yaml +3 -6
- package/sources/skills/mvt-create-skill/business.md +2 -14
- package/sources/skills/mvt-create-skill/manifest.yaml +2 -4
- 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 +3 -0
- package/sources/skills/mvt-fix/business.md +21 -6
- package/sources/skills/mvt-fix/manifest.yaml +4 -1
- package/sources/skills/mvt-help/business.md +11 -9
- package/sources/skills/mvt-help/manifest.yaml +0 -5
- package/sources/skills/mvt-implement/business.md +57 -10
- package/sources/skills/mvt-implement/manifest.yaml +3 -0
- package/sources/skills/mvt-init/business.md +23 -13
- package/sources/skills/mvt-init/manifest.yaml +4 -2
- package/sources/skills/mvt-manage-context/business.md +41 -14
- package/sources/skills/mvt-manage-context/manifest.yaml +7 -5
- package/sources/skills/mvt-plan-dev/business.md +17 -9
- package/sources/skills/mvt-plan-dev/manifest.yaml +3 -0
- package/sources/skills/mvt-quick-dev/business.md +22 -7
- package/sources/skills/mvt-quick-dev/manifest.yaml +3 -1
- package/sources/skills/mvt-refactor/business.md +32 -17
- package/sources/skills/mvt-refactor/manifest.yaml +2 -4
- package/sources/skills/mvt-resume/business.md +32 -12
- package/sources/skills/mvt-resume/manifest.yaml +3 -3
- package/sources/skills/mvt-review/business.md +24 -9
- package/sources/skills/mvt-review/manifest.yaml +3 -0
- package/sources/skills/mvt-status/business.md +37 -9
- package/sources/skills/mvt-status/manifest.yaml +2 -2
- package/sources/skills/mvt-sync-context/business.md +77 -34
- package/sources/skills/mvt-sync-context/manifest.yaml +6 -0
- package/sources/skills/mvt-template/business.md +1 -1
- package/sources/skills/mvt-template/manifest.yaml +0 -5
- package/sources/skills/mvt-test/business.md +30 -15
- package/sources/skills/mvt-test/manifest.yaml +3 -0
- package/sources/skills/mvt-update-plan/business.md +64 -33
- package/sources/skills/mvt-update-plan/manifest.yaml +10 -7
- package/sources/templates/decompose-output/body.md +13 -0
- package/sources/templates/decompose-output/manifest.yaml +11 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epic-update.js — Mechanical epic.yaml mutation script
|
|
5
|
+
*
|
|
6
|
+
* Deterministic mutations on epic.yaml: complete children, advance
|
|
7
|
+
* current_change, switch active pointer, add children, validate DAG.
|
|
8
|
+
* Mirrors plan-update.js structure and output protocol (ADR-9).
|
|
9
|
+
*
|
|
10
|
+
* NOTE: This source file uses `import from "yaml"`. During the build pipeline,
|
|
11
|
+
* esbuild bundles it into a zero-dependency single file deployed to
|
|
12
|
+
* .ai-agents/scripts/epic-update.cjs.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node .ai-agents/scripts/epic-update.cjs \
|
|
16
|
+
* --epic <path-to-epic.yaml> \
|
|
17
|
+
* --complete-child <change_id>
|
|
18
|
+
*
|
|
19
|
+
* node .ai-agents/scripts/epic-update.cjs \
|
|
20
|
+
* --epic <path> \
|
|
21
|
+
* --set-child-status <change_id> --child-status <status>
|
|
22
|
+
*
|
|
23
|
+
* node .ai-agents/scripts/epic-update.cjs \
|
|
24
|
+
* --epic <path> \
|
|
25
|
+
* --switch-active <change_id>
|
|
26
|
+
*
|
|
27
|
+
* node .ai-agents/scripts/epic-update.cjs \
|
|
28
|
+
* --epic <path> \
|
|
29
|
+
* --add-child <id> --child-title "<t>" --child-scope "<s>" \
|
|
30
|
+
* [--child-depends-on "dep1,dep2"] \
|
|
31
|
+
* [--add-child <id2> --child-title "<t2>" ...]
|
|
32
|
+
*
|
|
33
|
+
* node .ai-agents/scripts/epic-update.cjs \
|
|
34
|
+
* --validate <path-to-epic.yaml>
|
|
35
|
+
*
|
|
36
|
+
* Output:
|
|
37
|
+
* Success (exit 0): one-line JSON on stdout
|
|
38
|
+
* Failure (exit 1): plain-text error on stderr
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from "node:fs";
|
|
42
|
+
import { dirname, join, resolve } from "node:path";
|
|
43
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
44
|
+
|
|
45
|
+
// ── Project Discovery ──────────────────────────────────────────────────────
|
|
46
|
+
// Mirrors the helpers in plan-update.js. Used to default the project array for
|
|
47
|
+
// newly-added children to the actual workspace project name (not "default")
|
|
48
|
+
// when project-context.yaml is present and single-project.
|
|
49
|
+
function findProjectRootFromPath(filePath) {
|
|
50
|
+
let dir = resolve(dirname(filePath));
|
|
51
|
+
while (true) {
|
|
52
|
+
if (existsSync(join(dir, ".ai-agents"))) return dir;
|
|
53
|
+
const parent = dirname(dir);
|
|
54
|
+
if (parent === dir) return null;
|
|
55
|
+
dir = parent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadSoleProject(projectRoot) {
|
|
60
|
+
if (!projectRoot) return null;
|
|
61
|
+
const ctxPath = join(projectRoot, ".ai-agents/workspace/project-context.yaml");
|
|
62
|
+
if (!existsSync(ctxPath)) return null;
|
|
63
|
+
try {
|
|
64
|
+
const ctx = parseYaml(readFileSync(ctxPath, "utf-8"));
|
|
65
|
+
const projects = ctx?.projects;
|
|
66
|
+
if (!Array.isArray(projects) || projects.length !== 1) return null;
|
|
67
|
+
const name = projects[0]?.name;
|
|
68
|
+
if (typeof name !== "string" || name === "") return null;
|
|
69
|
+
return [name];
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Constants ─────────────────────────────────────────────────────────────
|
|
76
|
+
const VALID_CHILD_STATUSES = ["pending", "active", "done", "abandoned"];
|
|
77
|
+
const VALID_EPIC_STATUSES = ["in_progress", "done", "abandoned"];
|
|
78
|
+
const TERMINAL_STATUSES = ["done", "abandoned"];
|
|
79
|
+
|
|
80
|
+
const ERRORS = {
|
|
81
|
+
MISSING_EPIC: () => "Missing required argument: --epic (or --validate <path>)",
|
|
82
|
+
NO_OPERATION: () => "No operation specified. Use --complete-child, --set-child-status, --switch-active, --add-child, or --validate.",
|
|
83
|
+
EPIC_NOT_FOUND: (p) => `Epic file not found at ${p}.`,
|
|
84
|
+
EPIC_PARSE_FAILED: (detail) => `Failed to parse epic.yaml: ${detail}`,
|
|
85
|
+
CHILD_NOT_FOUND: (id, valid) =>
|
|
86
|
+
`Child "${id}" not found. Valid children: ${valid.length ? valid.join(", ") : "(none)"}.`,
|
|
87
|
+
VALIDATION_FAILED: (errs) =>
|
|
88
|
+
`Epic validation failed:\n - ${errs.join("\n - ")}`,
|
|
89
|
+
EPIC_WRITE_FAILED: (detail) => `Failed to write epic.yaml: ${detail}`,
|
|
90
|
+
INVALID_CHILD_STATUS: (val) =>
|
|
91
|
+
`Invalid --child-status "${val}". Must be one of: ${VALID_CHILD_STATUSES.join(", ")}.`,
|
|
92
|
+
MISSING_CHILD_STATUS: () => "--set-child-status requires --child-status <status>",
|
|
93
|
+
MULTIPLE_ACTIVE: () => "Cannot activate: another child is already active. Use --switch-active for atomic reorder.",
|
|
94
|
+
UNRESOLVED_DEPS: (id, deps) =>
|
|
95
|
+
`Cannot activate "${id}": unresolved depends_on: ${deps.join(", ")}`,
|
|
96
|
+
ADD_CHILD_MISSING: () => "--add-child requires an id argument",
|
|
97
|
+
ADD_CHILD_TITLE_MISSING: (id) => `--add-child "${id}" requires --child-title`,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ── CLI Parsing ─────────────────────────────────────────────────────────────
|
|
101
|
+
// Custom parser: handles --add-child as a repeatable grouped flag, and
|
|
102
|
+
// --set-child-status which consumes TWO positional values (id + child-status).
|
|
103
|
+
function parseArgs(argv) {
|
|
104
|
+
const args = {};
|
|
105
|
+
const addChildren = [];
|
|
106
|
+
|
|
107
|
+
for (let i = 2; i < argv.length; i++) {
|
|
108
|
+
const arg = argv[i];
|
|
109
|
+
|
|
110
|
+
if (arg === "--add-child") {
|
|
111
|
+
const next = argv[i + 1];
|
|
112
|
+
if (next && !next.startsWith("--")) {
|
|
113
|
+
addChildren.push({ id: next });
|
|
114
|
+
i++;
|
|
115
|
+
} else {
|
|
116
|
+
addChildren.push({ id: true });
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (arg === "--child-title" || arg === "--child-scope" || arg === "--child-depends-on") {
|
|
122
|
+
const next = argv[i + 1];
|
|
123
|
+
if (addChildren.length > 0 && next) {
|
|
124
|
+
const current = addChildren[addChildren.length - 1];
|
|
125
|
+
if (arg === "--child-depends-on") {
|
|
126
|
+
current.depends_on = next.split(",").map((s) => s.trim()).filter(Boolean);
|
|
127
|
+
} else {
|
|
128
|
+
// Strip "--child-" prefix: --child-title -> "title", --child-scope -> "scope"
|
|
129
|
+
current[arg.slice(8)] = next;
|
|
130
|
+
}
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (arg.startsWith("--")) {
|
|
137
|
+
const key = arg.slice(2);
|
|
138
|
+
const next = argv[i + 1];
|
|
139
|
+
|
|
140
|
+
if (key === "set-child-status" && next && !next.startsWith("--")) {
|
|
141
|
+
args[key] = next;
|
|
142
|
+
i++;
|
|
143
|
+
const statusVal = argv[i + 1];
|
|
144
|
+
if (statusVal && !statusVal.startsWith("--")) {
|
|
145
|
+
args["child-status"] = statusVal;
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (next && !next.startsWith("--")) {
|
|
152
|
+
args[key] = next;
|
|
153
|
+
i++;
|
|
154
|
+
} else {
|
|
155
|
+
args[key] = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (addChildren.length > 0) args["add-child"] = addChildren;
|
|
161
|
+
return args;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateArgs(args) {
|
|
165
|
+
if (!args.epic && !args.validate) return ERRORS.MISSING_EPIC();
|
|
166
|
+
|
|
167
|
+
const hasOp =
|
|
168
|
+
args["complete-child"] ||
|
|
169
|
+
args["set-child-status"] ||
|
|
170
|
+
args["switch-active"] ||
|
|
171
|
+
args["add-child"] ||
|
|
172
|
+
args.validate;
|
|
173
|
+
if (!hasOp) return ERRORS.NO_OPERATION();
|
|
174
|
+
|
|
175
|
+
if (args["set-child-status"] && !args["child-status"]) return ERRORS.MISSING_CHILD_STATUS();
|
|
176
|
+
if (args["child-status"] && !VALID_CHILD_STATUSES.includes(args["child-status"]))
|
|
177
|
+
return ERRORS.INVALID_CHILD_STATUS(args["child-status"]);
|
|
178
|
+
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── DAG ─────────────────────────────────────────────────────────────────────
|
|
183
|
+
// Kahn's algorithm: returns a cycle description array, or null if DAG is clean.
|
|
184
|
+
function findCycle(children) {
|
|
185
|
+
const idSet = new Set(children.map((c) => c.change_id));
|
|
186
|
+
const inDegree = new Map(children.map((c) => [c.change_id, 0]));
|
|
187
|
+
const adj = new Map(children.map((c) => [c.change_id, []]));
|
|
188
|
+
|
|
189
|
+
for (const c of children) {
|
|
190
|
+
for (const dep of c.depends_on || []) {
|
|
191
|
+
if (idSet.has(dep)) {
|
|
192
|
+
adj.get(dep).push(c.change_id);
|
|
193
|
+
inDegree.set(c.change_id, (inDegree.get(c.change_id) || 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const queue = [];
|
|
199
|
+
for (const [id, deg] of inDegree) {
|
|
200
|
+
if (deg === 0) queue.push(id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let processed = 0;
|
|
204
|
+
while (queue.length > 0) {
|
|
205
|
+
const node = queue.shift();
|
|
206
|
+
processed++;
|
|
207
|
+
for (const neighbor of adj.get(node) || []) {
|
|
208
|
+
const newDeg = inDegree.get(neighbor) - 1;
|
|
209
|
+
inDegree.set(neighbor, newDeg);
|
|
210
|
+
if (newDeg === 0) queue.push(neighbor);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (processed < children.length) {
|
|
215
|
+
const inCycle = children
|
|
216
|
+
.filter((c) => inDegree.get(c.change_id) > 0)
|
|
217
|
+
.map((c) => c.change_id);
|
|
218
|
+
return ["cycle", ...inCycle];
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Validation ──────────────────────────────────────────────────────────────
|
|
224
|
+
function validateEpic(epic) {
|
|
225
|
+
const errors = [];
|
|
226
|
+
const children = Array.isArray(epic.children) ? epic.children : [];
|
|
227
|
+
|
|
228
|
+
// 1. Unique change_ids
|
|
229
|
+
const ids = children.map((c) => c.change_id);
|
|
230
|
+
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
231
|
+
if (dupes.length) errors.push(`Duplicate change_ids: ${[...new Set(dupes)].join(", ")}`);
|
|
232
|
+
|
|
233
|
+
const idSet = new Set(ids);
|
|
234
|
+
|
|
235
|
+
// 2. depends_on references exist
|
|
236
|
+
for (const c of children) {
|
|
237
|
+
for (const d of c.depends_on || []) {
|
|
238
|
+
if (!idSet.has(d)) {
|
|
239
|
+
errors.push(`Child "${c.change_id}" depends_on unknown child "${d}"`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 3. DAG (no cycles)
|
|
245
|
+
const cycle = findCycle(children);
|
|
246
|
+
if (cycle) errors.push(`Dependency cycle: ${cycle.join(" -> ")}`);
|
|
247
|
+
|
|
248
|
+
// 4. current_change validity
|
|
249
|
+
if (epic.current_change) {
|
|
250
|
+
const target = children.find((c) => c.change_id === epic.current_change);
|
|
251
|
+
if (!target) {
|
|
252
|
+
errors.push(`current_change "${epic.current_change}" does not reference a child`);
|
|
253
|
+
} else if (!["pending", "active"].includes(target.status)) {
|
|
254
|
+
errors.push(
|
|
255
|
+
`current_change "${epic.current_change}" has status "${target.status}" (must be pending or active)`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. At most one active
|
|
261
|
+
const activeCount = children.filter((c) => c.status === "active").length;
|
|
262
|
+
if (activeCount > 1) {
|
|
263
|
+
errors.push(
|
|
264
|
+
`Multiple active children (${activeCount}): ${children
|
|
265
|
+
.filter((c) => c.status === "active")
|
|
266
|
+
.map((c) => c.change_id)
|
|
267
|
+
.join(", ")}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 6. Epic status consistency
|
|
272
|
+
const allTerminal = children.length > 0 && children.every((c) => TERMINAL_STATUSES.includes(c.status));
|
|
273
|
+
if (allTerminal && epic.status === "in_progress") {
|
|
274
|
+
errors.push("All children are done/abandoned but epic status is still in_progress");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return errors;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Advancement ─────────────────────────────────────────────────────────────
|
|
281
|
+
// After completing a child, recompute current_change:
|
|
282
|
+
// scan children in array order (deterministic tie-break), select the first
|
|
283
|
+
// pending child whose depends_on are all resolved (done + abandoned).
|
|
284
|
+
// If none found and all children are terminal → epic.status = done.
|
|
285
|
+
function recomputeCurrentChange(epic) {
|
|
286
|
+
const children = epic.children || [];
|
|
287
|
+
const resolvedIds = new Set(
|
|
288
|
+
children.filter((c) => TERMINAL_STATUSES.includes(c.status)).map((c) => c.change_id)
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const next = children.find(
|
|
292
|
+
(c) =>
|
|
293
|
+
c.status === "pending" &&
|
|
294
|
+
(c.depends_on || []).every((d) => resolvedIds.has(d))
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (next) {
|
|
298
|
+
next.status = "active";
|
|
299
|
+
epic.current_change = next.change_id;
|
|
300
|
+
} else {
|
|
301
|
+
epic.current_change = "";
|
|
302
|
+
const allTerminal = children.length > 0 && children.every((c) => TERMINAL_STATUSES.includes(c.status));
|
|
303
|
+
if (allTerminal) epic.status = "done";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return next ? next.change_id : "";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Operations ──────────────────────────────────────────────────────────────
|
|
310
|
+
function completeChild(epic, changeId, now) {
|
|
311
|
+
const child = (epic.children || []).find((c) => c.change_id === changeId);
|
|
312
|
+
if (!child) return { error: ERRORS.CHILD_NOT_FOUND(changeId, (epic.children || []).map((c) => c.change_id)) };
|
|
313
|
+
|
|
314
|
+
const oldStatus = child.status;
|
|
315
|
+
child.status = "done";
|
|
316
|
+
child.completed_at = now;
|
|
317
|
+
|
|
318
|
+
const nextId = recomputeCurrentChange(epic);
|
|
319
|
+
const doneCount = (epic.children || []).filter((c) => c.status === "done").length;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
child: { change_id: changeId, old_status: oldStatus, new_status: "done" },
|
|
323
|
+
current_change: nextId,
|
|
324
|
+
epic_status: epic.status,
|
|
325
|
+
progress: { done: doneCount, total: (epic.children || []).length },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function setChildStatus(epic, changeId, status, now) {
|
|
330
|
+
const child = (epic.children || []).find((c) => c.change_id === changeId);
|
|
331
|
+
if (!child) return { error: ERRORS.CHILD_NOT_FOUND(changeId, (epic.children || []).map((c) => c.change_id)) };
|
|
332
|
+
|
|
333
|
+
// At-most-one-active guard (use --switch-active for safe reorder)
|
|
334
|
+
if (status === "active") {
|
|
335
|
+
const existing = (epic.children || []).find(
|
|
336
|
+
(c) => c.status === "active" && c.change_id !== changeId
|
|
337
|
+
);
|
|
338
|
+
if (existing) return { error: ERRORS.MULTIPLE_ACTIVE() };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const oldStatus = child.status;
|
|
342
|
+
child.status = status;
|
|
343
|
+
if (status === "done") child.completed_at = now;
|
|
344
|
+
else if (oldStatus === "done" && status !== "done") child.completed_at = null;
|
|
345
|
+
|
|
346
|
+
if (status === "active") epic.current_change = changeId;
|
|
347
|
+
|
|
348
|
+
const doneCount = (epic.children || []).filter((c) => c.status === "done").length;
|
|
349
|
+
return {
|
|
350
|
+
child: { change_id: changeId, old_status: oldStatus, new_status: status },
|
|
351
|
+
current_change: epic.current_change || "",
|
|
352
|
+
epic_status: epic.status,
|
|
353
|
+
progress: { done: doneCount, total: (epic.children || []).length },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function switchActive(epic, changeId) {
|
|
358
|
+
const children = epic.children || [];
|
|
359
|
+
const target = children.find((c) => c.change_id === changeId);
|
|
360
|
+
if (!target) return { error: ERRORS.CHILD_NOT_FOUND(changeId, children.map((c) => c.change_id)) };
|
|
361
|
+
|
|
362
|
+
// Validate target's depends_on are resolved
|
|
363
|
+
const resolvedIds = new Set(
|
|
364
|
+
children.filter((c) => TERMINAL_STATUSES.includes(c.status)).map((c) => c.change_id)
|
|
365
|
+
);
|
|
366
|
+
const unresolved = (target.depends_on || []).filter((d) => !resolvedIds.has(d));
|
|
367
|
+
if (unresolved.length) return { error: ERRORS.UNRESOLVED_DEPS(changeId, unresolved) };
|
|
368
|
+
|
|
369
|
+
// Atomic: demote current active → pending, promote target → active
|
|
370
|
+
for (const c of children) {
|
|
371
|
+
if (c.status === "active" && c.change_id !== changeId) {
|
|
372
|
+
c.status = "pending";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
target.status = "active";
|
|
376
|
+
epic.current_change = changeId;
|
|
377
|
+
|
|
378
|
+
const doneCount = children.filter((c) => c.status === "done").length;
|
|
379
|
+
return {
|
|
380
|
+
child: { change_id: changeId, old_status: "pending", new_status: "active" },
|
|
381
|
+
current_change: changeId,
|
|
382
|
+
epic_status: epic.status,
|
|
383
|
+
progress: { done: doneCount, total: children.length },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function addChild(epic, childrenToAdd, epicPath) {
|
|
388
|
+
if (!Array.isArray(childrenToAdd) || childrenToAdd.length === 0) {
|
|
389
|
+
return { error: ERRORS.ADD_CHILD_MISSING() };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
epic.children = epic.children || [];
|
|
393
|
+
|
|
394
|
+
// Default project attribution: read the sole project from
|
|
395
|
+
// project-context.yaml when available, fall back to ["default"] for
|
|
396
|
+
// legacy / unconfigured workspaces.
|
|
397
|
+
const defaultProject = loadSoleProject(findProjectRootFromPath(epicPath)) || ["default"];
|
|
398
|
+
|
|
399
|
+
for (const child of childrenToAdd) {
|
|
400
|
+
if (!child.id || child.id === true) return { error: ERRORS.ADD_CHILD_MISSING() };
|
|
401
|
+
if (!child.title) return { error: ERRORS.ADD_CHILD_TITLE_MISSING(child.id) };
|
|
402
|
+
|
|
403
|
+
if (epic.children.some((c) => c.change_id === child.id)) {
|
|
404
|
+
return { error: `Duplicate change_id "${child.id}" in children` };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
epic.children.push({
|
|
408
|
+
change_id: child.id,
|
|
409
|
+
title: child.title,
|
|
410
|
+
status: "pending",
|
|
411
|
+
depends_on: child.depends_on || [],
|
|
412
|
+
project: defaultProject,
|
|
413
|
+
scope: child.scope || "",
|
|
414
|
+
completed_at: null,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const doneCount = epic.children.filter((c) => c.status === "done").length;
|
|
419
|
+
return {
|
|
420
|
+
child: { change_id: childrenToAdd[childrenToAdd.length - 1].id, new_status: "pending" },
|
|
421
|
+
current_change: epic.current_change || "",
|
|
422
|
+
epic_status: epic.status,
|
|
423
|
+
progress: { done: doneCount, total: epic.children.length },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Main ────────────────────────────────────────────────────────────────────
|
|
428
|
+
function main() {
|
|
429
|
+
const args = parseArgs(process.argv);
|
|
430
|
+
|
|
431
|
+
const argErr = validateArgs(args);
|
|
432
|
+
if (argErr) {
|
|
433
|
+
process.stderr.write(argErr + "\n");
|
|
434
|
+
process.exit(1);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const epicPath = args.epic || args.validate;
|
|
438
|
+
if (!existsSync(epicPath)) {
|
|
439
|
+
process.stderr.write(ERRORS.EPIC_NOT_FOUND(epicPath) + "\n");
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
let epic;
|
|
444
|
+
try {
|
|
445
|
+
epic = parseYaml(readFileSync(epicPath, "utf-8"));
|
|
446
|
+
} catch (e) {
|
|
447
|
+
process.stderr.write(ERRORS.EPIC_PARSE_FAILED(e.message) + "\n");
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!epic || typeof epic !== "object") {
|
|
452
|
+
process.stderr.write(ERRORS.EPIC_PARSE_FAILED("not a valid YAML object") + "\n");
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// --validate: read-only check, no writes
|
|
457
|
+
if (args.validate) {
|
|
458
|
+
const errors = validateEpic(epic);
|
|
459
|
+
if (errors.length) {
|
|
460
|
+
process.stderr.write(ERRORS.VALIDATION_FAILED(errors) + "\n");
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
process.stdout.write(JSON.stringify({ ok: true, valid: true }) + "\n");
|
|
464
|
+
process.exit(0);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const now = new Date().toISOString();
|
|
468
|
+
let result;
|
|
469
|
+
|
|
470
|
+
if (args["complete-child"]) {
|
|
471
|
+
result = completeChild(epic, args["complete-child"], now);
|
|
472
|
+
} else if (args["set-child-status"]) {
|
|
473
|
+
result = setChildStatus(epic, args["set-child-status"], args["child-status"], now);
|
|
474
|
+
} else if (args["switch-active"]) {
|
|
475
|
+
result = switchActive(epic, args["switch-active"]);
|
|
476
|
+
} else if (args["add-child"]) {
|
|
477
|
+
result = addChild(epic, args["add-child"], args.epic);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (result.error) {
|
|
481
|
+
process.stderr.write(result.error + "\n");
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Post-mutation validation
|
|
486
|
+
const errors = validateEpic(epic);
|
|
487
|
+
if (errors.length) {
|
|
488
|
+
process.stderr.write(ERRORS.VALIDATION_FAILED(errors) + "\n");
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
epic.updated_at = now;
|
|
493
|
+
|
|
494
|
+
// Atomic write
|
|
495
|
+
const tmpPath = epicPath + ".tmp";
|
|
496
|
+
try {
|
|
497
|
+
writeFileSync(tmpPath, stringifyYaml(epic, { lineWidth: 200 }), "utf-8");
|
|
498
|
+
renameSync(tmpPath, epicPath);
|
|
499
|
+
} catch (e) {
|
|
500
|
+
try {
|
|
501
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
502
|
+
} catch {
|
|
503
|
+
// best effort cleanup
|
|
504
|
+
}
|
|
505
|
+
process.stderr.write(ERRORS.EPIC_WRITE_FAILED(e.message) + "\n");
|
|
506
|
+
process.exit(1);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
process.stdout.write(JSON.stringify({ ok: true, ...result }) + "\n");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
main();
|