mdkg 0.3.4 → 0.3.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/CHANGELOG.md +69 -2
- package/CLI_COMMAND_MATRIX.md +73 -2
- package/README.md +39 -1
- package/dist/cli.js +178 -1
- package/dist/command-contract.json +506 -2
- package/dist/commands/graph.js +704 -0
- package/dist/commands/mcp.js +647 -0
- package/dist/commands/validate.js +16 -11
- package/dist/init/CLI_COMMAND_MATRIX.md +28 -0
- package/dist/init/README.md +26 -1
- package/dist/init/init-manifest.json +3 -3
- package/dist/util/argparse.js +4 -0
- package/package.json +4 -2
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runGraphCloneCommand = runGraphCloneCommand;
|
|
7
|
+
exports.runGraphForkCommand = runGraphForkCommand;
|
|
8
|
+
exports.runGraphImportTemplateCommand = runGraphImportTemplateCommand;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const bundle_1 = require("./bundle");
|
|
12
|
+
const index_1 = require("./index");
|
|
13
|
+
const validate_1 = require("./validate");
|
|
14
|
+
const config_1 = require("../core/config");
|
|
15
|
+
const workspace_path_1 = require("../core/workspace_path");
|
|
16
|
+
const indexer_1 = require("../graph/indexer");
|
|
17
|
+
const frontmatter_1 = require("../graph/frontmatter");
|
|
18
|
+
const errors_1 = require("../util/errors");
|
|
19
|
+
const qid_1 = require("../util/qid");
|
|
20
|
+
const atomic_1 = require("../util/atomic");
|
|
21
|
+
const zip_1 = require("../util/zip");
|
|
22
|
+
const lock_1 = require("../util/lock");
|
|
23
|
+
const date_1 = require("../util/date");
|
|
24
|
+
function writeJson(value) {
|
|
25
|
+
console.log(JSON.stringify(value, null, 2));
|
|
26
|
+
}
|
|
27
|
+
function toPosixPath(value) {
|
|
28
|
+
return value.split(path_1.default.sep).join("/");
|
|
29
|
+
}
|
|
30
|
+
function escapeRegExp(value) {
|
|
31
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
}
|
|
33
|
+
function rel(root, target) {
|
|
34
|
+
return toPosixPath(path_1.default.relative(root, target)) || ".";
|
|
35
|
+
}
|
|
36
|
+
function isInside(parent, child) {
|
|
37
|
+
const relative = path_1.default.relative(parent, child);
|
|
38
|
+
return relative === "" || (!relative.startsWith("..") && !path_1.default.isAbsolute(relative));
|
|
39
|
+
}
|
|
40
|
+
function resolveSourcePath(root, source) {
|
|
41
|
+
if (source.includes("\0")) {
|
|
42
|
+
throw new errors_1.UsageError("source cannot contain NUL bytes");
|
|
43
|
+
}
|
|
44
|
+
return path_1.default.isAbsolute(source) ? source : path_1.default.resolve(root, source);
|
|
45
|
+
}
|
|
46
|
+
function safeZipEntryPath(entryName) {
|
|
47
|
+
const normalized = entryName.replace(/\\/g, "/");
|
|
48
|
+
const parts = normalized.split("/");
|
|
49
|
+
if (path_1.default.isAbsolute(normalized) ||
|
|
50
|
+
parts.some((part) => part === "..") ||
|
|
51
|
+
parts.some((part) => part.length === 0)) {
|
|
52
|
+
throw new errors_1.ValidationError(`unsafe graph source entry path: ${entryName}`);
|
|
53
|
+
}
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
function resolveSourceDirectory(sourcePath) {
|
|
57
|
+
if (!fs_1.default.existsSync(sourcePath) || !fs_1.default.statSync(sourcePath).isDirectory()) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const directConfig = path_1.default.join(sourcePath, ".mdkg", "config.json");
|
|
61
|
+
if (fs_1.default.existsSync(directConfig)) {
|
|
62
|
+
return sourcePath;
|
|
63
|
+
}
|
|
64
|
+
if (path_1.default.basename(sourcePath) === ".mdkg" && fs_1.default.existsSync(path_1.default.join(sourcePath, "config.json"))) {
|
|
65
|
+
return path_1.default.dirname(sourcePath);
|
|
66
|
+
}
|
|
67
|
+
throw new errors_1.UsageError("directory source must contain .mdkg/config.json or be an .mdkg directory");
|
|
68
|
+
}
|
|
69
|
+
function loadGraphSource(root, source) {
|
|
70
|
+
const sourcePath = resolveSourcePath(root, source);
|
|
71
|
+
const sourceRoot = resolveSourceDirectory(sourcePath);
|
|
72
|
+
if (sourceRoot) {
|
|
73
|
+
const bundle = (0, bundle_1.buildBundle)({ root: sourceRoot, profile: "private" });
|
|
74
|
+
return {
|
|
75
|
+
kind: "directory",
|
|
76
|
+
sourcePath,
|
|
77
|
+
sourceRoot,
|
|
78
|
+
entries: new Map((0, zip_1.readZipEntries)(bundle.zip).map((entry) => [entry.name, entry.data])),
|
|
79
|
+
manifest: bundle.manifest,
|
|
80
|
+
zipSha256: bundle.zipSha256,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!fs_1.default.existsSync(sourcePath)) {
|
|
84
|
+
throw new errors_1.NotFoundError(`graph source not found: ${source}`);
|
|
85
|
+
}
|
|
86
|
+
if (!fs_1.default.statSync(sourcePath).isFile()) {
|
|
87
|
+
throw new errors_1.UsageError("source must be a bundle file or directory containing .mdkg");
|
|
88
|
+
}
|
|
89
|
+
const parsed = (0, bundle_1.parseBundle)(sourcePath);
|
|
90
|
+
return {
|
|
91
|
+
kind: "bundle",
|
|
92
|
+
sourcePath,
|
|
93
|
+
entries: parsed.entries,
|
|
94
|
+
manifest: parsed.manifest,
|
|
95
|
+
zipSha256: (0, bundle_1.sha256Buffer)(fs_1.default.readFileSync(sourcePath)),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function resolveTargetRoot(root, target) {
|
|
99
|
+
let contained;
|
|
100
|
+
try {
|
|
101
|
+
contained = (0, workspace_path_1.normalizeContainedWorkspacePath)(target, "--target");
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
throw new errors_1.UsageError(err instanceof Error ? err.message : String(err));
|
|
105
|
+
}
|
|
106
|
+
const targetRoot = path_1.default.resolve(root, contained);
|
|
107
|
+
if (!isInside(root, targetRoot)) {
|
|
108
|
+
throw new errors_1.UsageError("--target must stay inside the current mdkg root");
|
|
109
|
+
}
|
|
110
|
+
if (fs_1.default.existsSync(targetRoot)) {
|
|
111
|
+
const entries = fs_1.default.readdirSync(targetRoot);
|
|
112
|
+
if (entries.length > 0) {
|
|
113
|
+
throw new errors_1.UsageError(`target must be empty or absent: ${rel(root, targetRoot)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return targetRoot;
|
|
117
|
+
}
|
|
118
|
+
function assertSourceNotMutatedByTarget(source, targetRoot) {
|
|
119
|
+
if (source.kind !== "directory" || !source.sourceRoot) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const sourceRoot = path_1.default.resolve(source.sourceRoot);
|
|
123
|
+
if (isInside(sourceRoot, targetRoot)) {
|
|
124
|
+
throw new errors_1.UsageError("target must not be inside the live directory source");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function writeGraphFiles(targetRoot, source) {
|
|
128
|
+
const filesWritten = [];
|
|
129
|
+
const skippedPaths = [];
|
|
130
|
+
for (const file of source.manifest.files) {
|
|
131
|
+
const safeName = safeZipEntryPath(file.path);
|
|
132
|
+
if (file.kind === "generated_index") {
|
|
133
|
+
skippedPaths.push(safeName);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const data = source.entries.get(file.path);
|
|
137
|
+
if (!data) {
|
|
138
|
+
throw new errors_1.ValidationError(`graph source missing bundled file: ${file.path}`);
|
|
139
|
+
}
|
|
140
|
+
const output = path_1.default.join(targetRoot, safeName);
|
|
141
|
+
fs_1.default.mkdirSync(path_1.default.dirname(output), { recursive: true });
|
|
142
|
+
fs_1.default.writeFileSync(output, data);
|
|
143
|
+
filesWritten.push(safeName);
|
|
144
|
+
}
|
|
145
|
+
return { filesWritten: filesWritten.sort(), skippedPaths: skippedPaths.sort() };
|
|
146
|
+
}
|
|
147
|
+
function indexPathsReceipt(root, result) {
|
|
148
|
+
return {
|
|
149
|
+
nodes: rel(root, result.paths.nodes),
|
|
150
|
+
skills: rel(root, result.paths.skills),
|
|
151
|
+
capabilities: rel(root, result.paths.capabilities),
|
|
152
|
+
subgraphs: rel(root, result.paths.subgraphs),
|
|
153
|
+
sqlite: result.paths.sqlite ? rel(root, result.paths.sqlite) : null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function resolveStartGoal(targetRoot, requested) {
|
|
157
|
+
const config = (0, config_1.loadConfig)(targetRoot);
|
|
158
|
+
const index = (0, indexer_1.buildIndex)(targetRoot, config);
|
|
159
|
+
const resolved = (0, qid_1.resolveQid)(index, requested, undefined);
|
|
160
|
+
if (resolved.status !== "ok") {
|
|
161
|
+
throw new errors_1.NotFoundError((0, qid_1.formatResolveError)("goal", requested, resolved, undefined));
|
|
162
|
+
}
|
|
163
|
+
const node = index.nodes[resolved.qid];
|
|
164
|
+
if (!node || node.type !== "goal") {
|
|
165
|
+
throw new errors_1.UsageError(`start goal must resolve to a goal: ${requested}`);
|
|
166
|
+
}
|
|
167
|
+
return node;
|
|
168
|
+
}
|
|
169
|
+
function writeSelectedGoal(targetRoot, qid, id, ws) {
|
|
170
|
+
const statePath = path_1.default.join(targetRoot, ".mdkg", "state", "selected-goal.json");
|
|
171
|
+
const state = {
|
|
172
|
+
qid,
|
|
173
|
+
id,
|
|
174
|
+
ws,
|
|
175
|
+
selected_at: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
(0, atomic_1.atomicWriteFile)(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
178
|
+
return rel(targetRoot, statePath);
|
|
179
|
+
}
|
|
180
|
+
function qidForRoot(id) {
|
|
181
|
+
return `root:${id}`;
|
|
182
|
+
}
|
|
183
|
+
function idFromRootQid(qid) {
|
|
184
|
+
const [workspace, id] = qid.split(":");
|
|
185
|
+
if (workspace !== "root" || !id) {
|
|
186
|
+
throw new errors_1.UsageError(`invalid root qid: ${qid}`);
|
|
187
|
+
}
|
|
188
|
+
return id;
|
|
189
|
+
}
|
|
190
|
+
function ensureStatusAllowed(config, status) {
|
|
191
|
+
const normalized = status.toLowerCase();
|
|
192
|
+
const allowed = new Set(config.work.status_enum.map((value) => value.toLowerCase()));
|
|
193
|
+
if (!allowed.has(normalized)) {
|
|
194
|
+
throw new errors_1.UsageError(`goal status ${normalized} is not allowed by work.status_enum`);
|
|
195
|
+
}
|
|
196
|
+
return normalized;
|
|
197
|
+
}
|
|
198
|
+
function isActiveGoalStatus(status, goalState) {
|
|
199
|
+
return status === "progress" && goalState === "active";
|
|
200
|
+
}
|
|
201
|
+
function isClosedGoalStatus(status, goalState) {
|
|
202
|
+
return status === "done" || status === "archived" || goalState === "achieved" || goalState === "archived";
|
|
203
|
+
}
|
|
204
|
+
function activeLocalRootGoals(root) {
|
|
205
|
+
const config = (0, config_1.loadConfig)(root);
|
|
206
|
+
const index = (0, indexer_1.buildIndex)(root, config);
|
|
207
|
+
return Object.values(index.nodes)
|
|
208
|
+
.filter((node) => !node.source?.imported)
|
|
209
|
+
.filter((node) => node.ws === "root" && node.type === "goal")
|
|
210
|
+
.filter((node) => isActiveGoalStatus(node.status, String(node.attributes.goal_state ?? "")))
|
|
211
|
+
.sort((a, b) => a.qid.localeCompare(b.qid));
|
|
212
|
+
}
|
|
213
|
+
function localGoalLifecycleReceipt(node, status, goalState, planned) {
|
|
214
|
+
return {
|
|
215
|
+
workspace: node.ws,
|
|
216
|
+
id: node.id,
|
|
217
|
+
qid: node.qid,
|
|
218
|
+
path: node.path,
|
|
219
|
+
previous_status: node.status ?? "",
|
|
220
|
+
previous_goal_state: String(node.attributes.goal_state ?? ""),
|
|
221
|
+
status,
|
|
222
|
+
goal_state: goalState,
|
|
223
|
+
source: "local",
|
|
224
|
+
planned,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function importedGoalLifecycleReceipt(plan, status, goalState, planned) {
|
|
228
|
+
return {
|
|
229
|
+
workspace: "root",
|
|
230
|
+
id: plan.to_id,
|
|
231
|
+
qid: qidForRoot(plan.to_id),
|
|
232
|
+
path: plan.target_path,
|
|
233
|
+
previous_status: plan.status ?? "",
|
|
234
|
+
previous_goal_state: plan.goal_state ?? "",
|
|
235
|
+
status,
|
|
236
|
+
goal_state: goalState,
|
|
237
|
+
source: "imported",
|
|
238
|
+
planned,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function readNodeFile(root, nodePath) {
|
|
242
|
+
const filePath = path_1.default.join(root, nodePath);
|
|
243
|
+
const parsed = (0, frontmatter_1.parseFrontmatter)(fs_1.default.readFileSync(filePath, "utf8"), nodePath);
|
|
244
|
+
return { filePath, frontmatter: { ...parsed.frontmatter }, body: parsed.body };
|
|
245
|
+
}
|
|
246
|
+
function writeRenderedNodeFile(filePath, frontmatter, body) {
|
|
247
|
+
(0, atomic_1.atomicWriteFile)(filePath, renderNode(frontmatter, body));
|
|
248
|
+
}
|
|
249
|
+
function pauseLocalGoals(root, goals, config) {
|
|
250
|
+
const today = (0, date_1.formatDate)(new Date());
|
|
251
|
+
for (const goal of goals.filter((item) => item.source === "local")) {
|
|
252
|
+
const loaded = readNodeFile(root, goal.path);
|
|
253
|
+
loaded.frontmatter.status = ensureStatusAllowed(config, "blocked");
|
|
254
|
+
loaded.frontmatter.goal_state = "paused";
|
|
255
|
+
loaded.frontmatter.updated = today;
|
|
256
|
+
writeRenderedNodeFile(loaded.filePath, loaded.frontmatter, loaded.body);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function isWorkMarkdownPath(value) {
|
|
260
|
+
const normalized = value.replace(/\\/g, "/");
|
|
261
|
+
return normalized.startsWith(".mdkg/work/") && normalized.endsWith(".md");
|
|
262
|
+
}
|
|
263
|
+
function numericIdPrefix(value) {
|
|
264
|
+
const match = /^([a-z]+)-([0-9]+)$/.exec(value);
|
|
265
|
+
return match?.[1];
|
|
266
|
+
}
|
|
267
|
+
function normalizeIdPrefix(value) {
|
|
268
|
+
const normalized = value.trim().toLowerCase();
|
|
269
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(normalized)) {
|
|
270
|
+
throw new errors_1.UsageError("--id-prefix must start with a letter and use lowercase letters, numbers, underscore, or dash");
|
|
271
|
+
}
|
|
272
|
+
return normalized;
|
|
273
|
+
}
|
|
274
|
+
function nextNumericId(fromId, usedIds) {
|
|
275
|
+
const prefix = numericIdPrefix(fromId);
|
|
276
|
+
if (!prefix) {
|
|
277
|
+
throw new errors_1.UsageError(`cannot rewrite non-numeric template id without --id-prefix: ${fromId}`);
|
|
278
|
+
}
|
|
279
|
+
let next = 1;
|
|
280
|
+
while (usedIds.has(`${prefix}-${next}`)) {
|
|
281
|
+
next += 1;
|
|
282
|
+
}
|
|
283
|
+
return `${prefix}-${next}`;
|
|
284
|
+
}
|
|
285
|
+
function prefixedId(prefix, fromId, usedIds) {
|
|
286
|
+
let candidate = `${prefix}-${fromId}`.replace(/[^a-z0-9._-]+/g, "-");
|
|
287
|
+
let suffix = 2;
|
|
288
|
+
while (usedIds.has(candidate)) {
|
|
289
|
+
candidate = `${prefix}-${fromId}-${suffix}`.replace(/[^a-z0-9._-]+/g, "-");
|
|
290
|
+
suffix += 1;
|
|
291
|
+
}
|
|
292
|
+
return candidate;
|
|
293
|
+
}
|
|
294
|
+
function rewriteStringValue(value, idMap, pathLabel, field, rewrites) {
|
|
295
|
+
let output = value;
|
|
296
|
+
for (const [fromId, toId] of Array.from(idMap.entries()).sort((a, b) => b[0].length - a[0].length)) {
|
|
297
|
+
const replacements = [
|
|
298
|
+
[new RegExp(`\\broot:${escapeRegExp(fromId)}\\b`, "g"), `root:${toId}`, `root:${fromId}`],
|
|
299
|
+
[
|
|
300
|
+
new RegExp(`(^|[^a-zA-Z0-9._:-])${escapeRegExp(fromId)}(?=$|[^a-zA-Z0-9._:-])`, "g"),
|
|
301
|
+
`$1${toId}`,
|
|
302
|
+
fromId,
|
|
303
|
+
],
|
|
304
|
+
];
|
|
305
|
+
for (const [pattern, replacement, loggedFrom] of replacements) {
|
|
306
|
+
let count = 0;
|
|
307
|
+
output = output.replace(pattern, (...args) => {
|
|
308
|
+
count += 1;
|
|
309
|
+
return typeof replacement === "string" ? replacement.replace("$1", String(args[1] ?? "")) : replacement;
|
|
310
|
+
});
|
|
311
|
+
if (count > 0) {
|
|
312
|
+
rewrites.push({
|
|
313
|
+
path: pathLabel,
|
|
314
|
+
field,
|
|
315
|
+
from: loggedFrom,
|
|
316
|
+
to: loggedFrom.startsWith("root:") ? `root:${toId}` : toId,
|
|
317
|
+
count,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return output;
|
|
323
|
+
}
|
|
324
|
+
function rewriteFrontmatterValue(value, idMap, pathLabel, field, rewrites) {
|
|
325
|
+
if (Array.isArray(value)) {
|
|
326
|
+
return value.map((item) => rewriteStringValue(item, idMap, pathLabel, field, rewrites));
|
|
327
|
+
}
|
|
328
|
+
if (typeof value === "string") {
|
|
329
|
+
return rewriteStringValue(value, idMap, pathLabel, field, rewrites);
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
function targetPathForImport(sourcePath, fromId, toId, usedPaths) {
|
|
334
|
+
const basename = path_1.default.posix.basename(sourcePath);
|
|
335
|
+
const suffix = basename.startsWith(fromId) ? basename.slice(fromId.length) : ".md";
|
|
336
|
+
let candidate = `.mdkg/work/${toId}${suffix}`;
|
|
337
|
+
let count = 2;
|
|
338
|
+
while (usedPaths.has(candidate)) {
|
|
339
|
+
candidate = `.mdkg/work/${toId}-${count}.md`;
|
|
340
|
+
count += 1;
|
|
341
|
+
}
|
|
342
|
+
usedPaths.add(candidate);
|
|
343
|
+
return candidate;
|
|
344
|
+
}
|
|
345
|
+
function renderNode(frontmatter, body) {
|
|
346
|
+
return ["---", ...(0, frontmatter_1.formatFrontmatter)(frontmatter, frontmatter_1.DEFAULT_FRONTMATTER_KEY_ORDER), "---", body].join("\n");
|
|
347
|
+
}
|
|
348
|
+
function localIdsAndPaths(root) {
|
|
349
|
+
const config = (0, config_1.loadConfig)(root);
|
|
350
|
+
const index = (0, indexer_1.buildIndex)(root, config);
|
|
351
|
+
return {
|
|
352
|
+
ids: new Set(Object.values(index.nodes).filter((node) => !node.source?.imported).map((node) => node.id)),
|
|
353
|
+
paths: new Set(Object.values(index.nodes).filter((node) => !node.source?.imported).map((node) => node.path)),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function planImportTemplate(options) {
|
|
357
|
+
if (options.dryRun && options.apply) {
|
|
358
|
+
throw new errors_1.UsageError("choose either --dry-run or --apply, not both");
|
|
359
|
+
}
|
|
360
|
+
if (options.selectGoal && !options.startGoal) {
|
|
361
|
+
throw new errors_1.UsageError("--select-goal requires --start-goal <goal-id>");
|
|
362
|
+
}
|
|
363
|
+
const source = loadGraphSource(options.root, options.source);
|
|
364
|
+
const idPrefix = options.idPrefix ? normalizeIdPrefix(options.idPrefix) : undefined;
|
|
365
|
+
const { ids: usedIds, paths: usedPaths } = localIdsAndPaths(options.root);
|
|
366
|
+
const workFiles = source.manifest.files
|
|
367
|
+
.map((file) => safeZipEntryPath(file.path))
|
|
368
|
+
.filter(isWorkMarkdownPath)
|
|
369
|
+
.sort();
|
|
370
|
+
const skippedPaths = source.manifest.files
|
|
371
|
+
.map((file) => safeZipEntryPath(file.path))
|
|
372
|
+
.filter((filePath) => !isWorkMarkdownPath(filePath))
|
|
373
|
+
.sort();
|
|
374
|
+
const imported = workFiles.map((sourcePath) => {
|
|
375
|
+
const data = source.entries.get(sourcePath);
|
|
376
|
+
if (!data) {
|
|
377
|
+
throw new errors_1.ValidationError(`graph source missing bundled file: ${sourcePath}`);
|
|
378
|
+
}
|
|
379
|
+
const parsed = (0, frontmatter_1.parseFrontmatter)(data.toString("utf8"), sourcePath);
|
|
380
|
+
const id = typeof parsed.frontmatter.id === "string" ? parsed.frontmatter.id : undefined;
|
|
381
|
+
const type = typeof parsed.frontmatter.type === "string" ? parsed.frontmatter.type : undefined;
|
|
382
|
+
if (!id || !type) {
|
|
383
|
+
throw new errors_1.ValidationError(`${sourcePath}: imported node requires string id and type`);
|
|
384
|
+
}
|
|
385
|
+
return { sourcePath, parsed, id, type };
|
|
386
|
+
});
|
|
387
|
+
const idMap = new Map();
|
|
388
|
+
for (const node of imported) {
|
|
389
|
+
const mustRewrite = usedIds.has(node.id) || Boolean(numericIdPrefix(node.id));
|
|
390
|
+
if (mustRewrite && !numericIdPrefix(node.id) && !idPrefix) {
|
|
391
|
+
throw new errors_1.UsageError(`cannot rewrite non-numeric template id without --id-prefix: ${node.id}`);
|
|
392
|
+
}
|
|
393
|
+
const toId = mustRewrite
|
|
394
|
+
? numericIdPrefix(node.id)
|
|
395
|
+
? nextNumericId(node.id, usedIds)
|
|
396
|
+
: prefixedId(idPrefix, node.id, usedIds)
|
|
397
|
+
: node.id;
|
|
398
|
+
usedIds.add(toId);
|
|
399
|
+
idMap.set(node.id, toId);
|
|
400
|
+
}
|
|
401
|
+
const rewrittenRefs = [];
|
|
402
|
+
const plans = imported.map((node) => {
|
|
403
|
+
const toId = idMap.get(node.id) ?? node.id;
|
|
404
|
+
const frontmatter = { ...node.parsed.frontmatter, id: toId };
|
|
405
|
+
for (const [field, value] of Object.entries(frontmatter)) {
|
|
406
|
+
if (field === "id") {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
frontmatter[field] = rewriteFrontmatterValue(value, idMap, node.sourcePath, field, rewrittenRefs);
|
|
410
|
+
}
|
|
411
|
+
const body = rewriteStringValue(node.parsed.body, idMap, node.sourcePath, "body", rewrittenRefs);
|
|
412
|
+
const targetPath = targetPathForImport(node.sourcePath, node.id, toId, usedPaths);
|
|
413
|
+
const targetAbs = path_1.default.resolve(options.root, targetPath);
|
|
414
|
+
if (fs_1.default.existsSync(targetAbs)) {
|
|
415
|
+
throw new errors_1.UsageError(`import target already exists after rewrite: ${targetPath}`);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
source_path: node.sourcePath,
|
|
419
|
+
target_path: targetPath,
|
|
420
|
+
from_id: node.id,
|
|
421
|
+
to_id: toId,
|
|
422
|
+
type: node.type,
|
|
423
|
+
status: typeof frontmatter.status === "string" ? frontmatter.status : undefined,
|
|
424
|
+
goal_state: typeof frontmatter.goal_state === "string" ? frontmatter.goal_state : undefined,
|
|
425
|
+
title: typeof frontmatter.title === "string" ? frontmatter.title : undefined,
|
|
426
|
+
content: renderNode(frontmatter, body),
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
const startGoalToId = options.startGoal ? (idMap.get(options.startGoal) ?? options.startGoal) : undefined;
|
|
430
|
+
const startGoalPlan = startGoalToId
|
|
431
|
+
? plans.find((plan) => plan.to_id === startGoalToId && plan.type === "goal")
|
|
432
|
+
: undefined;
|
|
433
|
+
if (options.startGoal && !startGoalPlan) {
|
|
434
|
+
throw new errors_1.NotFoundError(`start goal not found in imported template graph: ${options.startGoal}`);
|
|
435
|
+
}
|
|
436
|
+
if (options.selectGoal && startGoalPlan && isClosedGoalStatus(startGoalPlan.status, startGoalPlan.goal_state)) {
|
|
437
|
+
throw new errors_1.UsageError(`cannot select achieved or archived imported start goal: ${options.startGoal}`);
|
|
438
|
+
}
|
|
439
|
+
const localActiveGoals = activeLocalRootGoals(options.root);
|
|
440
|
+
const importedActiveGoals = plans
|
|
441
|
+
.filter((plan) => plan.type === "goal")
|
|
442
|
+
.filter((plan) => isActiveGoalStatus(plan.status, plan.goal_state));
|
|
443
|
+
if (!options.selectGoal && localActiveGoals.length + importedActiveGoals.length > 1) {
|
|
444
|
+
throw new errors_1.UsageError("import-template would create multiple active root goals; use --select-goal --start-goal <goal-id> or pause active goals before importing");
|
|
445
|
+
}
|
|
446
|
+
const activatedGoal = options.selectGoal && startGoalPlan
|
|
447
|
+
? importedGoalLifecycleReceipt(startGoalPlan, "progress", "active", !options.apply)
|
|
448
|
+
: undefined;
|
|
449
|
+
const pausedGoals = options.selectGoal && startGoalPlan
|
|
450
|
+
? [
|
|
451
|
+
...localActiveGoals.map((node) => localGoalLifecycleReceipt(node, "blocked", "paused", !options.apply)),
|
|
452
|
+
...importedActiveGoals
|
|
453
|
+
.filter((plan) => plan.to_id !== startGoalPlan.to_id)
|
|
454
|
+
.map((plan) => importedGoalLifecycleReceipt(plan, "blocked", "paused", !options.apply)),
|
|
455
|
+
]
|
|
456
|
+
: [];
|
|
457
|
+
const warnings = pausedGoals.length > 0 ? [`paused ${pausedGoals.length} competing active goal(s)`] : [];
|
|
458
|
+
const mode = options.apply ? "import_template_applied" : "import_template_dry_run";
|
|
459
|
+
return {
|
|
460
|
+
action: "graph.import_template",
|
|
461
|
+
ok: true,
|
|
462
|
+
mode,
|
|
463
|
+
source: {
|
|
464
|
+
kind: source.kind,
|
|
465
|
+
path: rel(options.root, source.sourcePath),
|
|
466
|
+
...(source.sourceRoot ? { source_root: rel(options.root, source.sourceRoot) } : {}),
|
|
467
|
+
profile: source.manifest.profile,
|
|
468
|
+
selected_workspaces: source.manifest.selected_workspaces,
|
|
469
|
+
},
|
|
470
|
+
source_hash: {
|
|
471
|
+
source_tree_hash: source.manifest.source_tree_hash,
|
|
472
|
+
bundle_hash: source.manifest.bundle_hash,
|
|
473
|
+
zip_sha256: source.zipSha256,
|
|
474
|
+
},
|
|
475
|
+
preserved_ids: false,
|
|
476
|
+
rewritten_ids: plans.map((plan) => ({
|
|
477
|
+
from_id: plan.from_id,
|
|
478
|
+
to_id: plan.to_id,
|
|
479
|
+
from_path: plan.source_path,
|
|
480
|
+
to_path: plan.target_path,
|
|
481
|
+
reason: plan.from_id === plan.to_id ? "preserved_non_colliding_id" : "same_repo_import_rewrite",
|
|
482
|
+
})),
|
|
483
|
+
rewritten_refs: rewrittenRefs.sort((a, b) => `${a.path}:${a.field}:${a.from}`.localeCompare(`${b.path}:${b.field}:${b.from}`)),
|
|
484
|
+
planned_paths: plans.map((plan) => plan.target_path).sort(),
|
|
485
|
+
files_written: [],
|
|
486
|
+
skipped_paths: skippedPaths,
|
|
487
|
+
...(options.startGoal && startGoalToId
|
|
488
|
+
? { start_goal: { requested: options.startGoal, from_qid: `root:${options.startGoal}`, to_qid: `root:${startGoalToId}` } }
|
|
489
|
+
: {}),
|
|
490
|
+
...(options.selectGoal && startGoalToId
|
|
491
|
+
? { selected_goal: { qid: `root:${startGoalToId}`, path: ".mdkg/state/selected-goal.json", planned: !options.apply } }
|
|
492
|
+
: {}),
|
|
493
|
+
...(activatedGoal ? { activated_goal: activatedGoal } : {}),
|
|
494
|
+
paused_goals: pausedGoals,
|
|
495
|
+
warnings,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function applyImportTemplate(options, receipt) {
|
|
499
|
+
const config = (0, config_1.loadConfig)(options.root);
|
|
500
|
+
return (0, lock_1.withMutationLock)(options.root, config.index.lock_timeout_ms, () => {
|
|
501
|
+
for (const plan of receipt.rewritten_ids) {
|
|
502
|
+
const source = receipt.planned_paths.find((targetPath) => targetPath === plan.to_path);
|
|
503
|
+
if (!source) {
|
|
504
|
+
throw new errors_1.UsageError(`import plan missing target path for ${plan.from_id}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const source = loadGraphSource(options.root, options.source);
|
|
508
|
+
const idPrefix = options.idPrefix ? normalizeIdPrefix(options.idPrefix) : undefined;
|
|
509
|
+
const applyPlan = planImportTemplate({ ...options, apply: true, dryRun: false, idPrefix });
|
|
510
|
+
const files = applyPlan.planned_paths;
|
|
511
|
+
const workFiles = source.manifest.files
|
|
512
|
+
.map((file) => safeZipEntryPath(file.path))
|
|
513
|
+
.filter(isWorkMarkdownPath)
|
|
514
|
+
.sort();
|
|
515
|
+
const contentByTarget = new Map();
|
|
516
|
+
const { ids: usedIds, paths: usedPaths } = localIdsAndPaths(options.root);
|
|
517
|
+
const imported = workFiles.map((sourcePath) => {
|
|
518
|
+
const data = source.entries.get(sourcePath);
|
|
519
|
+
if (!data) {
|
|
520
|
+
throw new errors_1.ValidationError(`graph source missing bundled file: ${sourcePath}`);
|
|
521
|
+
}
|
|
522
|
+
const parsed = (0, frontmatter_1.parseFrontmatter)(data.toString("utf8"), sourcePath);
|
|
523
|
+
const id = String(parsed.frontmatter.id);
|
|
524
|
+
return { sourcePath, parsed, id };
|
|
525
|
+
});
|
|
526
|
+
const idMap = new Map();
|
|
527
|
+
for (const node of imported) {
|
|
528
|
+
const mustRewrite = usedIds.has(node.id) || Boolean(numericIdPrefix(node.id));
|
|
529
|
+
if (mustRewrite && !numericIdPrefix(node.id) && !idPrefix) {
|
|
530
|
+
throw new errors_1.UsageError(`cannot rewrite non-numeric template id without --id-prefix: ${node.id}`);
|
|
531
|
+
}
|
|
532
|
+
const toId = mustRewrite
|
|
533
|
+
? numericIdPrefix(node.id)
|
|
534
|
+
? nextNumericId(node.id, usedIds)
|
|
535
|
+
: prefixedId(idPrefix, node.id, usedIds)
|
|
536
|
+
: node.id;
|
|
537
|
+
usedIds.add(toId);
|
|
538
|
+
idMap.set(node.id, toId);
|
|
539
|
+
}
|
|
540
|
+
const ignoredRewrites = [];
|
|
541
|
+
for (const node of imported) {
|
|
542
|
+
const toId = idMap.get(node.id) ?? node.id;
|
|
543
|
+
const frontmatter = { ...node.parsed.frontmatter, id: toId };
|
|
544
|
+
for (const [field, value] of Object.entries(frontmatter)) {
|
|
545
|
+
if (field === "id") {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
frontmatter[field] = rewriteFrontmatterValue(value, idMap, node.sourcePath, field, ignoredRewrites);
|
|
549
|
+
}
|
|
550
|
+
const body = rewriteStringValue(node.parsed.body, idMap, node.sourcePath, "body", ignoredRewrites);
|
|
551
|
+
if (frontmatter.type === "goal" && options.selectGoal) {
|
|
552
|
+
if (qidForRoot(toId) === applyPlan.activated_goal?.qid) {
|
|
553
|
+
frontmatter.status = ensureStatusAllowed(config, "progress");
|
|
554
|
+
frontmatter.goal_state = "active";
|
|
555
|
+
}
|
|
556
|
+
else if (isActiveGoalStatus(String(frontmatter.status ?? ""), String(frontmatter.goal_state ?? ""))) {
|
|
557
|
+
frontmatter.status = ensureStatusAllowed(config, "blocked");
|
|
558
|
+
frontmatter.goal_state = "paused";
|
|
559
|
+
}
|
|
560
|
+
frontmatter.updated = (0, date_1.formatDate)(new Date());
|
|
561
|
+
}
|
|
562
|
+
const targetPath = targetPathForImport(node.sourcePath, node.id, toId, usedPaths);
|
|
563
|
+
contentByTarget.set(targetPath, renderNode(frontmatter, body));
|
|
564
|
+
}
|
|
565
|
+
for (const targetPath of files) {
|
|
566
|
+
const content = contentByTarget.get(targetPath);
|
|
567
|
+
if (!content) {
|
|
568
|
+
throw new errors_1.UsageError(`import plan content missing for ${targetPath}`);
|
|
569
|
+
}
|
|
570
|
+
const targetAbs = path_1.default.resolve(options.root, targetPath);
|
|
571
|
+
fs_1.default.mkdirSync(path_1.default.dirname(targetAbs), { recursive: true });
|
|
572
|
+
(0, atomic_1.atomicWriteFile)(targetAbs, content);
|
|
573
|
+
}
|
|
574
|
+
pauseLocalGoals(options.root, applyPlan.paused_goals, config);
|
|
575
|
+
const indexReceipt = (0, index_1.rebuildDerivedIndexCaches)({ root: options.root });
|
|
576
|
+
const validation = (0, validate_1.collectValidateReceipt)({ root: options.root, quiet: true });
|
|
577
|
+
if (validation.error_count > 0) {
|
|
578
|
+
throw new errors_1.ValidationError(`imported graph validation failed with ${validation.error_count} error(s)`);
|
|
579
|
+
}
|
|
580
|
+
if (options.selectGoal && options.startGoal) {
|
|
581
|
+
const selected = applyPlan.selected_goal?.qid;
|
|
582
|
+
if (!selected) {
|
|
583
|
+
throw new errors_1.UsageError("--select-goal could not resolve imported start goal");
|
|
584
|
+
}
|
|
585
|
+
const id = idFromRootQid(selected);
|
|
586
|
+
writeSelectedGoal(options.root, selected, id, "root");
|
|
587
|
+
applyPlan.selected_goal = { qid: selected, path: ".mdkg/state/selected-goal.json", planned: false };
|
|
588
|
+
if (applyPlan.activated_goal) {
|
|
589
|
+
applyPlan.activated_goal.planned = false;
|
|
590
|
+
}
|
|
591
|
+
applyPlan.paused_goals = applyPlan.paused_goals.map((goal) => ({ ...goal, planned: false }));
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
...applyPlan,
|
|
595
|
+
files_written: files,
|
|
596
|
+
index: {
|
|
597
|
+
rebuilt: true,
|
|
598
|
+
paths: indexPathsReceipt(options.root, indexReceipt),
|
|
599
|
+
},
|
|
600
|
+
validation,
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
function runGraphTransport(options, mode) {
|
|
605
|
+
const source = loadGraphSource(options.root, options.source);
|
|
606
|
+
const targetRoot = resolveTargetRoot(options.root, options.target);
|
|
607
|
+
assertSourceNotMutatedByTarget(source, targetRoot);
|
|
608
|
+
const warnings = [];
|
|
609
|
+
fs_1.default.mkdirSync(targetRoot, { recursive: true });
|
|
610
|
+
const { filesWritten, skippedPaths } = writeGraphFiles(targetRoot, source);
|
|
611
|
+
const indexReceipt = (0, index_1.rebuildDerivedIndexCaches)({ root: targetRoot });
|
|
612
|
+
let validation = (0, validate_1.collectValidateReceipt)({ root: targetRoot, quiet: true });
|
|
613
|
+
if (validation.error_count > 0) {
|
|
614
|
+
throw new errors_1.ValidationError(`cloned graph validation failed with ${validation.error_count} error(s)`);
|
|
615
|
+
}
|
|
616
|
+
let startGoal;
|
|
617
|
+
let selectedGoal;
|
|
618
|
+
if (mode === "fork" && options.startGoal) {
|
|
619
|
+
const node = resolveStartGoal(targetRoot, options.startGoal);
|
|
620
|
+
const statePath = writeSelectedGoal(targetRoot, node.qid, node.id, node.ws);
|
|
621
|
+
startGoal = {
|
|
622
|
+
requested: options.startGoal,
|
|
623
|
+
qid: node.qid,
|
|
624
|
+
path: node.path,
|
|
625
|
+
};
|
|
626
|
+
selectedGoal = {
|
|
627
|
+
qid: node.qid,
|
|
628
|
+
path: statePath,
|
|
629
|
+
};
|
|
630
|
+
validation = (0, validate_1.collectValidateReceipt)({ root: targetRoot, quiet: true });
|
|
631
|
+
if (validation.error_count > 0) {
|
|
632
|
+
throw new errors_1.ValidationError(`forked graph validation failed with ${validation.error_count} error(s)`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
else if (mode === "clone" && options.startGoal) {
|
|
636
|
+
warnings.push("--start-goal is ignored by graph clone; use graph fork for start-goal selection");
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
action: mode === "clone" ? "graph.clone" : "graph.fork",
|
|
640
|
+
ok: true,
|
|
641
|
+
mode,
|
|
642
|
+
source: {
|
|
643
|
+
kind: source.kind,
|
|
644
|
+
path: rel(options.root, source.sourcePath),
|
|
645
|
+
...(source.sourceRoot ? { source_root: rel(options.root, source.sourceRoot) } : {}),
|
|
646
|
+
profile: source.manifest.profile,
|
|
647
|
+
selected_workspaces: source.manifest.selected_workspaces,
|
|
648
|
+
},
|
|
649
|
+
target: rel(options.root, targetRoot),
|
|
650
|
+
source_hash: {
|
|
651
|
+
source_tree_hash: source.manifest.source_tree_hash,
|
|
652
|
+
bundle_hash: source.manifest.bundle_hash,
|
|
653
|
+
zip_sha256: source.zipSha256,
|
|
654
|
+
},
|
|
655
|
+
preserved_ids: true,
|
|
656
|
+
files_written: filesWritten,
|
|
657
|
+
skipped_paths: skippedPaths,
|
|
658
|
+
...(startGoal ? { start_goal: startGoal } : {}),
|
|
659
|
+
...(selectedGoal ? { selected_goal: selectedGoal } : {}),
|
|
660
|
+
index: {
|
|
661
|
+
rebuilt: true,
|
|
662
|
+
paths: indexPathsReceipt(targetRoot, indexReceipt),
|
|
663
|
+
},
|
|
664
|
+
validation,
|
|
665
|
+
warnings,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
function printReceipt(receipt, json) {
|
|
669
|
+
if (json) {
|
|
670
|
+
writeJson(receipt);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
console.log(`${receipt.action}: ${receipt.target}`);
|
|
674
|
+
console.log(`source: ${receipt.source.path}`);
|
|
675
|
+
console.log(`files: ${receipt.files_written.length}`);
|
|
676
|
+
console.log(`preserved_ids: ${receipt.preserved_ids}`);
|
|
677
|
+
if (receipt.start_goal) {
|
|
678
|
+
console.log(`start_goal: ${receipt.start_goal.qid}`);
|
|
679
|
+
}
|
|
680
|
+
if (receipt.warnings.length > 0) {
|
|
681
|
+
console.log(`warnings: ${receipt.warnings.length}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function runGraphCloneCommand(options) {
|
|
685
|
+
printReceipt(runGraphTransport(options, "clone"), options.json);
|
|
686
|
+
}
|
|
687
|
+
function runGraphForkCommand(options) {
|
|
688
|
+
printReceipt(runGraphTransport(options, "fork"), options.json);
|
|
689
|
+
}
|
|
690
|
+
function runGraphImportTemplateCommand(options) {
|
|
691
|
+
const plan = planImportTemplate(options);
|
|
692
|
+
const receipt = options.apply ? applyImportTemplate(options, plan) : plan;
|
|
693
|
+
if (options.json) {
|
|
694
|
+
writeJson(receipt);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
console.log(`${receipt.action}: ${receipt.mode}`);
|
|
698
|
+
console.log(`source: ${receipt.source.path}`);
|
|
699
|
+
console.log(`planned_paths: ${receipt.planned_paths.length}`);
|
|
700
|
+
console.log(`rewritten_ids: ${receipt.rewritten_ids.filter((item) => item.from_id !== item.to_id).length}`);
|
|
701
|
+
if (receipt.selected_goal) {
|
|
702
|
+
console.log(`selected_goal: ${receipt.selected_goal.qid}${receipt.selected_goal.planned ? " (planned)" : ""}`);
|
|
703
|
+
}
|
|
704
|
+
}
|