archgen-mcp 0.1.1 → 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 CHANGED
@@ -62,6 +62,20 @@ Every tool returns the same envelope:
62
62
  Call `archgen_help { topic: "<domain>" }` for the full briefing on any tool —
63
63
  including when to call it, every action, and its gotchas.
64
64
 
65
+ ### Fetching, not dumping
66
+
67
+ Every tool takes `limit` and `offset` (default 50 items) — the bound is the
68
+ caller's, and a bounded answer always reports what it left behind. Every
69
+ collection narrows to **one item**: `taskId`, `skillId`, `file` + `section`,
70
+ `harness`. Each answer's `meta` carries `available` (what else exists),
71
+ `more` (the call that fetches one item) and `next` (the exact call for the
72
+ next slice).
73
+
74
+ So the agent fetches what it needs instead of taking the whole set: a
75
+ reference topic is fetched **one section** at a time (`section: '*'` asks for
76
+ the whole topic deliberately), and a capability skill is fetched as its
77
+ **normative rules plus an index** — never as the file three times over.
78
+
65
79
  ### The judgment / output split
66
80
 
67
81
  The archgen **skill** carries the judgment (policy, rules, when-to-use). This
@@ -4,7 +4,7 @@ SCOPE: Read-only exploration, writing ONLY `<slug dir>/codebase-map.md`.
4
4
  DELIVERABLE: Write `<slug dir>/codebase-map.md` containing EXACTLY these four non-empty sections:
5
5
  ## Structure — Top 2 directory levels with 1-line purpose per folder
6
6
  ## Modules — Table: `module | responsibility | key entry points`
7
- ## Conventions — Observed file naming, error handling, and test placement patterns
7
+ ## Conventions — Observed file naming, error handling, and where the tests live today (the source roots and the test tree)
8
8
  ## Affected — Files/modules impacted by the feature, citing exact evidence paths
9
9
 
10
10
  NAVIGATION & VERIFICATION
@@ -9,7 +9,8 @@ STEP 2 — AUDIT JUDGMENT & CONTRACT COMPLETENESS:
9
9
  - Wave boundaries match true dependencies without artificial serialization.
10
10
  - Acceptance criteria are objectively verifiable (runnable command or observable output).
11
11
  - Edge-case coverage satisfies the scope class (`SMALL` / `MEDIUM` / `LARGE`).
12
- - Planned structure and naming obey `references/code-standards.md`.
12
+ - Planned structure and naming obey `references/code-standards.md` — including the separate source and test trees: no test tree inside the source tree, and every task that owns source owns the mirrored test path.
13
+ - The repository does not hide archgen artifacts: no `.gitignore` rule covers `.archgen/`, `.agents/skills/archgen`, `.claude/skills/archgen`, `AGENTS.md` or `CLAUDE.md`.
13
14
  - **CONTRACT-COMPLETENESS SWEEP** (evaluate every dimension; mark `N/A` only when the system genuinely lacks that surface):
14
15
  1. `functional coverage`: Every requirement maps to a task + objective acceptance check.
15
16
  2. `security & data protection`: Authn/authz flows, session/credential lifecycle, CSRF/replay defense, encryption/privacy boundaries, boundary input validation.
@@ -62,6 +62,7 @@ Adding or omitting files outside the scaffold output is a right-sizing defect. A
62
62
  - **Runnable verification commands:** Repo-root-relative (no `../` or `--plan .`), specifying exact commands, flags, sample sizes, and thresholds.
63
63
  - **Restricted YAML subset:** Block mappings, block sequences, and single-line flow collections (`["a/**", "b.ts"]`, `{id: A}`) ONLY. **BANNED:** block scalars (`|` / `>`), duplicate keys, and multi-line flow collections.
64
64
  - **Targeted `tasks.yaml` edits:** Apply small, surgical edits or a clean full regeneration—never large multi-region patches.
65
+ - **Both trees declared:** `architecture.yaml` `structure:` lists the source tree AND its sibling test tree, and a task that owns source owns the mirrored test path (`references/code-standards.md` owns the rule).
65
66
 
