generated from coulomb/repo-seed
WP-0016 finished: interactive registry maintain with llm-connect automation
Some checks failed
ci / validate-registry (push) Has been cancelled
Some checks failed
ci / validate-registry (push) Has been cancelled
Closes the registry maintenance loop from inside each domain repo: interactive prompting for judgment calls, full automation for safe and high-confidence changes, both backed by the llm-connect HTTP bridge. - New modules: maintain.py, maintain_llm.py, patches.py, interactive.py - Schema: schemas/registry-patch.schema.json - CLI: reuse-surface maintain; establish --scaffold --hook - Sibling templates: Makefile fragment, pre-commit hook - Deterministic signal collectors extended; validate cwd auto-detect - Docs, gap priority 28, SCOPE update - Tests: test_maintain.py, test_interactive.py (59 pytest total) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,9 @@ jobs:
|
|||||||
reuse-surface catalog
|
reuse-surface catalog
|
||||||
reuse-surface graph --check --fail-on-warnings
|
reuse-surface graph --check --fail-on-warnings
|
||||||
|
|
||||||
|
- name: Registry maintain dry-run (informational)
|
||||||
|
run: reuse-surface maintain --all --auto --no-llm || true
|
||||||
|
|
||||||
- name: Registry stats (informational)
|
- name: Registry stats (informational)
|
||||||
run: reuse-surface stats || true
|
run: reuse-surface stats || true
|
||||||
|
|
||||||
|
|||||||
2
SCOPE.md
2
SCOPE.md
@@ -68,6 +68,8 @@ The MVP registry foundation, CLI tooling (REUSE-WP-0003), federation stack
|
|||||||
`--roster registry/federation/local-repo-roster.yaml --federation-ready`)
|
`--roster registry/federation/local-repo-roster.yaml --federation-ready`)
|
||||||
- **Draft or refresh entries** with `reuse-surface establish --discover` and
|
- **Draft or refresh entries** with `reuse-surface establish --discover` and
|
||||||
`reuse-surface update` (optional llm-connect backend)
|
`reuse-surface update` (optional llm-connect backend)
|
||||||
|
- **Maintain registry interactively or automatically** with `reuse-surface maintain`
|
||||||
|
(TTY prompts, `--auto`, optional llm-connect, `--publish` chain)
|
||||||
- **Run the hub locally or in a container** with `reuse-surface serve`
|
- **Run the hub locally or in a container** with `reuse-surface serve`
|
||||||
- **Generate relation graphs** with `reuse-surface graph`
|
- **Generate relation graphs** with `reuse-surface graph`
|
||||||
- **Explore relations interactively** at `docs/graph/index.html`
|
- **Explore relations interactively** at `docs/graph/index.html`
|
||||||
|
|||||||
@@ -194,15 +194,17 @@ consumer telemetry.
|
|||||||
|
|
||||||
See §4 and archived workplans `workplans/archived/`.
|
See §4 and archived workplans `workplans/archived/`.
|
||||||
|
|
||||||
### Proposed next (priorities 25–27)
|
### Proposed next (priorities 25–28)
|
||||||
|
|
||||||
| Priority | Gap | Suggested outcome | Status |
|
| Priority | Gap | Suggested outcome | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 25 | Gitea publish visibility | Raw URL HTTP 200 for all roster rows | **Closed** (WP-0015-T01) |
|
| 25 | Gitea publish visibility | Raw URL HTTP 200 for all roster rows | **Closed** (WP-0015-T01) |
|
||||||
| 26 | Federated ID deduplication | Per-owner removal from reuse-surface index | **Closed** (WP-0015-T02) |
|
| 26 | Federated ID deduplication | Per-owner removal from reuse-surface index | **Closed** (WP-0015-T02) |
|
||||||
| 27 | Planning analytics + standardization | Gap report or standardization tracker | **Partial** — gap report shipped (T03); tracker deferred |
|
| 27 | Planning analytics + standardization | Gap report or standardization tracker | **Partial** — gap report shipped (T03); tracker deferred |
|
||||||
|
| 28 | Registry maintenance automation | Interactive `maintain` + `--auto` with llm-connect | **Closed** (WP-0016) |
|
||||||
|
|
||||||
**Workplan:** `workplans/REUSE-WP-0015-federation-polish-and-planning-analytics.md`
|
**Workplan:** `workplans/REUSE-WP-0016-interactive-registry-maintain.md` (priority 28);
|
||||||
|
`workplans/REUSE-WP-0015-federation-polish-and-planning-analytics.md` (25–27)
|
||||||
**Assessment:** `history/2026-06-16-intent-scope-assessment.md`
|
**Assessment:** `history/2026-06-16-intent-scope-assessment.md`
|
||||||
|
|
||||||
**Follow-up docs:**
|
**Follow-up docs:**
|
||||||
@@ -233,3 +235,4 @@ See §4 and archived workplans `workplans/archived/`.
|
|||||||
| 2026-06-16 | WP-0014 closed priority 18; 60 workstation repos |
|
| 2026-06-16 | WP-0014 closed priority 18; 60 workstation repos |
|
||||||
| 2026-06-16 | **SCOPE refresh + full INTENT success-criteria mapping**; priorities 25–27 proposed |
|
| 2026-06-16 | **SCOPE refresh + full INTENT success-criteria mapping**; priorities 25–27 proposed |
|
||||||
| 2026-06-16 | Assessment persisted; **REUSE-WP-0015** created for priorities 25–27 |
|
| 2026-06-16 | Assessment persisted; **REUSE-WP-0015** created for priorities 25–27 |
|
||||||
|
| 2026-06-16 | **REUSE-WP-0016** closed priority 28 (interactive `maintain`, `--auto`, templates) |
|
||||||
@@ -129,12 +129,27 @@ completes the establishment checklist below.
|
|||||||
cd ../state-hub
|
cd ../state-hub
|
||||||
reuse-surface establish --scaffold --domain helix_forge
|
reuse-surface establish --scaffold --domain helix_forge
|
||||||
# optional: LLM_CONNECT_URL=... reuse-surface establish --discover --dry-run
|
# optional: LLM_CONNECT_URL=... reuse-surface establish --discover --dry-run
|
||||||
reuse-surface validate --root .
|
reuse-surface validate
|
||||||
git push origin main
|
git push origin main
|
||||||
reuse-surface establish --publish-check \
|
reuse-surface establish --publish-check \
|
||||||
--raw-url https://gitea.coulomb.social/coulomb/state-hub/raw/main/registry/indexes/capabilities.yaml
|
--raw-url https://gitea.coulomb.social/coulomb/state-hub/raw/main/registry/indexes/capabilities.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Ongoing maintenance (from sibling repo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LLM_CONNECT_URL=http://127.0.0.1:8088 # optional
|
||||||
|
reuse-surface maintain --all --from-git-since origin/main
|
||||||
|
reuse-surface maintain --all --auto --no-llm # CI / pre-commit
|
||||||
|
reuse-surface maintain --publish \
|
||||||
|
--raw-url https://gitea.coulomb.social/coulomb/state-hub/raw/main/registry/indexes/capabilities.yaml \
|
||||||
|
--all --auto --no-llm
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `templates/Makefile.registry.fragment` for `make registry-maintain` /
|
||||||
|
`make registry-check`. Optional pre-commit hook:
|
||||||
|
`reuse-surface establish --scaffold --hook`.
|
||||||
|
|
||||||
### Registration checklist
|
### Registration checklist
|
||||||
|
|
||||||
1. Merge capability index to the default branch.
|
1. Merge capability index to the default branch.
|
||||||
|
|||||||
@@ -50,6 +50,33 @@ Discover drafts start at low maturity with explicit auto-draft risks in
|
|||||||
`known_reliability_risks`. Promote only with evidence per
|
`known_reliability_risks`. Promote only with evidence per
|
||||||
`specs/CapabilityMaturityStandard.md`.
|
`specs/CapabilityMaturityStandard.md`.
|
||||||
|
|
||||||
|
## Maintain session checklist (REUSE-WP-0016)
|
||||||
|
|
||||||
|
After code or doc changes in the owning repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
reuse-surface maintain --all --from-git-since origin/main
|
||||||
|
reuse-surface validate
|
||||||
|
git add registry/ && git commit -m "registry: maintain session"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Automation (CI or pre-commit):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
reuse-surface maintain --all --auto --no-llm
|
||||||
|
```
|
||||||
|
|
||||||
|
With llm-connect for maturity suggestions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LLM_CONNECT_URL=http://127.0.0.1:8088
|
||||||
|
reuse-surface maintain --all --from-git-since HEAD~5
|
||||||
|
```
|
||||||
|
|
||||||
|
Review every non-deterministic patch before merge; promotions require evidence
|
||||||
|
citations on disk per `specs/CapabilityMaturityStandard.md`.
|
||||||
|
|
||||||
## Manual validation checklist
|
## Manual validation checklist
|
||||||
|
|
||||||
Use this checklist until an automated CLI validator exists.
|
Use this checklist until an automated CLI validator exists.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Composed federated capability index. Regenerate with:
|
# Composed federated capability index. Regenerate with:
|
||||||
# reuse-surface federation compose
|
# reuse-surface federation compose
|
||||||
version: 1
|
version: 1
|
||||||
updated: '2026-06-16'
|
updated: '2026-06-18'
|
||||||
domain: helix_forge
|
domain: helix_forge
|
||||||
collision_policy: warn
|
collision_policy: warn
|
||||||
sources:
|
sources:
|
||||||
@@ -162,7 +162,7 @@ sources:
|
|||||||
url: https://gitea.coulomb.social/coulomb/ops-hub/raw/main/registry/indexes/capabilities.yaml
|
url: https://gitea.coulomb.social/coulomb/ops-hub/raw/main/registry/indexes/capabilities.yaml
|
||||||
cache: registry/federation/cache/ops-hub.yaml
|
cache: registry/federation/cache/ops-hub.yaml
|
||||||
- repo: ops-warden
|
- repo: ops-warden
|
||||||
count: 0
|
count: 1
|
||||||
url: https://gitea.coulomb.social/coulomb/ops-warden/raw/main/registry/indexes/capabilities.yaml
|
url: https://gitea.coulomb.social/coulomb/ops-warden/raw/main/registry/indexes/capabilities.yaml
|
||||||
cache: registry/federation/cache/ops-warden.yaml
|
cache: registry/federation/cache/ops-warden.yaml
|
||||||
- repo: phase-memory
|
- repo: phase-memory
|
||||||
@@ -430,6 +430,29 @@ capabilities:
|
|||||||
source_repo: reuse-surface
|
source_repo: reuse-surface
|
||||||
source_url: https://gitea.coulomb.social/coulomb/reuse-surface/raw/main/registry/indexes/capabilities.yaml
|
source_url: https://gitea.coulomb.social/coulomb/reuse-surface/raw/main/registry/indexes/capabilities.yaml
|
||||||
source_index: registry/federation/cache/reuse-surface.yaml
|
source_index: registry/federation/cache/reuse-surface.yaml
|
||||||
|
- id: capability.security.ssh-certificate-issuance
|
||||||
|
name: SSH Certificate Issuance
|
||||||
|
summary: Issue short-lived CA-signed SSH certificates for adm, agt, and atm actors
|
||||||
|
through a stable cert_command CLI interface; steward NetKingdom operational access
|
||||||
|
routing.
|
||||||
|
vector: D4 / A3 / C3 / R2
|
||||||
|
domain: helix_forge
|
||||||
|
status: draft
|
||||||
|
owner: ops-warden
|
||||||
|
path: registry/capabilities/capability.security.ssh-certificate-issuance.md
|
||||||
|
tags:
|
||||||
|
- ssh
|
||||||
|
- certificate
|
||||||
|
- ca
|
||||||
|
- ops-warden
|
||||||
|
- openbao
|
||||||
|
- security
|
||||||
|
consumption_modes:
|
||||||
|
- CLI
|
||||||
|
- cert_command subprocess
|
||||||
|
source_repo: ops-warden
|
||||||
|
source_url: https://gitea.coulomb.social/coulomb/ops-warden/raw/main/registry/indexes/capabilities.yaml
|
||||||
|
source_index: registry/federation/cache/ops-warden.yaml
|
||||||
- id: capability.statehub.progress-log
|
- id: capability.statehub.progress-log
|
||||||
name: Work Progress Logging
|
name: Work Progress Logging
|
||||||
summary: Record progress events, decisions, and session notes against workstreams
|
summary: Record progress events, decisions, and session notes against workstreams
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from reuse_surface.reports import (
|
|||||||
from reuse_surface.establish import (
|
from reuse_surface.establish import (
|
||||||
discover_capabilities,
|
discover_capabilities,
|
||||||
format_publish_check_markdown,
|
format_publish_check_markdown,
|
||||||
|
install_registry_hook,
|
||||||
publish_check,
|
publish_check,
|
||||||
scaffold_next_steps,
|
scaffold_next_steps,
|
||||||
scaffold_registry,
|
scaffold_registry,
|
||||||
@@ -52,6 +53,11 @@ from reuse_surface.stats import (
|
|||||||
format_stats_json,
|
format_stats_json,
|
||||||
format_stats_markdown,
|
format_stats_markdown,
|
||||||
)
|
)
|
||||||
|
from reuse_surface.maintain import (
|
||||||
|
format_maintain_json,
|
||||||
|
format_maintain_markdown,
|
||||||
|
run_maintain,
|
||||||
|
)
|
||||||
from reuse_surface.registry import (
|
from reuse_surface.registry import (
|
||||||
ROOT,
|
ROOT,
|
||||||
capability_paths,
|
capability_paths,
|
||||||
@@ -62,13 +68,26 @@ from reuse_surface.registry import (
|
|||||||
parse_front_matter,
|
parse_front_matter,
|
||||||
parse_vector,
|
parse_vector,
|
||||||
registry_paths,
|
registry_paths,
|
||||||
|
resolve_repo_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _registry_root(args: argparse.Namespace) -> Path:
|
def _registry_root(args: argparse.Namespace) -> Path:
|
||||||
if getattr(args, "root", None):
|
return resolve_repo_root(getattr(args, "root", None))
|
||||||
return Path(args.root).resolve()
|
|
||||||
return ROOT
|
|
||||||
|
def _make_validate_fn(repo_root: Path) -> Any:
|
||||||
|
def _validate() -> tuple[int, list[str], list[str]]:
|
||||||
|
errors, warnings, _paths = _run_validate(repo_root, target=None, relations=False)
|
||||||
|
for warning in warnings:
|
||||||
|
print(f"warning: {warning}", file=sys.stderr)
|
||||||
|
for error in errors:
|
||||||
|
print(f"error: {error}", file=sys.stderr)
|
||||||
|
if errors:
|
||||||
|
return 1, errors, warnings
|
||||||
|
return 0, errors, warnings
|
||||||
|
|
||||||
|
return _validate
|
||||||
|
|
||||||
|
|
||||||
def _check_index_drift(
|
def _check_index_drift(
|
||||||
@@ -427,6 +446,9 @@ def cmd_establish(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
for path in created:
|
for path in created:
|
||||||
print(f"ok: wrote {path.relative_to(repo_root)}")
|
print(f"ok: wrote {path.relative_to(repo_root)}")
|
||||||
|
if args.hook:
|
||||||
|
hook = install_registry_hook(repo_root, force=args.force)
|
||||||
|
print(f"ok: wrote {hook.relative_to(repo_root)}")
|
||||||
print(scaffold_next_steps(repo_root))
|
print(scaffold_next_steps(repo_root))
|
||||||
return 0
|
return 0
|
||||||
if args.publish_check:
|
if args.publish_check:
|
||||||
@@ -461,6 +483,38 @@ def cmd_establish(args: argparse.Namespace) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_maintain(args: argparse.Namespace) -> int:
|
||||||
|
repo_root = Path(args.path or ".").resolve()
|
||||||
|
try:
|
||||||
|
if not args.capability and not args.all:
|
||||||
|
print("error: specify --capability or --all", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
result = run_maintain(
|
||||||
|
repo_root,
|
||||||
|
capability_id=args.capability,
|
||||||
|
all_capabilities=args.all,
|
||||||
|
git_since=args.from_git_since,
|
||||||
|
llm_url=args.llm_url,
|
||||||
|
no_llm=args.no_llm,
|
||||||
|
auto=args.auto,
|
||||||
|
yes=args.yes,
|
||||||
|
auto_confidence=args.auto_confidence,
|
||||||
|
auto_max_delta=args.auto_max_delta,
|
||||||
|
publish=args.publish,
|
||||||
|
raw_url=args.raw_url,
|
||||||
|
output_format=args.format,
|
||||||
|
validate_fn=_make_validate_fn(repo_root),
|
||||||
|
)
|
||||||
|
if args.format == "json":
|
||||||
|
print(format_maintain_json(result))
|
||||||
|
else:
|
||||||
|
print(format_maintain_markdown(result), end="")
|
||||||
|
return result.exit_code
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def cmd_update(args: argparse.Namespace) -> int:
|
def cmd_update(args: argparse.Namespace) -> int:
|
||||||
repo_root = Path(args.path or ".").resolve()
|
repo_root = Path(args.path or ".").resolve()
|
||||||
try:
|
try:
|
||||||
@@ -782,6 +836,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
establish.add_argument("--raw-url", help="raw Gitea index URL for publish-check")
|
establish.add_argument("--raw-url", help="raw Gitea index URL for publish-check")
|
||||||
establish.add_argument("--llm-url", help="llm-connect base URL (or LLM_CONNECT_URL)")
|
establish.add_argument("--llm-url", help="llm-connect base URL (or LLM_CONNECT_URL)")
|
||||||
establish.add_argument("--context-max-files", type=int, default=12)
|
establish.add_argument("--context-max-files", type=int, default=12)
|
||||||
|
establish.add_argument(
|
||||||
|
"--hook",
|
||||||
|
action="store_true",
|
||||||
|
help="install registry pre-commit hook (with --scaffold)",
|
||||||
|
)
|
||||||
establish.set_defaults(func=cmd_establish)
|
establish.set_defaults(func=cmd_establish)
|
||||||
|
|
||||||
update = subparsers.add_parser("update", help="refresh registry metadata from repo signals")
|
update = subparsers.add_parser("update", help="refresh registry metadata from repo signals")
|
||||||
@@ -795,6 +854,28 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
update.add_argument("--format", choices=["markdown", "json"], default="markdown")
|
update.add_argument("--format", choices=["markdown", "json"], default="markdown")
|
||||||
update.set_defaults(func=cmd_update)
|
update.set_defaults(func=cmd_update)
|
||||||
|
|
||||||
|
maintain = subparsers.add_parser(
|
||||||
|
"maintain", help="interactive or automated registry maintenance"
|
||||||
|
)
|
||||||
|
maintain.add_argument("--path", help="repo root (default: cwd)")
|
||||||
|
maintain.add_argument("--capability", help="single capability id")
|
||||||
|
maintain.add_argument("--all", action="store_true")
|
||||||
|
maintain.add_argument("--from-git-since", help="git ref for change detection")
|
||||||
|
maintain.add_argument("--llm-url", help="llm-connect base URL (or LLM_CONNECT_URL)")
|
||||||
|
maintain.add_argument("--no-llm", action="store_true")
|
||||||
|
maintain.add_argument("--auto", action="store_true", help="apply safe + gated LLM patches")
|
||||||
|
maintain.add_argument("--yes", action="store_true", help="non-TTY auto-apply equivalent")
|
||||||
|
maintain.add_argument(
|
||||||
|
"--auto-confidence",
|
||||||
|
choices=["low", "medium", "high"],
|
||||||
|
default="high",
|
||||||
|
)
|
||||||
|
maintain.add_argument("--auto-max-delta", type=int, default=1)
|
||||||
|
maintain.add_argument("--publish", action="store_true")
|
||||||
|
maintain.add_argument("--raw-url", help="raw Gitea index URL for --publish")
|
||||||
|
maintain.add_argument("--format", choices=["markdown", "json"], default="markdown")
|
||||||
|
maintain.set_defaults(func=cmd_maintain)
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
return args.func(args)
|
return args.func(args)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ from typing import Any
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from reuse_surface.llm_bridge import request_registry_draft
|
from reuse_surface.llm_bridge import request_registry_draft
|
||||||
from reuse_surface.registry import load_index_at, registry_paths
|
from reuse_surface.registry import ROOT, load_index_at, registry_paths
|
||||||
|
|
||||||
|
HOOK_TEMPLATE = ROOT / "templates" / "git-hook.pre-commit.registry"
|
||||||
|
|
||||||
SCAFFOLD_README = """# Capability Registry
|
SCAFFOLD_README = """# Capability Registry
|
||||||
|
|
||||||
@@ -80,6 +82,18 @@ def scaffold_registry(
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def install_registry_hook(repo_root: Path, *, force: bool = False) -> Path:
|
||||||
|
hook_path = repo_root / ".git" / "hooks" / "pre-commit"
|
||||||
|
if not (repo_root / ".git").is_dir():
|
||||||
|
raise ValueError(f"not a git repository: {repo_root}")
|
||||||
|
if hook_path.exists() and not force:
|
||||||
|
raise ValueError(f"hook already exists: {hook_path}; use --force to overwrite")
|
||||||
|
hook_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
hook_path.write_text(HOOK_TEMPLATE.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
hook_path.chmod(0o755)
|
||||||
|
return hook_path
|
||||||
|
|
||||||
|
|
||||||
def scaffold_next_steps(repo_root: Path) -> str:
|
def scaffold_next_steps(repo_root: Path) -> str:
|
||||||
return textwrap.dedent(
|
return textwrap.dedent(
|
||||||
f"""
|
f"""
|
||||||
|
|||||||
119
reuse_surface/interactive.py
Normal file
119
reuse_surface/interactive.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from reuse_surface.patches import is_safe_patch
|
||||||
|
|
||||||
|
PromptAction = Literal["apply", "skip", "edit", "quit", "apply_all_safe"]
|
||||||
|
|
||||||
|
|
||||||
|
class NonInteractiveError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def is_tty() -> bool:
|
||||||
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||||
|
|
||||||
|
|
||||||
|
def format_patch_summary(patch: dict[str, Any]) -> str:
|
||||||
|
lines = [
|
||||||
|
f" capability: {patch['capability_id']}",
|
||||||
|
f" kind: {patch['kind']}",
|
||||||
|
f" confidence: {patch.get('confidence', 'n/a')}",
|
||||||
|
f" rationale: {patch.get('rationale', '')}",
|
||||||
|
]
|
||||||
|
for key in ("append", "value", "field_path", "dimension", "from_level", "to_level"):
|
||||||
|
if patch.get(key) is not None:
|
||||||
|
lines.append(f" {key}: {patch[key]}")
|
||||||
|
if patch.get("evidence_citations"):
|
||||||
|
lines.append(f" evidence: {', '.join(patch['evidence_citations'])}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def emit_event(event: str, payload: dict[str, Any]) -> None:
|
||||||
|
print(json.dumps({"event": event, **payload}, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_patch(patch: dict[str, Any]) -> PromptAction:
|
||||||
|
print("\n--- Registry patch ---")
|
||||||
|
print(format_patch_summary(patch))
|
||||||
|
while True:
|
||||||
|
choice = input("[a]pply [s]kip [e]dit [q]uit [A]pply all safe? ").strip().lower()
|
||||||
|
if choice in {"a", "apply"}:
|
||||||
|
return "apply"
|
||||||
|
if choice in {"s", "skip"}:
|
||||||
|
return "skip"
|
||||||
|
if choice in {"e", "edit"}:
|
||||||
|
return "edit"
|
||||||
|
if choice in {"q", "quit"}:
|
||||||
|
return "quit"
|
||||||
|
if choice == "":
|
||||||
|
continue
|
||||||
|
if choice.upper() == "A" or choice == "apply all safe":
|
||||||
|
return "apply_all_safe"
|
||||||
|
print("Invalid choice.")
|
||||||
|
|
||||||
|
|
||||||
|
def edit_patch(patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
editor = os.environ.get("EDITOR", "nano")
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as handle:
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
yaml.safe_dump(patch, handle, sort_keys=False)
|
||||||
|
temp_path = handle.name
|
||||||
|
subprocess.run([editor, temp_path], check=False)
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
edited = yaml.safe_load(Path(temp_path).read_text(encoding="utf-8"))
|
||||||
|
Path(temp_path).unlink(missing_ok=True)
|
||||||
|
if not isinstance(edited, dict):
|
||||||
|
return patch
|
||||||
|
return edited
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_batch(
|
||||||
|
patches: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
assume_yes: bool = False,
|
||||||
|
auto_mode: bool = False,
|
||||||
|
emit_json: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if auto_mode or assume_yes:
|
||||||
|
return list(patches)
|
||||||
|
|
||||||
|
if not is_tty():
|
||||||
|
if emit_json:
|
||||||
|
for patch in patches:
|
||||||
|
emit_event("suggestion", {"patch": patch, "default": "skip"})
|
||||||
|
raise NonInteractiveError(
|
||||||
|
"non-interactive stdin; use --auto or --yes to apply patches"
|
||||||
|
)
|
||||||
|
raise NonInteractiveError(
|
||||||
|
"non-interactive stdin; use --auto or --yes to apply patches"
|
||||||
|
)
|
||||||
|
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(patches):
|
||||||
|
patch = patches[index]
|
||||||
|
action = prompt_patch(patch)
|
||||||
|
if action == "apply_all_safe":
|
||||||
|
selected.extend(p for p in patches[index:] if is_safe_patch(p))
|
||||||
|
index = len(patches)
|
||||||
|
break
|
||||||
|
if action == "quit":
|
||||||
|
break
|
||||||
|
if action == "skip":
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if action == "edit":
|
||||||
|
patch = edit_patch(patch)
|
||||||
|
selected.append(patch)
|
||||||
|
index += 1
|
||||||
|
return selected
|
||||||
214
reuse_surface/maintain.py
Normal file
214
reuse_surface/maintain.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from reuse_surface.establish import format_publish_check_markdown, publish_check
|
||||||
|
from reuse_surface.interactive import NonInteractiveError, prompt_batch
|
||||||
|
from reuse_surface.maintain_llm import request_maintain_patches
|
||||||
|
from reuse_surface.patches import (
|
||||||
|
apply_patches_atomic,
|
||||||
|
filter_auto_patches,
|
||||||
|
patches_from_suggestions,
|
||||||
|
)
|
||||||
|
from reuse_surface.registry import load_index_at, registry_paths
|
||||||
|
from reuse_surface.registry_update import collect_deterministic_suggestions
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MaintainResult:
|
||||||
|
selected_count: int = 0
|
||||||
|
applied: list[str] = field(default_factory=list)
|
||||||
|
skipped: int = 0
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
publish: dict[str, Any] | None = None
|
||||||
|
exit_code: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def collect_capability_ids(
|
||||||
|
repo_root: Path,
|
||||||
|
*,
|
||||||
|
capability_id: str | None,
|
||||||
|
all_capabilities: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
index = load_index_at(registry_paths(repo_root)["index"])
|
||||||
|
ids = [row["id"] for row in index.get("capabilities", [])]
|
||||||
|
if capability_id:
|
||||||
|
if capability_id not in ids:
|
||||||
|
raise ValueError(f"capability not in index: {capability_id}")
|
||||||
|
return [capability_id]
|
||||||
|
if all_capabilities:
|
||||||
|
return ids
|
||||||
|
raise ValueError("specify --capability or --all")
|
||||||
|
|
||||||
|
|
||||||
|
def gather_patches(
|
||||||
|
repo_root: Path,
|
||||||
|
*,
|
||||||
|
capability_ids: list[str],
|
||||||
|
git_since: str | None,
|
||||||
|
llm_url: str | None,
|
||||||
|
no_llm: bool,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||||
|
patches: list[dict[str, Any]] = []
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
scope_id = capability_ids[0] if len(capability_ids) == 1 else None
|
||||||
|
suggestions = collect_deterministic_suggestions(
|
||||||
|
repo_root,
|
||||||
|
capability_id=scope_id,
|
||||||
|
git_since=git_since,
|
||||||
|
)
|
||||||
|
patches = patches_from_suggestions(suggestions)
|
||||||
|
if scope_id:
|
||||||
|
patches = [
|
||||||
|
patch
|
||||||
|
for patch in patches
|
||||||
|
if patch["capability_id"] == scope_id
|
||||||
|
or patch.get("kind") == "index_updated_bump"
|
||||||
|
]
|
||||||
|
|
||||||
|
if no_llm:
|
||||||
|
return patches, notes
|
||||||
|
|
||||||
|
try:
|
||||||
|
for cap_id in capability_ids:
|
||||||
|
payload = request_maintain_patches(
|
||||||
|
repo_root,
|
||||||
|
cap_id,
|
||||||
|
git_since=git_since,
|
||||||
|
llm_url=llm_url,
|
||||||
|
)
|
||||||
|
patches.extend(payload.get("patches", []))
|
||||||
|
notes.extend(payload.get("notes", []))
|
||||||
|
except ValueError as exc:
|
||||||
|
if "LLM backend not configured" in str(exc):
|
||||||
|
notes.append("LLM phase skipped: LLM_CONNECT_URL not set")
|
||||||
|
else:
|
||||||
|
notes.append(f"LLM phase skipped: {exc}")
|
||||||
|
|
||||||
|
return patches, notes
|
||||||
|
|
||||||
|
|
||||||
|
def run_maintain(
|
||||||
|
repo_root: Path,
|
||||||
|
*,
|
||||||
|
capability_id: str | None = None,
|
||||||
|
all_capabilities: bool = False,
|
||||||
|
git_since: str | None = None,
|
||||||
|
llm_url: str | None = None,
|
||||||
|
no_llm: bool = False,
|
||||||
|
auto: bool = False,
|
||||||
|
yes: bool = False,
|
||||||
|
auto_confidence: str = "high",
|
||||||
|
auto_max_delta: int = 1,
|
||||||
|
publish: bool = False,
|
||||||
|
raw_url: str | None = None,
|
||||||
|
output_format: str = "markdown",
|
||||||
|
validate_fn: Callable[[], tuple[int, list[str], list[str]]] | None = None,
|
||||||
|
) -> MaintainResult:
|
||||||
|
if publish and not raw_url:
|
||||||
|
raw_url = os.environ.get("REUSE_SURFACE_RAW_URL")
|
||||||
|
if publish and not raw_url:
|
||||||
|
raise ValueError("--publish requires --raw-url or REUSE_SURFACE_RAW_URL")
|
||||||
|
|
||||||
|
cap_ids = collect_capability_ids(
|
||||||
|
repo_root,
|
||||||
|
capability_id=capability_id,
|
||||||
|
all_capabilities=all_capabilities,
|
||||||
|
)
|
||||||
|
patches, notes = gather_patches(
|
||||||
|
repo_root,
|
||||||
|
capability_ids=cap_ids,
|
||||||
|
git_since=git_since,
|
||||||
|
llm_url=llm_url,
|
||||||
|
no_llm=no_llm,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = MaintainResult(notes=notes)
|
||||||
|
|
||||||
|
if not patches:
|
||||||
|
result.exit_code = 0
|
||||||
|
if publish and raw_url:
|
||||||
|
result.publish = publish_check(repo_root, raw_url=raw_url)
|
||||||
|
if not result.publish["ok"]:
|
||||||
|
result.exit_code = 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
if auto or yes:
|
||||||
|
selected = filter_auto_patches(
|
||||||
|
patches,
|
||||||
|
repo_root,
|
||||||
|
auto_confidence=auto_confidence,
|
||||||
|
auto_max_delta=auto_max_delta,
|
||||||
|
)
|
||||||
|
result.skipped = len(patches) - len(selected)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
selected = prompt_batch(
|
||||||
|
patches,
|
||||||
|
assume_yes=yes,
|
||||||
|
auto_mode=False,
|
||||||
|
emit_json=output_format == "json",
|
||||||
|
)
|
||||||
|
result.skipped = len(patches) - len(selected)
|
||||||
|
except NonInteractiveError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
|
||||||
|
result.selected_count = len(selected)
|
||||||
|
if not selected:
|
||||||
|
result.exit_code = 2
|
||||||
|
return result
|
||||||
|
|
||||||
|
if validate_fn is None:
|
||||||
|
raise ValueError("validate_fn is required")
|
||||||
|
|
||||||
|
applied, code = apply_patches_atomic(repo_root, selected, validate=validate_fn)
|
||||||
|
result.applied = applied
|
||||||
|
result.exit_code = code
|
||||||
|
|
||||||
|
if code == 0 and publish and raw_url:
|
||||||
|
result.publish = publish_check(repo_root, raw_url=raw_url)
|
||||||
|
if not result.publish["ok"]:
|
||||||
|
result.exit_code = 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_maintain_markdown(result: MaintainResult) -> str:
|
||||||
|
lines = ["# Registry maintain session", ""]
|
||||||
|
lines.append(f"**Selected:** {result.selected_count} patch(es)")
|
||||||
|
lines.append(f"**Skipped:** {result.skipped}")
|
||||||
|
lines.append(f"**Exit:** {result.exit_code}")
|
||||||
|
if result.applied:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Applied")
|
||||||
|
for item in result.applied:
|
||||||
|
lines.append(f"- {item}")
|
||||||
|
if result.notes:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Notes")
|
||||||
|
for note in result.notes:
|
||||||
|
lines.append(f"- {note}")
|
||||||
|
if result.publish:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(format_publish_check_markdown(result.publish).rstrip())
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def format_maintain_json(result: MaintainResult) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"selected_count": result.selected_count,
|
||||||
|
"skipped": result.skipped,
|
||||||
|
"applied": result.applied,
|
||||||
|
"notes": result.notes,
|
||||||
|
"publish": result.publish,
|
||||||
|
"exit_code": result.exit_code,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
160
reuse_surface/maintain_llm.py
Normal file
160
reuse_surface/maintain_llm.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
from reuse_surface.llm_bridge import execute_prompt, extract_json_object
|
||||||
|
from reuse_surface.registry import ROOT, load_index_at, parse_front_matter, registry_paths
|
||||||
|
|
||||||
|
PATCH_SCHEMA_PATH = ROOT / "schemas" / "registry-patch.schema.json"
|
||||||
|
|
||||||
|
MATURITY_SUMMARY = """
|
||||||
|
| Dimension | Levels | Question |
|
||||||
|
|---|---|---|
|
||||||
|
| discovery | D0–D7 | Planning/orientation reuse strength |
|
||||||
|
| availability | A0–A7 | Consumption mode and delivery artifacts |
|
||||||
|
| completeness | C0–C6 | Scope vs intent and expectations |
|
||||||
|
| reliability | R0–R6 | Consumer quality signals |
|
||||||
|
|
||||||
|
Promotion rules:
|
||||||
|
- Cite repo-relative evidence paths for every maturity_promote patch.
|
||||||
|
- Prefer single-step promotions (one level per dimension).
|
||||||
|
- Do not invent files; only cite paths visible in git diff or context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_patch_schema() -> dict[str, Any]:
|
||||||
|
return json.loads(PATCH_SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _git_diff(repo_root: Path, git_since: str | None) -> str:
|
||||||
|
if not git_since:
|
||||||
|
return ""
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(repo_root),
|
||||||
|
"diff",
|
||||||
|
git_since,
|
||||||
|
"HEAD",
|
||||||
|
"--",
|
||||||
|
"registry/",
|
||||||
|
"reuse_surface/",
|
||||||
|
"tests/",
|
||||||
|
"docs/",
|
||||||
|
".gitea/",
|
||||||
|
"pyproject.toml",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return proc.stdout[:12000]
|
||||||
|
|
||||||
|
|
||||||
|
def build_maintain_prompt(
|
||||||
|
repo_root: Path,
|
||||||
|
capability_id: str,
|
||||||
|
*,
|
||||||
|
git_since: str | None = None,
|
||||||
|
context_files: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
paths = registry_paths(repo_root)
|
||||||
|
index = load_index_at(paths["index"])
|
||||||
|
row = next((item for item in index["capabilities"] if item["id"] == capability_id), None)
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"capability not in index: {capability_id}")
|
||||||
|
entry = parse_front_matter(repo_root / row["path"])
|
||||||
|
diff = _git_diff(repo_root, git_since)
|
||||||
|
|
||||||
|
context_chunks: list[str] = []
|
||||||
|
for rel in context_files or []:
|
||||||
|
path = repo_root / rel
|
||||||
|
if path.is_file():
|
||||||
|
context_chunks.append(f"### {rel}\n{path.read_text(encoding='utf-8')[:4000]}")
|
||||||
|
|
||||||
|
schema_hint = json.dumps(
|
||||||
|
{
|
||||||
|
"patches": [
|
||||||
|
{
|
||||||
|
"capability_id": capability_id,
|
||||||
|
"kind": "maturity_promote",
|
||||||
|
"confidence": "medium",
|
||||||
|
"rationale": "CI gate added",
|
||||||
|
"dimension": "reliability",
|
||||||
|
"from_level": "R2",
|
||||||
|
"to_level": "R3",
|
||||||
|
"evidence_citations": ["tests/test_example.py"],
|
||||||
|
"promotion_history_entry": {
|
||||||
|
"date": "2026-06-16",
|
||||||
|
"dimension": "reliability",
|
||||||
|
"from": "R2",
|
||||||
|
"to": "R3",
|
||||||
|
"rationale": "pytest coverage for consumption path",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": ["optional human review items"],
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
return textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
Propose structured registry maintenance patches for `{capability_id}`.
|
||||||
|
|
||||||
|
Return ONLY JSON matching this shape (no markdown fences):
|
||||||
|
{schema_hint}
|
||||||
|
|
||||||
|
Allowed patch kinds: vector_sync, evidence_append, artifact_append,
|
||||||
|
maturity_promote, consumer_feedback, relation_add, index_row_add,
|
||||||
|
index_updated_bump.
|
||||||
|
|
||||||
|
Maturity reference:
|
||||||
|
{MATURITY_SUMMARY}
|
||||||
|
|
||||||
|
Current entry YAML:
|
||||||
|
{yaml.safe_dump(entry, sort_keys=False)}
|
||||||
|
|
||||||
|
Git diff since {git_since or 'N/A'}:
|
||||||
|
{diff or '(none)'}
|
||||||
|
|
||||||
|
Context files:
|
||||||
|
{chr(10).join(context_chunks) if context_chunks else '(none)'}
|
||||||
|
"""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def request_maintain_patches(
|
||||||
|
repo_root: Path,
|
||||||
|
capability_id: str,
|
||||||
|
*,
|
||||||
|
git_since: str | None = None,
|
||||||
|
context_files: list[str] | None = None,
|
||||||
|
llm_url: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
prompt = build_maintain_prompt(
|
||||||
|
repo_root,
|
||||||
|
capability_id,
|
||||||
|
git_since=git_since,
|
||||||
|
context_files=context_files,
|
||||||
|
)
|
||||||
|
content = execute_prompt(
|
||||||
|
prompt,
|
||||||
|
base_url=llm_url,
|
||||||
|
config={"temperature": 0.2, "max_tokens": 3000},
|
||||||
|
)
|
||||||
|
payload = extract_json_object(content)
|
||||||
|
validator = Draft202012Validator(load_patch_schema())
|
||||||
|
errors = sorted(validator.iter_errors(payload), key=lambda err: list(err.path))
|
||||||
|
if errors:
|
||||||
|
messages = "; ".join(error.message for error in errors[:3])
|
||||||
|
raise ValueError(f"patch schema validation failed: {messages}")
|
||||||
|
return payload
|
||||||
391
reuse_surface/patches.py
Normal file
391
reuse_surface/patches.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from reuse_surface.registry import (
|
||||||
|
LEVEL_ORDERS,
|
||||||
|
entry_vector,
|
||||||
|
load_index_at,
|
||||||
|
parse_front_matter,
|
||||||
|
registry_paths,
|
||||||
|
vectors_match,
|
||||||
|
)
|
||||||
|
SAFE_DETERMINISTIC_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
"vector_sync",
|
||||||
|
"vector_drift",
|
||||||
|
"evidence_append",
|
||||||
|
"evidence_test",
|
||||||
|
"artifact_append",
|
||||||
|
"availability_artifact",
|
||||||
|
"index_updated_bump",
|
||||||
|
"index_row_add",
|
||||||
|
"evidence_workflow",
|
||||||
|
"evidence_documentation",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIDENCE_ORDER = {"low": 0, "medium": 1, "high": 2}
|
||||||
|
|
||||||
|
DIMENSION_LEVEL_PREFIX = {
|
||||||
|
"discovery": "D",
|
||||||
|
"availability": "A",
|
||||||
|
"completeness": "C",
|
||||||
|
"reliability": "R",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def suggestion_to_patch(suggestion: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
kind = suggestion.get("kind")
|
||||||
|
if kind == "missing_entry":
|
||||||
|
return None
|
||||||
|
patch_body = suggestion.get("apply_patch")
|
||||||
|
if not patch_body:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cap_id = suggestion["capability_id"]
|
||||||
|
rationale = suggestion.get("detail", "deterministic signal")
|
||||||
|
|
||||||
|
if kind == "vector_drift":
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "vector_sync",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": rationale,
|
||||||
|
"value": patch_body["value"],
|
||||||
|
}
|
||||||
|
if kind in {"evidence_test", "evidence_workflow", "evidence_documentation"}:
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "evidence_append",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": rationale,
|
||||||
|
"field_path": patch_body["field"],
|
||||||
|
"append": patch_body["append"],
|
||||||
|
}
|
||||||
|
if kind == "availability_artifact":
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "artifact_append",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": rationale,
|
||||||
|
"append": patch_body["append"],
|
||||||
|
}
|
||||||
|
if kind == "index_row_add":
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "index_row_add",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": rationale,
|
||||||
|
"index_row": patch_body.get("index_row", {}),
|
||||||
|
}
|
||||||
|
if kind == "index_updated_stale":
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "index_updated_bump",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": rationale,
|
||||||
|
"value": patch_body.get("value", date.today().isoformat()),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def patches_from_suggestions(suggestions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
patches: list[dict[str, Any]] = []
|
||||||
|
for item in suggestions:
|
||||||
|
patch = suggestion_to_patch(item)
|
||||||
|
if patch:
|
||||||
|
patches.append(patch)
|
||||||
|
return patches
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_patch(patch: dict[str, Any]) -> bool:
|
||||||
|
return patch.get("kind") in SAFE_DETERMINISTIC_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def level_delta(dimension: str, from_level: str, to_level: str) -> int:
|
||||||
|
order = LEVEL_ORDERS[dimension]
|
||||||
|
return order.index(to_level) - order.index(from_level)
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_gate(repo_root: Path, patch: dict[str, Any]) -> bool:
|
||||||
|
if patch.get("kind") != "maturity_promote":
|
||||||
|
return True
|
||||||
|
citations = patch.get("evidence_citations") or []
|
||||||
|
if not citations:
|
||||||
|
return False
|
||||||
|
return all((repo_root / path).exists() for path in citations)
|
||||||
|
|
||||||
|
|
||||||
|
def promotion_delta_gate(patch: dict[str, Any], max_delta: int) -> bool:
|
||||||
|
if patch.get("kind") != "maturity_promote":
|
||||||
|
return True
|
||||||
|
dimension = patch.get("dimension")
|
||||||
|
from_level = patch.get("from_level")
|
||||||
|
to_level = patch.get("to_level")
|
||||||
|
if not dimension or not from_level or not to_level:
|
||||||
|
return False
|
||||||
|
delta = level_delta(dimension, from_level, to_level)
|
||||||
|
return 0 < delta <= max_delta
|
||||||
|
|
||||||
|
|
||||||
|
def confidence_gate(patch: dict[str, Any], minimum: str) -> bool:
|
||||||
|
return CONFIDENCE_ORDER[patch.get("confidence", "low")] >= CONFIDENCE_ORDER[minimum]
|
||||||
|
|
||||||
|
|
||||||
|
def filter_auto_patches(
|
||||||
|
patches: list[dict[str, Any]],
|
||||||
|
repo_root: Path,
|
||||||
|
*,
|
||||||
|
auto_confidence: str = "high",
|
||||||
|
auto_max_delta: int = 1,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
selected: list[dict[str, Any]] = []
|
||||||
|
for patch in patches:
|
||||||
|
if is_safe_patch(patch):
|
||||||
|
selected.append(patch)
|
||||||
|
continue
|
||||||
|
if not confidence_gate(patch, auto_confidence):
|
||||||
|
continue
|
||||||
|
if not evidence_gate(repo_root, patch):
|
||||||
|
continue
|
||||||
|
if not promotion_delta_gate(patch, auto_max_delta):
|
||||||
|
continue
|
||||||
|
selected.append(patch)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def _write_front_matter(path: Path, front_matter: dict[str, Any]) -> None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
marker_end = text.find("\n---", 4)
|
||||||
|
body = text[marker_end + 4 :] if marker_end != -1 else "\n"
|
||||||
|
path.write_text(
|
||||||
|
"---\n"
|
||||||
|
+ yaml.safe_dump(front_matter, sort_keys=False, allow_unicode=True)
|
||||||
|
+ "---"
|
||||||
|
+ body,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_maturity_promote(
|
||||||
|
front_matter: dict[str, Any],
|
||||||
|
patch: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
dimension = patch["dimension"]
|
||||||
|
to_level = patch["to_level"]
|
||||||
|
changed: list[str] = []
|
||||||
|
if dimension in {"discovery", "availability"}:
|
||||||
|
front_matter.setdefault("maturity", {}).setdefault(dimension, {})["current"] = to_level
|
||||||
|
if dimension == "availability":
|
||||||
|
front_matter.setdefault("availability", {})["current_level"] = to_level
|
||||||
|
changed.append(f"maturity.{dimension}.current -> {to_level}")
|
||||||
|
else:
|
||||||
|
key = "completeness" if dimension == "completeness" else "reliability"
|
||||||
|
front_matter.setdefault("external_evidence", {}).setdefault(key, {})["level"] = to_level
|
||||||
|
changed.append(f"external_evidence.{key}.level -> {to_level}")
|
||||||
|
|
||||||
|
entry = patch.get("promotion_history_entry")
|
||||||
|
if entry:
|
||||||
|
history = front_matter.setdefault("promotion_history", [])
|
||||||
|
history.append(entry)
|
||||||
|
changed.append("promotion_history +1")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_patch_to_state(
|
||||||
|
repo_root: Path,
|
||||||
|
patch: dict[str, Any],
|
||||||
|
index: dict[str, Any],
|
||||||
|
entry_cache: dict[str, dict[str, Any]],
|
||||||
|
entry_paths: dict[str, Path],
|
||||||
|
) -> list[str]:
|
||||||
|
cap_id = patch["capability_id"]
|
||||||
|
kind = patch["kind"]
|
||||||
|
changed: list[str] = []
|
||||||
|
index_by_id = {row["id"]: row for row in index.get("capabilities", [])}
|
||||||
|
|
||||||
|
if kind == "index_updated_bump":
|
||||||
|
index["updated"] = patch.get("value", date.today().isoformat())
|
||||||
|
return ["index.updated bumped"]
|
||||||
|
|
||||||
|
if kind == "index_row_add":
|
||||||
|
row = patch.get("index_row", {})
|
||||||
|
if cap_id not in index_by_id and row:
|
||||||
|
index.setdefault("capabilities", []).append(row)
|
||||||
|
changed.append(f"index row added for {cap_id}")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
row = index_by_id.get(cap_id)
|
||||||
|
if not row:
|
||||||
|
return changed
|
||||||
|
|
||||||
|
if kind == "vector_sync":
|
||||||
|
row["vector"] = patch["value"]
|
||||||
|
changed.append(f"index vector for {cap_id}")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
entry_path = repo_root / row["path"]
|
||||||
|
if cap_id not in entry_cache:
|
||||||
|
entry_cache[cap_id] = parse_front_matter(entry_path)
|
||||||
|
entry_paths[cap_id] = entry_path
|
||||||
|
front_matter = entry_cache[cap_id]
|
||||||
|
|
||||||
|
if kind == "evidence_append":
|
||||||
|
field = patch.get("field_path", "evidence.tests")
|
||||||
|
parts = field.split(".")
|
||||||
|
target = front_matter
|
||||||
|
for part in parts[:-1]:
|
||||||
|
target = target.setdefault(part, {})
|
||||||
|
items = target.setdefault(parts[-1], [])
|
||||||
|
append = patch["append"]
|
||||||
|
if append not in items:
|
||||||
|
items.append(append)
|
||||||
|
changed.append(f"{cap_id} {field} += {append}")
|
||||||
|
elif kind == "artifact_append":
|
||||||
|
artifacts = front_matter.setdefault("availability", {}).setdefault(
|
||||||
|
"current_artifacts", []
|
||||||
|
)
|
||||||
|
append = patch["append"]
|
||||||
|
if append not in artifacts:
|
||||||
|
artifacts.append(append)
|
||||||
|
changed.append(f"{cap_id} availability.current_artifacts += {append}")
|
||||||
|
elif kind == "consumer_feedback":
|
||||||
|
feedback = front_matter.setdefault("evidence", {}).setdefault(
|
||||||
|
"consumer_feedback", []
|
||||||
|
)
|
||||||
|
append = patch.get("append") or patch.get("value")
|
||||||
|
if append and append not in feedback:
|
||||||
|
feedback.append(str(append))
|
||||||
|
changed.append(f"{cap_id} consumer_feedback +1")
|
||||||
|
elif kind == "relation_add":
|
||||||
|
rel = patch.get("value") or {}
|
||||||
|
rel_type = rel.get("type", "related_to")
|
||||||
|
target_id = rel.get("target")
|
||||||
|
if target_id:
|
||||||
|
relations = front_matter.setdefault("relations", {}).setdefault(rel_type, [])
|
||||||
|
if target_id not in relations:
|
||||||
|
relations.append(target_id)
|
||||||
|
changed.append(f"{cap_id} relations.{rel_type} += {target_id}")
|
||||||
|
elif kind == "maturity_promote":
|
||||||
|
changed.extend(_apply_maturity_promote(front_matter, patch))
|
||||||
|
row["vector"] = entry_vector(front_matter)
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def apply_patches(repo_root: Path, patches: list[dict[str, Any]]) -> list[str]:
|
||||||
|
paths = registry_paths(repo_root)
|
||||||
|
index = load_index_at(paths["index"])
|
||||||
|
entry_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
entry_paths: dict[str, Path] = {}
|
||||||
|
changed: list[str] = []
|
||||||
|
|
||||||
|
for patch in patches:
|
||||||
|
changed.extend(
|
||||||
|
_apply_patch_to_state(repo_root, patch, index, entry_cache, entry_paths)
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
index["updated"] = date.today().isoformat()
|
||||||
|
paths["index"].write_text(
|
||||||
|
yaml.safe_dump(index, sort_keys=False, allow_unicode=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
for cap_id, front_matter in entry_cache.items():
|
||||||
|
_write_front_matter(entry_paths[cap_id], front_matter)
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_to_suggestion(patch: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
kind = patch["kind"]
|
||||||
|
cap_id = patch["capability_id"]
|
||||||
|
if kind == "vector_sync":
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "vector_drift",
|
||||||
|
"apply_patch": {"field": "index.vector", "value": patch["value"]},
|
||||||
|
}
|
||||||
|
if kind == "evidence_append":
|
||||||
|
field = patch.get("field_path", "evidence.tests")
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "evidence_test",
|
||||||
|
"apply_patch": {"field": field, "append": patch["append"]},
|
||||||
|
}
|
||||||
|
if kind == "artifact_append":
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "availability_artifact",
|
||||||
|
"apply_patch": {"field": "availability.current_artifacts", "append": patch["append"]},
|
||||||
|
}
|
||||||
|
if kind == "index_updated_bump":
|
||||||
|
return {
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "index_updated_stale",
|
||||||
|
"apply_patch": {"field": "index.updated", "value": patch.get("value")},
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_patches_atomic(
|
||||||
|
repo_root: Path,
|
||||||
|
patches: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
validate: Callable[[], tuple[int, list[str], list[str]]],
|
||||||
|
) -> tuple[list[str], int]:
|
||||||
|
if not patches:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
session_dir = repo_root / ".reuse-surface-session"
|
||||||
|
backup_dir = session_dir / "backup"
|
||||||
|
if session_dir.exists():
|
||||||
|
shutil.rmtree(session_dir)
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
paths = registry_paths(repo_root)
|
||||||
|
touched: set[Path] = set()
|
||||||
|
if paths["index"].exists():
|
||||||
|
rel = paths["index"].relative_to(repo_root)
|
||||||
|
dest = backup_dir / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(paths["index"], dest)
|
||||||
|
touched.add(paths["index"])
|
||||||
|
|
||||||
|
index = load_index_at(paths["index"]) if paths["index"].exists() else {}
|
||||||
|
for row in index.get("capabilities", []):
|
||||||
|
entry_path = repo_root / row["path"]
|
||||||
|
if entry_path.exists():
|
||||||
|
rel = entry_path.relative_to(repo_root)
|
||||||
|
dest = backup_dir / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(entry_path, dest)
|
||||||
|
touched.add(entry_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
changed = apply_patches(repo_root, patches)
|
||||||
|
code, errors, warnings = validate()
|
||||||
|
if code != 0:
|
||||||
|
for path in touched:
|
||||||
|
rel = path.relative_to(repo_root)
|
||||||
|
backup = backup_dir / rel
|
||||||
|
if backup.exists():
|
||||||
|
shutil.copy2(backup, path)
|
||||||
|
shutil.rmtree(session_dir, ignore_errors=True)
|
||||||
|
return changed, code
|
||||||
|
shutil.rmtree(session_dir, ignore_errors=True)
|
||||||
|
return changed, 0
|
||||||
|
except Exception:
|
||||||
|
for path in touched:
|
||||||
|
rel = path.relative_to(repo_root)
|
||||||
|
backup = backup_dir / rel
|
||||||
|
if backup.exists():
|
||||||
|
shutil.copy2(backup, path)
|
||||||
|
shutil.rmtree(session_dir, ignore_errors=True)
|
||||||
|
raise
|
||||||
@@ -88,3 +88,12 @@ def entry_vector(front_matter: dict[str, Any]) -> str:
|
|||||||
|
|
||||||
def vectors_match(index_vector: str, front_matter: dict[str, Any]) -> bool:
|
def vectors_match(index_vector: str, front_matter: dict[str, Any]) -> bool:
|
||||||
return index_vector.replace(" ", "") == entry_vector(front_matter).replace(" ", "")
|
return index_vector.replace(" ", "") == entry_vector(front_matter).replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repo_root(explicit: str | Path | None = None) -> Path:
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit).resolve()
|
||||||
|
cwd = Path.cwd()
|
||||||
|
if (cwd / "registry" / "indexes" / "capabilities.yaml").is_file():
|
||||||
|
return cwd.resolve()
|
||||||
|
return ROOT
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import textwrap
|
import textwrap
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ from reuse_surface.registry import (
|
|||||||
vectors_match,
|
vectors_match,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Safe to apply without interactive review (see patches.SAFE_DETERMINISTIC_KINDS).
|
||||||
SAFE_EVIDENCE_PREFIXES = ("tests/", ".gitea/workflows/")
|
SAFE_EVIDENCE_PREFIXES = ("tests/", ".gitea/workflows/")
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ def collect_deterministic_suggestions(
|
|||||||
changed_files = git_changed_files(repo_root, git_since) if git_since else []
|
changed_files = git_changed_files(repo_root, git_since) if git_since else []
|
||||||
suggestions: list[dict[str, Any]] = []
|
suggestions: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
suggestions.extend(_collect_index_orphans(repo_root, index, changed_files))
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
entry_path = repo_root / row["path"]
|
entry_path = repo_root / row["path"]
|
||||||
if not entry_path.exists():
|
if not entry_path.exists():
|
||||||
@@ -80,43 +84,167 @@ def collect_deterministic_suggestions(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
evidence_tests = front_matter.get("evidence", {}).get("tests", [])
|
suggestions.extend(
|
||||||
for changed in changed_files:
|
_collect_changed_file_suggestions(row["id"], front_matter, changed_files, repo_root)
|
||||||
if changed.startswith("tests/") and changed not in evidence_tests:
|
)
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_index_orphans(
|
||||||
|
repo_root: Path,
|
||||||
|
index: dict[str, Any],
|
||||||
|
changed_files: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
suggestions: list[dict[str, Any]] = []
|
||||||
|
indexed_paths = {row["path"] for row in index.get("capabilities", [])}
|
||||||
|
cap_dir = registry_paths(repo_root)["capabilities"]
|
||||||
|
if not cap_dir.exists():
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
for entry_file in sorted(cap_dir.glob("*.md")):
|
||||||
|
if entry_file.name == ".gitkeep":
|
||||||
|
continue
|
||||||
|
rel = str(entry_file.relative_to(repo_root))
|
||||||
|
if rel in indexed_paths:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
front_matter = parse_front_matter(entry_file)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
cap_id = front_matter.get("id", entry_file.stem.replace("-", "."))
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "index_row_add",
|
||||||
|
"detail": f"capability file not in index: {rel}",
|
||||||
|
"apply_patch": {
|
||||||
|
"field": "index.capabilities",
|
||||||
|
"index_row": {
|
||||||
|
"id": cap_id,
|
||||||
|
"name": front_matter.get("name", cap_id),
|
||||||
|
"summary": front_matter.get("summary", ""),
|
||||||
|
"vector": entry_vector(front_matter),
|
||||||
|
"domain": front_matter.get("domain", index.get("domain", "helix_forge")),
|
||||||
|
"status": front_matter.get("status", "draft"),
|
||||||
|
"owner": front_matter.get("owner", repo_root.name),
|
||||||
|
"path": rel,
|
||||||
|
"tags": front_matter.get("tags", []),
|
||||||
|
"consumption_modes": front_matter.get("availability", {}).get(
|
||||||
|
"consumption_modes", ["informational"]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
index_updated = index.get("updated")
|
||||||
|
registry_touched = any(path.startswith("registry/") for path in changed_files)
|
||||||
|
if registry_touched and index_updated != date.today().isoformat():
|
||||||
|
first_id = index.get("capabilities", [{}])[0].get("id", "registry")
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"capability_id": first_id,
|
||||||
|
"kind": "index_updated_stale",
|
||||||
|
"detail": "registry/ changed; bump index updated date",
|
||||||
|
"apply_patch": {"field": "index.updated", "value": date.today().isoformat()},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
|
||||||
|
def _pyproject_script_artifacts(repo_root: Path) -> list[str]:
|
||||||
|
pyproject = repo_root / "pyproject.toml"
|
||||||
|
if not pyproject.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
scripts = data.get("project", {}).get("scripts", {})
|
||||||
|
return [f"pyproject.toml:[project.scripts].{name}" for name in sorted(scripts)]
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_changed_file_suggestions(
|
||||||
|
cap_id: str,
|
||||||
|
front_matter: dict[str, Any],
|
||||||
|
changed_files: list[str],
|
||||||
|
repo_root: Path,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
suggestions: list[dict[str, Any]] = []
|
||||||
|
evidence = front_matter.setdefault("evidence", {})
|
||||||
|
evidence_tests = evidence.get("tests", [])
|
||||||
|
evidence_docs = evidence.get("documentation", [])
|
||||||
|
|
||||||
|
pkg_prefixes = tuple(
|
||||||
|
p.name + "/"
|
||||||
|
for p in repo_root.iterdir()
|
||||||
|
if p.is_dir() and (p / "__init__.py").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
for changed in changed_files:
|
||||||
|
if changed.startswith("tests/") and changed not in evidence_tests:
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "evidence_test",
|
||||||
|
"detail": f"new test file not cited: {changed}",
|
||||||
|
"apply_patch": {"field": "evidence.tests", "append": changed},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if changed.startswith(".gitea/workflows/") and changed.endswith((".yml", ".yaml")):
|
||||||
|
field = "evidence.tests" if "test" in changed.lower() else "evidence.documentation"
|
||||||
|
existing = evidence_tests if field == "evidence.tests" else evidence_docs
|
||||||
|
if changed not in existing:
|
||||||
suggestions.append(
|
suggestions.append(
|
||||||
{
|
{
|
||||||
"capability_id": row["id"],
|
"capability_id": cap_id,
|
||||||
"kind": "evidence_test",
|
"kind": "evidence_workflow",
|
||||||
"detail": f"new test file not cited: {changed}",
|
"detail": f"workflow changed not cited: {changed}",
|
||||||
|
"apply_patch": {"field": field, "append": changed},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if changed.startswith("docs/") and changed not in evidence_docs:
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "evidence_documentation",
|
||||||
|
"detail": f"doc changed not cited: {changed}",
|
||||||
|
"apply_patch": {"field": "evidence.documentation", "append": changed},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
artifacts = front_matter.get("availability", {}).get("current_artifacts", [])
|
||||||
|
for changed in changed_files:
|
||||||
|
if changed.endswith(".py") and changed.startswith(pkg_prefixes):
|
||||||
|
if changed not in artifacts:
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "availability_artifact",
|
||||||
|
"detail": f"changed module not cited: {changed}",
|
||||||
"apply_patch": {
|
"apply_patch": {
|
||||||
"field": "evidence.tests",
|
"field": "availability.current_artifacts",
|
||||||
"append": changed,
|
"append": changed,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if changed == "pyproject.toml":
|
||||||
artifacts = front_matter.get("availability", {}).get("current_artifacts", [])
|
for script_ref in _pyproject_script_artifacts(repo_root):
|
||||||
for changed in changed_files:
|
if script_ref not in artifacts:
|
||||||
if changed.endswith(".py") and changed.startswith(
|
|
||||||
tuple(
|
|
||||||
p.name + "/"
|
|
||||||
for p in repo_root.iterdir()
|
|
||||||
if p.is_dir() and (p / "__init__.py").exists()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
if changed not in artifacts:
|
|
||||||
suggestions.append(
|
suggestions.append(
|
||||||
{
|
{
|
||||||
"capability_id": row["id"],
|
"capability_id": cap_id,
|
||||||
"kind": "availability_artifact",
|
"kind": "availability_artifact",
|
||||||
"detail": f"changed module not cited: {changed}",
|
"detail": f"CLI script not cited: {script_ref}",
|
||||||
"apply_patch": {
|
"apply_patch": {
|
||||||
"field": "availability.current_artifacts",
|
"field": "availability.current_artifacts",
|
||||||
"append": changed,
|
"append": script_ref,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return suggestions
|
return suggestions
|
||||||
|
|
||||||
|
|
||||||
@@ -150,11 +278,12 @@ def apply_deterministic_suggestions(
|
|||||||
entry_paths[cap_id] = entry_path
|
entry_paths[cap_id] = entry_path
|
||||||
|
|
||||||
front_matter = entry_cache[cap_id]
|
front_matter = entry_cache[cap_id]
|
||||||
if patch["field"] == "evidence.tests":
|
if patch["field"] in {"evidence.tests", "evidence.documentation"}:
|
||||||
tests = front_matter.setdefault("evidence", {}).setdefault("tests", [])
|
bucket = patch["field"].split(".")[1]
|
||||||
if patch["append"] not in tests:
|
items = front_matter.setdefault("evidence", {}).setdefault(bucket, [])
|
||||||
tests.append(patch["append"])
|
if patch["append"] not in items:
|
||||||
changed.append(f"{cap_id} evidence.tests += {patch['append']}")
|
items.append(patch["append"])
|
||||||
|
changed.append(f"{cap_id} {patch['field']} += {patch['append']}")
|
||||||
if patch["field"] == "availability.current_artifacts":
|
if patch["field"] == "availability.current_artifacts":
|
||||||
artifacts = front_matter.setdefault("availability", {}).setdefault(
|
artifacts = front_matter.setdefault("availability", {}).setdefault(
|
||||||
"current_artifacts", []
|
"current_artifacts", []
|
||||||
@@ -165,7 +294,22 @@ def apply_deterministic_suggestions(
|
|||||||
f"{cap_id} availability.current_artifacts += {patch['append']}"
|
f"{cap_id} availability.current_artifacts += {patch['append']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for suggestion in suggestions:
|
||||||
|
patch = suggestion.get("apply_patch")
|
||||||
|
if not patch:
|
||||||
|
continue
|
||||||
|
if suggestion.get("kind") == "index_row_add":
|
||||||
|
cap_id = suggestion["capability_id"]
|
||||||
|
row = patch.get("index_row")
|
||||||
|
if row and cap_id not in index_by_id:
|
||||||
|
index.setdefault("capabilities", []).append(row)
|
||||||
|
changed.append(f"index row added for {cap_id}")
|
||||||
|
if suggestion.get("kind") == "index_updated_stale":
|
||||||
|
index["updated"] = patch.get("value", date.today().isoformat())
|
||||||
|
changed.append("index.updated bumped")
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
|
index["updated"] = date.today().isoformat()
|
||||||
paths["index"].write_text(
|
paths["index"].write_text(
|
||||||
yaml.safe_dump(index, sort_keys=False, allow_unicode=True),
|
yaml.safe_dump(index, sort_keys=False, allow_unicode=True),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
|
|||||||
83
schemas/registry-patch.schema.json
Normal file
83
schemas/registry-patch.schema.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://reuse-surface.local/schemas/registry-patch.schema.json",
|
||||||
|
"title": "RegistryMaintainPatchSet",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["patches"],
|
||||||
|
"properties": {
|
||||||
|
"patches": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["capability_id", "kind", "confidence", "rationale"],
|
||||||
|
"properties": {
|
||||||
|
"capability_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^capability\\.[a-z0-9]+(\\.[a-z0-9-]+)+$"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"vector_sync",
|
||||||
|
"evidence_append",
|
||||||
|
"artifact_append",
|
||||||
|
"maturity_promote",
|
||||||
|
"consumer_feedback",
|
||||||
|
"relation_add",
|
||||||
|
"index_row_add",
|
||||||
|
"index_updated_bump"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"]
|
||||||
|
},
|
||||||
|
"rationale": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"field_path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {},
|
||||||
|
"append": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dimension": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["discovery", "availability", "completeness", "reliability"]
|
||||||
|
},
|
||||||
|
"from_level": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"to_level": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"promotion_history_entry": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"index_row": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"evidence_citations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
templates/Makefile.registry.fragment
Normal file
12
templates/Makefile.registry.fragment
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Append to sibling repo Makefile (adjust REPO slug if needed).
|
||||||
|
REGISTRY_RAW_URL ?= https://gitea.coulomb.social/coulomb/$(notdir $(CURDIR))/raw/main/registry/indexes/capabilities.yaml
|
||||||
|
|
||||||
|
.PHONY: registry-maintain registry-check
|
||||||
|
|
||||||
|
registry-maintain:
|
||||||
|
reuse-surface maintain --all --from-git-since origin/main
|
||||||
|
|
||||||
|
registry-check:
|
||||||
|
reuse-surface maintain --all --from-git-since origin/main --auto --no-llm
|
||||||
|
reuse-surface validate --root .
|
||||||
|
reuse-surface establish --publish-check --raw-url $(REGISTRY_RAW_URL)
|
||||||
6
templates/git-hook.pre-commit.registry
Normal file
6
templates/git-hook.pre-commit.registry
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Optional pre-commit hook: deterministic registry sync when registry/ changes.
|
||||||
|
if git diff --cached --name-only | grep -q '^registry/'; then
|
||||||
|
reuse-surface maintain --all --auto --no-llm || exit 1
|
||||||
|
git add registry/
|
||||||
|
fi
|
||||||
45
tests/test_interactive.py
Normal file
45
tests/test_interactive.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from reuse_surface.interactive import NonInteractiveError, format_patch_summary, prompt_batch
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_patch_summary():
|
||||||
|
text = format_patch_summary(
|
||||||
|
{
|
||||||
|
"capability_id": "capability.demo.sample",
|
||||||
|
"kind": "vector_sync",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": "drift",
|
||||||
|
"value": "D2 / A0 / C0 / R0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert "vector_sync" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_batch_non_tty_raises():
|
||||||
|
with pytest.raises(NonInteractiveError):
|
||||||
|
prompt_batch(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"capability_id": "capability.demo.sample",
|
||||||
|
"kind": "vector_sync",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": "drift",
|
||||||
|
"value": "D2 / A0 / C0 / R0",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_batch_assume_yes():
|
||||||
|
patch = {
|
||||||
|
"capability_id": "capability.demo.sample",
|
||||||
|
"kind": "vector_sync",
|
||||||
|
"confidence": "high",
|
||||||
|
"rationale": "drift",
|
||||||
|
"value": "D2 / A0 / C0 / R0",
|
||||||
|
}
|
||||||
|
selected = prompt_batch([patch], assume_yes=True)
|
||||||
|
assert selected == [patch]
|
||||||
188
tests/test_maintain.py
Normal file
188
tests/test_maintain.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from reuse_surface.establish import scaffold_registry
|
||||||
|
from reuse_surface.maintain import run_maintain
|
||||||
|
from reuse_surface.maintain_llm import build_maintain_prompt, load_patch_schema
|
||||||
|
from reuse_surface.patches import (
|
||||||
|
apply_patches,
|
||||||
|
evidence_gate,
|
||||||
|
filter_auto_patches,
|
||||||
|
patches_from_suggestions,
|
||||||
|
promotion_delta_gate,
|
||||||
|
suggestion_to_patch,
|
||||||
|
)
|
||||||
|
from reuse_surface.registry import load_index_at, registry_paths
|
||||||
|
from reuse_surface.registry_update import collect_deterministic_suggestions
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_repo(tmp_path: Path) -> str:
|
||||||
|
scaffold_registry(tmp_path)
|
||||||
|
cap_id = "capability.demo.sample"
|
||||||
|
rel = "registry/capabilities/capability-demo-sample.md"
|
||||||
|
front_matter = {
|
||||||
|
"id": cap_id,
|
||||||
|
"name": "Sample",
|
||||||
|
"summary": "Sample",
|
||||||
|
"owner": "demo",
|
||||||
|
"status": "draft",
|
||||||
|
"domain": "helix_forge",
|
||||||
|
"tags": ["demo"],
|
||||||
|
"maturity": {
|
||||||
|
"discovery": {"current": "D2", "target": "D5", "confidence": "low"},
|
||||||
|
"availability": {"current": "A0", "target": "A3", "confidence": "low"},
|
||||||
|
},
|
||||||
|
"external_evidence": {
|
||||||
|
"completeness": {"level": "C0", "confidence": "low"},
|
||||||
|
"reliability": {"level": "R0", "confidence": "low"},
|
||||||
|
},
|
||||||
|
"discovery": {"intent": "demo", "includes": [], "excludes": []},
|
||||||
|
"availability": {
|
||||||
|
"current_level": "A0",
|
||||||
|
"target_level": "A3",
|
||||||
|
"current_artifacts": [],
|
||||||
|
"consumption_modes": ["informational"],
|
||||||
|
},
|
||||||
|
"relations": {"depends_on": [], "supports": [], "related_to": []},
|
||||||
|
"evidence": {"documentation": [], "tests": []},
|
||||||
|
"consumer_guidance": {
|
||||||
|
"recommended_for": [],
|
||||||
|
"not_recommended_for": [],
|
||||||
|
"known_limitations": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entry = tmp_path / rel
|
||||||
|
entry.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
entry.write_text("---\n" + yaml.safe_dump(front_matter, sort_keys=False) + "---\n")
|
||||||
|
index_path = registry_paths(tmp_path)["index"]
|
||||||
|
index = load_index_at(index_path)
|
||||||
|
index["capabilities"] = [
|
||||||
|
{
|
||||||
|
"id": cap_id,
|
||||||
|
"name": "Sample",
|
||||||
|
"summary": "Sample",
|
||||||
|
"vector": "D3 / A0 / C0 / R0",
|
||||||
|
"domain": "helix_forge",
|
||||||
|
"status": "draft",
|
||||||
|
"owner": "demo",
|
||||||
|
"path": rel,
|
||||||
|
"tags": ["demo"],
|
||||||
|
"consumption_modes": ["informational"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
index_path.write_text(yaml.safe_dump(index, sort_keys=False), encoding="utf-8")
|
||||||
|
return cap_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_schema_loads():
|
||||||
|
schema = load_patch_schema()
|
||||||
|
assert "patches" in schema["properties"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_maintain_prompt(tmp_path: Path):
|
||||||
|
cap_id = _seed_repo(tmp_path)
|
||||||
|
prompt = build_maintain_prompt(tmp_path, cap_id, git_since=None)
|
||||||
|
assert cap_id in prompt
|
||||||
|
assert "Return ONLY JSON" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggestion_to_patch_vector_sync():
|
||||||
|
patch = suggestion_to_patch(
|
||||||
|
{
|
||||||
|
"capability_id": "capability.demo.sample",
|
||||||
|
"kind": "vector_drift",
|
||||||
|
"detail": "drift",
|
||||||
|
"apply_patch": {"field": "index.vector", "value": "D2 / A0 / C0 / R0"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert patch is not None
|
||||||
|
assert patch["kind"] == "vector_sync"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_gate_requires_files(tmp_path: Path):
|
||||||
|
evidence = (tmp_path / "tests" / "test_x.py")
|
||||||
|
evidence.parent.mkdir(parents=True)
|
||||||
|
evidence.write_text("def test_x(): pass\n")
|
||||||
|
patch = {
|
||||||
|
"kind": "maturity_promote",
|
||||||
|
"evidence_citations": ["tests/test_x.py"],
|
||||||
|
}
|
||||||
|
assert evidence_gate(tmp_path, patch)
|
||||||
|
patch["evidence_citations"] = ["tests/missing.py"]
|
||||||
|
assert not evidence_gate(tmp_path, patch)
|
||||||
|
|
||||||
|
|
||||||
|
def test_promotion_delta_gate():
|
||||||
|
patch = {
|
||||||
|
"kind": "maturity_promote",
|
||||||
|
"dimension": "availability",
|
||||||
|
"from_level": "A2",
|
||||||
|
"to_level": "A3",
|
||||||
|
}
|
||||||
|
assert promotion_delta_gate(patch, 1)
|
||||||
|
patch["to_level"] = "A5"
|
||||||
|
assert not promotion_delta_gate(patch, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_patches_vector_sync(tmp_path: Path):
|
||||||
|
cap_id = _seed_repo(tmp_path)
|
||||||
|
suggestions = collect_deterministic_suggestions(tmp_path, capability_id=cap_id)
|
||||||
|
patches = patches_from_suggestions(suggestions)
|
||||||
|
changed = apply_patches(tmp_path, patches)
|
||||||
|
assert changed
|
||||||
|
index = load_index_at(registry_paths(tmp_path)["index"])
|
||||||
|
assert index["capabilities"][0]["vector"] == "D2 / A0 / C0 / R0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_auto_patches(tmp_path: Path):
|
||||||
|
cap_id = _seed_repo(tmp_path)
|
||||||
|
suggestions = collect_deterministic_suggestions(tmp_path, capability_id=cap_id)
|
||||||
|
patches = patches_from_suggestions(suggestions)
|
||||||
|
selected = filter_auto_patches(patches, tmp_path)
|
||||||
|
assert selected
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_maintain_auto_no_llm(tmp_path: Path):
|
||||||
|
_seed_repo(tmp_path)
|
||||||
|
|
||||||
|
def _validate() -> tuple[int, list[str], list[str]]:
|
||||||
|
return 0, [], []
|
||||||
|
|
||||||
|
result = run_maintain(
|
||||||
|
tmp_path,
|
||||||
|
all_capabilities=True,
|
||||||
|
auto=True,
|
||||||
|
no_llm=True,
|
||||||
|
validate_fn=_validate,
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.selected_count >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_maintain_patches_mock(tmp_path: Path):
|
||||||
|
cap_id = _seed_repo(tmp_path)
|
||||||
|
payload = {
|
||||||
|
"patches": [
|
||||||
|
{
|
||||||
|
"capability_id": cap_id,
|
||||||
|
"kind": "consumer_feedback",
|
||||||
|
"confidence": "medium",
|
||||||
|
"rationale": "note",
|
||||||
|
"append": "helpful",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"reuse_surface.maintain_llm.execute_prompt",
|
||||||
|
return_value=json.dumps(payload),
|
||||||
|
):
|
||||||
|
from reuse_surface.maintain_llm import request_maintain_patches
|
||||||
|
|
||||||
|
result = request_maintain_patches(tmp_path, cap_id, llm_url="http://example")
|
||||||
|
assert len(result["patches"]) == 1
|
||||||
@@ -147,7 +147,7 @@ local index YAML. `--discover` drafts capabilities via llm-connect (optional).
|
|||||||
Refresh registry metadata from repo drift signals.
|
Refresh registry metadata from repo drift signals.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
reuse-surface update --capability capability.registry.register --dry-run
|
reuse-surface update --capability capability.registry.register
|
||||||
reuse-surface update --all --from-git-since HEAD~5 --apply
|
reuse-surface update --all --from-git-since HEAD~5 --apply
|
||||||
reuse-surface update --capability capability.registry.register --suggest-maturity
|
reuse-surface update --capability capability.registry.register --suggest-maturity
|
||||||
```
|
```
|
||||||
@@ -155,6 +155,30 @@ reuse-surface update --capability capability.registry.register --suggest-maturit
|
|||||||
Deterministic patches (`vector_drift`, new `tests/` citations) apply with
|
Deterministic patches (`vector_drift`, new `tests/` citations) apply with
|
||||||
`--apply`. LLM suggestions use `--suggest-maturity` and remain review-only.
|
`--apply`. LLM suggestions use `--suggest-maturity` and remain review-only.
|
||||||
|
|
||||||
|
### maintain
|
||||||
|
|
||||||
|
Interactive or automated registry maintenance (REUSE-WP-0016). Preferred entry
|
||||||
|
point for sibling repo operators.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LLM_CONNECT_URL=http://127.0.0.1:8088 # optional
|
||||||
|
reuse-surface maintain --all --from-git-since origin/main
|
||||||
|
reuse-surface maintain --capability capability.registry.register
|
||||||
|
reuse-surface maintain --all --auto --no-llm
|
||||||
|
reuse-surface maintain --all --auto --from-git-since HEAD~3
|
||||||
|
reuse-surface maintain --publish --raw-url https://.../capabilities.yaml --all --auto --no-llm
|
||||||
|
```
|
||||||
|
|
||||||
|
| Mode | Flags | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| Interactive (TTY) | (default) | Prompt per patch: apply / skip / edit / quit |
|
||||||
|
| Full automation | `--auto` or `--yes` | Safe deterministic + gated LLM patches |
|
||||||
|
| Deterministic only | `--auto --no-llm` | No llm-connect required |
|
||||||
|
| Publish chain | `--publish --raw-url` | maintain → validate → publish-check |
|
||||||
|
|
||||||
|
Templates: `templates/Makefile.registry.fragment`, `templates/git-hook.pre-commit.registry`.
|
||||||
|
Install hook: `reuse-surface establish --scaffold --hook`.
|
||||||
|
|
||||||
### report cohorts
|
### report cohorts
|
||||||
|
|
||||||
Export capability cohorts for planning or implementation reuse decisions.
|
Export capability cohorts for planning or implementation reuse decisions.
|
||||||
@@ -196,6 +220,7 @@ Stable IDs and maturity fields are preserved for agent consumption (UC-RS-019).
|
|||||||
| Verify index publish URL | `reuse-surface establish --publish-check` |
|
| Verify index publish URL | `reuse-surface establish --publish-check` |
|
||||||
| Draft capabilities (LLM) | `reuse-surface establish --discover` |
|
| Draft capabilities (LLM) | `reuse-surface establish --discover` |
|
||||||
| Refresh entry metadata | `reuse-surface update` |
|
| Refresh entry metadata | `reuse-surface update` |
|
||||||
|
| Interactive registry maintain | `reuse-surface maintain` |
|
||||||
| Planning cohort export | `reuse-surface report cohorts` |
|
| Planning cohort export | `reuse-surface report cohorts` |
|
||||||
| Relation graph | `reuse-surface graph` |
|
| Relation graph | `reuse-surface graph` |
|
||||||
|
|
||||||
|
|||||||
377
workplans/REUSE-WP-0016-interactive-registry-maintain.md
Normal file
377
workplans/REUSE-WP-0016-interactive-registry-maintain.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
---
|
||||||
|
id: REUSE-WP-0016
|
||||||
|
type: workplan
|
||||||
|
title: "Interactive registry maintain with llm-connect automation"
|
||||||
|
domain: helix_forge
|
||||||
|
repo: reuse-surface
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: helix-forge
|
||||||
|
created: "2026-06-16"
|
||||||
|
updated: "2026-06-17"
|
||||||
|
state_hub_workstream_id: "2a7565a4-2627-44ca-a856-6c3f18576f92"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Interactive registry maintain with llm-connect automation
|
||||||
|
|
||||||
|
Follow-up to **REUSE-WP-0013** (`establish`, `update`, `stats`). Workstation
|
||||||
|
rollout (**REUSE-WP-0014**) gave every sibling repo a registry scaffold; operators
|
||||||
|
still maintain entries manually or run `reuse-surface update` as a **non-interactive
|
||||||
|
report**. LLM maturity hints (`--suggest-maturity`) dump JSON for human review
|
||||||
|
with no apply path.
|
||||||
|
|
||||||
|
This workplan closes the **registry maintenance loop** from inside each domain
|
||||||
|
repo: interactive prompting for judgment calls, full automation for safe and
|
||||||
|
high-confidence changes, both backed by the existing **llm-connect** HTTP bridge.
|
||||||
|
|
||||||
|
**Baseline vector:** `D5 / A4 / C5 / R3`
|
||||||
|
**Target vector:** `D5 / A4 / C5–C6 / R3` (tooling depth; reliability unchanged
|
||||||
|
until consumer telemetry program)
|
||||||
|
|
||||||
|
## Problem statement
|
||||||
|
|
||||||
|
| Pain | Today (WP-0013) | Target |
|
||||||
|
|---|---|---|
|
||||||
|
| Update registry after code changes | `update` prints suggestions; user must remember `--apply` | Guided session with per-change prompts |
|
||||||
|
| Maturity / evidence refresh | `--suggest-maturity` → JSON only | Structured LLM patches with review or auto-apply |
|
||||||
|
| Publish hygiene | Manual validate → commit → publish-check | `maintain` chains update → validate → optional publish-check |
|
||||||
|
| Agent vs human UX | Same stdout for both | TTY prompts for humans; JSON/event stream for agents |
|
||||||
|
| Sibling repo friction | `validate` defaults to install root | Auto-detect `registry/` in cwd |
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
1. **Deterministic first** — vector drift, missing index rows, and cited artifact
|
||||||
|
paths apply without LLM; same safe-apply list as WP-0013-T06, extended.
|
||||||
|
2. **Interactive by default in TTY** — `reuse-surface maintain` prompts before
|
||||||
|
any non-deterministic write; non-TTY requires `--yes` or `--auto`.
|
||||||
|
3. **Full automation is explicit** — `--auto` applies deterministic patches plus
|
||||||
|
LLM proposals that pass schema validation and evidence gates; never silent
|
||||||
|
promotion above configured ceilings (default: no auto D/A/C/R jumps > 1 level).
|
||||||
|
4. **LLM optional** — deterministic-only paths work without `LLM_CONNECT_URL`;
|
||||||
|
LLM steps skip gracefully with a clear message.
|
||||||
|
5. **Validate gate** — every write path ends with `reuse-surface validate --root
|
||||||
|
<repo>`; failed validation rolls back the session batch (atomic apply).
|
||||||
|
6. **Evidence-bound promotions** — auto-apply for maturity changes requires
|
||||||
|
cited repo paths (tests, workflows, docs) present on disk; align checks with
|
||||||
|
`specs/CapabilityMaturityStandard.md`.
|
||||||
|
7. **Boundary** — reuse-surface does not host models; llm-connect owns routing
|
||||||
|
and credentials (`POST {LLM_CONNECT_URL}/execute`).
|
||||||
|
|
||||||
|
## Proposed CLI surface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive maintain (default in TTY)
|
||||||
|
cd ~/state-hub
|
||||||
|
export LLM_CONNECT_URL=http://127.0.0.1:8088 # optional
|
||||||
|
reuse-surface maintain
|
||||||
|
reuse-surface maintain --from-git-since origin/main
|
||||||
|
reuse-surface maintain --capability capability.statehub.workstream-coordinate
|
||||||
|
|
||||||
|
# Full automation (CI, agents, pre-commit)
|
||||||
|
reuse-surface maintain --auto --from-git-since HEAD~1
|
||||||
|
reuse-surface maintain --auto --no-llm # deterministic only
|
||||||
|
|
||||||
|
# Non-interactive apply-all-safe (current update behavior, preserved)
|
||||||
|
reuse-surface update --all --from-git-since origin/main --apply
|
||||||
|
|
||||||
|
# Federation publish helper (chains maintain + validate + publish-check)
|
||||||
|
reuse-surface maintain --publish --raw-url https://gitea.../capabilities.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive prompt flow (TTY)
|
||||||
|
|
||||||
|
```text
|
||||||
|
reuse-surface maintain
|
||||||
|
→ collect repo signals (git diff, index drift, roster stats)
|
||||||
|
→ deterministic suggestions (always listed first)
|
||||||
|
→ optional LLM patch proposals per capability (llm-connect)
|
||||||
|
→ for each pending change:
|
||||||
|
[a]pply [s]kip [e]dit in $EDITOR [q]uit [A]pply all safe
|
||||||
|
→ atomic write + validate
|
||||||
|
→ summary: files changed, remaining manual items, publish reminder
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automation tiers (`--auto`)
|
||||||
|
|
||||||
|
| Tier | Applies without prompt |
|
||||||
|
|---|---|
|
||||||
|
| `safe` | Deterministic patches (vector drift, evidence path append) |
|
||||||
|
| `llm-metadata` | LLM `consumer_feedback`, `notes`, non-level field updates |
|
||||||
|
| `llm-promote` | Single-step maturity bumps with on-disk evidence citations |
|
||||||
|
|
||||||
|
Configure ceiling via `--auto-max-delta 1` (default) or `--auto-max-delta 0` to
|
||||||
|
disable promotions.
|
||||||
|
|
||||||
|
## Suggested execution order
|
||||||
|
|
||||||
|
```text
|
||||||
|
T01 registry-patch JSON schema + LLM prompt templates
|
||||||
|
→ T02 expand deterministic signal collectors
|
||||||
|
→ T03 interactive prompt module (TTY + non-TTY)
|
||||||
|
→ T04 maintain command (orchestrator)
|
||||||
|
→ T05 LLM patch apply path with evidence gates
|
||||||
|
→ T06 --auto mode + atomic batch apply/rollback
|
||||||
|
→ T07 validate cwd auto-detect; index.updated bump
|
||||||
|
→ T08 sibling integration (Makefile template, optional hook generator)
|
||||||
|
→ T09 docs, tests, gap-analysis priority 28
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Dependency | Owner | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| llm-connect | llm-connect | `LLM_CONNECT_URL`; mocked in pytest |
|
||||||
|
| WP-0013 modules | reuse-surface | `registry_update.py`, `llm_bridge.py`, `establish.py` |
|
||||||
|
| Maturity standard | reuse-surface | Promotion evidence rules in prompts and gates |
|
||||||
|
| Sibling repo adoption | Domain owners | Run `maintain` in each checkout; optional CI step |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add Registry Patch Schema And LLM Templates
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "f5daf384-ca4e-42ec-8530-bf5d46155284"
|
||||||
|
```
|
||||||
|
|
||||||
|
Define `schemas/registry-patch.schema.json` for structured update proposals
|
||||||
|
(consumed by interactive and `--auto` paths):
|
||||||
|
|
||||||
|
- `patches[]`: `{ capability_id, kind, confidence, rationale, field_path, value |
|
||||||
|
append, promotion_history_entry }`
|
||||||
|
- `kinds`: `vector_sync`, `evidence_append`, `artifact_append`, `maturity_promote`,
|
||||||
|
`consumer_feedback`, `relation_add`, `index_row_add`
|
||||||
|
- `evidence_citations[]`: repo-relative paths supporting each patch
|
||||||
|
|
||||||
|
Add prompt builders in `reuse_surface/registry_update.py` (or
|
||||||
|
`reuse_surface/maintain_llm.py`):
|
||||||
|
|
||||||
|
- `build_maintain_prompt(repo_root, capability_id, git_since, context_files)`
|
||||||
|
- Schema-constrained JSON via `request_json_object` + validator
|
||||||
|
- Reuse maturity level definitions from `CapabilityMaturityStandard.md` in prompt
|
||||||
|
context (summary table, not full doc)
|
||||||
|
|
||||||
|
Pytest: fixture repo + mocked llm-connect returning valid/invalid patches.
|
||||||
|
|
||||||
|
## Expand Deterministic Signal Collectors
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "55e6d943-6237-4332-9b01-2fa42aceff1f"
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend `collect_deterministic_suggestions` in `registry_update.py`:
|
||||||
|
|
||||||
|
| Signal | Suggested field |
|
||||||
|
|---|---|
|
||||||
|
| `.gitea/workflows/*.yml` changed | `evidence.tests` or `evidence.documentation` |
|
||||||
|
| `docs/**` changed | `evidence.documentation` |
|
||||||
|
| `pyproject.toml` / `[project.scripts]` added | `availability.current_artifacts` |
|
||||||
|
| New `registry/capabilities/*.md` without index row | `index_row_add` patch |
|
||||||
|
| `index.updated` stale vs last git touch on `registry/` | bump `updated` date |
|
||||||
|
| Missing entry file for index row | `missing_entry` (blocking warning) |
|
||||||
|
|
||||||
|
Keep `--apply` safe-list explicit in code (document in module docstring). Add
|
||||||
|
regression tests in `tests/test_registry_update.py`.
|
||||||
|
|
||||||
|
## Implement Interactive Prompt Module
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "fe3a2e99-8c40-48a7-9d70-0e92b48146d2"
|
||||||
|
```
|
||||||
|
|
||||||
|
New module `reuse_surface/interactive.py`:
|
||||||
|
|
||||||
|
- Detect TTY (`sys.stdin.isatty()`)
|
||||||
|
- `prompt_patch(patch) -> Literal["apply","skip","edit","quit"]` with short
|
||||||
|
summary (kind, capability_id, rationale, field preview)
|
||||||
|
- `prompt_batch(patches) -> list[patch]` supporting **Apply all safe** for
|
||||||
|
deterministic kinds only
|
||||||
|
- Non-TTY: raise unless `assume_yes` / `auto_mode` set; emit JSON lines
|
||||||
|
(`{"event":"suggestion",...}`) for agent consumers
|
||||||
|
- Optional `$EDITOR` flow: write temp YAML snippet, re-parse on save
|
||||||
|
|
||||||
|
No llm-connect dependency. Pytest with stdin mocked via `io.StringIO`.
|
||||||
|
|
||||||
|
## Implement maintain Command
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T04
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "6e3a7b3d-1037-49ed-ad7c-341d21c333da"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `reuse-surface maintain` in `cli.py` (or alias `update --interactive` if
|
||||||
|
prefer fewer top-level verbs — default to **`maintain`** as the user-facing
|
||||||
|
entry point):
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `--path` | Repo root (default cwd) |
|
||||||
|
| `--capability` / `--all` | Scope |
|
||||||
|
| `--from-git-since` | Git ref for change detection |
|
||||||
|
| `--llm-url` | Override `LLM_CONNECT_URL` |
|
||||||
|
| `--no-llm` | Skip LLM phase |
|
||||||
|
| `--publish` | Run `establish --publish-check` after successful validate |
|
||||||
|
| `--raw-url` | Required when `--publish` |
|
||||||
|
| `--format json` | Machine-readable session result |
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
|
||||||
|
1. Run T02 collectors
|
||||||
|
2. If LLM enabled: run T01 prompts per capability in scope
|
||||||
|
3. Merge deterministic + LLM into ordered patch list (deterministic first)
|
||||||
|
4. T03 interactive selection (unless `--auto` — T06)
|
||||||
|
5. T05 apply + T06 atomic validate
|
||||||
|
|
||||||
|
Preserve existing `update` command unchanged for scripting backward compatibility.
|
||||||
|
|
||||||
|
## LLM Patch Apply Path With Evidence Gates
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T05
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "f0baa772-b7f0-4143-9fd9-9c96db17f532"
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement `apply_patches(repo_root, patches) -> list[str]`:
|
||||||
|
|
||||||
|
- Reuse `apply_deterministic_suggestions` for overlapping kinds
|
||||||
|
- New writers: `promotion_history` append, `maturity.*.current` with vector
|
||||||
|
sync to index, `consumer_feedback` append, `relations.*` append (optional v1)
|
||||||
|
- **Evidence gate:** for `maturity_promote`, require every
|
||||||
|
`evidence_citations` path to exist under `repo_root`; reject patch if not
|
||||||
|
- **Level gate:** refuse promotion if delta > `--auto-max-delta` unless
|
||||||
|
interactive user confirms
|
||||||
|
- Bump `registry/indexes/capabilities.yaml` `updated` field on any write
|
||||||
|
|
||||||
|
Pytest: promote with/without evidence files; vector/index consistency after apply.
|
||||||
|
|
||||||
|
## Implement --auto Mode And Atomic Batch
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T06
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "bd8f6243-24a3-44f8-9824-4cc2518ad8d9"
|
||||||
|
```
|
||||||
|
|
||||||
|
`maintain --auto`:
|
||||||
|
|
||||||
|
- Apply all `safe` deterministic patches
|
||||||
|
- Apply LLM patches with `confidence >= --auto-confidence` (default `high`) and
|
||||||
|
passing evidence gates
|
||||||
|
- `--auto-max-delta` (default `1`) caps promotion steps per dimension per session
|
||||||
|
- `--yes` on non-TTY equivalent to `--auto` with default thresholds
|
||||||
|
|
||||||
|
**Atomic batch:** write all entry/index changes to temp files under
|
||||||
|
`.reuse-surface-session/`; on validate success, rename into place; on failure,
|
||||||
|
discard and print validator errors.
|
||||||
|
|
||||||
|
Exit codes: `0` ok, `1` validation/schema failure, `2` partial skip (no writes).
|
||||||
|
|
||||||
|
## Validate Cwd Auto-Detect And Publish Helper
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T07
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
state_hub_task_id: "a61c0843-f44b-4e75-9043-7d042087e015"
|
||||||
|
```
|
||||||
|
|
||||||
|
- When `--root` / `--path` omitted and `./registry/indexes/capabilities.yaml`
|
||||||
|
exists, default repo root to cwd (validate, update, maintain, stats)
|
||||||
|
- `maintain --publish --raw-url` chains: maintain session → validate →
|
||||||
|
`establish.publish_check` → print pass/fail markdown
|
||||||
|
- Document raw URL convention in session summary when `REUSE_SURFACE_RAW_URL` set
|
||||||
|
|
||||||
|
## Sibling Integration Templates
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T08
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
state_hub_task_id: "ec2d58a3-c797-464b-9fb3-464f71360c9c"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ship copy-paste artifacts (not installed into sibling repos automatically):
|
||||||
|
|
||||||
|
- `templates/Makefile.registry.fragment` — `registry-maintain`, `registry-check`
|
||||||
|
- `templates/git-hook.pre-commit.registry` — `maintain --auto --no-llm` when
|
||||||
|
`registry/` changed
|
||||||
|
- `establish --scaffold` append: optional `--hook` writes `.git/hooks/pre-commit`
|
||||||
|
(refuse overwrite unless `--force`)
|
||||||
|
|
||||||
|
Dogfood: run against `state-hub` checkout when available.
|
||||||
|
|
||||||
|
## Documentation, Tests, And Gap Note
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: REUSE-WP-0016-T09
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
state_hub_task_id: "85f8f549-7df9-493c-b43b-f1b67af3ee6c"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tools/README.md` — `maintain` command reference; interactive vs `--auto`
|
||||||
|
- `docs/RegistryFederation.md` — link maintain + publish to sibling onboarding
|
||||||
|
- `registry/README.md` — operator checklist after `maintain` session
|
||||||
|
- `docs/IntentScopeGapAnalysis.md` — add priority **28** (registry maintenance
|
||||||
|
automation); mark open
|
||||||
|
- `SCOPE.md` — extend "What Is Possible Now" when T04 ships
|
||||||
|
- CI: `maintain --auto --no-llm` on reuse-surface self-registry (informational
|
||||||
|
or gated); no live llm-connect in CI
|
||||||
|
- Pytest count increase; `reuse-surface validate` unchanged for default path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [x] `reuse-surface maintain` in TTY walks through suggestions with apply/skip/edit
|
||||||
|
- [x] `maintain --auto --no-llm` applies deterministic patches and validates atomically
|
||||||
|
- [x] LLM patches apply only with schema validation + evidence gates
|
||||||
|
- [x] `maintain --publish --raw-url` reports federation publish pass/fail
|
||||||
|
- [x] Non-TTY without `--auto`/`--yes` fails with clear message (no silent writes)
|
||||||
|
- [x] `validate` defaults to cwd when local `registry/` index exists
|
||||||
|
- [x] All new behavior documented; gap priority 28 recorded
|
||||||
|
|
||||||
|
## Completion notes (2026-06-17)
|
||||||
|
|
||||||
|
- Modules: `maintain.py`, `maintain_llm.py`, `patches.py`, `interactive.py`
|
||||||
|
- Schema: `schemas/registry-patch.schema.json`
|
||||||
|
- Templates: `templates/Makefile.registry.fragment`, `templates/git-hook.pre-commit.registry`
|
||||||
|
- CLI: `reuse-surface maintain`; `establish --scaffold --hook`
|
||||||
|
- Tests: `tests/test_maintain.py`, `tests/test_interactive.py` (59 pytest total)
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Hub cache invalidation webhooks (gap priority from §3.1 — separate workplan)
|
||||||
|
- Auto `hub register` (still operator step with token)
|
||||||
|
- Embedding / ML overlap detection (keep `overlaps` heuristic)
|
||||||
|
- llm-connect hosting or provider configuration inside reuse-surface
|
||||||
|
- Fully unattended maturity promotion without evidence citations
|
||||||
|
|
||||||
|
## Dogfood target
|
||||||
|
|
||||||
|
From `~/state-hub` (or any roster repo with `publish_check: pass`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LLM_CONNECT_URL=http://127.0.0.1:8088
|
||||||
|
reuse-surface maintain --from-git-since origin/main
|
||||||
|
reuse-surface maintain --auto --from-git-since HEAD~3
|
||||||
|
reuse-surface maintain --publish \
|
||||||
|
--raw-url https://gitea.coulomb.social/coulomb/state-hub/raw/main/registry/indexes/capabilities.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Success: registry files updated, `validate --root .` passes, publish-check 200.
|
||||||
Reference in New Issue
Block a user