You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/07/03 16:55:12 UTC

(magpie) branch main updated: Add compact inventory for spec-loop prompts (#702)

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie.git


The following commit(s) were added to refs/heads/main by this push:
     new 0fca2ee4 Add compact inventory for spec-loop prompts (#702)
0fca2ee4 is described below

commit 0fca2ee4ed36758fb442657f6d111cb76757af0a
Author: Justin Mclean <ju...@classsoftware.com>
AuthorDate: Sat Jul 4 02:55:06 2026 +1000

    Add compact inventory for spec-loop prompts (#702)
---
 docs/labels-and-capabilities.md                    |   1 +
 docs/vendor-neutrality.md                          |   7 +-
 pyproject.toml                                     |   1 +
 tools/spec-inventory/README.md                     |  51 +++
 tools/spec-inventory/pyproject.toml                |  75 ++++
 .../spec-inventory/src/spec_inventory/__init__.py  | 400 +++++++++++++++++++++
 tools/spec-inventory/tests/test_spec_inventory.py  | 227 ++++++++++++
 tools/spec-loop/AGENTS.md                          |   3 +
 tools/spec-loop/PROMPT_build.md                    |  20 +-
 tools/spec-loop/PROMPT_plan.md                     |  17 +-
 tools/spec-loop/PROMPT_update.md                   |  16 +-
 tools/spec-loop/README.md                          |   1 +
 tools/spec-loop/loop.sh                            |  20 ++
 tools/spec-loop/specs/meta-and-quality-tooling.md  |   6 +
 uv.lock                                            |  22 ++
 15 files changed, 846 insertions(+), 21 deletions(-)

diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 448651c6..8d1e7e44 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -274,6 +274,7 @@ it implements multiple contracts (e.g. `tools/gmail` provides both
 | [`tools/spec-loop`](../tools/spec-loop/) | `substrate:framework-dev` | Spec-driven build loop runner (Ralph-style) for framework development |
 | [`tools/skill-evals`](../tools/skill-evals/) | `substrate:framework-dev` | Eval harness for skills; framework-dev infrastructure whose run output is governance evidence |
 | [`tools/skill-and-tool-validator`](../tools/skill-and-tool-validator/) | `substrate:framework-dev` | Skill-frontmatter and convention validator |
+| [`tools/spec-inventory`](../tools/spec-inventory/) | `substrate:framework-dev` + `substrate:analytics` | Compact routing inventory for spec-loop prompts — summarizes specs, skills, and tool metadata so agents can choose relevant files before direct verification |
 | [`tools/spec-status-index`](../tools/spec-status-index/) | `substrate:framework-dev` + `substrate:analytics` | Index of spec / RFC implementation status — framework-dev substrate that also doubles as a governance/stats view (`analytics`) |
 | [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/) | `substrate:framework-dev` + `substrate:analytics` | Deterministic vendor-neutrality score — reads each contract tool's `**Kind:**` / `**Vendor:**` metadata and scores per-contract + per-skill neutrality (`analytics`); backs the score block in [`docs/vendor-neutrality.md`](vendor-neutrality.md) |
 | [`tools/spec-validator`](../tools/spec-validator/) | `substrate:framework-dev` | Spec-frontmatter and body-section validator — counterpart to `skill-and-tool-validator` for `tools/spec-loop/specs/` |
diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md
index 25f0f037..5950be5d 100644
--- a/docs/vendor-neutrality.md
+++ b/docs/vendor-neutrality.md
@@ -544,7 +544,6 @@ approval on endpoint identity rather than vendor. Both appear in the
 generated block below.
 
 <!-- BEGIN vendor-neutrality-score — generated by `uv run --project tools/vendor-neutrality-score vendor-neutrality-score --markdown`; do not edit by hand -->
-
 **Overall vendor-neutrality score: 10/10 capability contracts (100%).** Generated by [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/); re-run it to refresh this section.
 
 | Capability contract | Neutral? | Class | Backends today | Basis |
@@ -572,7 +571,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 49.
 
 **LLM / agent-integration neutrality**
 
-**Agent harness: 20/20 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness.
+**Agent harness: 21/21 substrate tools run under any harness unchanged (100%).** Substrate tools are Magpie's own machinery; each declares the agent harness it integrates with (`**Harness:**`), or `agnostic`. A tool is neutral when it is harness-agnostic or supports two or more harnesses; *coupled* when it targets a single harness.
 
 | Substrate tool | Substrate | Harness support | Verdict |
 |---|---|---|---|
@@ -591,6 +590,7 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 49.
 | `security-tracker-stats-dashboard` | analytics | any | ✅ agnostic |
 | `skill-and-tool-validator` | framework-dev | any | ✅ agnostic |
 | `skill-evals` | framework-dev | any | ✅ agnostic |
+| `spec-inventory` | framework-dev, analytics | any | ✅ agnostic |
 | `spec-loop` | framework-dev | Claude Code, Codex, Cursor, Gemini CLI, OpenCode | ✅ portable |
 | `spec-status-index` | framework-dev, analytics | any | ✅ agnostic |
 | `spec-validator` | framework-dev | any | ✅ agnostic |
@@ -604,7 +604,7 @@ Harness → substrate tools it supports:
 - **Cursor** (1): `spec-loop`
 - **Gemini CLI** (1): `spec-loop`
 - **OpenCode** (5): `agent-guard`, `agent-isolation`, `permission-audit`, `sandbox-lint`, `spec-loop`
-- **any harness** (15): `dashboard-generator`, `dev`, `egress-gateway`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `security-tracker-stats-dashboard`, `skill-and-tool-validator`, `skill-evals`, `spec-status-index`, `spec-validator`, `symlink-lint`, `vendor-neutrality-score`
+- **any harness** (16): `dashboard-generator`, `dev`, `egress-gateway`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `security-tracker-stats-dashboard`, `skill-and-tool-validator`, `skill-evals`, `spec-inventory`, `spec-status-index`, `spec-validator`, `symlink-lint`, `vendor-neutrality-score`
 
 **Model endpoint: neutral by construction — 4 default-approved endpoint classes across independent trust domains, plus adopter opt-in.** From the [`privacy-llm` registry](../tools/privacy-llm/models.md): the framework keys approval on *endpoint identity*, not on who hosts the model, so no single LLM vendor is privileged.
 
@@ -616,7 +616,6 @@ Harness → substrate tools it supports:
 | Air-gapped on-prem | A PMC-hosted inference appliance on a private VLAN |
 
 Every other endpoint is **opt-in** — the adopting project's security team declares it in `<project-config>/privacy-llm.md` (endpoint URL, data-residency contract, approver), so the choice is local and audited.
-
 <!-- END vendor-neutrality-score -->
 
 ### What the number means
diff --git a/pyproject.toml b/pyproject.toml
index e64ea59f..6687cda3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -114,6 +114,7 @@ members = [
   "tools/skill-and-tool-validator",
   "tools/skill-evals",
   "tools/pilot-report-validator",
+  "tools/spec-inventory",
   "tools/spec-status-index",
   "tools/spec-validator",
   "tools/symlink-lint",
diff --git a/tools/spec-inventory/README.md b/tools/spec-inventory/README.md
new file mode 100644
index 00000000..33ebc587
--- /dev/null
+++ b/tools/spec-inventory/README.md
@@ -0,0 +1,51 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents**  *generated with [DocToc](https://github.com/thlorenz/doctoc)*
+
+- [spec-inventory](#spec-inventory)
+  - [Prerequisites](#prerequisites)
+  - [Usage](#usage)
+  - [Run tests](#run-tests)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# spec-inventory
+
+**Capability:** substrate:framework-dev + substrate:analytics
+
+**Harness:** agnostic
+
+A deterministic `uv` tool that emits a compact routing inventory for the
+spec-loop prompts. It summarizes spec frontmatter, where-it-lives hints,
+validation commands, known gaps, skill frontmatter, and tool/test
+presence so agents can choose what to read next without first scanning
+the whole repository.
+
+The output is a routing aid, not proof. Prompts still require direct file
+reads or code search before declaring behaviour present or absent.
+
+## Prerequisites
+
+- **Runtime:** Python 3.11+ run via `uv`; stdlib-only (no runtime
+  dependencies). The `dev` group pulls `pytest`, `ruff`, and `mypy`.
+- **CLIs:** None beyond the runtime.
+- **Credentials / auth:** None.
+- **Network:** Runs fully offline; reads local specs, skills, and tool
+  metadata from the repository checkout.
+
+## Usage
+
+```bash
+uv run --project tools/spec-inventory spec-inventory
+uv run --project tools/spec-inventory spec-inventory --brief --max-where 1 --max-validation 1 --max-gaps 1
+uv run --project tools/spec-inventory spec-inventory --json
+```
+
+## Run tests
+
+```bash
+uv run --project tools/spec-inventory --group dev pytest tools/spec-inventory/tests
+```
diff --git a/tools/spec-inventory/pyproject.toml b/tools/spec-inventory/pyproject.toml
new file mode 100644
index 00000000..a31ec917
--- /dev/null
+++ b/tools/spec-inventory/pyproject.toml
@@ -0,0 +1,75 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "spec-inventory"
+version = "0.1.0"
+description = "Generate a compact routing inventory for the spec-loop prompts."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+dependencies = []
+
+[project.scripts]
+spec-inventory = "spec_inventory:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/spec_inventory"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF"]
+ignore = ["E501"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+warn_unused_ignores = true
+warn_redundant_casts = true
+warn_unreachable = true
+check_untyped_defs = true
+no_implicit_optional = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[dependency-groups]
+dev = [
+  "mypy>=2.1.0",
+  "pytest>=9.1.1",
+  "ruff>=0.15.18",
+]
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/spec-inventory/src/spec_inventory/__init__.py b/tools/spec-inventory/src/spec_inventory/__init__.py
new file mode 100644
index 00000000..03851a24
--- /dev/null
+++ b/tools/spec-inventory/src/spec_inventory/__init__.py
@@ -0,0 +1,400 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Generate a compact routing inventory for the spec-loop.
+
+The inventory is intentionally shallow: enough for an agent to choose the
+next relevant files, not enough to replace direct verification. It is
+stdlib-only so the loop can run it cheaply through ``uv``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+SPECS_DIR = Path("tools/spec-loop/specs")
+SKILLS_DIR = Path("skills")
+TOOLS_DIR = Path("tools")
+
+SKIP_SPEC_FILES = frozenset({"README.md", "overview.md"})
+SKIP_TOOL_DIRS = frozenset({"spec-loop"})
+
+_HTML_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
+_FENCED_CODE_RE = re.compile(r"^ {0,3}```(?:\w+)?\n([\s\S]*?)^ {0,3}```", re.MULTILINE)
+_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
+_SCRIPT_RE = re.compile(r"^\s*([A-Za-z0-9_.-]+)\s*=\s*\"([^\"]+)\"", re.MULTILINE)
+_SECTION_RE = re.compile(r"^\[([^\]]+)\]\s*$")
+_YAML_BLOCK_SCALAR_HEADERS = frozenset({"|", ">", "|-", "|+", ">-", ">+"})
+
+
+@dataclass
+class SpecSummary:
+    file: str
+    title: str
+    status: str
+    mode: str
+    kind: str
+    where: list[str]
+    validation: list[str]
+    known_gaps: list[str]
+
+
+@dataclass
+class SkillSummary:
+    file: str
+    name: str
+    mode: str
+    capability: str
+    organization: str
+    description: str
+
+
+@dataclass
+class ToolSummary:
+    path: str
+    has_pyproject: bool
+    has_tests: bool
+    scripts: list[str]
+
+
+@dataclass
+class Inventory:
+    specs: list[SpecSummary]
+    skills: list[SkillSummary]
+    tools: list[ToolSummary]
+
+
+def find_repo_root(start: Path) -> Path:
+    """Return the nearest parent containing .git."""
+    path = start.resolve()
+    while path != path.parent:
+        if (path / ".git").exists():
+            return path
+        path = path.parent
+    raise RuntimeError(f"Could not find repo root (.git) from {start}")
+
+
+def _frontmatter_bounds(text: str) -> tuple[int, int] | None:
+    idx = text.find("---\n")
+    if idx == -1:
+        return None
+    if _HTML_COMMENT_RE.sub("", text[:idx]).strip():
+        return None
+    try:
+        end = text.index("\n---\n", idx + 4)
+    except ValueError:
+        return None
+    return (idx + 4, end)
+
+
+def parse_frontmatter(text: str) -> dict[str, str]:
+    """Parse top-level YAML-ish frontmatter without external dependencies."""
+    bounds = _frontmatter_bounds(text)
+    if bounds is None:
+        return {}
+    block = text[bounds[0] : bounds[1]]
+    result: dict[str, str] = {}
+    current_key: str | None = None
+    current_value_lines: list[str] = []
+
+    for raw_line in block.splitlines():
+        line = raw_line.rstrip()
+        if line == "":
+            if current_key is not None:
+                current_value_lines.append("")
+            continue
+        if not line.startswith((" ", "\t")) and ":" in line:
+            if current_key is not None:
+                result[current_key] = "\n".join(current_value_lines).strip()
+            key, _, value = line.partition(":")
+            current_key = key.strip()
+            inline = value.strip()
+            current_value_lines = [inline] if inline and inline not in _YAML_BLOCK_SCALAR_HEADERS else []
+            continue
+        if current_key is not None:
+            stripped = line[2:] if line.startswith("  ") else line.strip()
+            current_value_lines.append(stripped)
+
+    if current_key is not None:
+        result[current_key] = "\n".join(current_value_lines).strip()
+    return result
+
+
+def _body_after_frontmatter(text: str) -> str:
+    bounds = _frontmatter_bounds(text)
+    if bounds is None:
+        return text
+    return text[bounds[1] + 5 :]
+
+
+def get_section_body(text: str, section: str) -> str:
+    """Return a ## section body, or an empty string."""
+    body = _body_after_frontmatter(text)
+    headings = list(_HEADING_RE.finditer(body))
+    for index, match in enumerate(headings):
+        if match.group(1).strip() != section:
+            continue
+        start = match.end()
+        end = headings[index + 1].start() if index + 1 < len(headings) else len(body)
+        return body[start:end].strip()
+    return ""
+
+
+def _compact_line(line: str) -> str:
+    return " ".join(line.strip().split())
+
+
+def _truncate(text: str, limit: int = 180) -> str:
+    if len(text) <= limit:
+        return text
+    return text[: limit - 1].rstrip() + "…"
+
+
+def section_bullets(text: str, section: str, limit: int) -> list[str]:
+    """Extract top-level bullets from a section."""
+    body = get_section_body(text, section)
+    bullets: list[str] = []
+    current: list[str] = []
+
+    def flush_current() -> None:
+        if current and len(bullets) < limit:
+            bullets.append(_truncate(_compact_line(" ".join(current))))
+        current.clear()
+
+    for line in body.splitlines():
+        if len(bullets) >= limit:
+            break
+        stripped = line.strip()
+        if stripped.startswith(("- ", "* ")):
+            flush_current()
+            current.append(stripped[2:])
+            continue
+        if current and stripped and not stripped.startswith(("```", "#")):
+            current.append(stripped)
+    flush_current()
+    return bullets
+
+
+def validation_commands(text: str, limit: int) -> list[str]:
+    """Extract compact command lines from fenced blocks in Validation."""
+    body = get_section_body(text, "Validation")
+    commands: list[str] = []
+    for block in _FENCED_CODE_RE.findall(body):
+        for line in block.splitlines():
+            stripped = line.strip()
+            if not stripped or stripped.startswith("#"):
+                continue
+            commands.append(_truncate(_compact_line(stripped), 220))
+            if len(commands) >= limit:
+                return commands
+    return commands
+
+
+def first_description_line(description: str) -> str:
+    for line in description.splitlines():
+        compact = _compact_line(line)
+        if compact:
+            return compact
+    return ""
+
+
+def compact_metadata_value(value: str) -> str:
+    """Compact scalar or simple YAML-list frontmatter values for one-line output."""
+    parts: list[str] = []
+    for line in value.splitlines():
+        stripped = line.strip()
+        if not stripped:
+            continue
+        if stripped.startswith("- "):
+            stripped = stripped[2:].strip()
+        parts.append(stripped)
+    return ", ".join(parts)
+
+
+def load_specs(repo_root: Path, *, max_where: int, max_validation: int, max_gaps: int) -> list[SpecSummary]:
+    specs_dir = repo_root / SPECS_DIR
+    entries: list[SpecSummary] = []
+    for path in sorted(specs_dir.glob("*.md")):
+        if path.name in SKIP_SPEC_FILES:
+            continue
+        text = path.read_text()
+        fm = parse_frontmatter(text)
+        if not fm:
+            continue
+        entries.append(
+            SpecSummary(
+                file=str(path.relative_to(repo_root)),
+                title=fm.get("title", path.stem),
+                status=fm.get("status", ""),
+                mode=fm.get("mode", ""),
+                kind=fm.get("kind", ""),
+                where=section_bullets(text, "Where it lives", max_where),
+                validation=validation_commands(text, max_validation),
+                known_gaps=section_bullets(text, "Known gaps", max_gaps),
+            )
+        )
+    return entries
+
+
+def load_skills(repo_root: Path) -> list[SkillSummary]:
+    skills_dir = repo_root / SKILLS_DIR
+    entries: list[SkillSummary] = []
+    for path in sorted(skills_dir.glob("*/SKILL.md")):
+        text = path.read_text()
+        fm = parse_frontmatter(text)
+        if not fm.get("name"):
+            continue
+        entries.append(
+            SkillSummary(
+                file=str(path.relative_to(repo_root)),
+                name=fm.get("name", path.parent.name),
+                mode=compact_metadata_value(fm.get("mode", "")),
+                capability=compact_metadata_value(fm.get("capability", "")),
+                organization=compact_metadata_value(fm.get("organization", "")),
+                description=first_description_line(fm.get("description", "")),
+            )
+        )
+    return entries
+
+
+def parse_project_scripts(pyproject_text: str) -> list[str]:
+    """Return script names from the [project.scripts] table only."""
+    scripts: list[str] = []
+    in_scripts = False
+    for line in pyproject_text.splitlines():
+        section_match = _SECTION_RE.match(line.strip())
+        if section_match:
+            in_scripts = section_match.group(1) == "project.scripts"
+            continue
+        if not in_scripts:
+            continue
+        script_match = _SCRIPT_RE.match(line)
+        if script_match:
+            scripts.append(script_match.group(1))
+    return scripts
+
+
+def load_tools(repo_root: Path) -> list[ToolSummary]:
+    tools_dir = repo_root / TOOLS_DIR
+    entries: list[ToolSummary] = []
+    for path in sorted(p for p in tools_dir.iterdir() if p.is_dir() and not p.name.startswith(".")):
+        if path.name in SKIP_TOOL_DIRS:
+            continue
+        pyproject = path / "pyproject.toml"
+        readme = path / "README.md"
+        tests = path / "tests"
+        if not pyproject.exists() and not readme.exists() and not tests.exists():
+            continue
+        scripts: list[str] = []
+        if pyproject.exists():
+            scripts = parse_project_scripts(pyproject.read_text())
+        entries.append(
+            ToolSummary(
+                path=str(path.relative_to(repo_root)),
+                has_pyproject=pyproject.exists(),
+                has_tests=tests.is_dir(),
+                scripts=scripts[:5],
+            )
+        )
+    return entries
+
+
+def build_inventory(repo_root: Path, *, max_where: int, max_validation: int, max_gaps: int) -> Inventory:
+    return Inventory(
+        specs=load_specs(repo_root, max_where=max_where, max_validation=max_validation, max_gaps=max_gaps),
+        skills=load_skills(repo_root),
+        tools=load_tools(repo_root),
+    )
+
+
+def _join(items: list[str]) -> str:
+    return "; ".join(items) if items else "-"
+
+
+def format_markdown(inventory: Inventory, *, brief: bool = False) -> str:
+    lines = [
+        "## Compact repository inventory",
+        "",
+        "Deterministic routing aid generated from local files. Use it to choose",
+        "what to inspect next, but verify claims with direct file reads or code",
+        "search before recording behaviour as present or absent.",
+        "",
+        "### Specs",
+        "",
+    ]
+    for spec in inventory.specs:
+        lines.append(f"- `{spec.file}` — {spec.title} [{spec.status}, {spec.mode}, {spec.kind}]")
+        lines.append(f"  where: {_join(spec.where)}")
+        lines.append(f"  validation: {_join(spec.validation)}")
+        lines.append(f"  known gaps: {_join(spec.known_gaps)}")
+    lines.extend(["", "### Skills", ""])
+    for skill in inventory.skills:
+        org = f", {skill.organization}" if skill.organization else ""
+        summary = f"- `{skill.file}` — {skill.name} [{skill.mode}, {skill.capability}{org}]"
+        if not brief and skill.description:
+            summary += f": {skill.description}"
+        lines.append(summary)
+    lines.extend(["", "### Tools", ""])
+    for tool in inventory.tools:
+        markers = []
+        if tool.has_pyproject:
+            markers.append("pyproject")
+        if tool.has_tests:
+            markers.append("tests")
+        script_suffix = f"; scripts: {', '.join(tool.scripts)}" if tool.scripts else ""
+        lines.append(f"- `{tool.path}` — {', '.join(markers) if markers else 'metadata'}{script_suffix}")
+    lines.append("")
+    return "\n".join(lines)
+
+
+def format_json(inventory: Inventory) -> str:
+    return json.dumps(asdict(inventory), indent=2)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Generate compact routing inventory for spec-loop prompts.")
+    parser.add_argument(
+        "--repo-root", type=Path, default=None, help="Repository root (default: nearest .git parent)."
+    )
+    parser.add_argument("--json", dest="as_json", action="store_true", help="Emit JSON instead of Markdown.")
+    parser.add_argument("--brief", action="store_true", help="Omit skill descriptions from Markdown output.")
+    parser.add_argument("--max-where", type=int, default=3, help="Max Where-it-lives bullets per spec.")
+    parser.add_argument("--max-validation", type=int, default=3, help="Max validation commands per spec.")
+    parser.add_argument("--max-gaps", type=int, default=2, help="Max known-gap bullets per spec.")
+    args = parser.parse_args()
+
+    try:
+        repo_root = args.repo_root.resolve() if args.repo_root else find_repo_root(Path.cwd())
+    except RuntimeError as exc:
+        print(f"error: {exc}", file=sys.stderr)
+        sys.exit(1)
+
+    inventory = build_inventory(
+        repo_root,
+        max_where=args.max_where,
+        max_validation=args.max_validation,
+        max_gaps=args.max_gaps,
+    )
+    if args.as_json:
+        print(format_json(inventory))
+    else:
+        print(format_markdown(inventory, brief=args.brief), end="")
diff --git a/tools/spec-inventory/tests/test_spec_inventory.py b/tools/spec-inventory/tests/test_spec_inventory.py
new file mode 100644
index 00000000..0f44443e
--- /dev/null
+++ b/tools/spec-inventory/tests/test_spec_inventory.py
@@ -0,0 +1,227 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from spec_inventory import (
+    build_inventory,
+    compact_metadata_value,
+    first_description_line,
+    format_json,
+    format_markdown,
+    parse_frontmatter,
+    parse_project_scripts,
+    validation_commands,
+)
+
+
+def test_parse_frontmatter_handles_block_scalars_and_comments() -> None:
+    text = """<!-- SPDX-License-Identifier: Apache-2.0 -->
+
+---
+name: magpie-example
+mode: Triage
+description: |
+  First line.
+  Second line.
+capability: capability:triage
+license: Apache-2.0
+---
+"""
+
+    fm = parse_frontmatter(text)
+
+    assert fm["name"] == "magpie-example"
+    assert fm["mode"] == "Triage"
+    assert fm["description"] == "First line.\nSecond line."
+    assert fm["capability"] == "capability:triage"
+
+
+def test_validation_commands_extracts_non_comment_lines() -> None:
+    text = """---
+title: Example
+status: experimental
+kind: feature
+mode: infra
+---
+
+## Validation
+
+```bash
+# comment
+bash -n tools/spec-loop/loop.sh
+uv run --project tools/spec-validator spec-validate
+```
+"""
+
+    assert validation_commands(text, 2) == [
+        "bash -n tools/spec-loop/loop.sh",
+        "uv run --project tools/spec-validator spec-validate",
+    ]
+
+
+def test_first_description_line_skips_blank_lines() -> None:
+    assert first_description_line("\n\n  Useful sentence.\nMore.") == "Useful sentence."
+
+
+def test_compact_metadata_value_handles_yaml_list() -> None:
+    assert compact_metadata_value("- capability:triage\n- capability:review") == (
+        "capability:triage, capability:review"
+    )
+
+
+def test_parse_project_scripts_only_reads_scripts_table() -> None:
+    pyproject = """[project]
+name = "example"
+version = "0.1.0"
+
+[project.scripts]
+example-tool = "example:main"
+
+[tool.ruff]
+line-length = 100
+"""
+
+    assert parse_project_scripts(pyproject) == ["example-tool"]
+
+
+def test_build_inventory_summarizes_specs_skills_and_tools(tmp_path: Path) -> None:
+    specs_dir = tmp_path / "tools" / "spec-loop" / "specs"
+    specs_dir.mkdir(parents=True)
+    (specs_dir / "example.md").write_text(
+        """<!-- SPDX-License-Identifier: Apache-2.0 -->
+
+---
+title: Example Spec
+status: experimental
+kind: feature
+mode: infra
+source: tests
+acceptance:
+  - It works.
+---
+
+# Example Spec
+
+## What it does
+
+Thing.
+
+## Where it lives
+
+- `tools/example/`
+
+## Behaviour & contract
+
+Thing.
+
+## Out of scope
+
+Thing.
+
+## Acceptance criteria
+
+1. Thing.
+
+## Validation
+
+```bash
+uv run --project tools/example pytest
+```
+
+## Known gaps
+
+- Needs more tests.
+"""
+    )
+    (specs_dir / "README.md").write_text("# skip\n")
+
+    skill_dir = tmp_path / "skills" / "example"
+    skill_dir.mkdir(parents=True)
+    (skill_dir / "SKILL.md").write_text(
+        """---
+name: magpie-example
+mode: Triage
+description: |
+  Summarize an example.
+capability: capability:triage
+license: Apache-2.0
+---
+"""
+    )
+
+    tool_dir = tmp_path / "tools" / "example"
+    tool_dir.mkdir(parents=True)
+    (tool_dir / "pyproject.toml").write_text(
+        """[project.scripts]
+example-tool = "example:main"
+"""
+    )
+    (tool_dir / "tests").mkdir()
+
+    inventory = build_inventory(tmp_path, max_where=2, max_validation=2, max_gaps=2)
+
+    assert inventory.specs[0].title == "Example Spec"
+    assert inventory.specs[0].where == ["`tools/example/`"]
+    assert inventory.specs[0].validation == ["uv run --project tools/example pytest"]
+    assert inventory.specs[0].known_gaps == ["Needs more tests."]
+    assert inventory.skills[0].name == "magpie-example"
+    assert inventory.skills[0].capability == "capability:triage"
+    assert inventory.tools[0].path == "tools/example"
+    assert inventory.tools[0].scripts == ["example-tool"]
+
+
+def test_formatters_emit_markdown_and_json(tmp_path: Path) -> None:
+    specs_dir = tmp_path / "tools" / "spec-loop" / "specs"
+    specs_dir.mkdir(parents=True)
+    (specs_dir / "example.md").write_text(
+        """---
+title: Example
+status: stable
+kind: feature
+mode: infra
+source: tests
+acceptance:
+  - It works.
+---
+
+## Where it lives
+- `tools/example/`
+## Validation
+```bash
+bash -n tools/example/run.sh
+```
+## Known gaps
+- None.
+"""
+    )
+    (tmp_path / "skills").mkdir()
+    (tmp_path / "tools" / "example").mkdir(parents=True)
+    (tmp_path / "tools" / "example" / "README.md").write_text("# Example\n")
+
+    inventory = build_inventory(tmp_path, max_where=1, max_validation=1, max_gaps=1)
+    markdown = format_markdown(inventory)
+    brief_markdown = format_markdown(inventory, brief=True)
+    parsed = json.loads(format_json(inventory))
+
+    assert "Compact repository inventory" in markdown
+    assert "`tools/spec-loop/specs/example.md`" in markdown
+    assert "Example" in brief_markdown
+    assert parsed["specs"][0]["title"] == "Example"
diff --git a/tools/spec-loop/AGENTS.md b/tools/spec-loop/AGENTS.md
index b34ba318..aa582613 100644
--- a/tools/spec-loop/AGENTS.md
+++ b/tools/spec-loop/AGENTS.md
@@ -30,6 +30,9 @@ Run the spec's own **Validation** block first. General checks:
 # Validate skill definitions (frontmatter, links, placeholders)
 uv run --project tools/skill-and-tool-validator --group dev skill-and-tool-validate
 
+# Validate the compact inventory helper
+uv run --project tools/spec-inventory --group dev pytest tools/spec-inventory/tests
+
 # A skill's behavioural eval suite (every skill must have one)
 uv run --project tools/skill-evals skill-eval tools/skill-evals/evals/<skill-name>/
 
diff --git a/tools/spec-loop/PROMPT_build.md b/tools/spec-loop/PROMPT_build.md
index 353c5f28..41558c56 100644
--- a/tools/spec-loop/PROMPT_build.md
+++ b/tools/spec-loop/PROMPT_build.md
@@ -10,6 +10,8 @@ Context to load first:
   commands, branch + hard-limit rules). The repo-wide `/AGENTS.md` also
   applies (commit trailers, placeholder convention, confidentiality).
 - `tools/spec-loop/IMPLEMENTATION_PLAN.md` — the prioritised work items.
+- The appended **Compact repository inventory** block from the runner —
+  use it to route to likely specs/source files before opening full files.
 - The appended **Open pull-request context** block from the runner.
 - The appended **Local work-item branches** block from the runner. The
   loop never pushes, so a work item it already built shows up here, not in
@@ -19,7 +21,11 @@ Context to load first:
 
 Steps:
 
-1. Read the appended **Open pull-request context** and **Local work-item
+1. Read the appended **Compact repository inventory**. Use it as a routing
+   aid for selecting likely relevant specs, skills, tools, and validation
+   commands. It is not proof; verify the selected work item against the
+   plan and exact source files before changing anything.
+2. Read the appended **Open pull-request context** and **Local work-item
    branches**. Treat both open PRs and existing local work-item branches as
    in-flight work. Pick the single highest-priority work item from
    `IMPLEMENTATION_PLAN.md`. If a **Tooling source** block is appended
@@ -30,32 +36,32 @@ Steps:
    not already built as a local work-item branch (the loop never pushes, so
    a built item lives only as a local branch until a human pushes it). One
    only.
-2. **Create its branch off the integration base**, then switch to it:
+3. **Create its branch off the integration base**, then switch to it:
    `git checkout -b <slug>` where `<slug>` is the work item's branch — the
    bare slug, **no `spec/` or other prefix** (e.g.
    `pairing-self-review`). NEVER commit the work to the integration
    branch. One branch per work item.
-3. Read only the relevant spec file(s) — from the control branch if a
+4. Read only the relevant spec file(s) — from the control branch if a
    **Tooling source** block is appended, otherwise from the working tree —
    plus the relevant `.claude/skills/` / `tools/` / `docs/` files from the
    working tree. Confirm what already exists before writing — do not assume.
-4. Implement the work item **completely** — no placeholders, no stubs.
+5. Implement the work item **completely** — no placeholders, no stubs.
    Skills: follow the skill format (frontmatter `name` / `description` /
    `license`, SPDX header, placeholder convention, every state change a
    confirmed proposal) **and ship an eval suite** under
    `tools/skill-evals/evals/<skill-name>/` exercising each step with
    fixture cases (per `/AGENTS.md` § Reusable skills — a skill without a
    matching eval suite is incomplete). Tools: ship tests.
-5. Run the work item's **Validation** command(s) from its spec (the
+6. Run the work item's **Validation** command(s) from its spec (the
    backpressure). Fix until they pass.
-6. Specs and `IMPLEMENTATION_PLAN.md` live on the control branch. If a
+7. Specs and `IMPLEMENTATION_PLAN.md` live on the control branch. If a
    **Tooling source** block is appended, they are **not** on this work
    branch — do not create or edit them here; instead note any `status` or
    `Known gap` change in the PR body for a later plan/update beat to
    reconcile. (If no such block is present, the tooling is on this branch:
    update **only that spec's** frontmatter/Known-gaps, and never
    `IMPLEMENTATION_PLAN.md`.)
-7. `git add -A` then `git commit` with an imperative subject and a
+8. `git add -A` then `git commit` with an imperative subject and a
    `Generated-by: Claude (Opus 4.7)` trailer. **Never** add a
    `Co-Authored-By:` trailer for an agent.
 
diff --git a/tools/spec-loop/PROMPT_plan.md b/tools/spec-loop/PROMPT_plan.md
index 91489543..fef55c10 100644
--- a/tools/spec-loop/PROMPT_plan.md
+++ b/tools/spec-loop/PROMPT_plan.md
@@ -11,30 +11,37 @@ Context to load first:
   applies.
 - `tools/spec-loop/specs/*` — the functional description of the product.
 - `tools/spec-loop/IMPLEMENTATION_PLAN.md` (if present; may be stale).
+- The appended **Compact repository inventory** block from the runner —
+  use it as the first routing map before opening full files.
 - The appended **Open pull-request context** block from the runner.
 - The appended **Local work-item branches** block from the runner. Built
   but un-pushed work items live here, not in the PR context.
 
 Steps:
 
-1. Study each spec in `tools/spec-loop/specs/` and compare it against the
+1. Read the appended **Compact repository inventory**. Use it to identify
+   the likely relevant specs, skills, tools, validation commands, and
+   known gaps before opening full files. The inventory is a routing aid,
+   not proof: before recording a gap or declaring one closed, confirm with
+   a code search or direct file read.
+2. Study each spec in `tools/spec-loop/specs/` and compare it against the
    actual code it names in **Where it lives** (`.claude/skills/`,
    `tools/`, `docs/`). You may use parallel subagents for reading. Do NOT
    assume something is missing — confirm with a code search first.
-2. Read the appended **Open pull-request context** and **Local work-item
+3. Read the appended **Open pull-request context** and **Local work-item
    branches**. Treat both open PRs and existing local work-item branches as
    in-flight work. If an apparent gap is already substantially covered by an
    open PR (including draft PRs) or already built on a local work-item
    branch, do not add it as a planned work item. The loop never pushes, so a
    built item may exist only as a local branch with no PR yet.
-3. For each spec, identify the **gaps**: a `proposed` area with no skill,
+4. For each spec, identify the **gaps**: a `proposed` area with no skill,
    a documented step that drifted from the code, a missing test, a
    `Known gaps` item. Each gap is a candidate work item.
-4. Rewrite `tools/spec-loop/IMPLEMENTATION_PLAN.md` as a prioritised list
+5. Rewrite `tools/spec-loop/IMPLEMENTATION_PLAN.md` as a prioritised list
    of work items. Each work item names: the change, the spec it serves,
    its **Validation** command, and a branch slug (`<slug>`, the bare
    slug — **no `spec/` or other prefix, no numbers**).
-5. Do NOT create work items against an `off` spec (e.g. Agentic Autonomous) —
+6. Do NOT create work items against an `off` spec (e.g. Agentic Autonomous) —
    that would skip the proof MISSION requires.
 
 Rules:
diff --git a/tools/spec-loop/PROMPT_update.md b/tools/spec-loop/PROMPT_update.md
index c0f5b21e..6c3d31e2 100644
--- a/tools/spec-loop/PROMPT_update.md
+++ b/tools/spec-loop/PROMPT_update.md
@@ -13,6 +13,8 @@ Context to load first:
 - `tools/spec-loop/AGENTS.md` and the repo-wide `/AGENTS.md`.
 - `tools/spec-loop/specs/*` — the current functional description.
 - The actual code: `.claude/skills/`, `tools/`, `docs/`, `docs/modes.md`.
+- The appended **Compact repository inventory** block from the runner —
+  use it as the first routing map before opening full files.
 
 Steps:
 
@@ -23,19 +25,23 @@ Steps:
    that commit. If the diff is empty, exit without creating a branch or
    commit (print "specs already in sync as of <SHA>"). If no previous
    sync commit is recorded, fall through to a full inventory.
-2. **Create a uniquely-named sync branch off the integration base**, then
+2. Read the appended **Compact repository inventory**. Use it to identify
+   likely spec/source relationships and validation commands before opening
+   full files. The inventory is a routing aid, not proof; confirm with a
+   code search before recording something as present or absent.
+3. **Create a uniquely-named sync branch off the integration base**, then
    switch to it: `git checkout -b "sync-specs-$(date +%Y%m%d-%H%M%S)"`. A
    fresh branch every run keeps each sync as its own reviewable PR and
    never collides with or commits on top of a previous `sync-specs*`
    branch. Note the exact name you created — you will print it in the
    human-run commands below. Never commit the sync to the integration
    branch.
-3. Inventory the code with parallel subagents (full inventory only if
+4. Inventory the code with parallel subagents (full inventory only if
    step 1 did not narrow the surface):
    - every `.claude/skills/*/SKILL.md` (name, mode, what it does);
    - every `tools/*` project (what it does, its tests);
    - the mode/status table in `docs/modes.md`.
-4. Diff that inventory against `tools/spec-loop/specs/`:
+5. Diff that inventory against `tools/spec-loop/specs/`:
    - **New functionality with no spec** → author a new topic-named spec
      (no number prefix) following the format in
      [`specs/README.md`](specs/README.md), grounded in the real code it
@@ -47,9 +53,9 @@ Steps:
      are reflected).
    - **Removed functionality** → mark the spec or move it to a `Known
      gaps`/retired note; do not silently delete history.
-5. Update `specs/overview.md` and `specs/README.md` indexes if areas were
+6. Update `specs/overview.md` and `specs/README.md` indexes if areas were
    added or renamed.
-6. `git add -A` then `git commit` with subject
+7. `git add -A` then `git commit` with subject
    `docs(spec-loop): sync specs with contributed functionality` and a
    `Generated-by: Claude (Opus 4.7)` trailer. **Do NOT touch
    `tools/spec-loop/.last-sync` yourself** — `loop.sh` amends the marker
diff --git a/tools/spec-loop/README.md b/tools/spec-loop/README.md
index ea7c566c..ae18f24d 100644
--- a/tools/spec-loop/README.md
+++ b/tools/spec-loop/README.md
@@ -80,6 +80,7 @@ See the SECURITY notes in [`loop.sh`](loop.sh).
 | [`AGENTS.md`](AGENTS.md) | Loop-scoped operational rules (repo map, validation commands, branch + hard-limit rules). |
 | `PROMPT_plan.md` / `PROMPT_build.md` / `PROMPT_update.md` / `PROMPT_consolidate.md` | The per-beat prompts. |
 | `loop.sh` | The runner. |
+| `../spec-inventory/` | Deterministic compact inventory helper appended to prompts as a routing aid. |
 
 ## Modes
 
diff --git a/tools/spec-loop/loop.sh b/tools/spec-loop/loop.sh
index 5a36809e..f2150fd8 100755
--- a/tools/spec-loop/loop.sh
+++ b/tools/spec-loop/loop.sh
@@ -335,6 +335,25 @@ local_branch_context() {
     done <<< "$branches"
 }
 
+compact_inventory_context() {
+    echo ""
+    if ! command -v uv >/dev/null 2>&1; then
+        echo "## Compact repository inventory"
+        echo ""
+        echo "- unavailable: uv CLI not found on PATH."
+        return 0
+    fi
+
+    local inventory
+    if inventory="$(uv run --project tools/spec-inventory spec-inventory --brief --max-where 1 --max-validation 1 --max-gaps 1 2>/dev/null)"; then
+        printf '%s\n' "$inventory"
+    else
+        echo "## Compact repository inventory"
+        echo ""
+        echo "- unavailable: spec-inventory failed. Fall back to direct file reads."
+    fi
+}
+
 while true; do
     if [ "$MAX_ITERATIONS" -gt 0 ] && [ "$ITERATION" -ge "$MAX_ITERATIONS" ]; then
         echo "Reached max iterations: $MAX_ITERATIONS"; break
@@ -387,6 +406,7 @@ while true; do
         echo "Error: could not read '$ACTIVE_PROMPT' from the working tree or control branch '$TOOLING_REF'." >&2
         rm -f "$PROMPT_WITH_CONTEXT"; break
     fi
+    compact_inventory_context >> "$PROMPT_WITH_CONTEXT"
     # Update mode just diffs code against specs; it doesn't pick a work item, so
     # the open-PR list (a network round-trip via gh) buys nothing. Skip it there.
     if [ "$MODE" != "update" ]; then
diff --git a/tools/spec-loop/specs/meta-and-quality-tooling.md b/tools/spec-loop/specs/meta-and-quality-tooling.md
index cec48c7d..64fa70dd 100644
--- a/tools/spec-loop/specs/meta-and-quality-tooling.md
+++ b/tools/spec-loop/specs/meta-and-quality-tooling.md
@@ -46,6 +46,9 @@ trustworthy as it grows.
 - `tools/spec-status-index/` — deterministic `uv` tool that reads
   `tools/spec-loop/specs/` and prints specs grouped by status; used by
   build iterations to mechanically select the next work item.
+- `tools/spec-inventory/` — deterministic `uv` tool that summarizes
+  specs, skills, and tools into a compact routing inventory for spec-loop
+  prompts.
 - `tools/spec-validator/` — validates spec-loop spec frontmatter
   (required keys, valid `status`/`kind`/`mode` values, body-section
   presence, `Known gaps` section required in functional specs,
@@ -100,12 +103,15 @@ trustworthy as it grows.
    implementation are explicitly marked reserved or future.
 6. `docs/modes.md` skill lists and shipped counts are checked against
    live skill frontmatter.
+7. `spec-inventory` emits a compact, deterministic routing map for specs,
+   skills, and tools, and has its own tests.
 
 ## Validation
 
 ```bash
 uv run --project tools/skill-and-tool-validator --group dev pytest
 uv run --project tools/skill-and-tool-validator --group dev skill-and-tool-validate
+uv run --project tools/spec-inventory --group dev pytest tools/spec-inventory/tests
 ```
 
 ## Known gaps
diff --git a/uv.lock b/uv.lock
index 95a9303e..c99e4b6c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -34,6 +34,7 @@ members = [
     "security-tracker-stats-dashboard",
     "skill-and-tool-validator",
     "skill-evals",
+    "spec-inventory",
     "spec-status-index",
     "spec-validator",
     "symlink-lint",
@@ -1127,6 +1128,27 @@ dev = [
     { name = "ruff", specifier = ">=0.15.18" },
 ]
 
+[[package]]
+name = "spec-inventory"
+version = "0.1.0"
+source = { editable = "tools/spec-inventory" }
+
+[package.dev-dependencies]
+dev = [
+    { name = "mypy" },
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "mypy", specifier = ">=2.1.0" },
+    { name = "pytest", specifier = ">=9.1.1" },
+    { name = "ruff", specifier = ">=0.15.18" },
+]
+
 [[package]]
 name = "spec-status-index"
 version = "0.1.0"