66
67
  ## Agent workspace & artifact location — the two-tier rule
67
68
 
@@ -79,6 +80,27 @@ Adding or omitting files outside the scaffold output is a right-sizing defect. A
79
80
  - Source code (`src/...`) and shipped user/operator documentation (`README.md`, API docs, runbooks).
80
81
  - *Durable ADRs:* Promote repo-wide architectural decisions from `.archgen/<slug>/decisions/` to the project's canonical ADR directory (`docs/adr/`). Supersede rather than deleting/renumbering accepted ADRs.
81
82
 
83
+ Every path under `.archgen/<slug>/` is committed — the `.agent/` run state included, because it is the audit trail of how the work went (`§artifact-tracking`).
84
+
85
+ ## Artifact tracking — every archgen artifact is committed
86
+
87
+ The plan of record belongs in the repository and in the history, next to the code it plans: `git log` must show what was decided, when it changed, and what shipped.
88
+
89
+ **Committed — the whole archgen footprint:**
90
+ - `.archgen/<slug>/` — the plan of record: `tasks.yaml`, `plans/`, `docs/`, `architecture.yaml`, `decisions/`, and the `.agent/` run state.
91
+ - `.agents/skills/archgen/` — the canonical installed skill, one copy for the team.
92
+ - `.claude/skills/archgen` — the harness bridge into that store.
93
+ - `AGENTS.md` and `CLAUDE.md` — the registry hub and its pointer.
94
+ - The project MCP config (`.mcp.json`, `.cursor/mcp.json`, `opencode.json`, `.vscode/mcp.json`, …) — the wiring that gives the team the tools.
95
+
96
+ **Never committed — machine-local state:** `.codegraph/` (the local symbol index), `.archgen/.runtime/` (the legacy heartbeat), the `.archgen-version` deploy stamp.
97
+
98
+ **Rules:**
99
+ 1. **NEVER gitignore an archgen artifact.** A rule covering `.archgen/`, `.agents/`, `.claude/` or `AGENTS.md` is a defect — remove it.
100
+ 2. **Commit the plan before the first wave**, then each wave's artifact state (statuses, notes, decisions, reports, snapshot) with that wave's commits.
101
+ 3. **Fix the ignore rule before force-adding** — a forced add hides the rule from the next person; if you must force-add, say so in the completion report.
102
+ 4. **Never rewrite published history** to hide an artifact — the plan of record is append-only, like the code it plans.
103
+
82
104
  ## architecture.yaml (LARGE scope)
83
105
 
84
106
  Generated via `archgen_plan{action:'scaffold', scope:'LARGE'}`. Authoring rules for the four anti-hallucination contract blocks:
@@ -4,6 +4,8 @@
4
4
 
5
5
  `npx archgen-skill init` (or `install`) automatically wires `archgen-mcp` and CodeGraph into every detected harness. Never configure them manually. If `archgen_*` tools are missing, run `npx archgen-skill init` at the repo root and ask the user to reload the harness (`§platforms/mcp-configuration`).
6
6
 
7
+ The install writes into the repository — the skill store, the harness bridge, `AGENTS.md`. Those files are **committed to git** (one copy, shared by the team), never gitignored (`§artifact-templates/artifact-tracking`).
8
+
7
9
  The procedures below govern **external** MCP servers (e.g., `context7`) and capability skills.
8
10
 
9
11
  ## INSTALL-MCP procedure (approval-gated)
