trackops 2.0.6 → 2.2.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 +307 -701
- package/bin/trackops.js +24 -16
- package/lib/config.js +265 -58
- package/lib/control.js +830 -292
- package/lib/init.js +46 -16
- package/lib/opera-bootstrap.js +85 -45
- package/lib/opera-phase-dod.js +485 -0
- package/lib/opera.js +8 -5
- package/lib/plans.js +1329 -0
- package/lib/quality-assert.js +49 -0
- package/lib/quality.js +1759 -0
- package/lib/release.js +18 -11
- package/lib/server.js +504 -192
- package/lib/skills.js +94 -41
- package/locales/en.json +249 -15
- package/locales/es.json +249 -15
- package/package.json +3 -2
- package/scripts/quality-unit-tests.js +130 -0
- package/scripts/skills-marketplace-smoke.js +156 -124
- package/scripts/smoke-tests.js +378 -71
- package/scripts/sync-skill-version.js +29 -19
- package/scripts/validate-skill.js +188 -103
- package/skills/trackops/SKILL.md +25 -7
- package/skills/trackops/locales/en/SKILL.md +25 -7
- package/skills/trackops/locales/en/references/activation.md +3 -3
- package/skills/trackops/locales/en/references/workflow.md +5 -4
- package/skills/trackops/references/activation.md +3 -3
- package/skills/trackops/references/workflow.md +5 -4
- package/skills/trackops/skill.json +29 -29
- package/skills/trackops-quality-guard/SKILL.md +78 -0
- package/skills/trackops-quality-guard/agents/openai.yaml +7 -0
- package/skills/trackops-quality-guard/locales/en/SKILL.md +78 -0
- package/skills/trackops-quality-guard/locales/en/references/commands.md +36 -0
- package/skills/trackops-quality-guard/locales/en/references/decision-policy.md +16 -0
- package/skills/trackops-quality-guard/locales/en/references/output-format.md +24 -0
- package/skills/trackops-quality-guard/references/commands.md +36 -0
- package/skills/trackops-quality-guard/references/decision-policy.md +16 -0
- package/skills/trackops-quality-guard/references/output-format.md +24 -0
- package/skills/trackops-quality-guard/skill.json +28 -0
- package/templates/skills/opera-skill/SKILL.md +12 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +12 -0
- package/templates/skills/trackops-quality-guard/SKILL.md +72 -0
- package/templates/skills/trackops-quality-guard/locales/en/SKILL.md +72 -0
- package/templates/skills/trackops-quality-guard/locales/en/references/commands.md +30 -0
- package/templates/skills/trackops-quality-guard/locales/en/references/decision-policy.md +14 -0
- package/templates/skills/trackops-quality-guard/locales/en/references/output-format.md +21 -0
- package/templates/skills/trackops-quality-guard/references/commands.md +30 -0
- package/templates/skills/trackops-quality-guard/references/decision-policy.md +14 -0
- package/templates/skills/trackops-quality-guard/references/output-format.md +21 -0
- package/ui/js/api.js +93 -26
- package/ui/js/app.js +13 -7
- package/ui/js/filters.js +49 -29
- package/ui/js/time-tracker.js +41 -28
- package/ui/js/views/board.js +22 -14
- package/ui/js/views/dashboard.js +206 -49
- package/ui/js/views/execution.js +7 -3
- package/ui/js/views/plans.js +284 -0
- package/ui/js/views/scrum.js +25 -13
- package/ui/js/views/sidebar.js +9 -8
- package/ui/js/views/tasks.js +238 -134
package/lib/plans.js
ADDED
|
@@ -0,0 +1,1329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const config = require("./config");
|
|
8
|
+
|
|
9
|
+
const PLAN_REGISTRY_VERSION = 1;
|
|
10
|
+
const PLAN_DOCUMENT_VERSION = 1;
|
|
11
|
+
const PREVIEW_VERSION = 1;
|
|
12
|
+
const SUPPORTED_ADAPTERS = ["canonical", "claude", "codex", "antigravity", "generic_markdown"];
|
|
13
|
+
const PLAN_SCAN_FILE_RE = /(?:^|[\\/])(plan|plans|tasks|implementation|implementation-plan|task-plan|todo)(?:[-_.].+)?\.(md|markdown|txt|json)$/i;
|
|
14
|
+
const MANAGED_FIELDS = [...config.DEFAULT_MANAGED_PLAN_FIELDS];
|
|
15
|
+
|
|
16
|
+
function nowIso() {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeText(filePath, content) {
|
|
21
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
22
|
+
fs.writeFileSync(filePath, content.replace(/\r?\n/g, "\n"), "utf8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeJson(filePath, data) {
|
|
26
|
+
writeText(filePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readJsonSafe(filePath, fallback = null) {
|
|
30
|
+
if (!filePath || !fs.existsSync(filePath)) return fallback;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
33
|
+
} catch (_error) {
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sha256(value) {
|
|
39
|
+
return crypto.createHash("sha256").update(String(value || ""), "utf8").digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function slugify(value) {
|
|
43
|
+
return String(value || "")
|
|
44
|
+
.normalize("NFD")
|
|
45
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
48
|
+
.replace(/^-+|-+$/g, "")
|
|
49
|
+
.slice(0, 64);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toList(value) {
|
|
53
|
+
if (Array.isArray(value)) return value.map((item) => String(item || "").trim()).filter(Boolean);
|
|
54
|
+
return String(value || "").split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clone(value) {
|
|
58
|
+
return JSON.parse(JSON.stringify(value));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureRegistry(context) {
|
|
62
|
+
fs.mkdirSync(context.paths.plansDir, { recursive: true });
|
|
63
|
+
if (!fs.existsSync(context.paths.plansRegistryFile)) {
|
|
64
|
+
writeJson(context.paths.plansRegistryFile, { version: PLAN_REGISTRY_VERSION, sources: [] });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadRegistry(contextOrRoot) {
|
|
69
|
+
const context = config.ensureContext(contextOrRoot);
|
|
70
|
+
ensureRegistry(context);
|
|
71
|
+
const registry = readJsonSafe(context.paths.plansRegistryFile, { version: PLAN_REGISTRY_VERSION, sources: [] }) || {};
|
|
72
|
+
registry.version = PLAN_REGISTRY_VERSION;
|
|
73
|
+
registry.sources = Array.isArray(registry.sources) ? registry.sources : [];
|
|
74
|
+
return registry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function saveRegistry(contextOrRoot, registry) {
|
|
78
|
+
const context = config.ensureContext(contextOrRoot);
|
|
79
|
+
ensureRegistry(context);
|
|
80
|
+
const next = {
|
|
81
|
+
version: PLAN_REGISTRY_VERSION,
|
|
82
|
+
sources: Array.isArray(registry?.sources) ? registry.sources : [],
|
|
83
|
+
};
|
|
84
|
+
writeJson(context.paths.plansRegistryFile, next);
|
|
85
|
+
return next;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function planFiles(contextOrRoot, sourceId) {
|
|
89
|
+
const context = config.ensureContext(contextOrRoot);
|
|
90
|
+
const id = slugify(sourceId);
|
|
91
|
+
const dir = path.join(context.paths.plansDir, id);
|
|
92
|
+
return {
|
|
93
|
+
dir,
|
|
94
|
+
manifestFile: path.join(dir, "manifest.json"),
|
|
95
|
+
normalizedFile: path.join(dir, "normalized-plan.json"),
|
|
96
|
+
previewFile: path.join(dir, "preview.json"),
|
|
97
|
+
historyDir: path.join(dir, "history"),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function planRawFile(files, ext) {
|
|
102
|
+
const normalizedExt = String(ext || ".md").startsWith(".") ? String(ext || ".md") : `.${ext}`;
|
|
103
|
+
return path.join(files.dir, `raw${normalizedExt.toLowerCase()}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeRegistrySource(entry) {
|
|
107
|
+
return {
|
|
108
|
+
id: String(entry?.id || "").trim(),
|
|
109
|
+
title: String(entry?.title || entry?.id || "").trim(),
|
|
110
|
+
adapter: String(entry?.adapter || "unknown").trim(),
|
|
111
|
+
path: entry?.path ? String(entry.path).trim() : null,
|
|
112
|
+
status: String(entry?.status || "previewed").trim(),
|
|
113
|
+
checksum: entry?.checksum ? String(entry.checksum).trim() : null,
|
|
114
|
+
latestImportId: entry?.latestImportId ? String(entry.latestImportId).trim() : null,
|
|
115
|
+
lastImportAt: entry?.lastImportAt ? String(entry.lastImportAt).trim() : null,
|
|
116
|
+
lastPreviewAt: entry?.lastPreviewAt ? String(entry.lastPreviewAt).trim() : null,
|
|
117
|
+
lastApplyAt: entry?.lastApplyAt ? String(entry.lastApplyAt).trim() : null,
|
|
118
|
+
warnings: Number.isFinite(Number(entry?.warnings)) ? Number(entry.warnings) : 0,
|
|
119
|
+
conflicts: Number.isFinite(Number(entry?.conflicts)) ? Number(entry.conflicts) : 0,
|
|
120
|
+
managedTaskCount: Number.isFinite(Number(entry?.managedTaskCount)) ? Number(entry.managedTaskCount) : 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function upsertRegistrySource(contextOrRoot, entry) {
|
|
125
|
+
const registry = loadRegistry(contextOrRoot);
|
|
126
|
+
const normalized = normalizeRegistrySource(entry);
|
|
127
|
+
const existingIndex = registry.sources.findIndex((source) => source.id === normalized.id);
|
|
128
|
+
if (existingIndex >= 0) registry.sources[existingIndex] = normalized;
|
|
129
|
+
else registry.sources.push(normalized);
|
|
130
|
+
saveRegistry(contextOrRoot, registry);
|
|
131
|
+
return normalized;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function listSources(contextOrRoot) {
|
|
135
|
+
const registry = loadRegistry(contextOrRoot);
|
|
136
|
+
return registry.sources.map((source) => normalizeRegistrySource(source));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function syncControlPlanMeta(controlState, contextOrRoot) {
|
|
140
|
+
const sources = listSources(contextOrRoot);
|
|
141
|
+
const previous = controlState.meta?.plans || {};
|
|
142
|
+
controlState.meta = controlState.meta || {};
|
|
143
|
+
controlState.meta.plans = {
|
|
144
|
+
activeSourceId:
|
|
145
|
+
(previous.activeSourceId && sources.some((source) => source.id === previous.activeSourceId))
|
|
146
|
+
? previous.activeSourceId
|
|
147
|
+
: sources[0]?.id || null,
|
|
148
|
+
sources: sources.map((source) => ({
|
|
149
|
+
id: source.id,
|
|
150
|
+
title: source.title,
|
|
151
|
+
adapter: source.adapter,
|
|
152
|
+
status: source.status,
|
|
153
|
+
lastPreviewAt: source.lastPreviewAt || source.lastImportAt || null,
|
|
154
|
+
lastApplyAt: source.lastApplyAt || null,
|
|
155
|
+
warnings: source.warnings || 0,
|
|
156
|
+
conflicts: source.conflicts || 0,
|
|
157
|
+
managedTaskCount: source.managedTaskCount || 0,
|
|
158
|
+
})),
|
|
159
|
+
lastImportAt: sources
|
|
160
|
+
.map((source) => source.lastImportAt || source.lastPreviewAt || "")
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.sort()
|
|
163
|
+
.slice(-1)[0] || null,
|
|
164
|
+
unresolvedConflicts: sources.reduce((sum, source) => sum + (source.conflicts || 0), 0),
|
|
165
|
+
};
|
|
166
|
+
return controlState.meta.plans;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizePriority(value) {
|
|
170
|
+
const match = String(value || "").trim().toUpperCase().match(/^P[0-3]$/);
|
|
171
|
+
return match ? match[0] : "P1";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeStatus(value) {
|
|
175
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
176
|
+
if (!normalized) return "pending";
|
|
177
|
+
if (["pending", "todo", "to_do"].includes(normalized)) return "pending";
|
|
178
|
+
if (["in_progress", "in-progress", "doing", "active", "started"].includes(normalized)) return "in_progress";
|
|
179
|
+
if (["in_review", "in-review", "review", "qa"].includes(normalized)) return "in_review";
|
|
180
|
+
if (["blocked", "waiting", "on_hold", "on-hold"].includes(normalized)) return "blocked";
|
|
181
|
+
if (["completed", "complete", "done", "closed"].includes(normalized)) return "completed";
|
|
182
|
+
if (["cancelled", "canceled", "dropped"].includes(normalized)) return "cancelled";
|
|
183
|
+
return "pending";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizePhase(value, fallbackText = "") {
|
|
187
|
+
const direct = String(value || "").trim().toUpperCase();
|
|
188
|
+
if (/^[OPERA]$/.test(direct)) return direct;
|
|
189
|
+
const text = `${value || ""} ${fallbackText || ""}`.toLowerCase();
|
|
190
|
+
if (/(discover|clarif|scope|problem|analysis|orquest|orchestr|alignment|define)/.test(text)) return "O";
|
|
191
|
+
if (/(prove|test|audit|research|spike|validate|verification)/.test(text)) return "P";
|
|
192
|
+
if (/(implement|build|develop|refactor|integrat|code|estructura|establish)/.test(text)) return "E";
|
|
193
|
+
if (/(refine|polish|review|docs|documentation|ux|copy|cleanup)/.test(text)) return "R";
|
|
194
|
+
if (/(autom|deploy|release|publish|ship|ci|workflow)/.test(text)) return "A";
|
|
195
|
+
return "E";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeTaskType(type, depth = 0) {
|
|
199
|
+
const normalized = String(type || "").trim().toLowerCase();
|
|
200
|
+
if (["epic", "task", "subtask"].includes(normalized)) return normalized;
|
|
201
|
+
if (depth <= 0) return "epic";
|
|
202
|
+
if (depth === 1) return "task";
|
|
203
|
+
return "subtask";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function indentWidth(value) {
|
|
207
|
+
return String(value || "").replace(/\t/g, " ").length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function firstHeading(content) {
|
|
211
|
+
const match = String(content || "").match(/^#{1,2}\s+(.+)$/m);
|
|
212
|
+
return match ? match[1].trim() : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function buildHeadingSourceAnchors(stack, lineStart) {
|
|
216
|
+
const anchors = stack.map((entry) => ({ label: entry.text, lineStart: entry.line }));
|
|
217
|
+
if (!anchors.length) anchors.push({ label: "source", lineStart });
|
|
218
|
+
return anchors;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractInlineMeta(rawText, fallbackStatus) {
|
|
222
|
+
let text = String(rawText || "").trim();
|
|
223
|
+
const meta = {
|
|
224
|
+
status: fallbackStatus || null,
|
|
225
|
+
priority: null,
|
|
226
|
+
phase: null,
|
|
227
|
+
stream: null,
|
|
228
|
+
required: true,
|
|
229
|
+
dependsOn: [],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const priorityMatch = text.match(/\[(P[0-3])\]/i);
|
|
233
|
+
if (priorityMatch) {
|
|
234
|
+
meta.priority = priorityMatch[1].toUpperCase();
|
|
235
|
+
text = text.replace(priorityMatch[0], "").trim();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const phaseMatch = text.match(/\[(?:phase\s*:?\s*)?([OPERA])\]/i);
|
|
239
|
+
if (phaseMatch) {
|
|
240
|
+
meta.phase = phaseMatch[1].toUpperCase();
|
|
241
|
+
text = text.replace(phaseMatch[0], "").trim();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const requiredMatch = text.match(/\[(required|optional)\]/i);
|
|
245
|
+
if (requiredMatch) {
|
|
246
|
+
meta.required = requiredMatch[1].toLowerCase() !== "optional";
|
|
247
|
+
text = text.replace(requiredMatch[0], "").trim();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const dependsMatch = text.match(/\bdepends(?:\s+on)?\s*:\s*([a-z0-9_,\-\s/]+)/i);
|
|
251
|
+
if (dependsMatch) {
|
|
252
|
+
meta.dependsOn = dependsMatch[1].split(/[,/]/).map((value) => value.trim()).filter(Boolean);
|
|
253
|
+
text = text.replace(dependsMatch[0], "").trim();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const streamMatch = text.match(/\bstream\s*:\s*([^|]+)$/i);
|
|
257
|
+
if (streamMatch) {
|
|
258
|
+
meta.stream = streamMatch[1].trim();
|
|
259
|
+
text = text.replace(streamMatch[0], "").trim();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
text = text.replace(/\s{2,}/g, " ").trim();
|
|
263
|
+
return { title: text || rawText.trim(), meta };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizePlanItem(rawItem, index, context = {}) {
|
|
267
|
+
const fallbackText = `${rawItem?.title || ""} ${(context.headingTrail || []).join(" ")}`.trim();
|
|
268
|
+
const itemId = String(rawItem?.id || "").trim() || `node-${index + 1}`;
|
|
269
|
+
return {
|
|
270
|
+
id: itemId,
|
|
271
|
+
parentId: rawItem?.parentId ? String(rawItem.parentId).trim() : null,
|
|
272
|
+
type: normalizeTaskType(rawItem?.type, rawItem?.depth || 0),
|
|
273
|
+
title: String(rawItem?.title || itemId).trim(),
|
|
274
|
+
summary: String(rawItem?.summary || "").trim(),
|
|
275
|
+
phase: normalizePhase(rawItem?.phase, fallbackText),
|
|
276
|
+
stream: String(rawItem?.stream || context.stream || "Planning").trim() || "Planning",
|
|
277
|
+
priority: normalizePriority(rawItem?.priority),
|
|
278
|
+
status: normalizeStatus(rawItem?.status),
|
|
279
|
+
required: rawItem?.required !== false,
|
|
280
|
+
dependsOn: toList(rawItem?.dependsOn),
|
|
281
|
+
acceptance: toList(rawItem?.acceptance),
|
|
282
|
+
warnings: toList(rawItem?.warnings),
|
|
283
|
+
sequence: Number.isFinite(Number(rawItem?.sequence)) ? Number(rawItem.sequence) : (index + 1) * 10,
|
|
284
|
+
sourceAnchors: Array.isArray(rawItem?.sourceAnchors) && rawItem.sourceAnchors.length
|
|
285
|
+
? rawItem.sourceAnchors.map((anchor) => ({
|
|
286
|
+
label: String(anchor?.label || "source").trim(),
|
|
287
|
+
lineStart: Number.isFinite(Number(anchor?.lineStart)) ? Number(anchor.lineStart) : 1,
|
|
288
|
+
}))
|
|
289
|
+
: [{ label: "source", lineStart: 1 }],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseCanonical(filePath, content, options = {}) {
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(content);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
throw new Error(`Canonical plan is not valid JSON: ${error.message}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!parsed || typeof parsed !== "object" || !parsed.plan || !Array.isArray(parsed.plan.items)) {
|
|
302
|
+
throw new Error("Canonical plan must include source metadata and plan.items[].");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const sourceTitle = String(parsed.source?.title || firstHeading(content) || path.basename(filePath)).trim();
|
|
306
|
+
const sourceId = slugify(options.sourceId || parsed.source?.id || sourceTitle || path.basename(filePath, path.extname(filePath)));
|
|
307
|
+
const adapter = SUPPORTED_ADAPTERS.includes(parsed.source?.adapter) ? parsed.source.adapter : "canonical";
|
|
308
|
+
const items = parsed.plan.items.map((item, index) => normalizePlanItem(item, index, { stream: sourceTitle }));
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
normalized: {
|
|
312
|
+
version: PLAN_DOCUMENT_VERSION,
|
|
313
|
+
source: {
|
|
314
|
+
id: sourceId,
|
|
315
|
+
title: sourceTitle,
|
|
316
|
+
adapter,
|
|
317
|
+
path: path.relative(options.baseRoot || process.cwd(), filePath).replace(/\\/g, "/"),
|
|
318
|
+
checksum: sha256(content),
|
|
319
|
+
generatedAt: parsed.source?.generatedAt || nowIso(),
|
|
320
|
+
},
|
|
321
|
+
plan: {
|
|
322
|
+
summary: String(parsed.plan.summary || "").trim(),
|
|
323
|
+
items,
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
warnings: [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function buildStableNodeId(parentNodeId, title, counters) {
|
|
331
|
+
const slug = slugify(title) || "node";
|
|
332
|
+
const key = `${parentNodeId || "root"}::${slug}`;
|
|
333
|
+
const next = (counters.get(key) || 0) + 1;
|
|
334
|
+
counters.set(key, next);
|
|
335
|
+
return next === 1
|
|
336
|
+
? (parentNodeId ? `${parentNodeId}/${slug}` : slug)
|
|
337
|
+
: (parentNodeId ? `${parentNodeId}/${slug}-${next}` : `${slug}-${next}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseMarkdownLike(filePath, content, adapter, options = {}) {
|
|
341
|
+
const lines = String(content || "").split(/\r?\n/);
|
|
342
|
+
const warnings = [];
|
|
343
|
+
const headingStack = [];
|
|
344
|
+
const siblingCounters = new Map();
|
|
345
|
+
const listNodes = [];
|
|
346
|
+
const listStack = [];
|
|
347
|
+
let currentNode = null;
|
|
348
|
+
let summary = "";
|
|
349
|
+
|
|
350
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
351
|
+
const rawLine = lines[i];
|
|
352
|
+
const trimmed = rawLine.trim();
|
|
353
|
+
if (!trimmed) {
|
|
354
|
+
currentNode = null;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const headingMatch = rawLine.match(/^(#{1,6})\s+(.+)$/);
|
|
359
|
+
if (headingMatch) {
|
|
360
|
+
const level = headingMatch[1].length;
|
|
361
|
+
const text = headingMatch[2].trim();
|
|
362
|
+
while (headingStack.length && headingStack[headingStack.length - 1].level >= level) {
|
|
363
|
+
headingStack.pop();
|
|
364
|
+
}
|
|
365
|
+
headingStack.push({ level, text, line: i + 1 });
|
|
366
|
+
if (!summary && level <= 2 && text) summary = text;
|
|
367
|
+
currentNode = null;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const listMatch = rawLine.match(/^(\s*)([-*+]|\d+[.)])\s+(?:\[( |x|X)\]\s*)?(.*)$/);
|
|
372
|
+
if (listMatch) {
|
|
373
|
+
const depth = Math.max(0, Math.floor(indentWidth(listMatch[1]) / 2));
|
|
374
|
+
while (listStack.length > depth) listStack.pop();
|
|
375
|
+
const parent = listStack[listStack.length - 1] || null;
|
|
376
|
+
const checkbox = listMatch[3];
|
|
377
|
+
const fallbackStatus = checkbox && /x/i.test(checkbox) ? "completed" : "pending";
|
|
378
|
+
const parsedText = extractInlineMeta(listMatch[4], fallbackStatus);
|
|
379
|
+
const nodeId = buildStableNodeId(parent?.id || null, parsedText.title, siblingCounters);
|
|
380
|
+
const headingTrail = headingStack.map((heading) => heading.text);
|
|
381
|
+
const node = normalizePlanItem({
|
|
382
|
+
id: nodeId,
|
|
383
|
+
parentId: parent?.id || null,
|
|
384
|
+
title: parsedText.title,
|
|
385
|
+
summary: "",
|
|
386
|
+
phase: parsedText.meta.phase,
|
|
387
|
+
stream: parsedText.meta.stream || headingStack[headingStack.length - 1]?.text || "Planning",
|
|
388
|
+
priority: parsedText.meta.priority,
|
|
389
|
+
status: parsedText.meta.status || fallbackStatus,
|
|
390
|
+
required: parsedText.meta.required,
|
|
391
|
+
dependsOn: parsedText.meta.dependsOn,
|
|
392
|
+
warnings: [],
|
|
393
|
+
sourceAnchors: buildHeadingSourceAnchors(headingStack, i + 1),
|
|
394
|
+
depth,
|
|
395
|
+
type: depth === 0 ? "epic" : depth === 1 ? "task" : "subtask",
|
|
396
|
+
}, listNodes.length, { headingTrail, stream: headingStack[headingStack.length - 1]?.text || "Planning" });
|
|
397
|
+
node.sourceAnchors.push({ label: parsedText.title, lineStart: i + 1 });
|
|
398
|
+
listNodes.push(node);
|
|
399
|
+
listStack.push(node);
|
|
400
|
+
currentNode = node;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (currentNode) {
|
|
405
|
+
if (/^(depends(?:\s+on)?|dependency|dependencies)\s*:/i.test(trimmed)) {
|
|
406
|
+
currentNode.dependsOn = toList(trimmed.replace(/^(depends(?:\s+on)?|dependency|dependencies)\s*:/i, ""));
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (/^(acceptance|criteria)\s*:/i.test(trimmed)) {
|
|
410
|
+
currentNode.acceptance = toList(trimmed.replace(/^(acceptance|criteria)\s*:/i, ""));
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (/^(warning|warnings|note|notes)\s*:/i.test(trimmed)) {
|
|
414
|
+
currentNode.warnings = [...(currentNode.warnings || []), trimmed.replace(/^(warning|warnings|note|notes)\s*:/i, "").trim()].filter(Boolean);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
currentNode.summary = [currentNode.summary, trimmed].filter(Boolean).join(" ");
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!summary) summary = trimmed;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let items = listNodes;
|
|
425
|
+
if (!items.length) {
|
|
426
|
+
const headingNodes = [];
|
|
427
|
+
const headingCounters = new Map();
|
|
428
|
+
const stack = [];
|
|
429
|
+
let activeNode = null;
|
|
430
|
+
|
|
431
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
432
|
+
const rawLine = lines[i];
|
|
433
|
+
const trimmed = rawLine.trim();
|
|
434
|
+
if (!trimmed) continue;
|
|
435
|
+
const headingMatch = rawLine.match(/^(#{1,6})\s+(.+)$/);
|
|
436
|
+
if (headingMatch) {
|
|
437
|
+
const level = headingMatch[1].length;
|
|
438
|
+
const text = headingMatch[2].trim();
|
|
439
|
+
if (level === 1) {
|
|
440
|
+
if (!summary) summary = text;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
|
|
444
|
+
const parent = stack[stack.length - 1] || null;
|
|
445
|
+
const nodeId = buildStableNodeId(parent?.node?.id || null, text, headingCounters);
|
|
446
|
+
const node = normalizePlanItem({
|
|
447
|
+
id: nodeId,
|
|
448
|
+
parentId: parent?.node?.id || null,
|
|
449
|
+
title: text,
|
|
450
|
+
summary: "",
|
|
451
|
+
phase: null,
|
|
452
|
+
stream: parent?.node?.title || "Planning",
|
|
453
|
+
sourceAnchors: [{ label: text, lineStart: i + 1 }],
|
|
454
|
+
depth: Math.max(0, level - 2),
|
|
455
|
+
type: Math.max(0, level - 2) === 0 ? "epic" : Math.max(0, level - 2) === 1 ? "task" : "subtask",
|
|
456
|
+
}, headingNodes.length, { headingTrail: stack.map((entry) => entry.node.title), stream: parent?.node?.title || "Planning" });
|
|
457
|
+
headingNodes.push(node);
|
|
458
|
+
stack.push({ level, node });
|
|
459
|
+
activeNode = node;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (activeNode) {
|
|
464
|
+
if (/^[-*+]\s+/.test(trimmed)) {
|
|
465
|
+
activeNode.acceptance = [...(activeNode.acceptance || []), trimmed.replace(/^[-*+]\s+/, "").trim()].filter(Boolean);
|
|
466
|
+
} else {
|
|
467
|
+
activeNode.summary = [activeNode.summary, trimmed].filter(Boolean).join(" ");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
items = headingNodes;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (!items.length) {
|
|
475
|
+
warnings.push("No task-like nodes were detected in the plan source.");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const sourceTitle = summary || firstHeading(content) || path.basename(filePath, path.extname(filePath));
|
|
479
|
+
const sourceId = slugify(options.sourceId || sourceTitle || path.basename(filePath, path.extname(filePath)));
|
|
480
|
+
return {
|
|
481
|
+
normalized: {
|
|
482
|
+
version: PLAN_DOCUMENT_VERSION,
|
|
483
|
+
source: {
|
|
484
|
+
id: sourceId,
|
|
485
|
+
title: sourceTitle,
|
|
486
|
+
adapter,
|
|
487
|
+
path: path.relative(options.baseRoot || process.cwd(), filePath).replace(/\\/g, "/"),
|
|
488
|
+
checksum: sha256(content),
|
|
489
|
+
generatedAt: nowIso(),
|
|
490
|
+
},
|
|
491
|
+
plan: {
|
|
492
|
+
summary: summary || `Imported ${items.length} items from ${path.basename(filePath)}.`,
|
|
493
|
+
items,
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
warnings,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function detectAdapter(filePath, content) {
|
|
501
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
502
|
+
const text = String(content || "");
|
|
503
|
+
const basename = path.basename(filePath).toLowerCase();
|
|
504
|
+
|
|
505
|
+
if (ext === ".json") {
|
|
506
|
+
try {
|
|
507
|
+
const parsed = JSON.parse(text);
|
|
508
|
+
if (parsed && parsed.plan && Array.isArray(parsed.plan.items)) {
|
|
509
|
+
return { adapter: "canonical", confidence: 1, reason: "canonical-json" };
|
|
510
|
+
}
|
|
511
|
+
} catch (_error) {
|
|
512
|
+
// noop
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (basename.includes("antigravity") || /antigravity|e\.t\.a\.p\.a|etapa/i.test(text)) {
|
|
517
|
+
return { adapter: "antigravity", confidence: 0.8, reason: "vendor-marker" };
|
|
518
|
+
}
|
|
519
|
+
if (basename.includes("claude") || /claude|anthropic/i.test(text)) {
|
|
520
|
+
return { adapter: "claude", confidence: 0.8, reason: "vendor-marker" };
|
|
521
|
+
}
|
|
522
|
+
if (basename.includes("codex") || /codex|openai/i.test(text)) {
|
|
523
|
+
return { adapter: "codex", confidence: 0.75, reason: "vendor-marker" };
|
|
524
|
+
}
|
|
525
|
+
if (/\n#{1,6}\s+|\n\s*[-*+]\s+|\n\s*\d+[.)]\s+/.test(`\n${text}`) || [".md", ".markdown", ".txt"].includes(ext)) {
|
|
526
|
+
return { adapter: "generic_markdown", confidence: 0.55, reason: "markdown-structure" };
|
|
527
|
+
}
|
|
528
|
+
return { adapter: ext === ".json" ? "canonical" : "generic_markdown", confidence: 0.2, reason: "fallback" };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function loadAndNormalizePlan(filePath, options = {}) {
|
|
532
|
+
const baseRoot = options.baseRoot || config.ensureContext(process.cwd()).workspaceRoot;
|
|
533
|
+
const absoluteFile = path.resolve(baseRoot, filePath);
|
|
534
|
+
if (!fs.existsSync(absoluteFile)) {
|
|
535
|
+
throw new Error(`Plan file not found: ${absoluteFile}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const content = fs.readFileSync(absoluteFile, "utf8");
|
|
539
|
+
const detected = options.adapter && options.adapter !== "auto"
|
|
540
|
+
? { adapter: options.adapter, confidence: 1, reason: "explicit" }
|
|
541
|
+
: detectAdapter(absoluteFile, content);
|
|
542
|
+
const adapter = detected.adapter;
|
|
543
|
+
const parsed = adapter === "canonical"
|
|
544
|
+
? parseCanonical(absoluteFile, content, options)
|
|
545
|
+
: parseMarkdownLike(absoluteFile, content, adapter, options);
|
|
546
|
+
|
|
547
|
+
parsed.normalized.source.adapter = adapter;
|
|
548
|
+
parsed.normalized.source.path = path.relative(baseRoot, absoluteFile).replace(/\\/g, "/");
|
|
549
|
+
parsed.normalized.source.checksum = sha256(content);
|
|
550
|
+
if (options.sourceId) parsed.normalized.source.id = slugify(options.sourceId);
|
|
551
|
+
if (!parsed.normalized.source.id) {
|
|
552
|
+
parsed.normalized.source.id = slugify(parsed.normalized.source.title || path.basename(absoluteFile, path.extname(absoluteFile)));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
filePath: absoluteFile,
|
|
557
|
+
rawContent: content,
|
|
558
|
+
adapter,
|
|
559
|
+
detection: detected,
|
|
560
|
+
normalizedPlan: parsed.normalized,
|
|
561
|
+
warnings: parsed.warnings || [],
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function normalizeFieldValue(field, value) {
|
|
566
|
+
if (field === "acceptance" || field === "dependsOn") return toList(value);
|
|
567
|
+
if (field === "required") return value !== false;
|
|
568
|
+
if (field === "parentId") return value == null ? null : String(value).trim() || null;
|
|
569
|
+
if (field === "sequence") {
|
|
570
|
+
const numeric = Number(value);
|
|
571
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
572
|
+
}
|
|
573
|
+
return String(value == null ? "" : value).trim();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function extractManagedSnapshot(task) {
|
|
577
|
+
return {
|
|
578
|
+
title: normalizeFieldValue("title", task?.title),
|
|
579
|
+
summary: normalizeFieldValue("summary", task?.summary),
|
|
580
|
+
acceptance: normalizeFieldValue("acceptance", task?.acceptance),
|
|
581
|
+
dependsOn: normalizeFieldValue("dependsOn", task?.dependsOn),
|
|
582
|
+
phase: normalizeFieldValue("phase", task?.phase),
|
|
583
|
+
stream: normalizeFieldValue("stream", task?.stream),
|
|
584
|
+
priority: normalizeFieldValue("priority", task?.priority),
|
|
585
|
+
required: normalizeFieldValue("required", task?.required),
|
|
586
|
+
parentId: normalizeFieldValue("parentId", task?.parentId),
|
|
587
|
+
sequence: normalizeFieldValue("sequence", task?.sequence),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function fingerprintSnapshot(snapshot) {
|
|
592
|
+
return sha256(JSON.stringify(snapshot));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function diffFields(a, b, fields = MANAGED_FIELDS) {
|
|
596
|
+
return fields.filter((field) => (
|
|
597
|
+
JSON.stringify(normalizeFieldValue(field, a?.[field])) !== JSON.stringify(normalizeFieldValue(field, b?.[field]))
|
|
598
|
+
));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function loadManifest(contextOrRoot, sourceId) {
|
|
602
|
+
const files = planFiles(contextOrRoot, sourceId);
|
|
603
|
+
return readJsonSafe(files.manifestFile, null);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function saveManifest(contextOrRoot, sourceId, manifest) {
|
|
607
|
+
const files = planFiles(contextOrRoot, sourceId);
|
|
608
|
+
writeJson(files.manifestFile, manifest);
|
|
609
|
+
return manifest;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function loadHistoryEntry(contextOrRoot, sourceId, importId) {
|
|
613
|
+
const files = planFiles(contextOrRoot, sourceId);
|
|
614
|
+
if (!importId) return null;
|
|
615
|
+
return readJsonSafe(path.join(files.historyDir, `${importId}.json`), null);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function saveHistoryEntry(contextOrRoot, sourceId, importId, payload) {
|
|
619
|
+
const files = planFiles(contextOrRoot, sourceId);
|
|
620
|
+
fs.mkdirSync(files.historyDir, { recursive: true });
|
|
621
|
+
writeJson(path.join(files.historyDir, `${importId}.json`), payload);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function buildExistingPlanMaps(controlState, sourceId) {
|
|
625
|
+
const byNode = new Map();
|
|
626
|
+
const byFingerprint = new Map();
|
|
627
|
+
const all = [];
|
|
628
|
+
|
|
629
|
+
(controlState.tasks || []).forEach((task) => {
|
|
630
|
+
const normalized = config.normalizeTaskShape(task);
|
|
631
|
+
all.push(normalized);
|
|
632
|
+
if (normalized.origin?.kind === "plan_import" && normalized.origin.sourceId === sourceId && normalized.origin.externalNodeId) {
|
|
633
|
+
byNode.set(normalized.origin.externalNodeId, normalized);
|
|
634
|
+
if (normalized.origin.fingerprint) {
|
|
635
|
+
if (!byFingerprint.has(normalized.origin.fingerprint)) byFingerprint.set(normalized.origin.fingerprint, []);
|
|
636
|
+
byFingerprint.get(normalized.origin.fingerprint).push(normalized);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
return { byNode, byFingerprint, all };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function loadAppliedSnapshots(contextOrRoot, sourceId, manifest = null) {
|
|
645
|
+
const effectiveManifest = manifest || loadManifest(contextOrRoot, sourceId);
|
|
646
|
+
if (!effectiveManifest?.lastApplyImportId) return new Map();
|
|
647
|
+
const history = loadHistoryEntry(contextOrRoot, sourceId, effectiveManifest.lastApplyImportId);
|
|
648
|
+
const snapshots = history?.managedSnapshots || {};
|
|
649
|
+
return new Map(Object.entries(snapshots));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function makeImportedTaskId(controlState, sourceId, externalNodeId, title) {
|
|
653
|
+
const existing = new Set((controlState.tasks || []).map((task) => String(task.id || "").trim()).filter(Boolean));
|
|
654
|
+
const sourceSlug = slugify(sourceId).slice(0, 18) || "plan";
|
|
655
|
+
const nodeSlug = slugify(externalNodeId || title).slice(0, 36) || "task";
|
|
656
|
+
const base = `${sourceSlug}-${nodeSlug}`;
|
|
657
|
+
if (!existing.has(base)) return base;
|
|
658
|
+
let index = 2;
|
|
659
|
+
while (existing.has(`${base}-${index}`)) index += 1;
|
|
660
|
+
return `${base}-${index}`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function buildPreview(controlState, normalizedPlan, contextOrRoot, options = {}) {
|
|
664
|
+
const sourceId = normalizedPlan.source.id;
|
|
665
|
+
const importId = options.importId || nowIso().replace(/[:.]/g, "-");
|
|
666
|
+
const manifest = loadManifest(contextOrRoot, sourceId);
|
|
667
|
+
const baselineSnapshots = loadAppliedSnapshots(contextOrRoot, sourceId, manifest);
|
|
668
|
+
const maps = buildExistingPlanMaps(controlState, sourceId);
|
|
669
|
+
const matchedTaskIds = new Set();
|
|
670
|
+
const itemMatches = new Map();
|
|
671
|
+
const planWarnings = [...(options.warnings || [])];
|
|
672
|
+
|
|
673
|
+
normalizedPlan.plan.items.forEach((item) => {
|
|
674
|
+
const desiredBare = {
|
|
675
|
+
title: item.title,
|
|
676
|
+
summary: item.summary,
|
|
677
|
+
acceptance: item.acceptance || [],
|
|
678
|
+
dependsOn: item.dependsOn || [],
|
|
679
|
+
phase: item.phase,
|
|
680
|
+
stream: item.stream,
|
|
681
|
+
priority: item.priority,
|
|
682
|
+
required: item.required !== false,
|
|
683
|
+
parentId: item.parentId,
|
|
684
|
+
sequence: item.sequence,
|
|
685
|
+
};
|
|
686
|
+
const desiredFingerprint = fingerprintSnapshot(extractManagedSnapshot(desiredBare));
|
|
687
|
+
let existing = maps.byNode.get(item.id) || null;
|
|
688
|
+
if (!existing && maps.byFingerprint.has(desiredFingerprint)) {
|
|
689
|
+
existing = maps.byFingerprint.get(desiredFingerprint).find((task) => !matchedTaskIds.has(task.id)) || null;
|
|
690
|
+
}
|
|
691
|
+
const taskId = existing?.id || makeImportedTaskId(controlState, sourceId, item.id, item.title);
|
|
692
|
+
if (existing) matchedTaskIds.add(existing.id);
|
|
693
|
+
itemMatches.set(item.id, { item, existing, taskId, desiredFingerprint });
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const previewTasks = new Map();
|
|
697
|
+
normalizedPlan.plan.items.forEach((item) => {
|
|
698
|
+
const match = itemMatches.get(item.id);
|
|
699
|
+
const parentTaskId = item.parentId ? itemMatches.get(item.parentId)?.taskId || null : null;
|
|
700
|
+
const translatedDepends = (item.dependsOn || []).map((dep) => {
|
|
701
|
+
if (itemMatches.has(dep)) return itemMatches.get(dep).taskId;
|
|
702
|
+
if ((controlState.tasks || []).some((task) => task.id === dep)) return dep;
|
|
703
|
+
planWarnings.push(`Dependency '${dep}' from '${item.title}' could not be mapped to an imported node.`);
|
|
704
|
+
return dep;
|
|
705
|
+
}).filter(Boolean);
|
|
706
|
+
|
|
707
|
+
const desiredTask = {
|
|
708
|
+
id: match.taskId,
|
|
709
|
+
title: item.title,
|
|
710
|
+
phase: item.phase,
|
|
711
|
+
stream: item.stream,
|
|
712
|
+
priority: item.priority,
|
|
713
|
+
status: match.existing?.status || item.status || "pending",
|
|
714
|
+
required: item.required !== false,
|
|
715
|
+
dependsOn: translatedDepends,
|
|
716
|
+
summary: item.summary || "",
|
|
717
|
+
acceptance: item.acceptance || [],
|
|
718
|
+
history: Array.isArray(match.existing?.history) ? clone(match.existing.history) : [],
|
|
719
|
+
parentId: parentTaskId,
|
|
720
|
+
sequence: item.sequence,
|
|
721
|
+
origin: {
|
|
722
|
+
kind: "plan_import",
|
|
723
|
+
sourceId,
|
|
724
|
+
adapter: normalizedPlan.source.adapter,
|
|
725
|
+
externalNodeId: item.id,
|
|
726
|
+
importId,
|
|
727
|
+
fingerprint: "",
|
|
728
|
+
managedFields: [...MANAGED_FIELDS],
|
|
729
|
+
lastImportedAt: normalizedPlan.source.generatedAt || nowIso(),
|
|
730
|
+
detached: false,
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
if (match.existing?.blocker) desiredTask.blocker = match.existing.blocker;
|
|
734
|
+
const desiredSnapshot = extractManagedSnapshot(desiredTask);
|
|
735
|
+
desiredTask.origin.fingerprint = fingerprintSnapshot(desiredSnapshot);
|
|
736
|
+
previewTasks.set(item.id, { ...match, desiredTask, desiredSnapshot });
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const operations = [];
|
|
740
|
+
let conflictCount = 0;
|
|
741
|
+
let warningCount = 0;
|
|
742
|
+
|
|
743
|
+
normalizedPlan.plan.items.forEach((item) => {
|
|
744
|
+
const entry = previewTasks.get(item.id);
|
|
745
|
+
const existing = entry.existing;
|
|
746
|
+
if (!existing) {
|
|
747
|
+
operations.push({
|
|
748
|
+
type: "create",
|
|
749
|
+
sourceId,
|
|
750
|
+
externalNodeId: item.id,
|
|
751
|
+
taskId: entry.taskId,
|
|
752
|
+
title: entry.desiredTask.title,
|
|
753
|
+
desiredTask: entry.desiredTask,
|
|
754
|
+
updatedFields: [...MANAGED_FIELDS],
|
|
755
|
+
warnings: [...item.warnings],
|
|
756
|
+
});
|
|
757
|
+
warningCount += item.warnings.length;
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const existingSnapshot = extractManagedSnapshot(existing);
|
|
762
|
+
const baselineSnapshot = baselineSnapshots.get(item.id) || null;
|
|
763
|
+
const changedFromSource = baselineSnapshot ? diffFields(baselineSnapshot, entry.desiredSnapshot) : diffFields(existingSnapshot, entry.desiredSnapshot);
|
|
764
|
+
const changedLocally = baselineSnapshot ? diffFields(baselineSnapshot, existingSnapshot) : [];
|
|
765
|
+
const conflictFields = changedFromSource.filter((field) => changedLocally.includes(field));
|
|
766
|
+
const updatedFields = diffFields(existingSnapshot, entry.desiredSnapshot);
|
|
767
|
+
let type = "noop";
|
|
768
|
+
if (conflictFields.length) type = "conflict";
|
|
769
|
+
else if (updatedFields.length === 1 && updatedFields[0] === "parentId") type = "reparent";
|
|
770
|
+
else if (updatedFields.length) type = "update";
|
|
771
|
+
|
|
772
|
+
if (type === "conflict") conflictCount += 1;
|
|
773
|
+
warningCount += item.warnings.length;
|
|
774
|
+
|
|
775
|
+
operations.push({
|
|
776
|
+
type,
|
|
777
|
+
sourceId,
|
|
778
|
+
externalNodeId: item.id,
|
|
779
|
+
taskId: existing.id,
|
|
780
|
+
title: entry.desiredTask.title,
|
|
781
|
+
desiredTask: entry.desiredTask,
|
|
782
|
+
existingTask: existing,
|
|
783
|
+
updatedFields,
|
|
784
|
+
changedFromSource,
|
|
785
|
+
changedLocally,
|
|
786
|
+
conflictFields,
|
|
787
|
+
warnings: [...item.warnings],
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
maps.all
|
|
792
|
+
.filter((task) => task.origin?.kind === "plan_import" && task.origin.sourceId === sourceId && !matchedTaskIds.has(task.id) && task.origin.detached !== true)
|
|
793
|
+
.forEach((task) => {
|
|
794
|
+
operations.push({
|
|
795
|
+
type: "detach",
|
|
796
|
+
sourceId,
|
|
797
|
+
externalNodeId: task.origin.externalNodeId,
|
|
798
|
+
taskId: task.id,
|
|
799
|
+
title: task.title,
|
|
800
|
+
existingTask: task,
|
|
801
|
+
updatedFields: [],
|
|
802
|
+
conflictFields: [],
|
|
803
|
+
warnings: [],
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const operationCounts = operations.reduce((accumulator, operation) => {
|
|
808
|
+
accumulator[operation.type] = (accumulator[operation.type] || 0) + 1;
|
|
809
|
+
return accumulator;
|
|
810
|
+
}, { create: 0, update: 0, reparent: 0, detach: 0, noop: 0, conflict: 0 });
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
version: PREVIEW_VERSION,
|
|
814
|
+
importId,
|
|
815
|
+
source: clone(normalizedPlan.source),
|
|
816
|
+
generatedAt: nowIso(),
|
|
817
|
+
summary: {
|
|
818
|
+
create: operationCounts.create || 0,
|
|
819
|
+
update: operationCounts.update || 0,
|
|
820
|
+
reparent: operationCounts.reparent || 0,
|
|
821
|
+
detach: operationCounts.detach || 0,
|
|
822
|
+
noop: operationCounts.noop || 0,
|
|
823
|
+
conflicts: conflictCount,
|
|
824
|
+
warnings: planWarnings.length + warningCount,
|
|
825
|
+
managedTaskCount: normalizedPlan.plan.items.length,
|
|
826
|
+
},
|
|
827
|
+
warnings: [...new Set(planWarnings)],
|
|
828
|
+
operations,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function persistImportedSource(contextOrRoot, imported, preview) {
|
|
833
|
+
const context = config.ensureContext(contextOrRoot);
|
|
834
|
+
const sourceId = imported.normalizedPlan.source.id;
|
|
835
|
+
const files = planFiles(context, sourceId);
|
|
836
|
+
const previousManifest = loadManifest(context, sourceId);
|
|
837
|
+
fs.mkdirSync(files.dir, { recursive: true });
|
|
838
|
+
const rawExt = path.extname(imported.filePath) || ".md";
|
|
839
|
+
const manifest = {
|
|
840
|
+
version: 1,
|
|
841
|
+
source: clone(imported.normalizedPlan.source),
|
|
842
|
+
latestImportId: preview.importId,
|
|
843
|
+
lastImportAt: nowIso(),
|
|
844
|
+
lastPreviewAt: preview.generatedAt,
|
|
845
|
+
lastApplyImportId: previousManifest?.lastApplyImportId || null,
|
|
846
|
+
lastApplyAt: previousManifest?.lastApplyAt || null,
|
|
847
|
+
status: preview.summary.conflicts ? "preview_blocked" : "previewed",
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
writeText(planRawFile(files, rawExt), imported.rawContent);
|
|
851
|
+
writeJson(files.normalizedFile, imported.normalizedPlan);
|
|
852
|
+
writeJson(files.previewFile, preview);
|
|
853
|
+
saveManifest(context, sourceId, manifest);
|
|
854
|
+
saveHistoryEntry(context, sourceId, preview.importId, {
|
|
855
|
+
version: 1,
|
|
856
|
+
importedAt: preview.generatedAt,
|
|
857
|
+
importId: preview.importId,
|
|
858
|
+
normalizedPlan: imported.normalizedPlan,
|
|
859
|
+
preview,
|
|
860
|
+
managedSnapshots: Object.fromEntries(
|
|
861
|
+
imported.normalizedPlan.plan.items.map((item) => {
|
|
862
|
+
const operation = preview.operations.find((entry) => entry.externalNodeId === item.id);
|
|
863
|
+
return [item.id, operation?.desiredTask ? extractManagedSnapshot(operation.desiredTask) : null];
|
|
864
|
+
})
|
|
865
|
+
),
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
const registrySource = upsertRegistrySource(context, {
|
|
869
|
+
id: sourceId,
|
|
870
|
+
title: imported.normalizedPlan.source.title,
|
|
871
|
+
adapter: imported.normalizedPlan.source.adapter,
|
|
872
|
+
path: imported.normalizedPlan.source.path,
|
|
873
|
+
checksum: imported.normalizedPlan.source.checksum,
|
|
874
|
+
status: manifest.status,
|
|
875
|
+
latestImportId: preview.importId,
|
|
876
|
+
lastImportAt: manifest.lastImportAt,
|
|
877
|
+
lastPreviewAt: manifest.lastPreviewAt,
|
|
878
|
+
lastApplyAt: manifest.lastApplyAt,
|
|
879
|
+
warnings: preview.summary.warnings,
|
|
880
|
+
conflicts: preview.summary.conflicts,
|
|
881
|
+
managedTaskCount: preview.summary.managedTaskCount,
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
return { files, manifest, registrySource };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function importPlan(contextOrRoot, options = {}) {
|
|
888
|
+
const context = config.ensureContext(contextOrRoot);
|
|
889
|
+
const controlState = config.loadControl(context);
|
|
890
|
+
const imported = loadAndNormalizePlan(options.file, { ...options, baseRoot: context.workspaceRoot });
|
|
891
|
+
const preview = buildPreview(controlState, imported.normalizedPlan, context, { warnings: imported.warnings });
|
|
892
|
+
const persisted = persistImportedSource(context, imported, preview);
|
|
893
|
+
syncControlPlanMeta(controlState, context);
|
|
894
|
+
config.saveControl(context, controlState);
|
|
895
|
+
return {
|
|
896
|
+
source: persisted.registrySource,
|
|
897
|
+
manifest: persisted.manifest,
|
|
898
|
+
normalizedPlan: imported.normalizedPlan,
|
|
899
|
+
preview,
|
|
900
|
+
control: controlState,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function loadSource(contextOrRoot, sourceId) {
|
|
905
|
+
const context = config.ensureContext(contextOrRoot);
|
|
906
|
+
const files = planFiles(context, sourceId);
|
|
907
|
+
const manifest = readJsonSafe(files.manifestFile, null);
|
|
908
|
+
if (!manifest) throw new Error(`Unknown plan source: ${sourceId}`);
|
|
909
|
+
const normalizedPlan = readJsonSafe(files.normalizedFile, null);
|
|
910
|
+
const preview = readJsonSafe(files.previewFile, null);
|
|
911
|
+
const registrySource = listSources(context).find((source) => source.id === slugify(sourceId)) || null;
|
|
912
|
+
return {
|
|
913
|
+
files,
|
|
914
|
+
manifest,
|
|
915
|
+
normalizedPlan,
|
|
916
|
+
preview,
|
|
917
|
+
source: registrySource || normalizeRegistrySource({
|
|
918
|
+
id: manifest.source?.id,
|
|
919
|
+
title: manifest.source?.title,
|
|
920
|
+
adapter: manifest.source?.adapter,
|
|
921
|
+
path: manifest.source?.path,
|
|
922
|
+
status: manifest.status,
|
|
923
|
+
latestImportId: manifest.latestImportId,
|
|
924
|
+
lastImportAt: manifest.lastImportAt,
|
|
925
|
+
lastPreviewAt: manifest.lastPreviewAt,
|
|
926
|
+
lastApplyAt: manifest.lastApplyAt,
|
|
927
|
+
warnings: preview?.summary?.warnings || 0,
|
|
928
|
+
conflicts: preview?.summary?.conflicts || 0,
|
|
929
|
+
managedTaskCount: preview?.summary?.managedTaskCount || normalizedPlan?.plan?.items?.length || 0,
|
|
930
|
+
}),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function applyManagedFields(targetTask, desiredTask, fields) {
|
|
935
|
+
fields.forEach((field) => {
|
|
936
|
+
targetTask[field] = clone(desiredTask[field]);
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function applyPlan(contextOrRoot, sourceId, options = {}) {
|
|
941
|
+
const context = config.ensureContext(contextOrRoot);
|
|
942
|
+
const controlState = config.loadControl(context);
|
|
943
|
+
const source = loadSource(context, sourceId);
|
|
944
|
+
const selectedImportId = options.importId && options.importId !== "latest"
|
|
945
|
+
? options.importId
|
|
946
|
+
: source.manifest.latestImportId;
|
|
947
|
+
const historyEntry = selectedImportId && selectedImportId !== source.manifest.latestImportId
|
|
948
|
+
? loadHistoryEntry(context, source.source.id, selectedImportId)
|
|
949
|
+
: null;
|
|
950
|
+
const normalizedPlan = historyEntry?.normalizedPlan || source.normalizedPlan;
|
|
951
|
+
if (!normalizedPlan) throw new Error(`No normalized plan found for source '${sourceId}'.`);
|
|
952
|
+
|
|
953
|
+
const preview = buildPreview(controlState, normalizedPlan, context, {
|
|
954
|
+
importId: selectedImportId || source.preview?.importId || nowIso().replace(/[:.]/g, "-"),
|
|
955
|
+
warnings: source.preview?.warnings || [],
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
const conflictMode = options.conflicts || "abort";
|
|
959
|
+
if (preview.summary.conflicts > 0 && conflictMode === "abort") {
|
|
960
|
+
const fields = preview.operations
|
|
961
|
+
.filter((operation) => operation.type === "conflict")
|
|
962
|
+
.map((operation) => `${operation.taskId}: ${operation.conflictFields.join(", ")}`)
|
|
963
|
+
.join("; ");
|
|
964
|
+
throw new Error(`Plan apply blocked: unresolved conflicts detected (${fields || "managed fields"}).`);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const tasks = controlState.tasks.map((task) => config.normalizeTaskShape(task));
|
|
968
|
+
const taskMap = new Map(tasks.map((task) => [task.id, task]));
|
|
969
|
+
const removedMode = options.removed || "detach";
|
|
970
|
+
|
|
971
|
+
preview.operations.forEach((operation) => {
|
|
972
|
+
if (operation.type === "create") {
|
|
973
|
+
const created = config.normalizeTaskShape(operation.desiredTask);
|
|
974
|
+
created.history = created.history || [];
|
|
975
|
+
created.history.push({
|
|
976
|
+
at: nowIso(),
|
|
977
|
+
action: "plan_import_create",
|
|
978
|
+
note: `Imported from plan '${preview.source.title}' (${preview.source.id}).`,
|
|
979
|
+
});
|
|
980
|
+
tasks.push(created);
|
|
981
|
+
taskMap.set(created.id, created);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (operation.type === "detach") {
|
|
986
|
+
const task = taskMap.get(operation.taskId);
|
|
987
|
+
if (!task) return;
|
|
988
|
+
if (removedMode === "delete") {
|
|
989
|
+
tasks.splice(tasks.findIndex((entry) => entry.id === task.id), 1);
|
|
990
|
+
taskMap.delete(task.id);
|
|
991
|
+
tasks.forEach((entry) => {
|
|
992
|
+
if (entry.parentId === task.id) entry.parentId = null;
|
|
993
|
+
if (Array.isArray(entry.dependsOn)) entry.dependsOn = entry.dependsOn.filter((dep) => dep !== task.id);
|
|
994
|
+
});
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
task.origin = task.origin || { kind: "plan_import" };
|
|
998
|
+
task.origin.detached = true;
|
|
999
|
+
task.history = task.history || [];
|
|
1000
|
+
task.history.push({
|
|
1001
|
+
at: nowIso(),
|
|
1002
|
+
action: "detach_from_plan",
|
|
1003
|
+
note: `Detached from plan '${preview.source.title}' (${preview.source.id}).`,
|
|
1004
|
+
});
|
|
1005
|
+
if (removedMode === "archive") task.status = "cancelled";
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const task = taskMap.get(operation.taskId);
|
|
1010
|
+
if (!task) return;
|
|
1011
|
+
const desired = config.normalizeTaskShape(operation.desiredTask);
|
|
1012
|
+
const fieldsToApply = [...(operation.updatedFields || [])];
|
|
1013
|
+
|
|
1014
|
+
if (operation.type === "conflict" && conflictMode === "prefer-local") {
|
|
1015
|
+
applyManagedFields(task, desired, fieldsToApply.filter((field) => !(operation.conflictFields || []).includes(field)));
|
|
1016
|
+
task.history = task.history || [];
|
|
1017
|
+
task.history.push({
|
|
1018
|
+
at: nowIso(),
|
|
1019
|
+
action: "plan_import_conflict",
|
|
1020
|
+
note: `Applied plan '${preview.source.title}' with local preference on ${operation.conflictFields.join(", ")}.`,
|
|
1021
|
+
});
|
|
1022
|
+
} else {
|
|
1023
|
+
applyManagedFields(task, desired, fieldsToApply);
|
|
1024
|
+
task.history = task.history || [];
|
|
1025
|
+
task.history.push({
|
|
1026
|
+
at: nowIso(),
|
|
1027
|
+
action: operation.type === "reparent" ? "plan_import_reparent" : "plan_import_update",
|
|
1028
|
+
note: `Applied plan '${preview.source.title}' (${operation.type}).`,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
task.origin = {
|
|
1033
|
+
...(task.origin || {}),
|
|
1034
|
+
kind: "plan_import",
|
|
1035
|
+
sourceId: preview.source.id,
|
|
1036
|
+
adapter: preview.source.adapter,
|
|
1037
|
+
externalNodeId: operation.externalNodeId,
|
|
1038
|
+
importId: preview.importId,
|
|
1039
|
+
fingerprint: desired.origin.fingerprint,
|
|
1040
|
+
managedFields: [...MANAGED_FIELDS],
|
|
1041
|
+
lastImportedAt: preview.generatedAt,
|
|
1042
|
+
detached: false,
|
|
1043
|
+
};
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
controlState.tasks = tasks;
|
|
1047
|
+
const appliedPreview = buildPreview(controlState, normalizedPlan, context, {
|
|
1048
|
+
importId: preview.importId,
|
|
1049
|
+
warnings: source.preview?.warnings || [],
|
|
1050
|
+
});
|
|
1051
|
+
const manifest = {
|
|
1052
|
+
...(source.manifest || {}),
|
|
1053
|
+
source: clone(normalizedPlan.source),
|
|
1054
|
+
latestImportId: source.manifest?.latestImportId || preview.importId,
|
|
1055
|
+
lastImportAt: source.manifest?.lastImportAt || preview.generatedAt,
|
|
1056
|
+
lastPreviewAt: appliedPreview.generatedAt,
|
|
1057
|
+
lastApplyImportId: preview.importId,
|
|
1058
|
+
lastApplyAt: nowIso(),
|
|
1059
|
+
status: "applied",
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
writeJson(source.files.previewFile, appliedPreview);
|
|
1063
|
+
saveManifest(context, preview.source.id, manifest);
|
|
1064
|
+
saveHistoryEntry(context, preview.source.id, preview.importId, {
|
|
1065
|
+
version: 1,
|
|
1066
|
+
importedAt: source.manifest?.lastImportAt || preview.generatedAt,
|
|
1067
|
+
appliedAt: manifest.lastApplyAt,
|
|
1068
|
+
importId: preview.importId,
|
|
1069
|
+
normalizedPlan,
|
|
1070
|
+
preview: appliedPreview,
|
|
1071
|
+
managedSnapshots: Object.fromEntries(
|
|
1072
|
+
normalizedPlan.plan.items.map((item) => {
|
|
1073
|
+
const linkedTask = controlState.tasks.find((task) => task.origin?.sourceId === preview.source.id && task.origin?.externalNodeId === item.id);
|
|
1074
|
+
return [item.id, linkedTask ? extractManagedSnapshot(linkedTask) : null];
|
|
1075
|
+
})
|
|
1076
|
+
),
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
upsertRegistrySource(context, {
|
|
1080
|
+
id: preview.source.id,
|
|
1081
|
+
title: preview.source.title,
|
|
1082
|
+
adapter: preview.source.adapter,
|
|
1083
|
+
path: preview.source.path,
|
|
1084
|
+
checksum: preview.source.checksum,
|
|
1085
|
+
status: manifest.status,
|
|
1086
|
+
latestImportId: manifest.latestImportId,
|
|
1087
|
+
lastImportAt: manifest.lastImportAt,
|
|
1088
|
+
lastPreviewAt: manifest.lastPreviewAt,
|
|
1089
|
+
lastApplyAt: manifest.lastApplyAt,
|
|
1090
|
+
warnings: appliedPreview.summary.warnings,
|
|
1091
|
+
conflicts: 0,
|
|
1092
|
+
managedTaskCount: appliedPreview.summary.managedTaskCount,
|
|
1093
|
+
});
|
|
1094
|
+
syncControlPlanMeta(controlState, context);
|
|
1095
|
+
config.saveControl(context, controlState);
|
|
1096
|
+
return {
|
|
1097
|
+
source: listSources(context).find((entry) => entry.id === preview.source.id),
|
|
1098
|
+
manifest,
|
|
1099
|
+
preview: appliedPreview,
|
|
1100
|
+
normalizedPlan,
|
|
1101
|
+
control: controlState,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function unlinkSource(contextOrRoot, sourceId, options = {}) {
|
|
1106
|
+
const context = config.ensureContext(contextOrRoot);
|
|
1107
|
+
const controlState = config.loadControl(context);
|
|
1108
|
+
const normalizedSourceId = slugify(sourceId);
|
|
1109
|
+
let affectedTasks = 0;
|
|
1110
|
+
|
|
1111
|
+
controlState.tasks = controlState.tasks.map((task) => {
|
|
1112
|
+
const normalized = config.normalizeTaskShape(task);
|
|
1113
|
+
if (normalized.origin?.sourceId !== normalizedSourceId) return normalized;
|
|
1114
|
+
affectedTasks += 1;
|
|
1115
|
+
normalized.history = normalized.history || [];
|
|
1116
|
+
normalized.history.push({
|
|
1117
|
+
at: nowIso(),
|
|
1118
|
+
action: "plan_unlink",
|
|
1119
|
+
note: options.keepTasks
|
|
1120
|
+
? `Unlinked from plan '${normalizedSourceId}' and converted to manual task.`
|
|
1121
|
+
: `Unlinked from plan '${normalizedSourceId}' and left detached.`,
|
|
1122
|
+
});
|
|
1123
|
+
if (options.keepTasks) {
|
|
1124
|
+
normalized.origin = {
|
|
1125
|
+
kind: "manual",
|
|
1126
|
+
sourceId: null,
|
|
1127
|
+
adapter: null,
|
|
1128
|
+
externalNodeId: null,
|
|
1129
|
+
importId: null,
|
|
1130
|
+
fingerprint: null,
|
|
1131
|
+
managedFields: [],
|
|
1132
|
+
lastImportedAt: null,
|
|
1133
|
+
detached: false,
|
|
1134
|
+
};
|
|
1135
|
+
} else {
|
|
1136
|
+
normalized.origin = {
|
|
1137
|
+
...(normalized.origin || {}),
|
|
1138
|
+
kind: "plan_import",
|
|
1139
|
+
detached: true,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
return normalized;
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
const registry = loadRegistry(context);
|
|
1146
|
+
saveRegistry(context, {
|
|
1147
|
+
version: PLAN_REGISTRY_VERSION,
|
|
1148
|
+
sources: registry.sources.filter((entry) => entry.id !== normalizedSourceId),
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
syncControlPlanMeta(controlState, context);
|
|
1152
|
+
config.saveControl(context, controlState);
|
|
1153
|
+
return { sourceId: normalizedSourceId, affectedTasks, control: controlState };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function parsePlanArgs(args = []) {
|
|
1157
|
+
const result = { _: [] };
|
|
1158
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1159
|
+
const token = args[i];
|
|
1160
|
+
if (token === "--file" && args[i + 1]) { result.file = args[i + 1]; i += 1; continue; }
|
|
1161
|
+
if (token === "--path" && args[i + 1]) { result.path = args[i + 1]; i += 1; continue; }
|
|
1162
|
+
if (token === "--adapter" && args[i + 1]) { result.adapter = args[i + 1]; i += 1; continue; }
|
|
1163
|
+
if (token === "--source-id" && args[i + 1]) { result.sourceId = args[i + 1]; i += 1; continue; }
|
|
1164
|
+
if (token === "--json") { result.json = true; continue; }
|
|
1165
|
+
if (token === "--keep-tasks") { result.keepTasks = true; continue; }
|
|
1166
|
+
if (token === "--conflicts" && args[i + 1]) { result.conflicts = args[i + 1]; i += 1; continue; }
|
|
1167
|
+
if (token === "--removed" && args[i + 1]) { result.removed = args[i + 1]; i += 1; continue; }
|
|
1168
|
+
if (token === "--import-id" && args[i + 1]) { result.importId = args[i + 1]; i += 1; continue; }
|
|
1169
|
+
result._.push(token);
|
|
1170
|
+
}
|
|
1171
|
+
return result;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function discoverFiles(targetPath, results, options = {}, depth = 0) {
|
|
1175
|
+
if (!targetPath || !fs.existsSync(targetPath)) return;
|
|
1176
|
+
const stat = fs.statSync(targetPath);
|
|
1177
|
+
if (stat.isFile()) {
|
|
1178
|
+
if (PLAN_SCAN_FILE_RE.test(targetPath)) results.add(path.resolve(targetPath));
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const maxDepth = options.maxDepth ?? 3;
|
|
1183
|
+
if (depth > maxDepth) return;
|
|
1184
|
+
|
|
1185
|
+
fs.readdirSync(targetPath, { withFileTypes: true }).forEach((entry) => {
|
|
1186
|
+
if (entry.name === "." || entry.name === "..") return;
|
|
1187
|
+
if (entry.isDirectory()) {
|
|
1188
|
+
if (entry.name.startsWith(".") && !options.includeHidden) return;
|
|
1189
|
+
if (["node_modules", "app", ".git"].includes(entry.name)) return;
|
|
1190
|
+
discoverFiles(path.join(targetPath, entry.name), results, options, depth + 1);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const candidate = path.join(targetPath, entry.name);
|
|
1194
|
+
if (PLAN_SCAN_FILE_RE.test(candidate)) results.add(path.resolve(candidate));
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function scan(contextOrRoot, options = {}) {
|
|
1199
|
+
const context = config.ensureContext(contextOrRoot);
|
|
1200
|
+
const files = new Set();
|
|
1201
|
+
|
|
1202
|
+
if (options.path) {
|
|
1203
|
+
discoverFiles(path.resolve(context.workspaceRoot, options.path), files, { maxDepth: 5, includeHidden: false });
|
|
1204
|
+
} else {
|
|
1205
|
+
discoverFiles(context.paths.plansDir, files, { maxDepth: 5, includeHidden: false });
|
|
1206
|
+
discoverFiles(path.join(context.workspaceRoot, "docs"), files, { maxDepth: 4, includeHidden: false });
|
|
1207
|
+
discoverFiles(context.workspaceRoot, files, { maxDepth: 0, includeHidden: false });
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
return [...files].sort().map((filePath) => {
|
|
1211
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
1212
|
+
const detection = detectAdapter(filePath, content);
|
|
1213
|
+
return {
|
|
1214
|
+
path: path.relative(context.workspaceRoot, filePath).replace(/\\/g, "/"),
|
|
1215
|
+
absolutePath: filePath,
|
|
1216
|
+
adapter: detection.adapter,
|
|
1217
|
+
confidence: detection.confidence,
|
|
1218
|
+
reason: detection.reason,
|
|
1219
|
+
title: firstHeading(content) || path.basename(filePath),
|
|
1220
|
+
};
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function syncProjectArtifacts(contextOrRoot, controlState) {
|
|
1225
|
+
const control = require("./control");
|
|
1226
|
+
const api = control.forProject(contextOrRoot);
|
|
1227
|
+
api.syncDocs(controlState);
|
|
1228
|
+
api.refreshRepoRuntime({ quiet: true });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function cmdPlan(root, args = []) {
|
|
1232
|
+
const context = config.ensureContext(root);
|
|
1233
|
+
const parsed = parsePlanArgs(args.slice(1));
|
|
1234
|
+
const subcommand = args[0];
|
|
1235
|
+
|
|
1236
|
+
if (subcommand === "scan") {
|
|
1237
|
+
const candidates = scan(context, { path: parsed.path });
|
|
1238
|
+
if (parsed.json) { console.log(JSON.stringify({ ok: true, candidates }, null, 2)); return; }
|
|
1239
|
+
if (!candidates.length) { console.log("No plan candidates detected."); return; }
|
|
1240
|
+
candidates.forEach((candidate) => {
|
|
1241
|
+
console.log(`- ${candidate.path} [${candidate.adapter}] (${Math.round(candidate.confidence * 100)}%)`);
|
|
1242
|
+
});
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (subcommand === "list") {
|
|
1247
|
+
const sources = listSources(context);
|
|
1248
|
+
if (parsed.json) { console.log(JSON.stringify({ ok: true, sources }, null, 2)); return; }
|
|
1249
|
+
if (!sources.length) { console.log("No imported plan sources."); return; }
|
|
1250
|
+
sources.forEach((source) => {
|
|
1251
|
+
console.log(`- ${source.id} :: ${source.title} [${source.adapter}] status=${source.status} warnings=${source.warnings} conflicts=${source.conflicts}`);
|
|
1252
|
+
});
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (subcommand === "import") {
|
|
1257
|
+
if (!parsed.file) throw new Error("trackops plan import requires --file <path>.");
|
|
1258
|
+
const result = importPlan(context, {
|
|
1259
|
+
file: parsed.file,
|
|
1260
|
+
adapter: parsed.adapter || "auto",
|
|
1261
|
+
sourceId: parsed.sourceId,
|
|
1262
|
+
});
|
|
1263
|
+
syncProjectArtifacts(context, result.control);
|
|
1264
|
+
if (parsed.json) { console.log(JSON.stringify({ ok: true, ...result }, null, 2)); return; }
|
|
1265
|
+
console.log(`Imported plan '${result.source.id}' (${result.source.adapter}). Preview: create=${result.preview.summary.create}, update=${result.preview.summary.update}, conflicts=${result.preview.summary.conflicts}.`);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (subcommand === "show") {
|
|
1270
|
+
const sourceId = parsed._[0];
|
|
1271
|
+
if (!sourceId) throw new Error("trackops plan show requires <source-id>.");
|
|
1272
|
+
const result = loadSource(context, sourceId);
|
|
1273
|
+
if (parsed.json) { console.log(JSON.stringify({ ok: true, ...result }, null, 2)); return; }
|
|
1274
|
+
console.log(`${result.source.title} [${result.source.id}]`);
|
|
1275
|
+
console.log(`Adapter: ${result.source.adapter}`);
|
|
1276
|
+
console.log(`Path: ${result.source.path}`);
|
|
1277
|
+
console.log(`Status: ${result.source.status}`);
|
|
1278
|
+
console.log(`Preview: create=${result.preview?.summary?.create || 0}, update=${result.preview?.summary?.update || 0}, conflicts=${result.preview?.summary?.conflicts || 0}`);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (subcommand === "apply") {
|
|
1283
|
+
const sourceId = parsed._[0];
|
|
1284
|
+
if (!sourceId) throw new Error("trackops plan apply requires <source-id>.");
|
|
1285
|
+
const result = applyPlan(context, sourceId, {
|
|
1286
|
+
importId: parsed.importId || "latest",
|
|
1287
|
+
conflicts: parsed.conflicts || "abort",
|
|
1288
|
+
removed: parsed.removed || "detach",
|
|
1289
|
+
});
|
|
1290
|
+
syncProjectArtifacts(context, result.control);
|
|
1291
|
+
if (parsed.json) { console.log(JSON.stringify({ ok: true, ...result }, null, 2)); return; }
|
|
1292
|
+
console.log(`Applied plan '${result.source.id}'. Managed tasks=${result.preview.summary.managedTaskCount}, conflicts=${result.preview.summary.conflicts}.`);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
if (subcommand === "unlink") {
|
|
1297
|
+
const sourceId = parsed._[0];
|
|
1298
|
+
if (!sourceId) throw new Error("trackops plan unlink requires <source-id>.");
|
|
1299
|
+
const result = unlinkSource(context, sourceId, { keepTasks: parsed.keepTasks });
|
|
1300
|
+
syncProjectArtifacts(context, result.control);
|
|
1301
|
+
if (parsed.json) { console.log(JSON.stringify({ ok: true, ...result }, null, 2)); return; }
|
|
1302
|
+
console.log(`Unlinked plan '${result.sourceId}'. Affected tasks=${result.affectedTasks}.`);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
console.log("trackops plan scan [--path <dir>] [--json]");
|
|
1307
|
+
console.log("trackops plan import --file <path> [--adapter auto|canonical|claude|codex|antigravity] [--source-id <id>] [--json]");
|
|
1308
|
+
console.log("trackops plan show <source-id> [--json]");
|
|
1309
|
+
console.log("trackops plan apply <source-id> [--import-id latest] [--conflicts abort|prefer-source|prefer-local] [--removed detach|archive|delete] [--json]");
|
|
1310
|
+
console.log("trackops plan list [--json]");
|
|
1311
|
+
console.log("trackops plan unlink <source-id> [--keep-tasks] [--json]");
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
module.exports = {
|
|
1315
|
+
MANAGED_FIELDS,
|
|
1316
|
+
SUPPORTED_ADAPTERS,
|
|
1317
|
+
detectAdapter,
|
|
1318
|
+
loadAndNormalizePlan,
|
|
1319
|
+
buildPreview,
|
|
1320
|
+
importPlan,
|
|
1321
|
+
applyPlan,
|
|
1322
|
+
unlinkSource,
|
|
1323
|
+
loadSource,
|
|
1324
|
+
listSources,
|
|
1325
|
+
scan,
|
|
1326
|
+
syncControlPlanMeta,
|
|
1327
|
+
extractManagedSnapshot,
|
|
1328
|
+
cmdPlan,
|
|
1329
|
+
};
|