recx-cli 0.1.0__tar.gz → 0.1.1__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 (79) hide show
  1. {recx_cli-0.1.0 → recx_cli-0.1.1}/PKG-INFO +12 -3
  2. {recx_cli-0.1.0 → recx_cli-0.1.1}/README.md +11 -2
  3. {recx_cli-0.1.0 → recx_cli-0.1.1}/pyproject.toml +1 -1
  4. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/cli.py +2 -2
  5. recx_cli-0.1.1/src/recx_cli/drift.py +312 -0
  6. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/hook.py +37 -9
  7. recx_cli-0.1.1/tests/test_drift.py +851 -0
  8. recx_cli-0.1.0/src/recx_cli/drift.py +0 -178
  9. recx_cli-0.1.0/tests/test_drift.py +0 -424
  10. {recx_cli-0.1.0 → recx_cli-0.1.1}/.gitignore +0 -0
  11. {recx_cli-0.1.0 → recx_cli-0.1.1}/LICENSE +0 -0
  12. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/SPEC.md +0 -0
  13. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/evidence-without-run.yaml +0 -0
  14. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/extension-value-not-scalar.yaml +0 -0
  15. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/http-url.yaml +0 -0
  16. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/license-not-permissive.yaml +0 -0
  17. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/missing-capabilities.yaml +0 -0
  18. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/multiline-summary.yaml +0 -0
  19. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/package-without-purl.yaml +0 -0
  20. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/path-escapes-repo.yaml +0 -0
  21. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/unknown-key.yaml +0 -0
  22. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid/unknown-version.yaml +0 -0
  23. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule1-duplicate-evidence-id.yaml +0 -0
  24. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule1-duplicate-module-id.yaml +0 -0
  25. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule2-covers-unknown-module.yaml +0 -0
  26. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule2-requires-unknown-module.yaml +0 -0
  27. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule5-over-64-kib.yaml +0 -0
  28. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule6-anchor-alias.yaml +0 -0
  29. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule6-duplicate-key.yaml +0 -0
  30. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/invalid-rules/rule6-merge-key.yaml +0 -0
  31. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/valid/full.yaml +0 -0
  32. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/examples/valid/minimal.yaml +0 -0
  33. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/index.schema.json +0 -0
  34. {recx_cli-0.1.0 → recx_cli-0.1.1}/spec/recx.schema.json +0 -0
  35. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/__init__.py +0 -0
  36. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/__main__.py +0 -0
  37. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/check.py +0 -0
  38. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/evidence.py +0 -0
  39. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/index.py +0 -0
  40. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/index_cache.py +0 -0
  41. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/match.py +0 -0
  42. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/search-result.schema.json +0 -0
  43. {recx_cli-0.1.0 → recx_cli-0.1.1}/src/recx_cli/search.py +0 -0
  44. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/anomr.yaml +0 -0
  45. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/argwise.yaml +0 -0
  46. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/confyaml.yaml +0 -0
  47. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/cronexpr.yaml +0 -0
  48. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/feedparse.yaml +0 -0
  49. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/feedwatch.yaml +0 -0
  50. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/kustomize-secrets.yaml +0 -0
  51. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/mcp-sqlite.yaml +0 -0
  52. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/mdhtml.yaml +0 -0
  53. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/mdlint.yaml +0 -0
  54. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/nextver.yaml +0 -0
  55. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/patient.yaml +0 -0
  56. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/pdfmine.yaml +0 -0
  57. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/pr-labeler.yaml +0 -0
  58. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/quickjson.yaml +0 -0
  59. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/schemacheck.yaml +0 -0
  60. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/sealbox.yaml +0 -0
  61. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/sitemapgen.yaml +0 -0
  62. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/slack-notify.yaml +0 -0
  63. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/steadyhttp.yaml +0 -0
  64. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/tablecheck.yaml +0 -0
  65. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/thumbsmith.yaml +0 -0
  66. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/tokencheck.yaml +0 -0
  67. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/tollgate.yaml +0 -0
  68. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/fixtures/search/yamlguard.yaml +0 -0
  69. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/search_cases.yaml +0 -0
  70. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_check.py +0 -0
  71. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_crawler_core.py +0 -0
  72. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_crawler_github.py +0 -0
  73. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_crawler_main.py +0 -0
  74. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_evidence.py +0 -0
  75. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_hook.py +0 -0
  76. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_index.py +0 -0
  77. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_index_cache.py +0 -0
  78. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_match.py +0 -0
  79. {recx_cli-0.1.0 → recx_cli-0.1.1}/tests/test_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: recx-cli
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Validator for RECX.yaml manifests: the recx check command.
5
5
  Project-URL: Homepage, https://github.com/jbmv/recx