@@ -33,7 +35,7 @@ Capability-skill loading is **MANDATORY** whenever planned work matches a regist
33
35
  ```
34
36
  This invokes `npx -y skills add <repo> --skill <slug> --agent <agent> --yes --json` to install the single matched skill non-interactively (never a full repo bundle) and verifies the resulting file path.
35
37
  4. **INTEGRATE (inline into worker contract):** Call `archgen_tasks{action:'contract', tasksYaml, taskId}`. It automatically extracts the normative rules (`MUST` / `SHOULD` / `NEVER`), file path, and section index from every matched required skill and embeds them directly into the worker prompt so workers cannot overlook them.
36
- - To inspect a skill manually, call `archgen_skills{action:'load', skillId}` (full rules + index), `{action:'digest', skillId}` (index only), or `{action:'load', skillId, section}` (single section).
38
+ - To inspect a skill manually, call `archgen_skills{action:'load', skillId}` (the normative rules + the section index — never the whole file), `{action:'digest', skillId}` (index only), or `{action:'load', skillId, section}` (single section).
37
39
  5. **AUDIT:** Every worker completion report MUST include an explicit compliance line:
38
40
  - `Skill compliance: followed <skill-slug> (<path/to/SKILL.md>)`
39
41
  - `Skill compliance: NOT followed — <reason>`
@@ -10,39 +10,33 @@ stack:
10
10
  database: postgres
11
11
  structure: # planned folder tree defined BEFORE coding
12
12
  - path: src/features/<feature>/
13
- purpose: feature modules (api, internal, colocated tests)
13
+ purpose: feature modules (api, internal)
14
14
  - path: src/shared/
15
15
  purpose: cross-feature utilities; MUST NOT import from features/
16
16
  - path: src/config/
17
17
  purpose: typed env parsing + constants
18
+ - path: tests/unit/features/<feature>/
19
+ purpose: unit tests — the test tree, a SIBLING of src/ that mirrors the source path
18
20
  naming_conventions: # referenced by every task step and file_ownership entry
19
21
  file_naming: kebab-case files; feature folder matches module name
20
- dir_layout: "<module>/{api,internal,tests}; shared utilities in src/shared/ only"
22
+ dir_layout: "<module>/{api,internal}; shared utilities in src/shared/ only; tests mirror the source path under tests/unit/"
21
23
  symbol_casing: PascalCase types, camelCase functions, SCREAMING_SNAKE env vars
22
- test_placement: colocated <name>.test.ts beside unit under test
23
- modules:
24
+ test_placement: tests/unit/<path under the source root>/<the language's test file> — the test tree is a SIBLING of the source tree, never inside it
25
+ modules: # one entry per module; repeat per module
24
26
  - name: catalog
25
27
  responsibility: product browsing + search
26
28
  owns: ["src/features/catalog/**"]
27
29
  depends_on_modules: [] # DAG; drives task depends_on edges
28
30
  key_interfaces: # ONLY exports other modules may import
29
31
  - CatalogApi (src/features/catalog/api/index.ts)
30
- - name: checkout
31
- responsibility: cart -> payment orchestration
32
- owns: ["src/features/checkout/**"]
33
- depends_on_modules: [catalog]
34
- key_interfaces:
35
- - CheckoutApi (src/features/checkout/api/index.ts)
36
- data_contracts:
37
- orders:
32
+ data_contracts: # one block per entity; repeat per entity
33
+ order:
38
34
  storage: postgres table orders (migrations/NNNN_orders.sql)
39
35
  fields:
40
36
  - { field: id, type: uuid, required: true, notes: pk }
41
- - { field: session_id, type: uuid, required: true, notes: fk sessions }
42
37
  - { field: total_cents, type: integer, required: true, notes: ">= 0" }
43
38
  - { field: status, type: "enum(pending,paid,failed)", required: true }
44
- - { field: created_at, type: timestamptz, required: true, notes: db default now() }
45
- api_contracts:
39
+ api_contracts: # one block per operation; repeat per operation
46
40
  payments:
47
41
  - endpoint: /api/checkout/pay
48
42
  method: POST
@@ -52,7 +46,6 @@ api_contracts:
52
46
  error_shape: { code: string, message: string, retryable: boolean }
53
47
  environment_matrix: # unparsed vars fail fast at startup in src/config/
54
48
  - { var: DATABASE_URL, dev: local docker postgres, ci: ephemeral service, prod: secret ref, required: true }
55
- - { var: PAYMENT_API_KEY, dev: stub key, ci: stub key, prod: secret ref, required: true }
56
49
  decisions: # summary index; full records live in decisions/NNNN-title.md
57
50
  - id: D1
58
51
  title: Postgres over MongoDB
@@ -7,7 +7,7 @@ tasks:
7
7
  - id: C
8
8
  title: Scaffold project skeleton + typed config loader
9
9
  depends_on: []
10
- file_ownership: ["src/config/**", "package.json"] # full repo-root-relative paths ONLY
10
+ file_ownership: ["src/config/**", "tests/unit/config/**", "package.json"] # full repo-root-relative paths ONLY
11
11
  acceptance:
12
12
  - "npm run build exits 0"
13
13
  - "config loads from env and fails fast on missing required vars"
@@ -37,7 +37,7 @@ tasks:
37
37
  parallel_group: wave-payments
38
38
  file_ownership:
39
39
  - src/features/checkout/internal/capture.ts # per naming_conventions.dir_layout
40
- - src/features/checkout/internal/capture.test.ts # per naming_conventions.test_placement
40
+ - tests/unit/features/checkout/internal/capture.test.ts # the mirrored test tree
41
41
  - docs/payment-retry.md
42
42
  artifacts: ["docs/payment-retry.md"]
43
43
  acceptance:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgen-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for archgen \u2014 the single runtime that owns architecture-plan validation, wave execution, artifact navigation, CodeGraph bootstrap and harness MCP wiring for coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -576,16 +576,32 @@ export function docIndex(input, opts = {}) {
576
576
  if (mode === 'map') {
577
577
  if (!token) throw invalidInput('map mode requires a token', {});
578
578
  // Section-level retrieval: WHERE is this token mentioned?
579
+ //
580
+ // The sections OVERLAP — a parent section's body contains its children's —
581
+ // so a naive walk reports the same line once per containing section. The
582
+ // agent then paid for the same sentence three times to learn one fact.
583
+ // Every hit is reported ONCE, under the DEEPEST section that contains it.
579
584
  const re = new RegExp(wholeWordSource(token));
580
585
  const hits = [];
581
586
  for (const doc of collectDocs().docs) {
582
- for (const section of buildSections(doc)) {
587
+ const sections = buildSections(doc);
588
+ const ownerOf = (line) => {
589
+ let owner = null;
590
+ for (const s of sections) {
591
+ if (line < s.line || line > s.endLine) continue;
592
+ if (owner === null || s.level > owner.level) owner = s;
593
+ }
594
+ return owner;
595
+ };
596
+ for (const section of sections) {
583
597
  const bodyLines = doc.lines.slice(section.line - 1, section.endLine);
584
598
  const matching = [];
585
599
  for (let i = 0; i < bodyLines.length; i++) {
586
- if (re.test(bodyLines[i])) {
587
- matching.push({ line: section.line + i, text: bodyLines[i].trim().slice(0, 200) });
588
- }
600
+ if (!re.test(bodyLines[i])) continue;
601
+ const line = section.line + i;
602
+ const owner = ownerOf(line);
603
+ if (!owner || owner.slug !== section.slug) continue; // its deepest owner reports it
604
+ matching.push({ line, text: bodyLines[i].trim().slice(0, 200) });
589
605
  }
590
606
  if (!matching.length) continue;
591
607
  hits.push({
@@ -601,7 +617,10 @@ export function docIndex(input, opts = {}) {
601
617
  }
602
618
  }
603
619
  hits.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
604
- return { mode, data: { ref: token, sections: hits, sectionCount: hits.length } };
620
+ return {
621
+ mode,
622
+ data: { ref: token, sections: hits, sectionCount: hits.length, matchCount: hits.reduce((n, h) => n + h.occurrences, 0) },
623
+ };
605
624
  }
606
625
 
607
626
  // Overview (scale guard): the deduped inventory is always computed in full
package/src/core/refs.mjs CHANGED
@@ -59,29 +59,61 @@ export function resolveTopic(topic, opts) {
59
59
  /**
60
60
  * Index one reference topic: its title, purpose and section list.
61
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
+ *
62
66
  * @param {string} topic
63
- * @param {{assetsDir: string, skillDir: string|null}} opts
67
+ * @param {{assetsDir: string, skillDir: string|null, section?: string|null}} opts
64
68
  */
65
69
  export function indexTopic(topic, opts) {
66
70
  const resolved = resolveTopic(topic, opts);
67
71
  if (!resolved) throw notFound(`unknown reference '${topic}'`, { topic, known: REF_TOPICS });
68
72
  const doc = parseMarkdown(readFileSync(resolved.path, 'utf8'));
69
- return {
70
- topic: resolved.topic,
71
- root: resolved.root,
72
- title: doc.headings.find((h) => h.level === 1)?.text ?? resolved.topic,
73
- 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) => ({
74
79
  slug: s.slug,
75
80
  title: s.title,
76
81
  level: s.level,
77
82
  line: s.line,
78
83
  endLine: s.endLine,
79
- })),
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,
80
102
  };
81
103
  }
82
104
 
83
105
  /**
84
- * 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.
85
117
  *
86
118
  * @param {string} topic
87
119
  * @param {{assetsDir: string, skillDir: string|null, section?: string|null}} opts
@@ -89,19 +121,32 @@ export function indexTopic(topic, opts) {
89
121
  export function readTopic(topic, opts) {
90
122
  const resolved = resolveTopic(topic, opts);
91
123
  if (!resolved) throw notFound(`unknown reference '${topic}'`, { topic, known: REF_TOPICS });
92
- const text = readFileSync(resolved.path, 'utf8');
93
- const doc = parseMarkdown(text);
94
124
 
95
- if (!opts.section) {
125
+ // The whole topic — asked for explicitly.
126
+ if (opts.section === '*') {
127
+ const doc = parseMarkdown(readFileSync(resolved.path, 'utf8'));
96
128
  return {
97
129
  topic: resolved.topic,
98
130
  root: resolved.root,
99
131
  section: null,
132
+ whole: true,
100
133
  text: doc.lines.join('\n'),
101
134
  lines: doc.lines.length,
102
135
  };
103
136
  }
104
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'));
105
150
  const section = findSection(doc, opts.section);
106
151
  if (!section) {
107
152
  const available = buildSections(doc).map((s) => s.slug);
@@ -136,14 +181,34 @@ export function searchTopic(topic, token, opts) {
136
181
  // Whole-word: a hit must not be glued to word characters on either side.
137
182
  const re = new RegExp(`(?<![A-Za-z0-9_])${esc}(?![A-Za-z0-9_])`, 'i');
138
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
+
139
202
  const hits = [];
140
- for (const section of buildSections(doc)) {
203
+ for (const section of sections) {
141
204
  const bodyLines = doc.lines.slice(section.line - 1, section.endLine);
142
205
  const matches = [];
143
206
  for (let i = 0; i < bodyLines.length; i++) {
144
- if (re.test(bodyLines[i])) {
145
- matches.push({ line: section.line + i, text: bodyLines[i].trim().slice(0, 200) });
146
- }
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) });
147
212
  }
148
213
  if (matches.length) {
149
214
  hits.push({
@@ -157,7 +222,15 @@ export function searchTopic(topic, token, opts) {
157
222
  });
158
223
  }
159
224
  }
160
- 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
+ };
161
234
  }
162
235
 
163
236
  /**
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
+
@@ -8,6 +8,7 @@ import { z } from 'zod/v4';
8
8
  import { registerTool, projectRootArg } from './_register.mjs';
9
9
  import { loadAsset } from '../context.mjs';
10
10
  import { manageCodegraph, assertLocation } from '../core/codegraph-manager.mjs';
11
+ import { moreMeta, project, withMeta } from '../envelope.mjs';
11
12
 
12
13
  const ACTION_DESC = 'ensure | status | wire — see archgen_help.';
13
14
 
@@ -17,6 +18,8 @@ const schema = z.object({
17
18
  approve: z.boolean().optional().describe('Required to write any MCP config. Without it you get the plan and nothing is written.'),
18
19
  telemetry: z.enum(['keep', 'off', 'on']).optional().describe('Telemetry preference. Defaults to keep (leave as-is).'),
19
20
  noInstall: z.boolean().optional().describe('Do not install the CLI; use the npx fallback in configs.'),
21
+ limit: z.number().int().positive().optional().describe('Max items to return. Default 50.'),
22
+ offset: z.number().int().nonnegative().optional().describe('Skip this many items.'),
20
23
  projectRoot: projectRootArg,
21
24
  });
22
25
 
@@ -37,7 +40,7 @@ export function registerCodegraphTools(server) {
37
40
  const registry = loadAsset('harness-mcp.json');
38
41
  const location = assertLocation(args.location ?? 'both');
39
42
  const mode = args.action === 'status' ? 'status' : args.action === 'wire' ? 'wire' : 'ensure';
40
- return manageCodegraph({
43
+ const report = await manageCodegraph({
41
44
  mode,
42
45
  projectRoot: ctx.projectRoot,
43
46
  registry,
@@ -48,6 +51,22 @@ export function registerCodegraphTools(server) {
48
51
  home: ctx.home,
49
52
  env: ctx.env,
50
53
  });
54
+ // The wiring lists grow with the number of detected harnesses, so they
55
+ // are bounded — and the agent is told what else it could fetch.
56
+ const p = project(report.detected ?? [], {
57
+ limit: args.limit,
58
+ offset: args.offset,
59
+ next: `archgen_codegraph{action:'status',limit:${args.limit ?? 50},offset:${(args.offset ?? 0) + (args.limit ?? 50)}}`,
60
+ });
61
+ return withMeta(
62
+ { ...report, detected: p.items },
63
+ moreMeta({
64
+ total: p.total,
65
+ returned: p.returned,
66
+ next: p.next,
67
+ available: { harnesses: p.total },
68
+ }),
69
+ );
51
70
  },
52
71
  });
53
72
  }
@@ -7,6 +7,7 @@ import { registerTool, projectRootArg } from './_register.mjs';
7
7
  import { docIndex } from '../core/doc-navigator.mjs';
8
8
  import { invalidInput } from '../core/errors.mjs';
9
9
  import { hint, teach } from '../core/guidance.mjs';
10
+ import { moreMeta, project, withMeta } from '../envelope.mjs';
10
11
 
11
12
  const ACTION_DESC = 'index | digest | section | refs | map | validate — see archgen_help.';
12
13
 
@@ -19,6 +20,8 @@ const schema = z.object({
19
20
  token: z.string().optional().describe('The token to look for (refs, map).'),
20
21
  section: z.string().optional().describe('Section path, optionally path#anchor (section).'),
21
22
  file: z.string().optional().describe('Scope to one document (repo-root-relative or slug-relative).'),
23
+ limit: z.number().int().positive().optional().describe('Max items to return. Default 50.'),
24
+ offset: z.number().int().nonnegative().optional().describe('Skip this many items.'),
22
25
  projectRoot: projectRootArg,
23
26
  });
24
27
 
@@ -36,22 +39,46 @@ export function registerDocTools(server) {
36
39
  idempotent: true,
37
40
  handler: (ctx, args) => {
38
41
  const slugDir = resolveSlugDir(ctx, args.slugDir);
42
+ const bound = args.limit ?? 50;
43
+ const from = args.offset ?? 0;
39
44
  switch (args.action) {
40
- case 'index':
41
- return docIndex(slugDir, { mode: 'overview', file: args.file ?? null, projectRoot: ctx.projectRoot });
42
- case 'digest':
43
- return docIndex(slugDir, { mode: 'digest', file: args.file ?? null, projectRoot: ctx.projectRoot });
44
- case 'validate':
45
+ case 'index': {
46
+ const overview = docIndex(slugDir, { mode: 'overview', file: args.file ?? null, projectRoot: ctx.projectRoot });
47
+ return bounded('files', overview.data, args, `archgen_docs{action:'index',slugDir:'${slugDir}',limit:${bound},offset:${from + bound}}`, {
48
+ files: (overview.data.files ?? []).length,
49
+ more: `archgen_docs{action:'section',slugDir:'${slugDir}',section:'<the section you need>'}`,
50
+ });
51
+ }
52
+ case 'digest': {
53
+ const digest = docIndex(slugDir, { mode: 'digest', file: args.file ?? null, projectRoot: ctx.projectRoot });
54
+ return bounded('files', digest.data, args, `archgen_docs{action:'digest',slugDir:'${slugDir}',limit:${bound},offset:${from + bound}}`, {
55
+ files: (digest.data.files ?? []).length,
56
+ more: `archgen_docs{action:'section',slugDir:'${slugDir}',section:'<the section you need>'}`,
57
+ });
58
+ }
59
+ case 'validate': {
45
60
  return docIndex(slugDir, { mode: 'validate', file: args.file ?? null, projectRoot: ctx.projectRoot });
46
- case 'refs':
61
+ }
62
+ case 'refs': {
47
63
  if (!args.token) throw invalidInput('refs requires a token', { action: args.action });
48
- return docIndex(slugDir, { mode: 'refs', token: args.token, file: args.file ?? null, projectRoot: ctx.projectRoot });
49
- case 'map':
64
+ const refs = docIndex(slugDir, { mode: 'refs', token: args.token, file: args.file ?? null, projectRoot: ctx.projectRoot });
65
+ return bounded('matches', refs.data, args, `archgen_docs{action:'refs',slugDir:'${slugDir}',token:'${args.token}',limit:${bound},offset:${from + bound}}`, {
66
+ matches: (refs.data.matches ?? []).length,
67
+ });
68
+ }
69
+ case 'map': {
50
70
  if (!args.token) throw invalidInput('map requires a token', { action: args.action });
51
- return docIndex(slugDir, { mode: 'map', token: args.token, file: args.file ?? null, projectRoot: ctx.projectRoot });
52
- case 'section':
71
+ const map = docIndex(slugDir, { mode: 'map', token: args.token, file: args.file ?? null, projectRoot: ctx.projectRoot });
72
+ return bounded('sections', map.data, args, `archgen_docs{action:'map',slugDir:'${slugDir}',token:'${args.token}',limit:${bound},offset:${from + bound}}`, {
73
+ sections: (map.data.sections ?? []).length,
74
+ matches: map.data.matchCount ?? undefined,
75
+ more: `archgen_docs{action:'section',slugDir:'${slugDir}',section:'<the section you need>'}`,
76
+ });
77
+ }
78
+ case 'section': {
53
79
  if (!args.section) throw invalidInput('section requires a section path', { action: args.action });
54
80
  return docIndex(slugDir, { mode: 'section', section: args.section, file: args.file ?? null, projectRoot: ctx.projectRoot });
81
+ }
55
82
  default:
56
83
  throw invalidInput(`unknown action '${args.action}'`, { action: args.action });
57
84
  }
@@ -59,6 +86,24 @@ export function registerDocTools(server) {
59
86
  });
60
87
  }
61
88
 
89
+ /**
90
+ * Bound a collection-shaped answer, and report what was left.
91
+ *
92
+ * The agent is told how many items exist, how many it got, and the exact
93
+ * call that returns the next slice — so a bound never hides data.
94
+ *
95
+ * @param {string} key the payload key the collection sits in
96
+ * @param {object} data the payload
97
+ * @param {any} args
98
+ * @param {string} next the exact next call
99
+ * @param {object} available what else exists
100
+ */
101
+ function bounded(key, data, args, next, available) {
102
+ const items = data?.[key] ?? [];
103
+ const p = project(items, { limit: args.limit, offset: args.offset, next });
104
+ return withMeta({ ...data, [key]: p.items }, moreMeta({ total: p.total, returned: p.returned, next: p.next, available }));
105
+ }
106
+
62
107
  /**
63
108
  * Resolve the slug directory: explicit argument, else the most recently
64
109
  * modified `.archgen/<slug>/` under the project root.