archgen-mcp 0.1.0 → 0.1.2
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 -0
- package/assets/briefs/survey.md +1 -1
- package/assets/briefs/verifier.md +2 -1
- package/assets/harness-mcp.json +10 -1
- package/assets/platform-capabilities.json +11 -0
- package/assets/references/artifact-templates.md +22 -0
- package/assets/references/mcp-and-skills.md +3 -1
- package/assets/references/platforms.md +9 -1
- package/assets/templates/architecture.yaml +9 -16
- package/assets/templates/tasks.yaml +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/core/codegraph-manager.mjs +2 -2
- package/src/core/doc-navigator.mjs +29 -6
- package/src/core/docs.mjs +24 -2
- package/src/core/fs-safety.mjs +64 -45
- package/src/core/guidance.mjs +142 -0
- package/src/core/harness-wiring.mjs +93 -9
- package/src/core/mcp-server-manager.mjs +4 -2
- package/src/core/plan-grapher.mjs +2 -1
- package/src/core/refs.mjs +91 -21
- package/src/core/scaffold.mjs +6 -0
- package/src/core/worker-contract.mjs +3 -2
- package/src/envelope.mjs +98 -1
- package/src/tools/codegraph.mjs +20 -1
- package/src/tools/docs.mjs +65 -12
- package/src/tools/help.mjs +22 -5
- package/src/tools/plan.mjs +68 -3
- package/src/tools/refs.mjs +78 -7
- package/src/tools/registry.mjs +15 -1
- package/src/tools/setup.mjs +55 -18
- package/src/tools/skills.mjs +148 -40
- package/src/tools/tasks.mjs +68 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// guidance.mjs — every failure teaches the agent what to do NEXT, in ONE line.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS.
|
|
4
|
+
//
|
|
5
|
+
// An error that only says WHAT FAILED leaves the agent to guess, and a guess
|
|
6
|
+
// that fails the same way is a loop. An agent that called
|
|
7
|
+
// `archgen_refs{action:'read', section:'plan-depth'}` and got
|
|
8
|
+
// "no section 'plan-depth'" had no way to know the slug it was told to use
|
|
9
|
+
// was a shorthand for a longer one — so it burned a round-trip rediscovering
|
|
10
|
+
// the vocabulary, and did it again for the next section.
|
|
11
|
+
//
|
|
12
|
+
// So every archgen failure is ONE line that carries the cause, the fix, and
|
|
13
|
+
// the exact next call:
|
|
14
|
+
//
|
|
15
|
+
// <cause> — <the fix in a clause>. Next: <the call, ready to paste>
|
|
16
|
+
//
|
|
17
|
+
// The vocabulary is SHARED, so every tool teaches the same way. And where a
|
|
18
|
+
// failure can be prevented rather than explained, it is: the section
|
|
19
|
+
// resolver accepts the shorthand the skill teaches, so the question never
|
|
20
|
+
// comes up.
|
|
21
|
+
//
|
|
22
|
+
// `hint()` adds machine-readable next-step fields to the error DETAILS, so a
|
|
23
|
+
// client can act on them without parsing prose.
|
|
24
|
+
import { invalidInput, notFound } from './errors.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compose a ONE-LINE teaching message.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} cause what went wrong, in a few words
|
|
30
|
+
* @param {string} fix the fix, in one clause
|
|
31
|
+
* @param {string} action the exact next call, ready to paste
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function teach(cause, fix, action) {
|
|
35
|
+
return `${cause} — ${fix}. Next: ${action}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Machine-readable next-step fields, attached to an error's details.
|
|
40
|
+
*
|
|
41
|
+
* @param {{action?: string, reason?: string, alternatives?: string[], available?: unknown[]}} spec
|
|
42
|
+
* @returns {Record<string, unknown>}
|
|
43
|
+
*/
|
|
44
|
+
export function hint(spec) {
|
|
45
|
+
const out = {};
|
|
46
|
+
if (spec.action) out.nextAction = spec.action;
|
|
47
|
+
if (spec.reason) out.reason = spec.reason;
|
|
48
|
+
if (spec.alternatives?.length) out.alternatives = spec.alternatives;
|
|
49
|
+
if (spec.available?.length) out.available = spec.available;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* "The artifact space does not exist yet."
|
|
55
|
+
*
|
|
56
|
+
* `archgen_plan{action:'scaffold'}` is a GENERATOR: it returns the CONTENT
|
|
57
|
+
* of the files to create, and writes nothing. An agent that assumes otherwise
|
|
58
|
+
* gets "missing or not a directory" from every artifact tool, and the
|
|
59
|
+
* natural reading — "the tool is broken" — sends it hunting for a flag that
|
|
60
|
+
* does not exist.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} slug the slug, when known
|
|
63
|
+
* @param {string} slugDir the path that was looked for
|
|
64
|
+
*/
|
|
65
|
+
export function artifactSpaceMissing(slug, slugDir) {
|
|
66
|
+
return invalidInput(
|
|
67
|
+
teach(
|
|
68
|
+
`no artifact space at ${slugDir}`,
|
|
69
|
+
'scaffold returns file CONTENT and writes nothing, so write it first',
|
|
70
|
+
"archgen_plan{action:'scaffold',scope:'LARGE',slug:'<slug>'}",
|
|
71
|
+
),
|
|
72
|
+
hint({ action: "archgen_plan{action:'scaffold',scope:'LARGE',slug:'<slug>'}", reason: 'scaffold returns content; you write it.' }),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* "That section does not exist."
|
|
78
|
+
*
|
|
79
|
+
* @param {string} topic the reference topic
|
|
80
|
+
* @param {string} section the section that was asked for
|
|
81
|
+
* @param {string[]} available the valid slugs
|
|
82
|
+
*/
|
|
83
|
+
export function noSuchSection(topic, section, available) {
|
|
84
|
+
return notFound(
|
|
85
|
+
teach(
|
|
86
|
+
`no section '${section}' in '${topic}'`,
|
|
87
|
+
'a prefix of the slug works too, if unambiguous',
|
|
88
|
+
`archgen_refs{action:'index',topic:'${topic}'}`,
|
|
89
|
+
),
|
|
90
|
+
hint({ action: `archgen_refs{action:'index',topic:'${topic}'}`, reason: 'the index lists every exact slug.', available }),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* "That skill is not installed."
|
|
96
|
+
*
|
|
97
|
+
* @param {string} slug the skill slug
|
|
98
|
+
*/
|
|
99
|
+
export function skillNotInstalled(slug) {
|
|
100
|
+
return invalidInput(
|
|
101
|
+
teach(
|
|
102
|
+
`the capability skill '${slug}' is not installed`,
|
|
103
|
+
'install it before dispatching the worker',
|
|
104
|
+
`archgen_skills{action:'install',skillId:'${slug}',approve:true}`,
|
|
105
|
+
),
|
|
106
|
+
hint({ action: `archgen_skills{action:'install',skillId:'${slug}',approve:true}`, reason: 'a required skill must be on disk before dispatch.' }),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* "That task id is not in the plan."
|
|
112
|
+
*
|
|
113
|
+
* @param {string} taskId the id that was asked for
|
|
114
|
+
* @param {string[]} available the task ids that exist
|
|
115
|
+
*/
|
|
116
|
+
export function noSuchTask(taskId, available) {
|
|
117
|
+
return notFound(
|
|
118
|
+
teach(
|
|
119
|
+
`no task '${taskId}' in the plan`,
|
|
120
|
+
'task ids are case-sensitive',
|
|
121
|
+
"archgen_tasks{action:'list',tasksYaml:'<tasks.yaml>'}",
|
|
122
|
+
),
|
|
123
|
+
hint({ action: "archgen_tasks{action:'list',tasksYaml:'<tasks.yaml>'}", reason: 'the list carries every task id.', available }),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* "That path is outside the project."
|
|
129
|
+
*
|
|
130
|
+
* @param {string} path the refused path
|
|
131
|
+
* @param {string} root the anchor the server is bound to
|
|
132
|
+
*/
|
|
133
|
+
export function outsideProject(path, root) {
|
|
134
|
+
return invalidInput(
|
|
135
|
+
teach(
|
|
136
|
+
`the path is outside the project: ${path}`,
|
|
137
|
+
'the server is anchored to one root and cannot read or write outside it',
|
|
138
|
+
`use a path under ${root}`,
|
|
139
|
+
),
|
|
140
|
+
hint({ action: `use a path under ${root}`, reason: 'the anchor is the boundary.' }),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -24,23 +24,103 @@ import { assertNotSymlink, assertWithinRoot, withFileLock, writeFileAtomic } fro
|
|
|
24
24
|
import { mergeEntry, parseJsonc, removeEntry } from './jsonc-config.mjs';
|
|
25
25
|
import { invalidInput } from './errors.mjs';
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The Copilot home: `$COPILOT_HOME`, or `~/.copilot`.
|
|
29
|
+
*
|
|
30
|
+
* VS Code reads the Copilot CLI's MCP configuration from here, and the
|
|
31
|
+
* documentation is explicit that `COPILOT_HOME` REPLACES the default:
|
|
32
|
+
* "VS Code does not check both locations."
|
|
33
|
+
*
|
|
34
|
+
* So a user who sets it has their configuration in ONE place and the other
|
|
35
|
+
* is ignored. Writing to the ignored one is exactly the "the agent cannot
|
|
36
|
+
* see the MCP" failure — which is why the config location and the detection
|
|
37
|
+
* must both follow this same home.
|
|
38
|
+
*
|
|
39
|
+
* @param {Record<string, string|undefined>} [env]
|
|
40
|
+
* @param {string} [home]
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function copilotHome(env = process.env, home = homedir()) {
|
|
44
|
+
const fromEnv = env.COPILOT_HOME;
|
|
45
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv);
|
|
46
|
+
return join(home, '.copilot');
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
/**
|
|
28
50
|
* Detect which harnesses are present in this environment.
|
|
29
51
|
* @param {any} registry the harness-mcp.json content
|
|
30
52
|
* @param {string} projectRoot
|
|
31
53
|
* @param {string} [home]
|
|
54
|
+
* @param {Record<string, string|undefined>} [env]
|
|
32
55
|
* @returns {Array<{id: string, label: string, atHome: boolean, atProject: boolean, spec: any}>}
|
|
33
56
|
*/
|
|
34
|
-
export function detectHarnesses(registry, projectRoot, home = homedir()) {
|
|
57
|
+
export function detectHarnesses(registry, projectRoot, home = homedir(), env = process.env) {
|
|
35
58
|
const found = [];
|
|
36
59
|
for (const [id, h] of Object.entries(registry.harnesses ?? {})) {
|
|
37
|
-
const atHome =
|
|
60
|
+
const atHome =
|
|
61
|
+
(h.detectHome ?? []).some((m) => existsSync(join(home, m))) ||
|
|
62
|
+
// A harness whose configuration lives in a NAMED home (the Copilot
|
|
63
|
+
// home) is present when that home exists. `COPILOT_HOME` replaces the
|
|
64
|
+
// default, and the client checks only one of them — so detecting the
|
|
65
|
+
// default alone would miss a user who set the variable, and wiring the
|
|
66
|
+
// default alone would write a file the client never reads.
|
|
67
|
+
namedHomeExists(h, env, home);
|
|
38
68
|
const atProject = (h.detectProject ?? []).some((m) => existsSync(join(projectRoot, m)));
|
|
39
69
|
if (atHome || atProject) found.push({ id, label: h.label, atHome, atProject, spec: h });
|
|
40
70
|
}
|
|
41
71
|
return found;
|
|
42
72
|
}
|
|
43
73
|
|
|
74
|
+
/** True when the home a harness's configuration lives in exists. */
|
|
75
|
+
function namedHomeExists(harness, env, home) {
|
|
76
|
+
for (const cfg of Object.values(harness.configs ?? {})) {
|
|
77
|
+
if (cfg.home === 'copilot' && existsSync(copilotHome(env, home))) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where a harness's configuration file lives — and the root it must stay
|
|
84
|
+
* inside.
|
|
85
|
+
*
|
|
86
|
+
* THREE BASES:
|
|
87
|
+
* - `home: "copilot"` — the Copilot home (`$COPILOT_HOME`, or
|
|
88
|
+
* `~/.copilot`), with the path relative to it.
|
|
89
|
+
* - a path starting with `~/` — relative to the home directory.
|
|
90
|
+
* - anything else — relative to the project root.
|
|
91
|
+
*
|
|
92
|
+
* The path and its containment root come from ONE function on purpose: when
|
|
93
|
+
* they were computed separately, the root said "the home directory" while
|
|
94
|
+
* the path said "$COPILOT_HOME" — and the write was then refused by the very
|
|
95
|
+
* guard meant to protect it.
|
|
96
|
+
*
|
|
97
|
+
* @param {any} cfg the registry's config entry
|
|
98
|
+
* @param {{projectRoot: string, home: string, env?: Record<string, string|undefined>}} ctx
|
|
99
|
+
* @returns {{path: string, root: string}} the absolute path and its root
|
|
100
|
+
*/
|
|
101
|
+
export function configTargetFor(cfg, ctx) {
|
|
102
|
+
const env = ctx.env ?? process.env;
|
|
103
|
+
if (cfg.home === 'copilot') {
|
|
104
|
+
const base = copilotHome(env, ctx.home);
|
|
105
|
+
return { path: join(base, cfg.path), root: base };
|
|
106
|
+
}
|
|
107
|
+
if (cfg.path.startsWith('~/')) {
|
|
108
|
+
return { path: expandHome(cfg.path, ctx.home), root: ctx.home };
|
|
109
|
+
}
|
|
110
|
+
return { path: join(ctx.projectRoot, cfg.path), root: ctx.projectRoot };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Where a harness's configuration file lives.
|
|
115
|
+
*
|
|
116
|
+
* @param {any} cfg the registry's config entry
|
|
117
|
+
* @param {{projectRoot: string, home: string, env?: Record<string, string|undefined>}} ctx
|
|
118
|
+
* @returns {string} the absolute path
|
|
119
|
+
*/
|
|
120
|
+
export function configPathFor(cfg, ctx) {
|
|
121
|
+
return configTargetFor(cfg, ctx).path;
|
|
122
|
+
}
|
|
123
|
+
|
|
44
124
|
/** Expand a leading `~/` to the home directory. */
|
|
45
125
|
export function expandHome(p, home = homedir()) {
|
|
46
126
|
return p.startsWith('~/') ? join(home, p.slice(2)) : p;
|
|
@@ -361,9 +441,10 @@ export function wireServer(opts) {
|
|
|
361
441
|
fallbackCommand = serverId,
|
|
362
442
|
skipHarnesses = [],
|
|
363
443
|
targets = null,
|
|
444
|
+
env = process.env,
|
|
364
445
|
} = opts;
|
|
365
446
|
|
|
366
|
-
const detected = detectHarnesses(registry, projectRoot, home);
|
|
447
|
+
const detected = detectHarnesses(registry, projectRoot, home, env);
|
|
367
448
|
// A caller that narrowed the set of harnesses must get exactly that set.
|
|
368
449
|
// The native path honoured the filter and this one did not, so a request
|
|
369
450
|
// for two harnesses wired ten.
|
|
@@ -383,9 +464,10 @@ export function wireServer(opts) {
|
|
|
383
464
|
if (!cfg) continue;
|
|
384
465
|
if (loc === 'global' && !d.atHome) continue;
|
|
385
466
|
if (loc === 'project' && !d.atProject) continue;
|
|
386
|
-
const
|
|
467
|
+
const target = configTargetFor(cfg, { projectRoot, home, env });
|
|
468
|
+
const absPath = target.path;
|
|
387
469
|
const entry = buildEntry(entries, npxEntries, cfg.entry, binPath, fallbackCommand);
|
|
388
|
-
const root =
|
|
470
|
+
const root = target.root;
|
|
389
471
|
const res =
|
|
390
472
|
cfg.format === 'toml'
|
|
391
473
|
? writeTomlConfig(absPath, serverId, entry, { approved, dryRun, root })
|
|
@@ -438,7 +520,7 @@ export function recordWiring(projectRoot, serverId, entries) {
|
|
|
438
520
|
* @param {'project'|'global'|'both'} [location] which scope to remove
|
|
439
521
|
* @returns {{removed: string[], skipped: string[]}}
|
|
440
522
|
*/
|
|
441
|
-
export function removeWiring(projectRoot, serverId, home = homedir(), location = 'both') {
|
|
523
|
+
export function removeWiring(projectRoot, serverId, home = homedir(), location = 'both', env = process.env) {
|
|
442
524
|
const p = join(projectRoot, '.archgen', '.mcp-wiring.json');
|
|
443
525
|
let manifest = null;
|
|
444
526
|
try {
|
|
@@ -463,9 +545,11 @@ export function removeWiring(projectRoot, serverId, home = homedir(), location =
|
|
|
463
545
|
// The manifest is a file ON DISK in the project — repository content.
|
|
464
546
|
// Its paths are therefore untrusted: a clone could ship a manifest
|
|
465
547
|
// naming any file on the machine. A recorded path must resolve inside
|
|
466
|
-
// the project root
|
|
467
|
-
|
|
468
|
-
|
|
548
|
+
// the project root, the user's home, or the Copilot home — those are
|
|
549
|
+
// the three places a harness's own configuration may live.
|
|
550
|
+
const allowedRoots = [projectRoot, home, copilotHome(env, home)];
|
|
551
|
+
if (!allowedRoots.some((r) => pathIsWithin(abs, r))) {
|
|
552
|
+
skipped.push(`${abs} (outside the project, home and Copilot directories)`);
|
|
469
553
|
continue;
|
|
470
554
|
}
|
|
471
555
|
if (!existsSync(abs)) {
|
|
@@ -112,9 +112,11 @@ export function wireArchgenMcp(opts) {
|
|
|
112
112
|
* Remove the archgen MCP entries this server wrote.
|
|
113
113
|
* @param {string} projectRoot
|
|
114
114
|
* @param {string} [home]
|
|
115
|
+
* @param {'project'|'global'|'both'} [location]
|
|
116
|
+
* @param {Record<string, string|undefined>} [env]
|
|
115
117
|
*/
|
|
116
|
-
export function uninstallArchgenMcp(projectRoot, home = homedir(), location = 'both') {
|
|
117
|
-
return removeWiring(projectRoot, 'archgen', home, location);
|
|
118
|
+
export function uninstallArchgenMcp(projectRoot, home = homedir(), location = 'both', env = process.env) {
|
|
119
|
+
return removeWiring(projectRoot, 'archgen', home, location, env);
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
/**
|
|
@@ -19,6 +19,7 @@ import { parseYaml } from './yaml.mjs';
|
|
|
19
19
|
import { buildGraph, findCycle, computeWaves, dedupeDependencies, computeQualityStats, GraphError } from './graph.mjs';
|
|
20
20
|
import { resolveSlugInput, ScopeError } from './scope.mjs';
|
|
21
21
|
import { cycle, invalidInput, notFound } from './errors.mjs';
|
|
22
|
+
import { noSuchTask } from './guidance.mjs';
|
|
22
23
|
|
|
23
24
|
// Whole-graph Mermaid above this node count is refused.
|
|
24
25
|
export const MERMAID_WHOLE_MAX_NODES = 250;
|
|
@@ -238,7 +239,7 @@ export function planGraph(input, opts = {}) {
|
|
|
238
239
|
|
|
239
240
|
if (node !== null) {
|
|
240
241
|
const t = byId.get(node);
|
|
241
|
-
if (!t) throw
|
|
242
|
+
if (!t) throw noSuchTask(node, uniqueTasks.map((x) => x.id));
|
|
242
243
|
if (mermaid) {
|
|
243
244
|
const up = [...bfsDistances(t.id, prerequisites)].filter(([id]) => id !== t.id).map(([id]) => id).sort();
|
|
244
245
|
const down = [...bfsDistances(t.id, dependents)].filter(([id]) => id !== t.id).map(([id]) => id).sort();
|
package/src/core/refs.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { parseMarkdown, buildSections, findSection } from './docs.mjs';
|
|
25
25
|
import { notFound } from './errors.mjs';
|
|
26
|
+
import { noSuchSection } from './guidance.mjs';
|
|
26
27
|
|
|
27
28
|
/** The reference documents this server serves. */
|
|
28
29
|
export const REF_TOPICS = [
|
|
@@ -58,29 +59,61 @@ export function resolveTopic(topic, opts) {
|
|
|
58
59
|
/**
|
|
59
60
|
* Index one reference topic: its title, purpose and section list.
|
|
60
61
|
*
|
|
62
|
+
* With `section`, it returns THAT section's entry — the same map, narrowed.
|
|
63
|
+
* A topic has a dozen sections; an agent that wants one of them should not
|
|
64
|
+
* have to take the dozen to find it.
|
|
65
|
+
*
|
|
61
66
|
* @param {string} topic
|
|
62
|
-
* @param {{assetsDir: string, skillDir: string|null}} opts
|
|
67
|
+
* @param {{assetsDir: string, skillDir: string|null, section?: string|null}} opts
|
|
63
68
|
*/
|
|
64
69
|
export function indexTopic(topic, opts) {
|
|
65
70
|
const resolved = resolveTopic(topic, opts);
|
|
66
71
|
if (!resolved) throw notFound(`unknown reference '${topic}'`, { topic, known: REF_TOPICS });
|
|
67
72
|
const doc = parseMarkdown(readFileSync(resolved.path, 'utf8'));
|
|
68
|
-
|
|
69
|
-
topic
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
const sections = buildSections(doc)
|
|
74
|
+
// The topic's own H1 is not a SECTION — it is the topic. Listing it
|
|
75
|
+
// would put "the whole document" at index 0 and make every section map
|
|
76
|
+
// carry an entry that says nothing.
|
|
77
|
+
.filter((s) => s.level > 1)
|
|
78
|
+
.map((s) => ({
|
|
73
79
|
slug: s.slug,
|
|
74
80
|
title: s.title,
|
|
75
81
|
level: s.level,
|
|
76
82
|
line: s.line,
|
|
77
83
|
endLine: s.endLine,
|
|
78
|
-
}))
|
|
84
|
+
}));
|
|
85
|
+
if (opts.section) {
|
|
86
|
+
const hit = findSection(doc, opts.section);
|
|
87
|
+
if (!hit) throw noSuchSection(resolved.topic, opts.section, sections.map((s) => s.slug));
|
|
88
|
+
const one = sections.find((s) => s.slug === hit.slug);
|
|
89
|
+
return {
|
|
90
|
+
topic: resolved.topic,
|
|
91
|
+
root: resolved.root,
|
|
92
|
+
title: doc.headings.find((h) => h.level === 1)?.text ?? resolved.topic,
|
|
93
|
+
section: one,
|
|
94
|
+
sections: [one],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
topic: resolved.topic,
|
|
99
|
+
root: resolved.root,
|
|
100
|
+
title: doc.headings.find((h) => h.level === 1)?.text ?? resolved.topic,
|
|
101
|
+
sections,
|
|
79
102
|
};
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
/**
|
|
83
|
-
* Read one section (or the
|
|
106
|
+
* Read one section (or the topic's section INDEX).
|
|
107
|
+
*
|
|
108
|
+
* WHY THE DEFAULT IS THE INDEX: a whole topic is ~11 KB of English, and the
|
|
109
|
+
* whole design of this module is that the agent reads a SECTION. Returning
|
|
110
|
+
* the entire topic when the caller named no section made the expensive
|
|
111
|
+
* answer the default — so an agent that forgot the section paid for the
|
|
112
|
+
* whole protocol instead of getting the map that would have told it which
|
|
113
|
+
* section it needed.
|
|
114
|
+
*
|
|
115
|
+
* So: no section → the INDEX (the map) plus the exact next call. The whole
|
|
116
|
+
* topic is `section: '*'` — deliberate, never the default.
|
|
84
117
|
*
|
|
85
118
|
* @param {string} topic
|
|
86
119
|
* @param {{assetsDir: string, skillDir: string|null, section?: string|null}} opts
|
|
@@ -88,27 +121,36 @@ export function indexTopic(topic, opts) {
|
|
|
88
121
|
export function readTopic(topic, opts) {
|
|
89
122
|
const resolved = resolveTopic(topic, opts);
|
|
90
123
|
if (!resolved) throw notFound(`unknown reference '${topic}'`, { topic, known: REF_TOPICS });
|
|
91
|
-
const text = readFileSync(resolved.path, 'utf8');
|
|
92
|
-
const doc = parseMarkdown(text);
|
|
93
124
|
|
|
94
|
-
|
|
125
|
+
// The whole topic — asked for explicitly.
|
|
126
|
+
if (opts.section === '*') {
|
|
127
|
+
const doc = parseMarkdown(readFileSync(resolved.path, 'utf8'));
|
|
95
128
|
return {
|
|
96
129
|
topic: resolved.topic,
|
|
97
130
|
root: resolved.root,
|
|
98
131
|
section: null,
|
|
132
|
+
whole: true,
|
|
99
133
|
text: doc.lines.join('\n'),
|
|
100
134
|
lines: doc.lines.length,
|
|
101
135
|
};
|
|
102
136
|
}
|
|
103
137
|
|
|
138
|
+
// No section named: the MAP, not the text.
|
|
139
|
+
if (!opts.section) {
|
|
140
|
+
const indexed = indexTopic(topic, opts);
|
|
141
|
+
return {
|
|
142
|
+
...indexed,
|
|
143
|
+
whole: false,
|
|
144
|
+
next: `archgen_refs{action:'read',topic:'${resolved.topic}',section:'<the section you need>'}`,
|
|
145
|
+
hint: 'read ONE section — a whole topic is a map plus the text you did not ask for.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const doc = parseMarkdown(readFileSync(resolved.path, 'utf8'));
|
|
104
150
|
const section = findSection(doc, opts.section);
|
|
105
151
|
if (!section) {
|
|
106
152
|
const available = buildSections(doc).map((s) => s.slug);
|
|
107
|
-
throw
|
|
108
|
-
topic: resolved.topic,
|
|
109
|
-
section: opts.section,
|
|
110
|
-
available,
|
|
111
|
-
});
|
|
153
|
+
throw noSuchSection(resolved.topic, opts.section, available);
|
|
112
154
|
}
|
|
113
155
|
return {
|
|
114
156
|
topic: resolved.topic,
|
|
@@ -139,14 +181,34 @@ export function searchTopic(topic, token, opts) {
|
|
|
139
181
|
// Whole-word: a hit must not be glued to word characters on either side.
|
|
140
182
|
const re = new RegExp(`(?<![A-Za-z0-9_])${esc}(?![A-Za-z0-9_])`, 'i');
|
|
141
183
|
|
|
184
|
+
// The sections OVERLAP: a parent section's body runs to the next heading at
|
|
185
|
+
// the same or shallower level, so it CONTAINS its children. Reporting a hit
|
|
186
|
+
// once per containing section therefore delivered the same line three times
|
|
187
|
+
// for a three-level nesting — the agent paid for the same sentence three
|
|
188
|
+
// times to learn one fact.
|
|
189
|
+
//
|
|
190
|
+
// So every hit is reported ONCE, under the DEEPEST section that contains
|
|
191
|
+
// it: the most specific place the line actually lives.
|
|
192
|
+
const sections = buildSections(doc).filter((s) => s.level > 1);
|
|
193
|
+
const ownerOf = (line) => {
|
|
194
|
+
let owner = null;
|
|
195
|
+
for (const s of sections) {
|
|
196
|
+
if (line < s.line || line > s.endLine) continue;
|
|
197
|
+
if (owner === null || s.level > owner.level) owner = s;
|
|
198
|
+
}
|
|
199
|
+
return owner;
|
|
200
|
+
};
|
|
201
|
+
|
|
142
202
|
const hits = [];
|
|
143
|
-
for (const section of
|
|
203
|
+
for (const section of sections) {
|
|
144
204
|
const bodyLines = doc.lines.slice(section.line - 1, section.endLine);
|
|
145
205
|
const matches = [];
|
|
146
206
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
147
|
-
if (re.test(bodyLines[i]))
|
|
148
|
-
|
|
149
|
-
|
|
207
|
+
if (!re.test(bodyLines[i])) continue;
|
|
208
|
+
const line = section.line + i;
|
|
209
|
+
const owner = ownerOf(line);
|
|
210
|
+
if (!owner || owner.slug !== section.slug) continue; // its deepest owner reports it
|
|
211
|
+
matches.push({ line, text: bodyLines[i].trim().slice(0, 200) });
|
|
150
212
|
}
|
|
151
213
|
if (matches.length) {
|
|
152
214
|
hits.push({
|
|
@@ -160,7 +222,15 @@ export function searchTopic(topic, token, opts) {
|
|
|
160
222
|
});
|
|
161
223
|
}
|
|
162
224
|
}
|
|
163
|
-
return {
|
|
225
|
+
return {
|
|
226
|
+
topic: resolved.topic,
|
|
227
|
+
root: resolved.root,
|
|
228
|
+
token,
|
|
229
|
+
sections: hits,
|
|
230
|
+
sectionCount: hits.length,
|
|
231
|
+
// The distinct lines behind those hits — reported once, counted once.
|
|
232
|
+
matchCount: hits.reduce((n, h) => n + h.occurrences, 0),
|
|
233
|
+
};
|
|
164
234
|
}
|
|
165
235
|
|
|
166
236
|
/**
|
package/src/core/scaffold.mjs
CHANGED
|
@@ -81,6 +81,12 @@ export function buildScaffold(ladder, scopeClass, opts = {}) {
|
|
|
81
81
|
return {
|
|
82
82
|
scopeClass: key,
|
|
83
83
|
label: spec.label,
|
|
84
|
+
// THIS TOOL WRITES NOTHING. It returns the CONTENT of every file the
|
|
85
|
+
// scope class needs, and the caller writes them. An agent that assumes
|
|
86
|
+
// otherwise gets "missing or not a directory" from every artifact tool
|
|
87
|
+
// and reads it as a broken tool rather than an unwritten plan.
|
|
88
|
+
writes: false,
|
|
89
|
+
writeThese: artifacts.map((a) => a.path),
|
|
84
90
|
intentDefaults: spec.intentDefaults ?? [],
|
|
85
91
|
roundCap: spec.roundCap ?? null,
|
|
86
92
|
artifacts,
|
|
@@ -30,7 +30,8 @@ import { dirname, join } from 'node:path';
|
|
|
30
30
|
import { parseYaml } from './yaml.mjs';
|
|
31
31
|
import { buildGraph, GraphError } from './graph.mjs';
|
|
32
32
|
import { skillDirective } from './skill-manager.mjs';
|
|
33
|
-
import { invalidInput
|
|
33
|
+
import { invalidInput } from './errors.mjs';
|
|
34
|
+
import { noSuchTask } from './guidance.mjs';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Assemble the worker prompt for one task.
|
|
@@ -64,7 +65,7 @@ export function buildWorkerContract(opts) {
|
|
|
64
65
|
}
|
|
65
66
|
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
66
67
|
const task = tasks.find((t) => t?.id === taskId);
|
|
67
|
-
if (!task) throw
|
|
68
|
+
if (!task) throw noSuchTask(taskId, tasks.map((t) => t?.id).filter(Boolean));
|
|
68
69
|
|
|
69
70
|
// The dependency neighbourhood — what must exist BEFORE this task, and what
|
|
70
71
|
// waits on it. Derived from depends_on, never hand-labelled.
|
package/src/envelope.mjs
CHANGED
|
@@ -42,7 +42,12 @@ export function fail(e, meta = {}) {
|
|
|
42
42
|
export async function guard(body, ctx, args) {
|
|
43
43
|
try {
|
|
44
44
|
const data = await body(ctx, args);
|
|
45
|
-
|
|
45
|
+
// A tool that carries its own meta (the map to the rest of the data)
|
|
46
|
+
// EXTENDS the envelope's meta — it never replaces it. The envelope's
|
|
47
|
+
// meta is the contract (server version, skill version, project root,
|
|
48
|
+
// warnings); the tool's meta is the map to whatever it did not send.
|
|
49
|
+
const own = typeof data === 'object' && data !== null && data.__meta ? data.__meta : null;
|
|
50
|
+
const meta = own ? { ...ctx.meta(), ...own } : ctx.meta();
|
|
46
51
|
const payload = typeof data === 'object' && data !== null && data.__meta ? data.data : data;
|
|
47
52
|
return ok(payload, meta);
|
|
48
53
|
} catch (e) {
|
|
@@ -63,3 +68,95 @@ export function toToolResult(envelope) {
|
|
|
63
68
|
...(envelope.ok ? {} : { isError: true }),
|
|
64
69
|
};
|
|
65
70
|
}
|
|
71
|
+
|
|
72
|
+
// ── collection projection ───────────────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// WHY: a tool that answers "give me everything in this collection" makes the
|
|
75
|
+
// agent pay for the whole collection every time it needs ONE item — and the
|
|
76
|
+
// only way to get one item is to take the whole collection and pick from it.
|
|
77
|
+
// That is the wrong default for both sides: the server sends text nobody
|
|
78
|
+
// asked for, and the agent pays for it in context.
|
|
79
|
+
//
|
|
80
|
+
// The fix is not a smaller cap — a cap silently truncates, and the agent
|
|
81
|
+
// cannot tell what it missed. It is a SELECTOR plus a MAP TO THE REST: every
|
|
82
|
+
// collection-shaped answer can be narrowed to one item, and every answer says
|
|
83
|
+
// what else exists and exactly how to fetch it. The agent fetches; it never
|
|
84
|
+
// receives a dump.
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The default bound on a collection.
|
|
88
|
+
*
|
|
89
|
+
* It exists so a LARGE project cannot dump itself: a 500-task plan, a
|
|
90
|
+
* 250-node graph, a 200-file artifact tree. The bound is the CALLER's to
|
|
91
|
+
* raise (`limit`), and every answer says how many items exist and exactly
|
|
92
|
+
* which call returns the next slice — so a bound never hides data, it only
|
|
93
|
+
* makes the agent ask for it.
|
|
94
|
+
*/
|
|
95
|
+
export const DEFAULT_LIMIT = 50;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Narrow a collection to what the caller asked for, and report what is left.
|
|
99
|
+
*
|
|
100
|
+
* `limit` is the caller's bound, not the server's: a caller that needs the
|
|
101
|
+
* whole set asks for it (`limit: null` means no bound). The default is
|
|
102
|
+
* finite so a huge collection is never dumped — and the report says exactly
|
|
103
|
+
* how many remain and which call gets the next slice.
|
|
104
|
+
*
|
|
105
|
+
* @param {any[]} items
|
|
106
|
+
* @param {{limit?: number|null, offset?: number|null, next?: string|null}} [opts]
|
|
107
|
+
* @returns {{items: any[], total: number, returned: number, offset: number, limit: number|null, truncated: boolean, next: string|null}}
|
|
108
|
+
*/
|
|
109
|
+
export function project(items, opts = {}) {
|
|
110
|
+
const all = Array.isArray(items) ? items : [];
|
|
111
|
+
const offset = Math.max(0, Number(opts.offset ?? 0) || 0);
|
|
112
|
+
const rawLimit = opts.limit === undefined ? DEFAULT_LIMIT : opts.limit;
|
|
113
|
+
const limit = rawLimit === null ? null : Math.max(0, Math.trunc(Number(rawLimit) || 0));
|
|
114
|
+
const slice = limit === null ? all.slice(offset) : all.slice(offset, offset + limit);
|
|
115
|
+
const truncated = offset + slice.length < all.length;
|
|
116
|
+
return {
|
|
117
|
+
items: slice,
|
|
118
|
+
total: all.length,
|
|
119
|
+
returned: slice.length,
|
|
120
|
+
offset,
|
|
121
|
+
limit,
|
|
122
|
+
truncated,
|
|
123
|
+
// The exact next call — not a hint, not a cursor the agent has to build.
|
|
124
|
+
next: truncated ? (opts.next ?? null) : null,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The uniform "what else is there" block.
|
|
130
|
+
*
|
|
131
|
+
* Every collection-shaped answer carries it, so the agent always knows that
|
|
132
|
+
* more exists and exactly how to get it — without the server having sent any
|
|
133
|
+
* of it.
|
|
134
|
+
*
|
|
135
|
+
* @param {{total?: number, returned?: number, available?: object, more?: string|null, next?: string|null}} info
|
|
136
|
+
* @returns {object}
|
|
137
|
+
*/
|
|
138
|
+
export function moreMeta(info = {}) {
|
|
139
|
+
const meta = {};
|
|
140
|
+
if (info.total !== undefined) meta.total = info.total;
|
|
141
|
+
if (info.returned !== undefined) meta.returned = info.returned;
|
|
142
|
+
if (info.available && Object.keys(info.available).length) meta.available = info.available;
|
|
143
|
+
if (info.more) meta.more = info.more;
|
|
144
|
+
if (info.next) meta.next = info.next;
|
|
145
|
+
return meta;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Attach meta to a payload.
|
|
150
|
+
*
|
|
151
|
+
* The envelope already understands this shape (`__meta` is lifted out of the
|
|
152
|
+
* data), so a tool can add the map to the rest without knowing anything
|
|
153
|
+
* about the envelope.
|
|
154
|
+
*
|
|
155
|
+
* @param {unknown} data
|
|
156
|
+
* @param {object} meta
|
|
157
|
+
* @returns {{data: unknown, __meta: object}}
|
|
158
|
+
*/
|
|
159
|
+
export function withMeta(data, meta) {
|
|
160
|
+
return { data, __meta: meta };
|
|
161
|
+
}
|
|
162
|
+
|