archgen-mcp 0.0.6 → 0.0.7
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 +14 -5
- package/assets/harness-mcp.json +285 -55
- package/assets/references/artifact-templates.md +20 -6
- package/assets/references/platforms.md +7 -5
- package/bin/archgen-mcp.mjs +6 -4
- package/package.json +1 -1
- package/src/context.mjs +43 -3
- package/src/core/briefs.mjs +13 -0
- package/src/core/codegraph-manager.mjs +112 -48
- package/src/core/compat.mjs +13 -3
- package/src/core/doc-navigator.mjs +26 -7
- package/src/core/fs-safety.mjs +98 -10
- package/src/core/graph.mjs +8 -3
- package/src/core/harness-wiring.mjs +132 -18
- package/src/core/mcp-server-manager.mjs +2 -3
- package/src/core/plan-coverage.mjs +43 -6
- package/src/core/plan-grapher.mjs +3 -3
- package/src/core/platform-caps.mjs +9 -2
- package/src/core/resume-snapshot.mjs +16 -4
- package/src/core/scope.mjs +39 -5
- package/src/core/status-mutator.mjs +34 -3
- package/src/core/ui-channel.mjs +55 -4
- package/src/core/validator.mjs +16 -3
- package/src/core/verifier.mjs +3 -9
- package/src/core/worker-contract.mjs +16 -2
- package/src/core/yaml.mjs +31 -6
- package/src/tools/_register.mjs +5 -4
- package/src/tools/codegraph.mjs +0 -2
- package/src/tools/docs.mjs +6 -6
- package/src/tools/help.mjs +1 -1
- package/src/tools/plan.mjs +44 -7
- package/src/tools/setup.mjs +7 -3
- package/src/tools/tasks.mjs +38 -5
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
// - The per-platform file path + top-level key live in assets/harness-mcp.json
|
|
18
18
|
// — one source of truth, shared by the tool, the CLI and the docs.
|
|
19
19
|
// - Writes are atomic and refuse to go through a symlink.
|
|
20
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
|
-
import { join } from 'node:path';
|
|
23
|
-
import { assertNotSymlink, assertWithinRoot, writeFileAtomic } from './fs-safety.mjs';
|
|
24
|
-
'./errors.mjs';
|
|
22
|
+
import { join, resolve, sep } from 'node:path';
|
|
23
|
+
import { assertNotSymlink, assertWithinRoot, withFileLock, writeFileAtomic } from './fs-safety.mjs';
|
|
24
|
+
import { invalidInput } from './errors.mjs';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Detect which harnesses are present in this environment.
|
|
@@ -45,6 +45,28 @@ export function expandHome(p, home = homedir()) {
|
|
|
45
45
|
return p.startsWith('~/') ? join(home, p.slice(2)) : p;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* True when `abs` resolves inside `root`.
|
|
50
|
+
*
|
|
51
|
+
* The path is compared lexically after normalization, and symlinks are
|
|
52
|
+
* resolved on both sides when they exist — a symlink pointing out of the
|
|
53
|
+
* root must not pass as "inside".
|
|
54
|
+
*/
|
|
55
|
+
export function pathIsWithin(abs, root) {
|
|
56
|
+
if (!abs || !root) return false;
|
|
57
|
+
const resolvedRoot = resolve(root);
|
|
58
|
+
const resolved = resolve(abs);
|
|
59
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + sep)) return false;
|
|
60
|
+
try {
|
|
61
|
+
const realRoot = realpathSync(resolvedRoot);
|
|
62
|
+
const real = realpathSync(resolved);
|
|
63
|
+
return real === realRoot || real.startsWith(realRoot + sep);
|
|
64
|
+
} catch {
|
|
65
|
+
// The path does not exist yet: the lexical check above stands.
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
/**
|
|
49
71
|
* Build the server entry for a harness, substituting the resolved binary path.
|
|
50
72
|
*
|
|
@@ -57,12 +79,53 @@ export function expandHome(p, home = homedir()) {
|
|
|
57
79
|
* @param {string|null} binPath the resolved absolute binary path, or null for the npx form
|
|
58
80
|
* @param {string} fallbackCommand the command used when binPath is null
|
|
59
81
|
*/
|
|
82
|
+
/**
|
|
83
|
+
* Build the entry for one harness config.
|
|
84
|
+
*
|
|
85
|
+
* THE TWO FORMS ARE DIFFERENT COMMANDS, NOT ONE COMMAND WITH A SWAPPED
|
|
86
|
+
* EXECUTABLE.
|
|
87
|
+
*
|
|
88
|
+
* installed the server's own binary: `codegraph serve --mcp`
|
|
89
|
+
* npx the same server, fetched: `npx -y @colbymchenry/codegraph serve --mcp`
|
|
90
|
+
*
|
|
91
|
+
* When the binary was resolved, the installed form is used and its
|
|
92
|
+
* executable is replaced with the resolved absolute path. When it was NOT,
|
|
93
|
+
* the npx form is used VERBATIM — its executable is already `npx`, and
|
|
94
|
+
* rewriting it to the server's own name produced the nonsense command
|
|
95
|
+
* `codegraph -y @colbymchenry/codegraph serve --mcp`, which can never start.
|
|
96
|
+
*
|
|
97
|
+
* @param {any} entries the entries block (entries / archgenEntries)
|
|
98
|
+
* @param {any} npxEntries the npx fallback block (npxEntries / archgenNpxEntries)
|
|
99
|
+
* @param {string} entryKey the entry shape ('stdio' | 'stdioNoType' | …)
|
|
100
|
+
* @param {string|null} binPath the resolved absolute binary path, or null for the npx form
|
|
101
|
+
* @param {string} fallbackCommand the command used when no npx form exists
|
|
102
|
+
* @returns {any} a fresh entry object (the source is never mutated)
|
|
103
|
+
*/
|
|
60
104
|
export function buildEntry(entries, npxEntries, entryKey, binPath, fallbackCommand) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
105
|
+
const installed = entries?.[entryKey];
|
|
106
|
+
const npx = npxEntries?.[entryKey];
|
|
107
|
+
const useNpx = !binPath && Boolean(npx);
|
|
108
|
+
const source = useNpx ? npx : installed;
|
|
109
|
+
|
|
110
|
+
if (!source || typeof source !== 'object') {
|
|
111
|
+
// A registry entry naming a shape the writer does not know is a REGISTRY
|
|
112
|
+
// bug, not a user error. Say so plainly instead of crashing with a
|
|
113
|
+
// SyntaxError from JSON.parse(undefined).
|
|
114
|
+
throw invalidInput(
|
|
115
|
+
`unknown MCP entry shape '${entryKey}' — the harness registry and the writer disagree`,
|
|
116
|
+
{ entryKey, available: Object.keys(entries ?? {}) },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entry = structuredClone(source);
|
|
121
|
+
if (binPath) {
|
|
122
|
+
if (Array.isArray(entry.command)) entry.command = [binPath, ...entry.command.slice(1)];
|
|
123
|
+
else entry.command = binPath;
|
|
124
|
+
} else if (!useNpx) {
|
|
125
|
+
// No npx form for this shape: fall back to the server's own name.
|
|
126
|
+
if (Array.isArray(entry.command)) entry.command = [fallbackCommand, ...entry.command.slice(1)];
|
|
127
|
+
else entry.command = fallbackCommand;
|
|
128
|
+
}
|
|
66
129
|
return entry;
|
|
67
130
|
}
|
|
68
131
|
|
|
@@ -79,6 +142,12 @@ export function readJsonConfig(abs) {
|
|
|
79
142
|
/**
|
|
80
143
|
* Merge a server entry into a JSON MCP config.
|
|
81
144
|
*
|
|
145
|
+
* The read-merge-write runs under an exclusive lock on the config file. The
|
|
146
|
+
* write itself is atomic, but that only stops a reader seeing a half-written
|
|
147
|
+
* file — two writers still lose each other's work: one merges the archgen
|
|
148
|
+
* entry, the other merges the codegraph entry, and whichever lands last
|
|
149
|
+
* discards the first.
|
|
150
|
+
*
|
|
82
151
|
* @param {string} abs absolute config path
|
|
83
152
|
* @param {string} key the top-level key ('mcpServers' | 'servers' | 'mcp' | 'context_servers')
|
|
84
153
|
* @param {string} serverId the entry name inside the bucket ('codegraph' | 'archgen')
|
|
@@ -87,6 +156,16 @@ export function readJsonConfig(abs) {
|
|
|
87
156
|
* @returns {{status:'written'|'unchanged'|'skipped'|'planned'|'error', detail?:string, path?:string}}
|
|
88
157
|
*/
|
|
89
158
|
export function writeJsonConfig(abs, key, serverId, entry, opts) {
|
|
159
|
+
const { approved, dryRun = false, root } = opts;
|
|
160
|
+
try {
|
|
161
|
+
return withFileLock(abs, () => mergeJsonConfig(abs, key, serverId, entry, opts));
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { status: 'error', detail: e.message, path: abs };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The merge itself. Runs with the config file's lock held. */
|
|
168
|
+
function mergeJsonConfig(abs, key, serverId, entry, opts) {
|
|
90
169
|
const { approved, dryRun = false, root } = opts;
|
|
91
170
|
const { data, existed, error } = readJsonConfig(abs);
|
|
92
171
|
if (error) {
|
|
@@ -108,7 +187,7 @@ export function writeJsonConfig(abs, key, serverId, entry, opts) {
|
|
|
108
187
|
try {
|
|
109
188
|
if (root) assertWithinRoot(root, abs);
|
|
110
189
|
assertNotSymlink(abs);
|
|
111
|
-
writeFileAtomic(abs, JSON.stringify(obj, null, 2) + '\n');
|
|
190
|
+
writeFileAtomic(abs, JSON.stringify(obj, null, 2) + '\n', { root });
|
|
112
191
|
} catch (e) {
|
|
113
192
|
return { status: 'error', detail: e.message, path: abs };
|
|
114
193
|
}
|
|
@@ -155,6 +234,15 @@ export function entryToToml(entry) {
|
|
|
155
234
|
* @param {{approved: boolean, dryRun?: boolean, root?: string}} opts
|
|
156
235
|
*/
|
|
157
236
|
export function writeTomlConfig(abs, serverId, entry, opts) {
|
|
237
|
+
try {
|
|
238
|
+
return withFileLock(abs, () => mergeTomlConfig(abs, serverId, entry, opts));
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return { status: 'error', detail: e.message, path: abs };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The merge itself. Runs with the config file's lock held. */
|
|
245
|
+
function mergeTomlConfig(abs, serverId, entry, opts) {
|
|
158
246
|
const { approved, dryRun = false, root } = opts;
|
|
159
247
|
const existed = existsSync(abs);
|
|
160
248
|
let raw = '';
|
|
@@ -197,7 +285,7 @@ export function writeTomlConfig(abs, serverId, entry, opts) {
|
|
|
197
285
|
try {
|
|
198
286
|
if (root) assertWithinRoot(root, abs);
|
|
199
287
|
assertNotSymlink(abs);
|
|
200
|
-
writeFileAtomic(abs, out);
|
|
288
|
+
writeFileAtomic(abs, out, { root });
|
|
201
289
|
} catch (e) {
|
|
202
290
|
return { status: 'error', detail: e.message, path: abs };
|
|
203
291
|
}
|
|
@@ -220,6 +308,7 @@ export function writeTomlConfig(abs, serverId, entry, opts) {
|
|
|
220
308
|
* binPath?: string|null,
|
|
221
309
|
* fallbackCommand?: string,
|
|
222
310
|
* skipHarnesses?: string[],
|
|
311
|
+
* targets?: string[]|null,
|
|
223
312
|
* }} opts
|
|
224
313
|
* @returns {{detected: string[], wired: Array<Record<string, unknown>>, skipped: Array<Record<string, unknown>>, planned: boolean}}
|
|
225
314
|
*/
|
|
@@ -237,14 +326,19 @@ export function wireServer(opts) {
|
|
|
237
326
|
binPath = null,
|
|
238
327
|
fallbackCommand = serverId,
|
|
239
328
|
skipHarnesses = [],
|
|
329
|
+
targets = null,
|
|
240
330
|
} = opts;
|
|
241
331
|
|
|
242
332
|
const detected = detectHarnesses(registry, projectRoot, home);
|
|
333
|
+
// A caller that narrowed the set of harnesses must get exactly that set.
|
|
334
|
+
// The native path honoured the filter and this one did not, so a request
|
|
335
|
+
// for two harnesses wired ten.
|
|
336
|
+
const selected = targets ? detected.filter((d) => targets.includes(d.id)) : detected;
|
|
243
337
|
const wantedLocations = location === 'both' ? ['global', 'project'] : [location];
|
|
244
338
|
const wired = [];
|
|
245
339
|
const skipped = [];
|
|
246
340
|
|
|
247
|
-
for (const d of
|
|
341
|
+
for (const d of selected) {
|
|
248
342
|
if (skipHarnesses.includes(d.id)) {
|
|
249
343
|
skipped.push({ harness: d.id, reason: 'skipped by request' });
|
|
250
344
|
continue;
|
|
@@ -262,12 +356,17 @@ export function wireServer(opts) {
|
|
|
262
356
|
cfg.format === 'toml'
|
|
263
357
|
? writeTomlConfig(absPath, serverId, entry, { approved, dryRun, root })
|
|
264
358
|
: writeJsonConfig(absPath, cfg.key, serverId, entry, { approved, dryRun, root });
|
|
265
|
-
|
|
359
|
+
// The key recorded here is the one the writer ACTUALLY used. A JSON
|
|
360
|
+
// config uses the registry's top-level key; a TOML config's section is
|
|
361
|
+
// derived from the server id. Recording the registry's key instead made
|
|
362
|
+
// uninstall search for the wrong section and leave the real one behind.
|
|
363
|
+
const writtenKey = cfg.format === 'toml' ? `mcp_servers.${serverId}` : cfg.key;
|
|
364
|
+
wired.push({ harness: d.id, location: loc, path: absPath, key: writtenKey, ...res });
|
|
266
365
|
}
|
|
267
366
|
}
|
|
268
367
|
|
|
269
368
|
return {
|
|
270
|
-
detected:
|
|
369
|
+
detected: selected.map((d) => d.id),
|
|
271
370
|
wired,
|
|
272
371
|
skipped,
|
|
273
372
|
planned: !approved && !dryRun && wired.some((w) => w.status === 'planned'),
|
|
@@ -291,7 +390,7 @@ export function recordWiring(projectRoot, serverId, entries) {
|
|
|
291
390
|
}
|
|
292
391
|
existing.servers[serverId] = { writtenAt: new Date().toISOString(), entries };
|
|
293
392
|
try {
|
|
294
|
-
writeFileAtomic(p, JSON.stringify(existing, null, 2) + '\n');
|
|
393
|
+
writeFileAtomic(p, JSON.stringify(existing, null, 2) + '\n', { root: projectRoot });
|
|
295
394
|
} catch {
|
|
296
395
|
/* the manifest is a convenience, never a blocker */
|
|
297
396
|
}
|
|
@@ -302,9 +401,10 @@ export function recordWiring(projectRoot, serverId, entries) {
|
|
|
302
401
|
* @param {string} projectRoot
|
|
303
402
|
* @param {string} serverId
|
|
304
403
|
* @param {string} [home]
|
|
404
|
+
* @param {'project'|'global'|'both'} [location] which scope to remove
|
|
305
405
|
* @returns {{removed: string[], skipped: string[]}}
|
|
306
406
|
*/
|
|
307
|
-
export function removeWiring(projectRoot, serverId, home = homedir()) {
|
|
407
|
+
export function removeWiring(projectRoot, serverId, home = homedir(), location = 'both') {
|
|
308
408
|
const p = join(projectRoot, '.archgen', '.mcp-wiring.json');
|
|
309
409
|
let manifest = null;
|
|
310
410
|
try {
|
|
@@ -320,6 +420,20 @@ export function removeWiring(projectRoot, serverId, home = homedir()) {
|
|
|
320
420
|
for (const entry of record.entries) {
|
|
321
421
|
try {
|
|
322
422
|
const abs = entry.path;
|
|
423
|
+
// The scope is honoured: a request to remove the global wiring leaves
|
|
424
|
+
// the project's wiring in place.
|
|
425
|
+
if (location !== 'both' && entry.location && entry.location !== location) {
|
|
426
|
+
skipped.push(`${abs} (${entry.location} scope)`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
// The manifest is a file ON DISK in the project — repository content.
|
|
430
|
+
// Its paths are therefore untrusted: a clone could ship a manifest
|
|
431
|
+
// naming any file on the machine. A recorded path must resolve inside
|
|
432
|
+
// the project root or the user's home, or it is refused.
|
|
433
|
+
if (!pathIsWithin(abs, projectRoot) && !pathIsWithin(abs, home)) {
|
|
434
|
+
skipped.push(`${abs} (outside the project and home directories)`);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
323
437
|
if (!existsSync(abs)) {
|
|
324
438
|
skipped.push(`${entry.path} (absent)`);
|
|
325
439
|
continue;
|
|
@@ -336,7 +450,7 @@ export function removeWiring(projectRoot, serverId, home = homedir()) {
|
|
|
336
450
|
}
|
|
337
451
|
let next = start + 1;
|
|
338
452
|
while (next < lines.length && !/^\s*\[/.test(lines[next])) next++;
|
|
339
|
-
writeFileAtomic(abs, [...lines.slice(0, start), ...lines.slice(next)].join('\n'));
|
|
453
|
+
writeFileAtomic(abs, [...lines.slice(0, start), ...lines.slice(next)].join('\n'), { root: projectRoot });
|
|
340
454
|
removed.push(abs);
|
|
341
455
|
} else {
|
|
342
456
|
const { data } = readJsonConfig(abs);
|
|
@@ -350,7 +464,7 @@ export function removeWiring(projectRoot, serverId, home = homedir()) {
|
|
|
350
464
|
continue;
|
|
351
465
|
}
|
|
352
466
|
delete bucket[serverId];
|
|
353
|
-
writeFileAtomic(abs, JSON.stringify(data, null, 2) + '\n');
|
|
467
|
+
writeFileAtomic(abs, JSON.stringify(data, null, 2) + '\n', { root: projectRoot });
|
|
354
468
|
removed.push(abs);
|
|
355
469
|
}
|
|
356
470
|
} catch (e) {
|
|
@@ -18,7 +18,6 @@ import { homedir } from 'node:os';
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { wireServer, recordWiring, removeWiring } from './harness-wiring.mjs';
|
|
20
20
|
import { which } from './codegraph-manager.mjs';
|
|
21
|
-
'./errors.mjs';
|
|
22
21
|
|
|
23
22
|
export const MCP_PACKAGE = 'archgen-mcp';
|
|
24
23
|
|
|
@@ -114,8 +113,8 @@ export function wireArchgenMcp(opts) {
|
|
|
114
113
|
* @param {string} projectRoot
|
|
115
114
|
* @param {string} [home]
|
|
116
115
|
*/
|
|
117
|
-
export function uninstallArchgenMcp(projectRoot, home = homedir()) {
|
|
118
|
-
return removeWiring(projectRoot, 'archgen', home);
|
|
116
|
+
export function uninstallArchgenMcp(projectRoot, home = homedir(), location = 'both') {
|
|
117
|
+
return removeWiring(projectRoot, 'archgen', home, location);
|
|
119
118
|
}
|
|
120
119
|
|
|
121
120
|
/**
|
|
@@ -5,16 +5,39 @@
|
|
|
5
5
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} planDir
|
|
10
|
+
* @param {any[]} tasks
|
|
11
|
+
* @returns {{findings: string[], referenced: Set<string>, mentionedInTasks: Set<string>}}
|
|
12
|
+
* `findings` error findings (empty = coverage OK)
|
|
13
|
+
* `referenced` task ids that a plan file mentions
|
|
14
|
+
* `mentionedInTasks` task ids another task's text mentions
|
|
15
|
+
*
|
|
16
|
+
* The sets are RETURNED, not just used internally. A consumer that needs to
|
|
17
|
+
* know which tasks are covered must read them from here: re-deriving the
|
|
18
|
+
* answer from `findings` — which are NEGATIVE — inverts it, because an
|
|
19
|
+
* uncovered task's finding names the task.
|
|
20
|
+
*/
|
|
9
21
|
export function checkPlanCoverage(planDir, tasks) {
|
|
10
22
|
const findings = [];
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
23
|
+
const referenced = new Set();
|
|
24
|
+
const mentionedInTasks = new Set();
|
|
25
|
+
if (!existsSync(planDir)) return { findings: [`plans dir not found: ${planDir}`], referenced, mentionedInTasks };
|
|
26
|
+
let files;
|
|
27
|
+
try {
|
|
28
|
+
files = readdirSync(planDir).filter((f) => f.endsWith('.md'));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return { findings: [`cannot read plans dir ${planDir}: ${e.message}`], referenced, mentionedInTasks };
|
|
31
|
+
}
|
|
32
|
+
let planText = '';
|
|
33
|
+
try {
|
|
34
|
+
planText = files.map((f) => readFileSync(join(planDir, f), 'utf8')).join('\n');
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { findings: [`cannot read a plan file under ${planDir}: ${e.message}`], referenced, mentionedInTasks };
|
|
37
|
+
}
|
|
14
38
|
|
|
15
39
|
const taskIds = new Set(tasks.map((t) => t.id));
|
|
16
40
|
// Task ids referenced in plans: bare tokens that match an existing-or-not id pattern.
|
|
17
|
-
const referenced = new Set();
|
|
18
41
|
for (const t of taskIds) {
|
|
19
42
|
const re = new RegExp(`\\b${escapeRe(t)}\\b`);
|
|
20
43
|
if (re.test(planText)) referenced.add(t);
|
|
@@ -22,11 +45,25 @@ export function checkPlanCoverage(planDir, tasks) {
|
|
|
22
45
|
for (const t of tasks) {
|
|
23
46
|
if (!referenced.has(t.id)) findings.push(`task '${t.id}' is not mentioned in any plan file under ${planDir}`);
|
|
24
47
|
}
|
|
48
|
+
// A task may also be "covered" by ANOTHER task naming it — the dependency
|
|
49
|
+
// graph documents it.
|
|
50
|
+
for (const t of tasks) {
|
|
51
|
+
for (const other of tasks) {
|
|
52
|
+
if (other?.id === t.id) continue;
|
|
53
|
+
if (mentions(other, t.id)) mentionedInTasks.add(t.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
25
56
|
// Inverse: plan mentions ids like TASK-xx not present in tasks.yaml.
|
|
26
57
|
for (const m of planText.matchAll(/\b[A-Z][A-Z0-9]+-[0-9]+\b/g)) {
|
|
27
58
|
if (!taskIds.has(m[0])) findings.push(`plan references unknown task id '${m[0]}' (not in tasks.yaml)`);
|
|
28
59
|
}
|
|
29
|
-
return findings;
|
|
60
|
+
return { findings, referenced, mentionedInTasks };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Does any task reference `id` in its artifacts / acceptance text? */
|
|
64
|
+
function mentions(task, id) {
|
|
65
|
+
const hay = JSON.stringify([task?.artifacts ?? [], task?.acceptance ?? [], task?.title ?? '']);
|
|
66
|
+
return hay.includes(id);
|
|
30
67
|
}
|
|
31
68
|
|
|
32
69
|
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
@@ -85,10 +85,10 @@ function bfsDistances(start, adjacency) {
|
|
|
85
85
|
* @param {string} input
|
|
86
86
|
* @returns {{root: string, slug: string, tasksFile: string}}
|
|
87
87
|
*/
|
|
88
|
-
export function resolvePlanScope(input) {
|
|
88
|
+
export function resolvePlanScope(input, projectRoot = null) {
|
|
89
89
|
let scoped;
|
|
90
90
|
try {
|
|
91
|
-
scoped = resolveSlugInput(input);
|
|
91
|
+
scoped = resolveSlugInput(input, projectRoot);
|
|
92
92
|
} catch (e) {
|
|
93
93
|
if (e instanceof ScopeError) throw invalidInput(`plan-graph: ${e.message}`, { input });
|
|
94
94
|
throw invalidInput(`cannot read ${input} (missing or unreadable)`, { input });
|
|
@@ -101,7 +101,7 @@ export function resolvePlanScope(input) {
|
|
|
101
101
|
* @param {string} input slug dir or tasks.yaml path
|
|
102
102
|
*/
|
|
103
103
|
function loadPlan(input) {
|
|
104
|
-
const { root, slug, tasksFile } = resolvePlanScope(input);
|
|
104
|
+
const { root, slug, tasksFile } = resolvePlanScope(input, opts.projectRoot ?? null);
|
|
105
105
|
|
|
106
106
|
let raw;
|
|
107
107
|
try {
|
|
@@ -67,12 +67,19 @@ export function detectHarness(registry, opts) {
|
|
|
67
67
|
return { detected: found, primary: found[0], capabilities: platformCapabilities(registry, found[0].id) };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* The rank of a harness in the detection order (unknown → last).
|
|
72
|
+
*
|
|
73
|
+
* The match is EXACT. A prefix match ranked `code` against `codex` — two
|
|
74
|
+
* different harnesses — and whichever came first in the registry won, so
|
|
75
|
+
* the detected platform depended on the registry's ordering rather than on
|
|
76
|
+
* what is actually installed.
|
|
77
|
+
*/
|
|
71
78
|
function rank(id, order) {
|
|
72
79
|
const keys = Object.keys(order);
|
|
73
80
|
const i = keys.findIndex((k) => {
|
|
74
81
|
const clean = k.replace(/^\./, '').replace(/\/$/, '').replace(/\.json$/, '');
|
|
75
|
-
return clean === id
|
|
82
|
+
return clean === id;
|
|
76
83
|
});
|
|
77
84
|
return i === -1 ? keys.length : i;
|
|
78
85
|
}
|
|
@@ -21,6 +21,19 @@ import { writeFileAtomic } from './fs-safety.mjs';
|
|
|
21
21
|
* @param {any[]} waves resolved waves
|
|
22
22
|
* @returns {string}
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* The wave execution resumes at, 1-based.
|
|
26
|
+
*
|
|
27
|
+
* When every wave is done there is no NEXT wave, so the resume point is the
|
|
28
|
+
* LAST wave — not one past the end of the list. The file and the tool
|
|
29
|
+
* response used two different formulas, so the snapshot could say "wave 3 of
|
|
30
|
+
* 2" while the response said wave 2.
|
|
31
|
+
*/
|
|
32
|
+
export function resumeWaveIndex(waves, statusOf) {
|
|
33
|
+
const active = waves.findIndex((w) => w.some((t) => statusOf(t) !== 'done'));
|
|
34
|
+
return active === -1 ? waves.length : active + 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
export function renderResume(slug, tasks, waves) {
|
|
25
38
|
const byId = new Map(tasks.map((t) => [t?.id, t]));
|
|
26
39
|
const statusOf = (t) => String(t?.status ?? 'pending');
|
|
@@ -32,14 +45,13 @@ export function renderResume(slug, tasks, waves) {
|
|
|
32
45
|
|
|
33
46
|
// The first wave that still has work in it is where execution resumes.
|
|
34
47
|
const activeWaveIndex = waves.findIndex((w) => w.some((t) => statusOf(t) !== 'done'));
|
|
35
|
-
const waveIndex = activeWaveIndex === -1 ? waves.length : activeWaveIndex;
|
|
36
48
|
const remaining = waves[activeWaveIndex] ?? [];
|
|
37
49
|
|
|
38
50
|
const lines = [];
|
|
39
51
|
lines.push(`# Resume — ${slug}`);
|
|
40
52
|
lines.push('');
|
|
41
53
|
lines.push(`- slug: ${slug}`);
|
|
42
|
-
lines.push(`- wave: ${
|
|
54
|
+
lines.push(`- wave: ${resumeWaveIndex(waves, statusOf)} of ${waves.length}`);
|
|
43
55
|
lines.push(`- done: ${done.length ? done.join(', ') : 'none'}`);
|
|
44
56
|
lines.push(`- failed: ${failed.length ? failed.join(', ') : 'none'}`);
|
|
45
57
|
lines.push(`- blocked: ${blocked.length ? blocked.join(', ') : 'none'}`);
|
|
@@ -91,13 +103,13 @@ export function writeResumeSnapshot(slugDir, waves) {
|
|
|
91
103
|
const body = renderResume(slug, tasks, waves);
|
|
92
104
|
const out = join(slugDir, '.agent', 'resume.md');
|
|
93
105
|
mkdirSync(dirname(out), { recursive: true });
|
|
94
|
-
writeFileAtomic(out, body);
|
|
106
|
+
writeFileAtomic(out, body, { root: slugDir });
|
|
95
107
|
|
|
96
108
|
const statusOf = (t) => String(t?.status ?? 'pending');
|
|
97
109
|
const activeWaveIndex = waves.findIndex((w) => w.some((t) => statusOf(t) !== 'done'));
|
|
98
110
|
return {
|
|
99
111
|
path: out,
|
|
100
|
-
waveIndex:
|
|
112
|
+
waveIndex: resumeWaveIndex(waves, statusOf),
|
|
101
113
|
done: tasks.filter((t) => statusOf(t) === 'done').map((t) => t.id),
|
|
102
114
|
failed: tasks.filter((t) => statusOf(t) === 'failed').map((t) => t.id),
|
|
103
115
|
blocked: tasks.filter((t) => statusOf(t) === 'blocked').map((t) => t.id),
|
package/src/core/scope.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// - Everything else throws ScopeError. Missing/unreadable inputs surface as
|
|
20
20
|
// the underlying realpath/stat error so callers keep their own messages.
|
|
21
21
|
import { realpathSync, statSync } from 'node:fs';
|
|
22
|
-
import { basename, dirname, sep } from 'node:path';
|
|
22
|
+
import { basename, dirname, resolve, sep } from 'node:path';
|
|
23
23
|
|
|
24
24
|
export class ScopeError extends Error {
|
|
25
25
|
constructor(input) {
|
|
@@ -34,7 +34,19 @@ function segmentsOf(abs) {
|
|
|
34
34
|
return process.platform === 'win32' ? abs.split(/[\\/]+/).filter(Boolean) : abs.split(sep).filter(Boolean);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a path to the artifact space it names.
|
|
39
|
+
*
|
|
40
|
+
* A path qualifies when it is a `.archgen/<slug>/` directory (or the
|
|
41
|
+
* `tasks.yaml` inside one). When `root` is given, the artifact space must
|
|
42
|
+
* also be INSIDE that root — otherwise the gate would accept
|
|
43
|
+
* `/somewhere/else/.archgen/demo`, which is not this project's artifacts.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} rawInput
|
|
46
|
+
* @param {string|null} [root] the project root the artifact space must be inside
|
|
47
|
+
* @returns {{kind: 'dir'|'tasks', root: string, slug: string}}
|
|
48
|
+
*/
|
|
49
|
+
export function resolveSlugInput(rawInput, root = null) {
|
|
38
50
|
let real;
|
|
39
51
|
try {
|
|
40
52
|
real = realpathSync(rawInput); // follows symlinks: judge the TARGET, not the link
|
|
@@ -53,13 +65,35 @@ export function resolveSlugInput(rawInput) {
|
|
|
53
65
|
const depthFromArchgen = parts.length - 1 - i; // segments after this `.archgen`
|
|
54
66
|
if (st.isDirectory() && depthFromArchgen === 1) {
|
|
55
67
|
// (a) `<...>/.archgen/<slug>` — the slug dir itself
|
|
56
|
-
|
|
68
|
+
const slugRoot = real;
|
|
69
|
+
if (root && !isWithin(slugRoot, root)) throw new ScopeError(rawInput);
|
|
70
|
+
return { kind: 'dir', root: slugRoot, slug: parts[i + 1] };
|
|
57
71
|
}
|
|
58
72
|
if (st.isFile() && basename(real) === 'tasks.yaml' && depthFromArchgen === 2) {
|
|
59
73
|
// (b) `<...>/.archgen/<slug>/tasks.yaml` — direct tasks.yaml mode
|
|
60
|
-
const
|
|
61
|
-
|
|
74
|
+
const slugRoot = dirname(real);
|
|
75
|
+
if (root && !isWithin(slugRoot, root)) throw new ScopeError(rawInput);
|
|
76
|
+
return { kind: 'tasks', root: slugRoot, slug: basename(slugRoot) };
|
|
62
77
|
}
|
|
63
78
|
}
|
|
64
79
|
throw new ScopeError(rawInput);
|
|
65
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when `abs` is inside `root`.
|
|
84
|
+
*
|
|
85
|
+
* The REAL comparison is authoritative when both paths exist: it is what
|
|
86
|
+
* catches a symlinked parent pointing out of the tree. The lexical
|
|
87
|
+
* comparison only stands in when the target does not exist yet.
|
|
88
|
+
*/
|
|
89
|
+
function isWithin(abs, root) {
|
|
90
|
+
const resolvedRoot = resolve(root);
|
|
91
|
+
const resolved = resolve(abs);
|
|
92
|
+
try {
|
|
93
|
+
const realRoot = realpathSync(resolvedRoot);
|
|
94
|
+
const real = realpathSync(resolved);
|
|
95
|
+
return real === realRoot || real.startsWith(realRoot + sep);
|
|
96
|
+
} catch {
|
|
97
|
+
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
18
|
import { parseYaml, stringifyYaml } from './yaml.mjs';
|
|
19
19
|
import { conflict, invalidInput } from './errors.mjs';
|
|
20
|
-
import { writeFileAtomic } from './fs-safety.mjs';
|
|
20
|
+
import { writeFileAtomic, withFileLock } from './fs-safety.mjs';
|
|
21
21
|
|
|
22
22
|
export const STATUSES = ['pending', 'ready', 'running', 'blocked', 'done', 'failed'];
|
|
23
23
|
|
|
@@ -32,6 +32,23 @@ export function detectEol(raw) {
|
|
|
32
32
|
return crlfCount > loneLfCount ? '\r\n' : '\n';
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Do two task ids name the same task?
|
|
37
|
+
*
|
|
38
|
+
* Textually equal ids match. A NUMERIC id also matches by value, because
|
|
39
|
+
* YAML resolves `id: 007` to the number 7 — the agent holds the text it
|
|
40
|
+
* read and the parser holds the value. A QUOTED id is text and matches
|
|
41
|
+
* exactly: `id: "7"` and "07" are different tasks.
|
|
42
|
+
*/
|
|
43
|
+
export function sameId(a, b) {
|
|
44
|
+
const sa = String(a ?? '');
|
|
45
|
+
const sb = String(b ?? '');
|
|
46
|
+
if (sa === sb) return true;
|
|
47
|
+
if (typeof a !== 'number') return false;
|
|
48
|
+
const nb = Number(sb);
|
|
49
|
+
return sb.trim() !== '' && Number.isFinite(nb) && a === nb;
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
/**
|
|
36
53
|
* Set a task's status atomically.
|
|
37
54
|
*
|
|
@@ -48,6 +65,15 @@ export function setTaskStatus(file, taskId, nextStatus, opts = {}) {
|
|
|
48
65
|
throw invalidInput(`invalid status '${nextStatus}' (allowed: ${STATUSES.join('|')})`, { status: nextStatus });
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
// The whole read-modify-write runs under an exclusive lock. Atomic writes
|
|
69
|
+
// keep a reader from seeing a half-written file, but two writers still
|
|
70
|
+
// lose each other's changes without this: parallel workers in one wave
|
|
71
|
+
// each read the same bytes and each write its own status over the other's.
|
|
72
|
+
return withFileLock(file, () => mutateStatus(file, taskId, nextStatus, force));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The read-modify-write itself. Runs with the file's lock held. */
|
|
76
|
+
function mutateStatus(file, taskId, nextStatus, force) {
|
|
51
77
|
let raw;
|
|
52
78
|
try {
|
|
53
79
|
raw = readFileSync(file, 'utf8');
|
|
@@ -71,7 +97,11 @@ export function setTaskStatus(file, taskId, nextStatus, opts = {}) {
|
|
|
71
97
|
const tasks = data?.tasks;
|
|
72
98
|
if (!Array.isArray(tasks)) throw invalidInput(`${file}: missing 'tasks:' sequence`, { file });
|
|
73
99
|
|
|
74
|
-
|
|
100
|
+
// Ids are matched as TEXT, and — when both sides are numeric — as NUMBERS.
|
|
101
|
+
// YAML reads `id: 007` as the number 7, so the id the agent holds ("007")
|
|
102
|
+
// and the id the file holds (7) are different strings for the same task.
|
|
103
|
+
// A strict comparison made such a task permanently unaddressable.
|
|
104
|
+
const task = tasks.find((t) => t && sameId(t.id, taskId));
|
|
75
105
|
if (!task) throw invalidInput(`task '${taskId}' not found in ${file}`, { file, taskId });
|
|
76
106
|
|
|
77
107
|
const prev = task.status ?? 'pending';
|
|
@@ -82,9 +112,10 @@ export function setTaskStatus(file, taskId, nextStatus, opts = {}) {
|
|
|
82
112
|
);
|
|
83
113
|
}
|
|
84
114
|
|
|
115
|
+
const changed = prev !== nextStatus;
|
|
85
116
|
task.status = nextStatus;
|
|
86
117
|
const content = stringifyYaml(data, comments, { eol });
|
|
87
118
|
writeFileAtomic(file, content);
|
|
88
119
|
|
|
89
|
-
return { taskId, from: prev, to: nextStatus, changed
|
|
120
|
+
return { taskId, from: prev, to: nextStatus, changed, file };
|
|
90
121
|
}
|