6
6
  Project-URL: Repository, https://github.com/jbmv/recx
@@ -75,6 +75,14 @@ anything listed can be reused without asking.
75
75
 
76
76
  Then add the GitHub topic `recx` to the repository, so the daily index finds it promptly.
77
77
 
78
+ The plugin's hook runs when a session or one of its subagents stops (Claude Code's Stop and
79
+ SubagentStop events). It blocks on an invalid manifest, and it reminds, without blocking, when
80
+ files under a module's `path` changed during the session while RECX.yaml did not. A commit can
81
+ record that module entries were checked against its tree with a trailer such as
82
+ `Recx-Checked: lib, cli`; the reminder then disregards that commit's changes to those modules
83
+ and the changes of the session's commits it descends from. `RECX_DRIFT_REMINDER=off` turns
84
+ the reminder off. The [manifest skill](plugin/skills/manifest/SKILL.md) describes both in full.
85
+
78
86
  ## Searching
79
87
 
80
88
  ```sh
@@ -95,8 +103,9 @@ Every result is written by the repository's author and has been checked only aga
95
103
  schema. The output says so in a fixed `notice`, and the JSON lists the author-written paths in
96
104
  `untrusted`. The index never contains a manifest's `setup` or `run` commands. Before reusing
97
105
  anything, read the repository's licence file and its current `RECX.yaml`, and run its evidence
98
- only in a sandbox. [docs/design/search.md](docs/design/search.md) describes the index, the
99
- crawler and the ranking in full.
106
+ only in a sandbox. [docs/integrating.md](docs/integrating.md) explains how to call search from
107
+ another tool, and [docs/design/search.md](docs/design/search.md) describes the index, the crawler
108
+ and the ranking in full.
100
109
 
101
110
  ## What's here
102
111
 
@@ -61,6 +61,14 @@ anything listed can be reused without asking.
61
61
 
62
62
  Then add the GitHub topic `recx` to the repository, so the daily index finds it promptly.
63
63
 
64
+ The plugin's hook runs when a session or one of its subagents stops (Claude Code's Stop and
65
+ SubagentStop events). It blocks on an invalid manifest, and it reminds, without blocking, when
66
+ files under a module's `path` changed during the session while RECX.yaml did not. A commit can
67
+ record that module entries were checked against its tree with a trailer such as
68
+ `Recx-Checked: lib, cli`; the reminder then disregards that commit's changes to those modules
69
+ and the changes of the session's commits it descends from. `RECX_DRIFT_REMINDER=off` turns
70
+ the reminder off. The [manifest skill](plugin/skills/manifest/SKILL.md) describes both in full.
71
+
64
72
  ## Searching
65
73
 
66
74
  ```sh
@@ -81,8 +89,9 @@ Every result is written by the repository's author and has been checked only aga
81
89
  schema. The output says so in a fixed `notice`, and the JSON lists the author-written paths in
82
90
  `untrusted`. The index never contains a manifest's `setup` or `run` commands. Before reusing
83
91
  anything, read the repository's licence file and its current `RECX.yaml`, and run its evidence
84
- only in a sandbox. [docs/design/search.md](docs/design/search.md) describes the index, the
85
- crawler and the ranking in full.
92
+ only in a sandbox. [docs/integrating.md](docs/integrating.md) explains how to call search from
93
+ another tool, and [docs/design/search.md](docs/design/search.md) describes the index, the crawler
94
+ and the ranking in full.
86
95
 
87
96
  ## What's here
88
97
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "recx-cli"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Validator for RECX.yaml manifests: the recx check command."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -168,8 +168,8 @@ def build_parser() -> argparse.ArgumentParser:
168
168
  hook_sub = hook.add_subparsers(dest="hook_command", required=True, parser_class=_Parser)
