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.
@@ -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 = (h.detectHome ?? []).some((m) => existsSync(join(home, m)));
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 absPath = cfg.path.startsWith('~') ? expandHome(cfg.path, home) : join(projectRoot, cfg.path);
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 = loc === 'global' ? home : projectRoot;
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 or the user's home, or it is refused.
467
- if (!pathIsWithin(abs, projectRoot) && !pathIsWithin(abs, home)) {
468
- skipped.push(`${abs} (outside the project and home directories)`);
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 notFound(`no task '${node}' in ${tasksFile}`, { node, file: tasksFile });
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
- return {
69
- topic: resolved.topic,
70
- root: resolved.root,
71
- title: doc.headings.find((h) => h.level === 1)?.text ?? resolved.topic,
72
- sections: buildSections(doc).map((s) => ({
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 whole topic).
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
- if (!opts.section) {
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 notFound(`no section '${opts.section}' in '${resolved.topic}'`, {
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 buildSections(doc)) {
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
- matches.push({ line: section.line + i, text: bodyLines[i].trim().slice(0, 200) });
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 { topic: resolved.topic, root: resolved.root, token, sections: hits, sectionCount: hits.length };
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
  /**
@@ -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, notFound } from './errors.mjs';
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 notFound(`task '${taskId}' not found in ${tasksYaml}`, { taskId, file: tasksYaml });
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
- const meta = typeof data === 'object' && data !== null && data.__meta ? data.__meta : ctx.meta();
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
+