archgen-mcp 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/assets/briefs/survey.md +1 -1
- package/assets/briefs/verifier.md +2 -1
- package/assets/harness-mcp.json +10 -1
- package/assets/platform-capabilities.json +11 -0
- package/assets/references/artifact-templates.md +22 -0
- package/assets/references/mcp-and-skills.md +3 -1
- package/assets/references/platforms.md +9 -1
- package/assets/templates/architecture.yaml +9 -16
- package/assets/templates/tasks.yaml +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/core/codegraph-manager.mjs +2 -2
- package/src/core/doc-navigator.mjs +29 -6
- package/src/core/docs.mjs +24 -2
- package/src/core/fs-safety.mjs +64 -45
- package/src/core/guidance.mjs +142 -0
- package/src/core/harness-wiring.mjs +93 -9
- package/src/core/mcp-server-manager.mjs +4 -2
- package/src/core/plan-grapher.mjs +2 -1
- package/src/core/refs.mjs +91 -21
- package/src/core/scaffold.mjs +6 -0
- package/src/core/worker-contract.mjs +3 -2
- package/src/envelope.mjs +98 -1
- package/src/tools/codegraph.mjs +20 -1
- package/src/tools/docs.mjs +65 -12
- package/src/tools/help.mjs +22 -5
- package/src/tools/plan.mjs +68 -3
- package/src/tools/refs.mjs +78 -7
- package/src/tools/registry.mjs +15 -1
- package/src/tools/setup.mjs +55 -18
- package/src/tools/skills.mjs +148 -40
- package/src/tools/tasks.mjs +68 -3
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.
|
package/assets/harness-mcp.json
CHANGED
|
@@ -320,12 +320,21 @@
|
|
|
320
320
|
"vscode": {
|
|
321
321
|
"label": "VS Code / Copilot",
|
|
322
322
|
"codegraphTarget": null,
|
|
323
|
-
"detectHome": [
|
|
323
|
+
"detectHome": [
|
|
324
|
+
".copilot"
|
|
325
|
+
],
|
|
324
326
|
"detectProject": [
|
|
325
327
|
".vscode",
|
|
326
328
|
".github/copilot-instructions.md"
|
|
327
329
|
],
|
|
328
330
|
"configs": {
|
|
331
|
+
"global": {
|
|
332
|
+
"path": "mcp-config.json",
|
|
333
|
+
"home": "copilot",
|
|
334
|
+
"format": "json",
|
|
335
|
+
"key": "mcpServers",
|
|
336
|
+
"entry": "stdio"
|
|
337
|
+
},
|
|
329
338
|
"project": {
|
|
330
339
|
"path": ".vscode/mcp.json",
|
|
331
340
|
"format": "json",
|
|
@@ -414,6 +414,17 @@
|
|
|
414
414
|
"env"
|
|
415
415
|
],
|
|
416
416
|
"urlField": "url"
|
|
417
|
+
},
|
|
418
|
+
"global": {
|
|
419
|
+
"path": "~/.copilot/mcp-config.json",
|
|
420
|
+
"key": "mcpServers",
|
|
421
|
+
"note": "$COPILOT_HOME replaces this location, and VS Code checks only one of them",
|
|
422
|
+
"stdioFields": [
|
|
423
|
+
"command",
|
|
424
|
+
"args",
|
|
425
|
+
"env"
|
|
426
|
+
],
|
|
427
|
+
"urlField": "url"
|
|
417
428
|
}
|
|
418
429
|
},
|
|
419
430
|
"mcpCli": "copilot mcp add; palette \"MCP: Add Server\"",
|
|
@@ -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>`
|
|
@@ -34,10 +34,18 @@ Never hand-edit the `AGENTS.md` registry table (`| Feature | Status | Updated |`
|
|
|
34
34
|
Query `archgen_setup{action:'harness_info'}` for exact per-platform MCP schemas:
|
|
35
35
|
- **Top-level keys vary:**
|
|
36
36
|
- `mcpServers`: Claude Code, Cursor, Windsurf, Gemini CLI, Antigravity, Kiro, Trae
|
|
37
|
-
- `servers`: VS Code /
|
|
37
|
+
- `servers`: VS Code (the `.vscode/mcp.json` workspace format)
|
|
38
38
|
- `mcp.servers.<id>`: OpenCode (nested under `mcp.servers`, never flat `mcp.<id>`)
|
|
39
39
|
- `context_servers`: Zed
|
|
40
40
|
- `[mcp_servers.<id>]`: Codex CLI (TOML)
|
|
41
|
+
- **VS Code reads TWO formats.** The VS Code format is `servers` in
|
|
42
|
+
`.vscode/mcp.json`; the PORTABLE format is `mcpServers` in the Copilot
|
|
43
|
+
user's `mcp-config.json` — `$COPILOT_HOME/mcp-config.json`, or
|
|
44
|
+
`~/.copilot/mcp-config.json`. Copilot harness sessions read the portable
|
|
45
|
+
format directly, and `COPILOT_HOME` REPLACES the default (VS Code checks
|
|
46
|
+
only one of the two). The global wiring therefore goes to the Copilot home
|
|
47
|
+
in the portable format, so VS Code stable, VS Code Insiders, the Copilot
|
|
48
|
+
CLI and every workspace see the same server.
|
|
41
49
|
- **Remote URL keys vary:** `url` vs `serverUrl` vs `httpUrl` (check `harness_info`).
|
|
42
50
|
- **Automated wiring:** Use `archgen_setup{action:'install_mcp', approve:true}` rather than hand-editing config files.
|
|
43
51
|
|
|
@@ -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",
|
package/src/cli.mjs
CHANGED
|
@@ -204,7 +204,7 @@ export async function runCli(argv, io = {}) {
|
|
|
204
204
|
codegraph: codegraph ? { bin: codegraph.bin, version: codegraph.version } : null,
|
|
205
205
|
mcpBinary: status.binary,
|
|
206
206
|
mcpTargets: status.targets,
|
|
207
|
-
harnesses: detectHarnesses(registry, ctx.projectRoot, ctx.home).map((d) => d.id),
|
|
207
|
+
harnesses: detectHarnesses(registry, ctx.projectRoot, ctx.home, ctx.env).map((d) => d.id),
|
|
208
208
|
};
|
|
209
209
|
if (opts.json) {
|
|
210
210
|
out(JSON.stringify(report, null, 2));
|
|
@@ -225,7 +225,7 @@ export async function runCli(argv, io = {}) {
|
|
|
225
225
|
err('uninstall-mcp needs --yes (nothing was removed)');
|
|
226
226
|
return 2;
|
|
227
227
|
}
|
|
228
|
-
const res = uninstallArchgenMcp(ctx.projectRoot, ctx.home);
|
|
228
|
+
const res = uninstallArchgenMcp(ctx.projectRoot, ctx.home, 'both', ctx.env);
|
|
229
229
|
if (opts.json) out(JSON.stringify(res, null, 2));
|
|
230
230
|
else {
|
|
231
231
|
out(`removed ${res.removed.length} config(s)`);
|
|
@@ -263,7 +263,7 @@ export async function manageCodegraph(opts) {
|
|
|
263
263
|
|
|
264
264
|
/** Wire the MCP server into every detected harness (native + fallback). */
|
|
265
265
|
const wireHarnesses = async (cli) => {
|
|
266
|
-
const detected = detectHarnesses(registry, projectRoot, home);
|
|
266
|
+
const detected = detectHarnesses(registry, projectRoot, home, env);
|
|
267
267
|
const wanted = targets ?? detected.map((d) => d.id);
|
|
268
268
|
const selected = detected.filter((d) => wanted.includes(d.id));
|
|
269
269
|
|
|
@@ -428,7 +428,7 @@ export async function manageCodegraph(opts) {
|
|
|
428
428
|
report.cli.latest = m ? m[1] : null;
|
|
429
429
|
}
|
|
430
430
|
report.telemetry.state = telemetryState(cli?.bin ?? null);
|
|
431
|
-
const detected = detectHarnesses(registry, projectRoot, home);
|
|
431
|
+
const detected = detectHarnesses(registry, projectRoot, home, env);
|
|
432
432
|
report.detected = detected.map((d) => d.id);
|
|
433
433
|
for (const d of detected) {
|
|
434
434
|
for (const cfg of Object.values(d.spec.configs ?? {})) {
|
|
@@ -32,6 +32,7 @@ import { buildGraph, GraphError } from './graph.mjs';
|
|
|
32
32
|
import { resolveSlugInput, ScopeError } from './scope.mjs';
|
|
33
33
|
import { parseMarkdown, buildSections, findSection, anchorExists } from './docs.mjs';
|
|
34
34
|
import { cycle, invalidInput, notFound } from './errors.mjs';
|
|
35
|
+
import { artifactSpaceMissing } from './guidance.mjs';
|
|
35
36
|
|
|
36
37
|
// Overview emits at most this many files (sorted); above it, truncated:true.
|
|
37
38
|
export const OVERVIEW_MAX_FILES = 100;
|
|
@@ -71,7 +72,10 @@ export function resolveDocScope(input, projectRoot = null) {
|
|
|
71
72
|
scoped = resolveSlugInput(input, projectRoot);
|
|
72
73
|
} catch (e) {
|
|
73
74
|
if (e instanceof ScopeError) throw invalidInput(`doc-index: ${e.message}`, { input });
|
|
74
|
-
|
|
75
|
+
// The artifact space does not exist yet. Say WHY, and what to do: the
|
|
76
|
+
// scaffold tool returns CONTENT and writes nothing, so the caller has to
|
|
77
|
+
// write it before any artifact tool can read it.
|
|
78
|
+
throw artifactSpaceMissing(null, input);
|
|
75
79
|
}
|
|
76
80
|
return {
|
|
77
81
|
slugDir: scoped.root,
|
|
@@ -572,16 +576,32 @@ export function docIndex(input, opts = {}) {
|
|
|
572
576
|
if (mode === 'map') {
|
|
573
577
|
if (!token) throw invalidInput('map mode requires a token', {});
|
|
574
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.
|
|
575
584
|
const re = new RegExp(wholeWordSource(token));
|
|
576
585
|
const hits = [];
|
|
577
586
|
for (const doc of collectDocs().docs) {
|
|
578
|
-
|
|
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) {
|
|
579
597
|
const bodyLines = doc.lines.slice(section.line - 1, section.endLine);
|
|
580
598
|
const matching = [];
|
|
581
599
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
582
|
-
if (re.test(bodyLines[i]))
|
|
583
|
-
|
|
584
|
-
|
|
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) });
|
|
585
605
|
}
|
|
586
606
|
if (!matching.length) continue;
|
|
587
607
|
hits.push({
|
|
@@ -597,7 +617,10 @@ export function docIndex(input, opts = {}) {
|
|
|
597
617
|
}
|
|
598
618
|
}
|
|
599
619
|
hits.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
600
|
-
return {
|
|
620
|
+
return {
|
|
621
|
+
mode,
|
|
622
|
+
data: { ref: token, sections: hits, sectionCount: hits.length, matchCount: hits.reduce((n, h) => n + h.occurrences, 0) },
|
|
623
|
+
};
|
|
601
624
|
}
|
|
602
625
|
|
|
603
626
|
// Overview (scale guard): the deduped inventory is always computed in full
|
package/src/core/docs.mjs
CHANGED
|
@@ -129,7 +129,22 @@ export function buildSections(doc) {
|
|
|
129
129
|
return sections;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a section by slug, title, tolerant slug variant, or PREFIX.
|
|
134
|
+
*
|
|
135
|
+
* The PREFIX form matters: the skill's `§` markers use SHORT names
|
|
136
|
+
* (`§artifact-templates/plan-depth`) while the headings produce longer
|
|
137
|
+
* slugs (`plan-depth-by-scope-class`). Every `§` shorthand in the skill is
|
|
138
|
+
* a prefix of a real slug, so accepting a prefix makes the shorthand the
|
|
139
|
+
* skill teaches actually RESOLVABLE — an agent can pass exactly what it was
|
|
140
|
+
* told without a discovery round-trip first.
|
|
141
|
+
*
|
|
142
|
+
* A prefix that matches MORE THAN ONE section is refused, never guessed:
|
|
143
|
+
* two sections sharing a prefix means the caller must use the full slug.
|
|
144
|
+
*
|
|
145
|
+
* @param {any} doc
|
|
146
|
+
* @param {string} ref
|
|
147
|
+
*/
|
|
133
148
|
export function findSection(doc, ref) {
|
|
134
149
|
const sections = buildSections(doc);
|
|
135
150
|
const wanted = String(ref).trim();
|
|
@@ -142,7 +157,14 @@ export function findSection(doc, ref) {
|
|
|
142
157
|
// Case-insensitive + collapsed-hyphen comparison: agents type
|
|
143
158
|
// "FR-Catalog-01" or "fr-catalog-01" for the same heading.
|
|
144
159
|
const lower = wanted.toLowerCase();
|
|
145
|
-
|
|
160
|
+
const byLoose = sections.find((s) => slugsMatch(s.slug.toLowerCase(), lower));
|
|
161
|
+
if (byLoose) return byLoose;
|
|
162
|
+
// The `§` shorthand: a PREFIX of the slug. Only an UNAMBIGUOUS prefix
|
|
163
|
+
// resolves — a guess would send the agent to the wrong section.
|
|
164
|
+
const needle = slugifyHeading(wanted).toLowerCase();
|
|
165
|
+
if (!needle) return null;
|
|
166
|
+
const byPrefix = sections.filter((s) => s.slug.toLowerCase().startsWith(needle));
|
|
167
|
+
return byPrefix.length === 1 ? byPrefix[0] : null;
|
|
146
168
|
}
|
|
147
169
|
|
|
148
170
|
/** Does `anchor` exist in the target document? */
|
package/src/core/fs-safety.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// skill/scripts/set-status.mjs — the MCP server must not be weaker than the
|
|
19
19
|
// scripts it replaces.
|
|
20
20
|
import { lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
|
-
import { dirname, resolve, sep } from 'node:path';
|
|
21
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
22
22
|
import { conflict, permissionDenied } from './errors.mjs';
|
|
23
23
|
|
|
24
24
|
/** Canonical refusal message — identical wording to the CLI's. */
|
|
@@ -41,20 +41,58 @@ export function assertNotSymlink(absPath) {
|
|
|
41
41
|
if (st.isSymbolicLink()) throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath });
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The REAL path of a path, climbing to the nearest existing ancestor when
|
|
46
|
+
* the leaf does not exist yet — so a symlinked PARENT is caught too.
|
|
47
|
+
*
|
|
48
|
+
* Returns null when the chain cannot be resolved at all (a broken symlink,
|
|
49
|
+
* or a component that is not a directory).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} abs
|
|
52
|
+
* @returns {string|null}
|
|
53
|
+
*/
|
|
54
|
+
function realPathWithTail(abs) {
|
|
55
|
+
const tail = [];
|
|
56
|
+
let probe = resolve(abs);
|
|
57
|
+
for (;;) {
|
|
58
|
+
try {
|
|
59
|
+
const real = realpathSync(probe);
|
|
60
|
+
return tail.length ? join(real, ...tail) : real;
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (!e || e.code !== 'ENOENT') return null;
|
|
63
|
+
const parent = dirname(probe);
|
|
64
|
+
if (parent === probe) return null;
|
|
65
|
+
tail.unshift(basename(probe));
|
|
66
|
+
probe = parent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
44
71
|
/**
|
|
45
72
|
* Realpath-escape guard: refuse anything that lands outside `rootAbs`.
|
|
46
73
|
*
|
|
47
|
-
* TWO
|
|
74
|
+
* TWO COMPARISONS, AND THE PATH IS ALLOWED IF EITHER SAYS IT IS INSIDE.
|
|
75
|
+
*
|
|
76
|
+
* 1. LEXICAL — the caller's spelling against the root's spelling.
|
|
77
|
+
* 2. REAL — where the path actually LANDS against where the root actually
|
|
78
|
+
* IS. This is the one that catches a symlinked PARENT redirecting a
|
|
79
|
+
* write elsewhere, and it is the only one that can answer the question
|
|
80
|
+
* when the two paths are spelled differently.
|
|
48
81
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* inside the root's real path. This is what catches a symlinked
|
|
54
|
-
* PARENT directory that would redirect the write elsewhere.
|
|
82
|
+
* They can disagree, and they DO whenever the root and the path are spelled
|
|
83
|
+
* differently — which is the NORMAL case on macOS (`/tmp`, `/var`) and for
|
|
84
|
+
* any symlinked project: `process.cwd()` is the REAL path while the caller
|
|
85
|
+
* passes the LOGICAL one.
|
|
55
86
|
*
|
|
56
|
-
* The guard
|
|
57
|
-
*
|
|
87
|
+
* The old guard compared the LEXICAL path against the REAL root and refused
|
|
88
|
+
* on that mismatch, so a perfectly valid path inside the project was
|
|
89
|
+
* reported as "outside the project" — and the two names the SAME place. The
|
|
90
|
+
* real comparison is the one that can actually answer the question, and it
|
|
91
|
+
* must be allowed to.
|
|
92
|
+
*
|
|
93
|
+
* The guard does NOT fail open: if the real comparison cannot be made, the
|
|
94
|
+
* lexical one stands on its own, and a path outside the root by every
|
|
95
|
+
* measure we could make is always refused.
|
|
58
96
|
*
|
|
59
97
|
* @param {string} rootAbs
|
|
60
98
|
* @param {string} absPath
|
|
@@ -62,43 +100,24 @@ export function assertNotSymlink(absPath) {
|
|
|
62
100
|
export function assertWithinRoot(rootAbs, absPath) {
|
|
63
101
|
const root = resolve(rootAbs);
|
|
64
102
|
const abs = resolve(absPath);
|
|
65
|
-
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
66
|
-
throw permissionDenied(
|
|
67
|
-
`refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
|
|
68
|
-
{ path: absPath, root: rootAbs },
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
exists = false;
|
|
85
|
-
}
|
|
86
|
-
if (exists) {
|
|
87
|
-
let real;
|
|
88
|
-
try {
|
|
89
|
-
real = realpathSync(probe);
|
|
90
|
-
} catch {
|
|
91
|
-
return; // broken chain: nothing writable through it
|
|
92
|
-
}
|
|
93
|
-
if (real !== rootReal && !real.startsWith(rootReal + sep)) {
|
|
94
|
-
throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath, root: rootAbs });
|
|
95
|
-
}
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const parent = dirname(probe);
|
|
99
|
-
if (parent === probe) return;
|
|
100
|
-
probe = parent;
|
|
104
|
+
const rootReal = realPathWithTail(root);
|
|
105
|
+
const pathReal = realPathWithTail(abs);
|
|
106
|
+
|
|
107
|
+
const lexicalInside = abs === root || abs.startsWith(root + sep);
|
|
108
|
+
const realInside =
|
|
109
|
+
rootReal && pathReal ? pathReal === rootReal || pathReal.startsWith(rootReal + sep) : null;
|
|
110
|
+
|
|
111
|
+
if (realInside === true || (realInside === null && lexicalInside)) return;
|
|
112
|
+
|
|
113
|
+
if (lexicalInside) {
|
|
114
|
+
// Lexically inside, but it lands somewhere else: a symlinked parent.
|
|
115
|
+
throw permissionDenied(refuseSymlinkMessage(absPath), { path: absPath, root: rootAbs });
|
|
101
116
|
}
|
|
117
|
+
throw permissionDenied(
|
|
118
|
+
`refusing a path outside the project: ${absPath} is not inside ${rootAbs}`,
|
|
119
|
+
{ path: absPath, root: rootAbs },
|
|
120
|
+
);
|
|
102
121
|
}
|
|
103
122
|
|
|
104
123
|
/**
|