169
169
  hook_sub.add_parser(
170
170
  "stop",
171
- help="Stop hook: keep the repository's RECX.yaml valid as a session ends",
172
- description="Read Claude Code's Stop hook JSON on stdin. Always exits 0. "
171
+ help="Stop and SubagentStop hook: keep the repository's RECX.yaml valid",
172
+ description="Read Claude Code's Stop or SubagentStop hook JSON on stdin. Always exits 0. "
173
173
  "RECX_STOP_HOOK=block (default) blocks the stop while RECX.yaml is invalid, "
174
174
  "warn only gives feedback, off does nothing.",
175
175
  )
@@ -0,0 +1,312 @@
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` (run on Stop and on SubagentStop) then asks which modules have
5
+ changed files since, and, when RECX.yaml is not among them, reminds once per set
6
+ of modules. A commit whose message carries the trailer
7
+ `Recx-Checked: <module-id>[, <module-id>...]` records that those modules' entries
8
+ were checked against the tree at that commit, so its changes to them, and those
9
+ of its ancestors in the session's range, do not count. The reminder is only ever
10
+ feedback, never a block. Callers swallow every exception raised here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import tempfile
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from recx_cli.check import parse
24
+
25
+ MANIFEST = "RECX.yaml"
26
+ SKILL = "recx:manifest"
27
+ _HEAD = re.compile(r"[0-9a-fA-F]{7,64}")
28
+ _SAFE_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
29
+
30
+
31
+ def enabled() -> bool:
32
+ return os.environ.get("RECX_DRIFT_REMINDER", "").strip().lower() != "off"
33
+
34
+
35
+ def state_dir() -> Path:
36
+ plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA")
37
+ if plugin_data:
38
+ return Path(plugin_data)
39
+ base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
40
+ return Path(base) / "recx"
41
+
42
+
43
+ def _state_file(session_id: str) -> Path | None:
44
+ if not isinstance(session_id, str) or not _SAFE_ID.fullmatch(session_id):
45
+ return None
46
+ return state_dir() / f"{session_id}.json"
47
+
48
+
49
+ def read_state(session_id: str) -> dict:
50
+ """The session's state, or {} when absent, unreadable or corrupt."""
51
+ try:
52
+ path = _state_file(session_id)
53
+ if path is None:
54
+ return {}
55
+ data = json.loads(path.read_text(encoding="utf-8"))
56
+ if not isinstance(data, dict):
57
+ return {}
58
+ state: dict = {}
59
+ head = data.get("head")
60
+ if isinstance(head, str) and _HEAD.fullmatch(head):
61
+ state["head"] = head
62
+ reminded = data.get("reminded")
63
+ if isinstance(reminded, list):
64
+ state["reminded"] = [m for m in reminded if isinstance(m, str)]
65
+ agents = data.get("agents")
66
+ if isinstance(agents, dict):
67
+ state["agents"] = {
68
+ a: [m for m in r if isinstance(m, str)]
69
+ for a, r in agents.items()
70
+ if isinstance(a, str) and _SAFE_ID.fullmatch(a) and isinstance(r, list)
71
+ }
72
+ return state
73
+ except Exception: # noqa: BLE001 - a bad state file is ignored
74
+ return {}
75
+
76
+
77
+ def write_state(session_id: str, state: dict) -> None:
78
+ path = _state_file(session_id)
79
+ if path is None:
80
+ return
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".json")
83
+ try:
84
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
85
+ json.dump(state, fh)
86
+ os.replace(tmp, path)
87
+ except BaseException:
88
+ try:
89
+ os.unlink(tmp)
90
+ except OSError:
91
+ pass
92
+ raise
93
+
94
+
95
+ def _git(top: Path, *args: str) -> str:
96
+ out = subprocess.run(
97
+ ["git", "-C", str(top), *args], capture_output=True, text=True, timeout=20, check=True
98
+ )
99
+ return out.stdout
100
+
101
+
102
+ def head_of(top: Path) -> str | None:
103
+ value = _git(top, "rev-parse", "HEAD").strip()
104
+ return value if _HEAD.fullmatch(value) else None
105
+
106
+
107
+ STATE_MAX_AGE_DAYS = 30
108
+
109
+
110
+ def prune_state(max_age_days: int = STATE_MAX_AGE_DAYS) -> None:
111
+ """Delete session state files older than max_age_days; one is written per session."""
112
+ try:
113
+ cutoff = time.time() - max_age_days * 86400
114
+ for path in state_dir().glob("*.json"):
115
+ try:
116
+ if path.stat().st_mtime < cutoff:
117
+ path.unlink()
118
+ except OSError:
119
+ continue
120
+ except OSError:
121
+ pass
122
+
123
+
124
+ def session_start(session_id: str, top: Path) -> None:
125
+ """Record HEAD for the session. Skips when there is no manifest or the reminder is off."""
126
+ if not enabled() or not (top / MANIFEST).is_file():
127
+ return
128
+ prune_state()
129
+ head = head_of(top)
130
+ if head:
131
+ write_state(session_id, {"head": head, "reminded": []})
132
+
133
+
134
+ _TRAILER_LINE = re.compile(r"\s*recx-checked\s*:(.*)", re.IGNORECASE)
135
+ _ID_SEP = re.compile(r"[,\s]+")
136
+
137
+
138
+ def _uncommitted(top: Path) -> set[str]:
139
+ """Files changed against HEAD in the index or working tree, plus untracked files."""
140
+ files = set(_git(top, "diff", "--name-only", "-z", "HEAD", "--").split("\0"))
141
+ files.update(_git(top, "ls-files", "--others", "--exclude-standard", "-z").split("\0"))
142
+ files.discard("")
143
+ return files
144
+
145
+
146
+ def changed_files(top: Path, head: str | None) -> set[str]:
147
+ """The net change since head (or HEAD) plus untracked files: the reading without trailers."""
148
+ files: set[str] = set()
149
+ for base in [head, "HEAD"] if head else ["HEAD"]:
150
+ try:
151
+ files.update(_git(top, "diff", "--name-only", "-z", base, "--").split("\0"))
152
+ break
153
+ except (OSError, subprocess.SubprocessError):
154
+ continue
155
+ files.update(_git(top, "ls-files", "--others", "--exclude-standard", "-z").split("\0"))
156
+ files.discard("")
157
+ return files
158
+
159
+
160
+ def checked_ids(trailers: str) -> set[str]:
161
+ """Module ids named by Recx-Checked trailers; anything malformed names nothing."""
162
+ ids: set[str] = set()
163
+ for line in trailers.splitlines():
164
+ m = _TRAILER_LINE.fullmatch(line)
165
+ if m:
166
+ ids.update(i for i in _ID_SEP.split(m.group(1)) if i)
167
+ return ids
168
+
169
+
170
+ class Commit:
171
+ __slots__ = ("sha", "parents", "checked", "files")
172
+
173
+ def __init__(self, sha: str, parents: list[str], checked: set[str], files: set[str]) -> None:
174
+ self.sha, self.parents, self.checked, self.files = sha, parents, checked, files
175
+
176
+
177
+ def range_commits(top: Path, head: str) -> list[Commit]:
178
+ """Every commit in head..HEAD with its parents, Recx-Checked ids and touched files.
179
+
180
+ Two `git log` calls: one for the graph and the files, which holds no free text,
181
+ and one for the trailers, NUL-separated, since a commit message cannot hold a
182
+ NUL. Merge commits list no files of their own; the commits they bring in are in
183
+ the range themselves. Raises when git fails (unknown head, shallow history, no
184
+ git); a record that does not parse is skipped.
185
+ """
186
+ rng = f"{head}..HEAD"
187
+ graph = _git(
188
+ top, "log", "-z", "--no-renames", "--no-ext-diff", "--format=%x1e%H %P%x1f", "--name-only", rng, "--"
189
+ )
190
+ trailers = _git(top, "log", "-z", "--format=%H%n%(trailers:only,unfold)", rng, "--")
191
+ checked: dict[str, set[str]] = {}
192
+ for record in trailers.split("\0"):
193
+ sha, _, text = record.partition("\n")
194
+ if _HEAD.fullmatch(sha):
195
+ checked[sha] = checked_ids(text)
196
+ commits: list[Commit] = []
197
+ for record in graph.split("\x1e"):
198
+ header, sep, rest = record.partition("\x1f")
199
+ shas = header.split()
200
+ if not sep or not shas or not all(_HEAD.fullmatch(x) for x in shas):
201
+ continue
202
+ rest = rest.lstrip("\0")
203
+ if rest.startswith("\n"):
204
+ rest = rest[1:]
205
+ files = {f for f in rest.split("\0") if f}
206
+ commits.append(Commit(shas[0], shas[1:], checked.get(shas[0], set()), files))
207
+ return commits
208
+
209
+
210
+ def _covered(commits: list[Commit], module_id: str) -> set[str]:
211
+ """Commits in the range that a Recx-Checked trailer naming module_id vouches for.
212
+
213
+ A check vouches for the tree it was made on, so a checking commit covers itself
214
+ and every ancestor of it within the range. Every path from a commit in the range
215
+ to an ancestor in the range stays inside the range, so walking parents that are
216
+ in the range finds exactly those ancestors.
217
+ """
218
+ by_sha = {c.sha: c for c in commits}
219
+ covered: set[str] = set()
220
+ stack = [c.sha for c in commits if module_id in c.checked]
221
+ while stack:
222
+ sha = stack.pop()
223
+ if sha in covered:
224
+ continue
225
+ covered.add(sha)
226
+ stack.extend(p for p in by_sha[sha].parents if p in by_sha and p not in covered)
227
+ return covered
228
+
229
+
230
+ def _modules(manifest: Path) -> list[tuple[str, str]]:
231
+ data = parse(manifest.read_text(encoding="utf-8"))
232
+ modules = data.get("modules") if isinstance(data, dict) else None
233
+ found: list[tuple[str, str]] = []
234
+ for mod in modules if isinstance(modules, list) else []:
235
+ if not isinstance(mod, dict):
236
+ continue
237
+ mid, path = mod.get("id"), mod.get("path")
238
+ if isinstance(mid, str) and isinstance(path, str):
239
+ found.append((mid, path))
240
+ return found
241
+
242
+
243
+ def _touches(path: str, files: set[str]) -> bool:
244
+ if path.endswith("/"):
245
+ return any(f.startswith(path) for f in files)
246
+ return path in files
247
+
248
+
249
+ def affected_modules(manifest: Path, files: set[str], commits: list[Commit] | None = None) -> list[str]:
250
+ """Modules whose path is touched by files, or by a commit no trailer for them covers."""
251
+ found: list[str] = []
252
+ for mid, path in _modules(manifest):
253
+ if mid in found:
254
+ continue
255
+ hit = _touches(path, files)
256
+ if not hit and commits:
257
+ covered = _covered(commits, mid)
258
+ hit = any(c.sha not in covered and _touches(path, c.files) for c in commits)
259
+ if hit:
260
+ found.append(mid)
261
+ return found
262
+
263
+
264
+ def _changes(top: Path, head: str | None) -> tuple[set[str], list[Commit] | None]:
265
+ """(files changed outside any commit's trailer, the session's commits).
266
+
267
+ With a recorded head whose range git can read, the files are the uncommitted
268
+ and untracked ones and the commits are read one by one. Otherwise (no head,
269
+ or any failure reading the range) this is the reading without trailers: the
270
+ net change since head, or the uncommitted change alone, with no commits.
271
+ """
272
+ if head:
273
+ try:
274
+ return _uncommitted(top), range_commits(top, head)
275
+ except Exception: # noqa: BLE001 - reading commits or trailers never costs the reminder
276
+ pass
277
+ return changed_files(top, head), None
278
+
279
+
280
+ def reminder(session_id: str | None, top: Path, agent_id: str | None = None) -> str | None:
281
+ """The reminder text to emit now, or None. Records what it reminded about.
282
+
283
+ The session and each subagent (by agent_id) keep their own record of what they
284
+ were reminded about, so a subagent that is reminded never silences the session.
285
+ """
286
+ if not enabled():
287
+ return None
288
+ state = read_state(session_id) if session_id else {}
289
+ files, commits = _changes(top, state.get("head"))
290
+ if MANIFEST in files or any(MANIFEST in c.files for c in commits or []):
291
+ return None
292
+ affected = affected_modules(top / MANIFEST, files, commits)
293
+ if agent_id is not None and not _SAFE_ID.fullmatch(agent_id):
294
+ agent_id = None
295
+ agents = state.get("agents", {})
296
+ already = set(agents.get(agent_id, []) if agent_id else state.get("reminded", []))
297
+ if not affected or set(affected) <= already:
298
+ return None
299
+ if session_id:
300
+ seen = sorted(already | set(affected))
301
+ update = {"agents": {**agents, agent_id: seen}} if agent_id else {"reminded": seen}
302
+ try:
303
+ write_state(session_id, {**state, **update})
304
+ except Exception: # noqa: BLE001 - failing to record must not lose the reminder
305
+ pass
306
+ names = ", ".join(affected)
307
+ return (
308
+ f"Reminder, not an error: files under these {MANIFEST} modules changed this session "
309
+ f"while {MANIFEST} did not: {names}. Check whether the manifest still describes them, "
310
+ f"using the {SKILL} skill, and update it if it does not. If it is still accurate, "
311
+ f"nothing needs doing."
312
+ )
@@ -1,8 +1,9 @@
1
- """`recx hook stop`: Claude Code's Stop hook, keeping a RECX.yaml valid as a session ends.
1
+ """`recx hook stop`: Claude Code's Stop and SubagentStop hook, keeping a RECX.yaml valid.
2
2
 
3
- The hook must never trap a session on its own failure, so every error path
4
- exits 0 without output. Only an invalid manifest produces output, and it is
5
- always JSON on stdout with exit status 0.
3
+ The same entry point serves both events; the event's own `hook_event_name` is
4
+ named back in any `hookSpecificOutput`. The hook must never trap a session on
5
+ its own failure, so every error path exits 0 without output. Output is always
6
+ JSON on stdout with exit status 0.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
@@ -18,6 +19,7 @@ from recx_cli.check import Unreadable, check_file, format_problem
18
19
 
19
20
  MANIFEST = "RECX.yaml"
20
21
  SKILL = "recx:manifest"
22
+ EVENTS = ("Stop", "SubagentStop")
21
23
 
22
24
 
23
25
  def _mode() -> str:
@@ -55,6 +57,27 @@ def _read_event() -> tuple[dict, Path] | None:
55
57
  return event, _manifest_dir(cwd)
56
58
 
57
59
 
60
+ def _event_name(event: dict) -> str:
61
+ name = event.get("hook_event_name")
62
+ return name if name in EVENTS else "Stop"
63
+
64
+
65
+ def _internal_agent(event: dict) -> bool:
66
+ """A SubagentStop from one of Claude Code's own agents (prompt suggestions, /btw).
67
+
68
+ Claude Code documents an empty `agent_type` for those when the session runs
69
+ without --agent. Such an agent did not do the session's work and cannot act on
70
+ feedback, so neither the check nor the reminder is spent on it.
71
+ """
72
+ return _event_name(event) == "SubagentStop" and not event.get("agent_type")
73
+
74
+
75
+ def _context(event: dict, text: str) -> str:
76
+ return json.dumps(
77
+ {"hookSpecificOutput": {"hookEventName": _event_name(event), "additionalContext": text}}
78
+ )
79
+
80
+
58
81
  def _validity(event: dict, top: Path) -> tuple[bool, str | None]:
59
82
  """(manifest is valid, output to print). Knows nothing about drift."""
60
83
  manifest = top / MANIFEST
@@ -75,20 +98,23 @@ def _validity(event: dict, top: Path) -> tuple[bool, str | None]:
75
98
  return False, json.dumps({"decision": "block", "reason": reason})
76
99
  still = " still" if event.get("stop_hook_active") is True else ""
77
100
  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
- )
101
+ return False, _context(event, context)
81
102
 
82
103
 
83
104
  def _drift(event: dict, top: Path) -> str | None:
84
105
  try:
85
106
  session = event.get("session_id")
86
- text = drift.reminder(session if isinstance(session, str) else None, top)
107
+ agent = event.get("agent_id") if _event_name(event) == "SubagentStop" else None
108
+ text = drift.reminder(
109
+ session if isinstance(session, str) else None,
110
+ top,
111
+ agent if isinstance(agent, str) else None,
112
+ )
87
113
  except BaseException: # noqa: BLE001 - the drift path must never block or raise
88
114
  return None
89
115
  if text is None:
90
116
  return None
91
- return json.dumps({"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": text}})
117
+ return _context(event, text)
92
118
 
93
119
 
94
120
  def _run() -> None:
@@ -98,6 +124,8 @@ def _run() -> None:
98
124
  if parsed is None:
99
125
  return
100
126
  event, top = parsed
127
+ if _internal_agent(event):
128
+ return
101
129
  valid, output = _validity(event, top)
102
130
  if output is not None:
103
131
  print(output)