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 +14 -0
- package/assets/briefs/survey.md +1 -1
- package/assets/briefs/verifier.md +2 -1
- package/assets/references/artifact-templates.md +22 -0
- package/assets/references/mcp-and-skills.md +3 -1
- package/assets/templates/architecture.yaml +9 -16
- package/assets/templates/tasks.yaml +2 -2
- package/package.json +1 -1
- package/src/core/doc-navigator.mjs +24 -5
- package/src/core/refs.mjs +89 -16
- package/src/envelope.mjs +98 -1
- package/src/tools/codegraph.mjs +20 -1
- package/src/tools/docs.mjs +55 -10
- package/src/tools/help.mjs +13 -3
- 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 +53 -16
- package/src/tools/skills.mjs +135 -37
- package/src/tools/tasks.mjs +68 -3
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
|
package/assets/briefs/survey.md
CHANGED
|
@@ -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
|
|
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}` (
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
70
|
-
topic
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
|
package/src/tools/codegraph.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tools/docs.mjs
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
61
|
+
}
|
|
62
|
+
case 'refs': {
|
|
47
63
|
if (!args.token) throw invalidInput('refs requires a token', { action: args.action });
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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.
|