recx-cli 0.0.2__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. recx_cli-0.0.4/PKG-INFO +98 -0
  2. recx_cli-0.0.4/README.md +84 -0
  3. {recx_cli-0.0.2 → recx_cli-0.0.4}/pyproject.toml +1 -1
  4. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/SPEC.md +5 -5
  5. {recx_cli-0.0.2 → recx_cli-0.0.4}/src/recx_cli/cli.py +30 -3
  6. recx_cli-0.0.4/src/recx_cli/drift.py +178 -0
  7. recx_cli-0.0.4/src/recx_cli/evidence.py +77 -0
  8. {recx_cli-0.0.2 → recx_cli-0.0.4}/src/recx_cli/hook.py +67 -21
  9. {recx_cli-0.0.2 → recx_cli-0.0.4}/tests/test_check.py +3 -2
  10. recx_cli-0.0.4/tests/test_drift.py +424 -0
  11. recx_cli-0.0.4/tests/test_evidence.py +137 -0
  12. recx_cli-0.0.2/PKG-INFO +0 -57
  13. recx_cli-0.0.2/README.md +0 -43
  14. {recx_cli-0.0.2 → recx_cli-0.0.4}/.gitignore +0 -0
  15. {recx_cli-0.0.2 → recx_cli-0.0.4}/LICENSE +0 -0
  16. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/evidence-without-run.yaml +0 -0
  17. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/extension-value-not-scalar.yaml +0 -0
  18. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/http-url.yaml +0 -0
  19. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/license-not-permissive.yaml +0 -0
  20. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/missing-capabilities.yaml +0 -0
  21. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/multiline-summary.yaml +0 -0
  22. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/package-without-purl.yaml +0 -0
  23. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/path-escapes-repo.yaml +0 -0
  24. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/unknown-key.yaml +0 -0
  25. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid/unknown-version.yaml +0 -0
  26. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml +0 -0
  27. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml +0 -0
  28. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml +0 -0
  29. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml +0 -0
  30. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule5-over-64-kib.yaml +0 -0
  31. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule6-anchor-alias.yaml +0 -0
  32. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule6-duplicate-key.yaml +0 -0
  33. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/invalid-rules/rule6-merge-key.yaml +0 -0
  34. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/valid/full.yaml +0 -0
  35. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/examples/valid/minimal.yaml +0 -0
  36. {recx_cli-0.0.2 → recx_cli-0.0.4}/spec/recx.schema.json +0 -0
  37. {recx_cli-0.0.2 → recx_cli-0.0.4}/src/recx_cli/__init__.py +0 -0
  38. {recx_cli-0.0.2 → recx_cli-0.0.4}/src/recx_cli/__main__.py +0 -0
  39. {recx_cli-0.0.2 → recx_cli-0.0.4}/src/recx_cli/check.py +0 -0
  40. {recx_cli-0.0.2 → recx_cli-0.0.4}/tests/test_hook.py +0 -0
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: recx-cli
3
+ Version: 0.0.4
4
+ Summary: Validator for RECX.yaml manifests: the recx check command.
5
+ Project-URL: Homepage, https://github.com/jbmv/recx
6
+ Project-URL: Repository, https://github.com/jbmv/recx
7
+ Author: James Bullard
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: jsonschema[format-nongpl]>=4.18
12
+ Requires-Dist: pyyaml>=6
13
+ Description-Content-Type: text/markdown
14
+
15
+ # recx
16
+
17
+ *res extra commercium*: things outside commerce. A way for agents to find reusable, permissively
18
+ licensed code from solo and small-team projects.
19
+
20
+ A `RECX.yaml` at the root of a repository tells other people's coding agents what the repository
21
+ does, which parts of it they can take on their own, and which checks show those parts work. recx
22
+ is that format, a validator for it, and a Claude Code plugin that keeps the file current.
23
+
24
+ ## Why
25
+
26
+ Coding agents have increased the amount of software that individuals and small teams produce.
27
+ Much of it remains internal tooling, and the projects that are published are hard to find among
28
+ the number of new repositories. Evaluating an unfamiliar open-source project also takes time, so
29
+ writing a similar tool is often the quicker option, and comparable tools get written more than
30
+ once.
31
+
32
+ recx moves that search and evaluation to the agent doing the work, in two parts. The first is
33
+ the manifest: a `RECX.yaml` in each participating repository that describes the project's
34
+ capabilities, its separable modules and their interfaces, and the commands that verify them, in
35
+ a fixed machine-readable form. The second is search: a way for an agent, while planning new
36
+ work, to find manifests with relevant modules, check their evidence, and reuse a module rather
37
+ than write it again, recording where it came from in its own project's manifest.
38
+
39
+ Taking part is meant to cost a repository little. One prompt produces a complete, valid
40
+ manifest, and the Claude Code plugin checks it whenever a session ends, so it stays valid as
41
+ the code changes. No hosted service is involved, because the fixed filename serves as the index.
42
+ On GitHub, manifests can be found by searching code for `path:/(^|\/)recx\.ya?ml$/i`.
43
+
44
+ ## The manifest
45
+
46
+ ```yaml
47
+ recx: "0.1"
48
+ name: tinyfmt
49
+ summary: Formats byte counts as human-readable strings like 1.2 MB.
50
+ license: MIT
51
+ status: stable
52
+ capabilities:
53
+ - format a byte count as a human-readable size string
54
+ ```
55
+
56
+ Those six keys are the minimum. The optional parts carry most of the information: modules with
57
+ interfaces and extraction notes, evidence anyone can rerun, and records of what was reused from
58
+ where. [spec/SPEC.md](spec/SPEC.md) defines every field, puts a length limit on every string,
59
+ and sets out how to read a stranger's manifest safely.
60
+
61
+ Only permissively licensed projects qualify (MIT, Apache-2.0, the BSDs and similar), so
62
+ anything listed can be reused without asking.
63
+
64
+ ## Get started
65
+
66
+ - **Claude Code:** `/plugin marketplace add jbmv/recx`, then `/plugin install recx@recx`
67
+ (or, on Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx`). Then
68
+ ask Claude to add a RECX.yaml. A Stop hook keeps the manifest valid; set
69
+ `RECX_STOP_HOOK=warn` or `off` to soften or disable it.
70
+ - **Any other agent:** paste the prompt in [ADOPT.md](ADOPT.md).
71
+ - **By hand:** start from the example above, then run `uvx --from recx-cli recx check`.
72
+
73
+ ## What's here
74
+
75
+ | | |
76
+ |---|---|
77
+ | [spec/](spec/) | The spec, its JSON Schema, and valid and invalid example manifests |
78
+ | `recx-cli` on PyPI | `recx check`, the validator (`pip install recx-cli`) |
79
+ | `@jbmv/recx` on npm | The schema and examples, for JavaScript tooling |
80
+ | [plugin/](plugin/) | The Claude Code plugin; this repository is its marketplace |
81
+ | [RECX.yaml](RECX.yaml) | recx's own manifest |
82
+
83
+ ## Status
84
+
85
+ Experimental. The spec is a v0.1 draft and may change before 1.0; every manifest states the
86
+ version it was written against. `recx check` enforces the whole spec except rule 4, that the
87
+ license file matches `license`. The manifest, the validator and the plugin exist; search, the
88
+ second part described above, is not built yet.
89
+
90
+ ## Kyosei
91
+
92
+ I built recx for Kyosei, a record-keeping tool for running projects with coding agents. Kyosei
93
+ isn't public yet. It will generate its own RECX.yaml from its records. Nothing in the spec
94
+ depends on Kyosei: it is one client of the spec, like any other tool.
95
+
96
+ ## License
97
+
98
+ MIT
@@ -0,0 +1,84 @@
1
+ # recx
2
+
3
+ *res extra commercium*: things outside commerce. A way for agents to find reusable, permissively
4
+ licensed code from solo and small-team projects.
5
+
6
+ A `RECX.yaml` at the root of a repository tells other people's coding agents what the repository
7
+ does, which parts of it they can take on their own, and which checks show those parts work. recx
8
+ is that format, a validator for it, and a Claude Code plugin that keeps the file current.
9
+
10
+ ## Why
11
+
12
+ Coding agents have increased the amount of software that individuals and small teams produce.
13
+ Much of it remains internal tooling, and the projects that are published are hard to find among
14
+ the number of new repositories. Evaluating an unfamiliar open-source project also takes time, so
15
+ writing a similar tool is often the quicker option, and comparable tools get written more than
16
+ once.
17
+
18
+ recx moves that search and evaluation to the agent doing the work, in two parts. The first is
19
+ the manifest: a `RECX.yaml` in each participating repository that describes the project's
20
+ capabilities, its separable modules and their interfaces, and the commands that verify them, in
21
+ a fixed machine-readable form. The second is search: a way for an agent, while planning new
22
+ work, to find manifests with relevant modules, check their evidence, and reuse a module rather
23
+ than write it again, recording where it came from in its own project's manifest.
24
+
25
+ Taking part is meant to cost a repository little. One prompt produces a complete, valid
26
+ manifest, and the Claude Code plugin checks it whenever a session ends, so it stays valid as
27
+ the code changes. No hosted service is involved, because the fixed filename serves as the index.
28
+ On GitHub, manifests can be found by searching code for `path:/(^|\/)recx\.ya?ml$/i`.
29
+
30
+ ## The manifest
31
+
32
+ ```yaml
33
+ recx: "0.1"
34
+ name: tinyfmt
35
+ summary: Formats byte counts as human-readable strings like 1.2 MB.
36
+ license: MIT
37
+ status: stable
38
+ capabilities:
39
+ - format a byte count as a human-readable size string
40
+ ```
41
+
42
+ Those six keys are the minimum. The optional parts carry most of the information: modules with
43
+ interfaces and extraction notes, evidence anyone can rerun, and records of what was reused from
44
+ where. [spec/SPEC.md](spec/SPEC.md) defines every field, puts a length limit on every string,
45
+ and sets out how to read a stranger's manifest safely.
46
+
47
+ Only permissively licensed projects qualify (MIT, Apache-2.0, the BSDs and similar), so
48
+ anything listed can be reused without asking.
49
+
50
+ ## Get started
51
+
52
+ - **Claude Code:** `/plugin marketplace add jbmv/recx`, then `/plugin install recx@recx`
53
+ (or, on Claude Code 2.1.275 or later, `/plugin install recx --marketplace jbmv/recx`). Then
54
+ ask Claude to add a RECX.yaml. A Stop hook keeps the manifest valid; set
55
+ `RECX_STOP_HOOK=warn` or `off` to soften or disable it.
56
+ - **Any other agent:** paste the prompt in [ADOPT.md](ADOPT.md).
57
+ - **By hand:** start from the example above, then run `uvx --from recx-cli recx check`.
58
+
59
+ ## What's here
60
+
61
+ | | |
62
+ |---|---|
63
+ | [spec/](spec/) | The spec, its JSON Schema, and valid and invalid example manifests |
64
+ | `recx-cli` on PyPI | `recx check`, the validator (`pip install recx-cli`) |
65
+ | `@jbmv/recx` on npm | The schema and examples, for JavaScript tooling |
66
+ | [plugin/](plugin/) | The Claude Code plugin; this repository is its marketplace |
67
+ | [RECX.yaml](RECX.yaml) | recx's own manifest |
68
+
69
+ ## Status
70
+
71
+ Experimental. The spec is a v0.1 draft and may change before 1.0; every manifest states the
72
+ version it was written against. `recx check` enforces the whole spec except rule 4, that the
73
+ license file matches `license`. The manifest, the validator and the plugin exist; search, the
74
+ second part described above, is not built yet.
75
+
76
+ ## Kyosei
77
+
78
+ I built recx for Kyosei, a record-keeping tool for running projects with coding agents. Kyosei
79
+ isn't public yet. It will generate its own RECX.yaml from its records. Nothing in the spec
80
+ depends on Kyosei: it is one client of the spec, like any other tool.
81
+
82
+ ## License
83
+
84
+ MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "recx-cli"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  description = "Validator for RECX.yaml manifests: the recx check command."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -7,11 +7,11 @@ written against, so a change never silently reinterprets an old file.
7
7
 
8
8
  ## Purpose
9
9
 
10
- A great deal of permissively licensed code is being written by people working alone, and much
11
- of it is never read. recx assumes the reader that will look is another person's coding agent,
12
- and gives it something to read. `RECX.yaml` says what a repository does, which of its parts can
13
- be taken on their own, how to take them, and which checks anyone can rerun to see that they
14
- work.
10
+ recx is meant to let coding agents find and evaluate reusable code from solo and small-team
11
+ projects, which are otherwise difficult to discover among the volume of new repositories.
12
+ `RECX.yaml` states, in a fixed machine-readable form, what a repository does, which of its parts
13
+ can be taken on their own, how to take them, and which checks anyone can rerun to confirm that
14
+ they work.
15
15
 
16
16
  It complements `AGENTS.md` rather than replacing it. `AGENTS.md` tells an agent how to work
17
17
  *inside* a repository. `RECX.yaml` tells an agent working somewhere else what it can take
@@ -1,4 +1,4 @@
1
- """Command line: `recx check [PATH] [--no-repo]`."""
1
+ """Command line: `recx check`, `recx evidence`, `recx hook stop`."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,6 +8,8 @@ from importlib import metadata
8
8
  from pathlib import Path
9
9
 
10
10
  from recx_cli.check import Unreadable, check_file, format_problem
11
+ from recx_cli.evidence import HELP as EVIDENCE_HELP
12
+ from recx_cli.evidence import evidence
11
13
 
12
14
 
13
15
  def _version() -> str:
@@ -45,6 +47,22 @@ def build_parser() -> argparse.ArgumentParser:
45
47
  action="store_true",
46
48
  help="skip the checks that look at the repository (rule 3: module paths exist)",
47
49
  )
50
+ ev = sub.add_parser(
51
+ "evidence",
52
+ help="run a manifest's evidence commands (your own repository or a sandbox only)",
53
+ description=EVIDENCE_HELP,
54
+ )
55
+ ev.add_argument(
56
+ "path",
57
+ nargs="?",
58
+ default="RECX.yaml",
59
+ metavar="PATH",
60
+ help="a manifest file, or a directory holding RECX.yaml (default: ./RECX.yaml)",
61
+ )
62
+ ev.add_argument(
63
+ "--list", action="store_true", help="print each selected entry's commands without running anything"
64
+ )
65
+ ev.add_argument("--only", nargs="+", metavar="ID", help="run only the named evidence entries")
48
66
  hook = sub.add_parser(
49
67
  "hook",
50
68
  help="entry points for Claude Code hooks",
@@ -58,15 +76,24 @@ def build_parser() -> argparse.ArgumentParser:
58
76
  "RECX_STOP_HOOK=block (default) blocks the stop while RECX.yaml is invalid, "
59
77
  "warn only gives feedback, off does nothing.",
60
78
  )
79
+ hook_sub.add_parser(
80
+ "session-start",
81
+ help="SessionStart hook: record HEAD so the Stop hook can remind about manifest drift",
82
+ description="Read Claude Code's SessionStart hook JSON on stdin and record the "
83
+ "repository's HEAD for the session. Always exits 0 with no output. "
84
+ "RECX_DRIFT_REMINDER=off or RECX_STOP_HOOK=off does nothing.",
85
+ )
61
86
  return parser
62
87
 
63
88
 
64
89
  def main(argv: list[str] | None = None) -> int:
65
90
  args = build_parser().parse_args(argv)
66
91
  if args.command == "hook":
67
- from recx_cli.hook import stop
92
+ from recx_cli.hook import session_start, stop
68
93
 
69
- return stop()
94
+ return session_start() if args.hook_command == "session-start" else stop()
95
+ if args.command == "evidence":
96
+ return evidence(args.path, args.list, args.only)
70
97
  target = Path(args.path)
71
98
  if target.is_dir():
72
99
  target = target / "RECX.yaml"
@@ -0,0 +1,178 @@
1
+ """Drift reminder: a manifest can be valid and stale.
2
+
3
+ `recx hook session-start` records the repository's HEAD for the session.
4
+ `recx hook stop` then asks which modules have changed files since, and, when
5
+ RECX.yaml is not among them, reminds once per set of modules. The reminder is
6
+ only ever feedback, never a block. Callers swallow every exception raised here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import tempfile
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from recx_cli.check import parse
20
+
21
+ MANIFEST = "RECX.yaml"
22
+ SKILL = "recx:manifest"
23
+ _HEAD = re.compile(r"[0-9a-fA-F]{7,64}")
24
+ _SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
25
+
26
+
27
+ def enabled() -> bool:
28
+ return os.environ.get("RECX_DRIFT_REMINDER", "").strip().lower() != "off"
29
+
30
+
31
+ def state_dir() -> Path:
32
+ plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA")
33
+ if plugin_data:
34
+ return Path(plugin_data)
35
+ base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
36
+ return Path(base) / "recx"
37
+
38
+
39
+ def _state_file(session_id: str) -> Path | None:
40
+ if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id):
41
+ return None
42
+ return state_dir() / f"{session_id}.json"
43
+
44
+
45
+ def read_state(session_id: str) -> dict:
46
+ """The session's state, or {} when absent, unreadable or corrupt."""
47
+ try:
48
+ path = _state_file(session_id)
49
+ if path is None:
50
+ return {}
51
+ data = json.loads(path.read_text(encoding="utf-8"))
52
+ if not isinstance(data, dict):
53
+ return {}
54
+ state: dict = {}
55
+ head = data.get("head")
56
+ if isinstance(head, str) and _HEAD.fullmatch(head):
57
+ state["head"] = head
58
+ reminded = data.get("reminded")
59
+ if isinstance(reminded, list):
60
+ state["reminded"] = [m for m in reminded if isinstance(m, str)]
61
+ return state
62
+ except Exception: # noqa: BLE001 - a bad state file is ignored
63
+ return {}
64
+
65
+
66
+ def write_state(session_id: str, state: dict) -> None:
67
+ path = _state_file(session_id)
68
+ if path is None:
69
+ return
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".json")
72
+ try:
73
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
74
+ json.dump(state, fh)
75
+ os.replace(tmp, path)
76
+ except BaseException:
77
+ try:
78
+ os.unlink(tmp)
79
+ except OSError:
80
+ pass
81
+ raise
82
+
83
+
84
+ def _git(top: Path, *args: str) -> str:
85
+ out = subprocess.run(
86
+ ["git", "-C", str(top), *args], capture_output=True, text=True, timeout=20, check=True
87
+ )
88
+ return out.stdout
89
+
90
+
91
+ def head_of(top: Path) -> str | None:
92
+ value = _git(top, "rev-parse", "HEAD").strip()
93
+ return value if _HEAD.fullmatch(value) else None
94
+
95
+
96
+ STATE_MAX_AGE_DAYS = 30
97
+
98
+
99
+ def prune_state(max_age_days: int = STATE_MAX_AGE_DAYS) -> None:
100
+ """Delete session state files older than max_age_days; one is written per session."""
101
+ try:
102
+ cutoff = time.time() - max_age_days * 86400
103
+ for path in state_dir().glob("*.json"):
104
+ try:
105
+ if path.stat().st_mtime < cutoff:
106
+ path.unlink()
107
+ except OSError:
108
+ continue
109
+ except OSError:
110
+ pass
111
+
112
+
113
+ def session_start(session_id: str, top: Path) -> None:
114
+ """Record HEAD for the session. Skips when there is no manifest or the reminder is off."""
115
+ if not enabled() or not (top / MANIFEST).is_file():
116
+ return
117
+ prune_state()
118
+ head = head_of(top)
119
+ if head:
120
+ write_state(session_id, {"head": head, "reminded": []})
121
+
122
+
123
+ def changed_files(top: Path, head: str | None) -> set[str]:
124
+ files: set[str] = set()
125
+ for base in [head, "HEAD"] if head else ["HEAD"]:
126
+ try:
127
+ files.update(_git(top, "diff", "--name-only", "-z", base, "--").split("\0"))
128
+ break
129
+ except (OSError, subprocess.SubprocessError):
130
+ continue
131
+ files.update(_git(top, "ls-files", "--others", "--exclude-standard", "-z").split("\0"))
132
+ files.discard("")
133
+ return files
134
+
135
+
136
+ def affected_modules(manifest: Path, files: set[str]) -> list[str]:
137
+ data = parse(manifest.read_text(encoding="utf-8"))
138
+ modules = data.get("modules") if isinstance(data, dict) else None
139
+ found: list[str] = []
140
+ for mod in modules if isinstance(modules, list) else []:
141
+ if not isinstance(mod, dict):
142
+ continue
143
+ mid, path = mod.get("id"), mod.get("path")
144
+ if not isinstance(mid, str) or not isinstance(path, str):
145
+ continue
146
+ if path.endswith("/"):
147
+ hit = any(f.startswith(path) for f in files)
148
+ else:
149
+ hit = path in files
150
+ if hit and mid not in found:
151
+ found.append(mid)
152
+ return found
153
+
154
+
155
+ def reminder(session_id: str | None, top: Path) -> str | None:
156
+ """The reminder text to emit now, or None. Records what it reminded about."""
157
+ if not enabled():
158
+ return None
159
+ state = read_state(session_id) if session_id else {}
160
+ files = changed_files(top, state.get("head"))
161
+ if MANIFEST in files:
162
+ return None
163
+ affected = affected_modules(top / MANIFEST, files)
164
+ already = set(state.get("reminded", []))
165
+ if not affected or set(affected) <= already:
166
+ return None
167
+ if session_id:
168
+ try:
169
+ write_state(session_id, {**state, "reminded": sorted(already | set(affected))})
170
+ except Exception: # noqa: BLE001 - failing to record must not lose the reminder
171
+ pass
172
+ names = ", ".join(affected)
173
+ return (
174
+ f"Reminder, not an error: files under these {MANIFEST} modules changed this session "
175
+ f"while {MANIFEST} did not: {names}. Check whether the manifest still describes them, "
176
+ f"using the {SKILL} skill, and update it if it does not. If it is still accurate, "
177
+ f"nothing needs doing."
178
+ )
@@ -0,0 +1,77 @@
1
+ """`recx evidence`: run a manifest's own evidence commands, in the repository owner's CI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from recx_cli.check import Unreadable, check_file, format_problem, parse
11
+
12
+ HELP = (
13
+ "Run each evidence entry's setup and run commands (sh -c, from the manifest's directory, "
14
+ "with your environment) and report pass or fail. This runs the manifest's commands: use it "
15
+ "on your own repository or inside a sandbox, never on a stranger's manifest outside one "
16
+ "(SPEC, Trust). The manifest must pass `recx check` first. "
17
+ "Exit status: 0 all selected entries passed, 1 an entry failed or the manifest is invalid, "
18
+ "2 usage error or unreadable file."
19
+ )
20
+
21
+
22
+ def _run(command: str, cwd: Path) -> int:
23
+ sys.stdout.flush()
24
+ sys.stderr.flush()
25
+ return subprocess.run(["sh", "-c", command], cwd=cwd).returncode
26
+
27
+
28
+ def evidence(path: str, list_only: bool = False, only: list[str] | None = None) -> int:
29
+ target = Path(path)
30
+ if target.is_dir():
31
+ target = target / "RECX.yaml"
32
+ label = str(target)
33
+ try:
34
+ problems = check_file(target, repo=True)
35
+ except Unreadable as exc:
36
+ print(f"{label}: cannot read: {exc}", file=sys.stderr)
37
+ return 2
38
+ if problems:
39
+ for p in problems:
40
+ print(format_problem(label, p))
41
+ return 1
42
+ entries = parse(target.read_text(encoding="utf-8")).get("evidence") or []
43
+ if only:
44
+ known = [e["id"] for e in entries]
45
+ unknown = [i for i in only if i not in known]
46
+ if unknown:
47
+ print(
48
+ f"recx evidence: no evidence entry with id {', '.join(unknown)} "
49
+ f"(known: {', '.join(known) or 'none'})",
50
+ file=sys.stderr,
51
+ )
52
+ return 2
53
+ entries = [e for e in entries if e["id"] in only]
54
+ if list_only:
55
+ for e in entries:
56
+ print(e["id"])
57
+ if "setup" in e:
58
+ print(f" setup: {e['setup']}")
59
+ print(f" run: {e['run']}")
60
+ return 0
61
+ cwd = target.resolve().parent
62
+ failed = False
63
+ for e in entries:
64
+ start = time.monotonic()
65
+ code = 0
66
+ for step in ("setup", "run"):
67
+ if step in e:
68
+ code = _run(e[step], cwd)
69
+ if code != 0:
70
+ break
71
+ secs = f"{time.monotonic() - start:.1f}s"
72
+ if code == 0:
73
+ print(f"{e['id']}: pass ({secs})", flush=True)
74
+ else:
75
+ failed = True
76
+ print(f"{e['id']}: fail ({secs}, exit {code})", flush=True)
77
+ return 1 if failed else 0
@@ -13,6 +13,7 @@ import subprocess
13
13
  import sys
14
14
  from pathlib import Path
15
15
 
16
+ from recx_cli import drift
16
17
  from recx_cli.check import Unreadable, check_file, format_problem
17
18
 
18
19
  MANIFEST = "RECX.yaml"
@@ -41,44 +42,89 @@ def _manifest_dir(cwd: str) -> Path:
41
42
  return start
42
43
 
43
44
 
44
- def _run() -> None:
45
- if _mode() == "off":
46
- return
45
+ def _read_event() -> tuple[dict, Path] | None:
47
46
  raw = sys.stdin.read()
48
47
  if not raw.strip():
49
- return
48
+ return None
50
49
  event = json.loads(raw)
51
50
  if not isinstance(event, dict):
52
- return
51
+ return None
53
52
  cwd = event.get("cwd")
54
53
  if not isinstance(cwd, str) or not cwd:
55
- return
56
- manifest = _manifest_dir(cwd) / MANIFEST
54
+ return None
55
+ return event, _manifest_dir(cwd)
56
+
57
+
58
+ def _validity(event: dict, top: Path) -> tuple[bool, str | None]:
59
+ """(manifest is valid, output to print). Knows nothing about drift."""
60
+ manifest = top / MANIFEST
57
61
  if not manifest.is_file():
58
- return
62
+ return False, None
59
63
  try:
60
64
  problems = check_file(manifest, repo=True)
61
65
  except Unreadable:
62
- return
66
+ return False, None
63
67
  if not problems:
64
- return
68
+ return True, None
65
69
  lines = "\n".join(format_problem(MANIFEST, p) for p in problems)
66
70
  if _mode() == "block" and event.get("stop_hook_active") is not True:
67
71
  reason = (
68
72
  f"{MANIFEST} is invalid. Fix every problem below, using the {SKILL} skill, "
69
73
  f"then check it with `recx check` (or `uvx --from recx-cli recx check`):\n{lines}"
70
74
  )
71
- print(json.dumps({"decision": "block", "reason": reason}))
72
- else:
73
- still = " still" if event.get("stop_hook_active") is True else ""
74
- context = (
75
- f"{MANIFEST} is{still} invalid. The {SKILL} skill explains how to fix it:\n{lines}"
76
- )
77
- print(
78
- json.dumps(
79
- {"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": context}}
80
- )
81
- )
75
+ return False, json.dumps({"decision": "block", "reason": reason})
76
+ still = " still" if event.get("stop_hook_active") is True else ""
77
+ context = f"{MANIFEST} is{still} invalid. The {SKILL} skill explains how to fix it:\n{lines}"
78
+ return False, json.dumps(
79
+ {"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": context}}
80
+ )
81
+
82
+
83
+ def _drift(event: dict, top: Path) -> str | None:
84
+ try:
85
+ session = event.get("session_id")
86
+ text = drift.reminder(session if isinstance(session, str) else None, top)
87
+ except BaseException: # noqa: BLE001 - the drift path must never block or raise
88
+ return None
89
+ if text is None:
90
+ return None
91
+ return json.dumps({"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": text}})
92
+
93
+
94
+ def _run() -> None:
95
+ if _mode() == "off":
96
+ return
97
+ parsed = _read_event()
98
+ if parsed is None:
99
+ return
100
+ event, top = parsed
101
+ valid, output = _validity(event, top)
102
+ if output is not None:
103
+ print(output)
104
+ elif valid:
105
+ extra = _drift(event, top)
106
+ if extra is not None:
107
+ print(extra)
108
+
109
+
110
+ def _session_start() -> None:
111
+ if _mode() == "off" or not drift.enabled():
112
+ return
113
+ parsed = _read_event()
114
+ if parsed is None:
115
+ return
116
+ event, top = parsed
117
+ session = event.get("session_id")
118
+ if isinstance(session, str):
119
+ drift.session_start(session, top)
120
+
121
+
122
+ def session_start() -> int:
123
+ try:
124
+ _session_start()
125
+ except BaseException: # noqa: BLE001 - a hook failure must not block the session
126
+ pass
127
+ return 0
82
128
 
83
129
 
84
130
  def stop() -> int:
@@ -238,8 +238,9 @@ def test_build_produces_sdist_and_wheel(tmp_path):
238
238
  out = tmp_path / "dist"
239
239
  env = {k: v for k, v in __import__("os").environ.items() if k != "VIRTUAL_ENV"}
240
240
  subprocess.run(["uv", "build", "--out-dir", str(out)], cwd=ROOT, check=True, capture_output=True, env=env)
241
- wheels = list(out.glob("recx_cli-0.0.2-*.whl"))
242
- sdists = list(out.glob("recx_cli-0.0.2.tar.gz"))
241
+ version = __import__("tomllib").loads((ROOT / "pyproject.toml").read_text())["project"]["version"]
242
+ wheels = list(out.glob(f"recx_cli-{version}-*.whl"))
243
+ sdists = list(out.glob(f"recx_cli-{version}.tar.gz"))
243
244
  assert len(wheels) == 1 and len(sdists) == 1
244
245
  with zipfile.ZipFile(wheels[0]) as z:
245
246
  names = z.namelist()