@julioborges/gantry 0.1.0

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 (46) hide show
  1. package/.agents/skills/gantry/SKILL.md +166 -0
  2. package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
  3. package/.agents/skills/gantry/capabilities/codex.json +14 -0
  4. package/.agents/skills/gantry/capabilities/opencode.json +15 -0
  5. package/.agents/skills/gantry/dashboard/static/app.js +100 -0
  6. package/.agents/skills/gantry/dashboard/static/index.html +16 -0
  7. package/.agents/skills/gantry/dashboard/static/style.css +74 -0
  8. package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
  9. package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
  10. package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
  11. package/.agents/skills/gantry/hooks/git/pre-push +123 -0
  12. package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
  13. package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
  14. package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
  15. package/.agents/skills/gantry/reference/round-workflow.md +755 -0
  16. package/.agents/skills/gantry/schemas/critic.json +93 -0
  17. package/.agents/skills/gantry/schemas/implementer.json +52 -0
  18. package/.agents/skills/gantry/schemas/learner.json +35 -0
  19. package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
  20. package/.agents/skills/gantry/schemas/planner.json +64 -0
  21. package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
  22. package/.agents/skills/gantry/schemas/reviewer.json +52 -0
  23. package/.agents/skills/gantry/scripts/acceptance.py +66 -0
  24. package/.agents/skills/gantry/scripts/budget.py +162 -0
  25. package/.agents/skills/gantry/scripts/cleanup.py +186 -0
  26. package/.agents/skills/gantry/scripts/common.py +361 -0
  27. package/.agents/skills/gantry/scripts/dashboard.py +233 -0
  28. package/.agents/skills/gantry/scripts/frontier.py +192 -0
  29. package/.agents/skills/gantry/scripts/gates.py +401 -0
  30. package/.agents/skills/gantry/scripts/guard.py +568 -0
  31. package/.agents/skills/gantry/scripts/learner.py +99 -0
  32. package/.agents/skills/gantry/scripts/result.py +104 -0
  33. package/.agents/skills/gantry/scripts/roadmap.py +212 -0
  34. package/.agents/skills/gantry/scripts/runlog.py +491 -0
  35. package/.agents/skills/gantry/scripts/setup.py +139 -0
  36. package/.agents/skills/gantry/scripts/spec.py +252 -0
  37. package/.agents/skills/gantry/templates/issue.md +32 -0
  38. package/.agents/skills/gantry/templates/prd.md +26 -0
  39. package/.agents/skills/gantry/templates/spec.md +48 -0
  40. package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
  41. package/.agents/skills/gantry-setup/SKILL.md +30 -0
  42. package/LICENSE +201 -0
  43. package/README.md +437 -0
  44. package/bin/gantry.mjs +45 -0
  45. package/package.json +36 -0
  46. package/scripts/ensure-npm-author.mjs +29 -0
@@ -0,0 +1,568 @@
1
+ #!/usr/bin/env python3
2
+ """Guard hook handler: protects Issue/Roadmap authority and records events.
3
+
4
+ `guard.py` is invoked by a harness hook with the event name as its one positional
5
+ argument and the harness's JSON payload on standard input. Per ADR-0003, it never
6
+ decides whether work is ready or done -- that authority stays with `roadmap.py` and
7
+ the other workflow scripts. It only blocks specific shortcuts (editing the roadmap or
8
+ an Issue's Status/checkbox fields outside `roadmap.py`, and a Bash command that would
9
+ disable the git-level guard hooks) and records hook and subagent events into the Run
10
+ log. Any payload shape it does not recognise degrades to "record what is safely
11
+ recordable, grant no authority" -- it never denies without both a rule and a refused
12
+ path, and it never manufactures a false completion. Empty, undecodable or non-object
13
+ stdin is recorded as a `hook.degraded` event with `data.missing == ["payload"]`
14
+ whenever a Run ID is resolvable from `--run-id`, `$GANTRY_RUN_ID` or this worktree's
15
+ current-Run marker (never from the payload, since there is none to read); with no Run ID
16
+ at all, nothing is recorded.
17
+
18
+ Per `docs/adr/0005-git-hooks-enforce-git-rules.md`, no-force-push and no-test-skip-commit
19
+ are enforced by the repository's own `pre-push`/`pre-commit` git hooks
20
+ (`.agents/skills/gantry/hooks/git/`), which see the actual ref update or staged diff
21
+ rather than a Bash command string. `guard.py`'s only remaining Bash rule is a linear
22
+ substring check refusing a command that would disable that git layer.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import datetime
28
+ import json
29
+ import os
30
+ import re
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ sys.path.insert(0, str(Path(__file__).parent))
35
+
36
+ import runlog # noqa: E402
37
+
38
+ DECISION_EVENTS = {"PreToolUse", "tool.execute.before"}
39
+ SUBAGENT_START_EVENTS = {"SubagentStart"}
40
+ SUBAGENT_STOP_EVENTS = {"SubagentStop"}
41
+ COMPACTION_EVENTS = {"PreCompact", "session.compacted"}
42
+
43
+ CAPABILITIES_DIR = Path(__file__).parent.parent / "capabilities"
44
+ HARNESS_BY_EVENT = {
45
+ "PreToolUse": "claude-code",
46
+ "PostToolUse": "claude-code",
47
+ "SubagentStart": "claude-code",
48
+ "SubagentStop": "claude-code",
49
+ "PreCompact": "claude-code",
50
+ "tool.execute.before": "opencode",
51
+ "session.compacted": "opencode",
52
+ }
53
+ # The concepts (not literal keys -- a harness may spell them differently) a given
54
+ # event needs to be handled without guessing. Anything a concept resolves to a
55
+ # harness's declared `payload_fields` name, and is then absent from the payload
56
+ # itself, is recorded as a degradation, never a denial.
57
+ EVENT_REQUIRED_CONCEPTS = {
58
+ "PreToolUse": ("tool_name", "tool_input"),
59
+ "PostToolUse": ("tool_name", "tool_input"),
60
+ "SubagentStart": ("session_id",),
61
+ "SubagentStop": ("session_id",),
62
+ "PreCompact": (),
63
+ "tool.execute.before": ("tool_name", "tool_input"),
64
+ "session.compacted": ("session_id",),
65
+ }
66
+ # Which literal `payload_fields` name each harness uses for a concept.
67
+ CONCEPT_FIELD_BY_HARNESS = {
68
+ "claude-code": {"tool_name": "tool_name", "tool_input": "tool_input", "session_id": "session_id"},
69
+ "opencode": {"tool_name": "tool", "tool_input": "args", "session_id": "sessionID"},
70
+ }
71
+ CONCEPT_ALIASES = {
72
+ "tool_name": ("tool_name", "tool", "toolName"),
73
+ "tool_input": ("tool_input", "args", "toolInput", "input"),
74
+ "session_id": ("session_id", "sessionID", "sessionId"),
75
+ }
76
+
77
+ MODIFYING_TOOLS = {"edit", "write", "multiedit", "notebookedit", "applypatch", "patch"}
78
+ BASH_TOOLS = {"bash", "shell", "exec"}
79
+
80
+ ISSUE_FILE_RE = re.compile(r"^\d{2,}-[a-z0-9-]+\.md$")
81
+ STATUS_LINE_RE = re.compile(r"(?m)^Status:\s*(\S+)")
82
+ # `[ \t]*` (never `\s*`) after `^` in multiline mode: `\s` matches `\n`, so `^\s*` can consume
83
+ # whole blank/whitespace-only lines before backtracking one character at a time back to the
84
+ # previous line start it already tried -- O(n) backtrack retried from each of the O(n) line
85
+ # starts a large whitespace-heavy text has, i.e. O(n^2). Restricting to horizontal whitespace
86
+ # bounds the backtrack to the current line's length, keeping the match O(n) overall.
87
+ CHECKBOX_LINE_RE = re.compile(r"(?m)^[ \t]*-\s\[[ xX]\]")
88
+ CHECKBOX_FULL_LINE_RE = re.compile(r"(?m)^[ \t]*-\s\[[ xX]\].*$")
89
+ CHECKED_CHECKBOX_RE = re.compile(r"(?m)^[ \t]*-\s\[[xX]\]")
90
+ # The only remaining Bash rule (docs/adr/0005): a linear substring check refusing a
91
+ # command that would disable the git-level guard hooks. It tokenises nothing, so a
92
+ # 1 MB command is answered in a few milliseconds, and it allows every other Bash
93
+ # command -- git decides no-force-push and no-test-skip-commit itself, at the
94
+ # pre-push/pre-commit layer.
95
+ # `--no-veri`, not `--no-verify`: git accepts any unambiguous abbreviation of a long option, and
96
+ # `--no-veri` is the shortest one it accepts for `--no-verify` on both `commit` and `push`
97
+ # (`--no-ver` is refused as ambiguous with `--no-verbose`). As a plain substring it therefore
98
+ # catches `--no-veri`, `--no-verif` and `--no-verify` alike, while `--no-verb`/`--no-verbose` --
99
+ # a different flag, which does not disable a hook -- stays allowed.
100
+ HOOK_DISABLING_SUBSTRINGS = ("--no-veri", "--git-dir", "GIT_DIR=", "GIT_CONFIG=", "GIT_CONFIG_")
101
+ # Matched against the lowercased command: git configuration keys are case-insensitive, so
102
+ # `-c core.hookspath=/dev/null` and `-c CORE.HOOKSPATH=...` disable the hooks exactly as
103
+ # `core.hooksPath` does. Environment-variable names are not case-insensitive, so the
104
+ # `GIT_*` spellings above stay case-sensitive.
105
+ HOOK_DISABLING_SUBSTRINGS_CASE_INSENSITIVE = ("core.hookspath",)
106
+
107
+
108
+ class Decision:
109
+ def __init__(self, allow: bool, rule: str | None = None, path: str | None = None):
110
+ self.allow = allow
111
+ self.rule = rule
112
+ self.path = path
113
+
114
+
115
+ def now_iso() -> str:
116
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
117
+
118
+
119
+ def _first(payload: dict, keys: tuple[str, ...]) -> object:
120
+ for key in keys:
121
+ if isinstance(payload, dict) and key in payload and payload[key] not in (None, ""):
122
+ return payload[key]
123
+ return None
124
+
125
+
126
+ def tool_name(payload: dict) -> str:
127
+ value = _first(payload, ("tool_name", "tool", "toolName"))
128
+ return str(value).strip().lower() if value else ""
129
+
130
+
131
+ def tool_input(payload: dict) -> dict:
132
+ value = _first(payload, ("tool_input", "args", "toolInput", "input"))
133
+ return value if isinstance(value, dict) else {}
134
+
135
+
136
+ def extract_path(payload: dict, arguments: dict) -> str | None:
137
+ value = _first(arguments, ("file_path", "filePath", "path", "filename")) or _first(payload, ("file_path", "path"))
138
+ return str(value) if value else None
139
+
140
+
141
+ def extract_text(arguments: dict) -> str:
142
+ parts: list[str] = []
143
+ for key in ("old_string", "oldString", "new_string", "newString", "content", "text"):
144
+ value = arguments.get(key)
145
+ if isinstance(value, str):
146
+ parts.append(value)
147
+ edits = arguments.get("edits")
148
+ if isinstance(edits, list):
149
+ for edit in edits:
150
+ if isinstance(edit, dict):
151
+ for key in ("old_string", "oldString", "new_string", "newString"):
152
+ value = edit.get(key)
153
+ if isinstance(value, str):
154
+ parts.append(value)
155
+ return "\n".join(parts)
156
+
157
+
158
+ def extract_new_text(arguments: dict) -> str:
159
+ """The resulting text a modifying tool would write -- never the text it replaces."""
160
+ parts: list[str] = []
161
+ for key in ("new_string", "newString", "content", "text"):
162
+ value = arguments.get(key)
163
+ if isinstance(value, str):
164
+ parts.append(value)
165
+ edits = arguments.get("edits")
166
+ if isinstance(edits, list):
167
+ for edit in edits:
168
+ if isinstance(edit, dict):
169
+ for key in ("new_string", "newString"):
170
+ value = edit.get(key)
171
+ if isinstance(value, str):
172
+ parts.append(value)
173
+ return "\n".join(parts)
174
+
175
+
176
+ def is_draft_safe(new_text: str) -> bool:
177
+ """True when the resulting content only ever declares `Status: draft` and no checked box."""
178
+ statuses = STATUS_LINE_RE.findall(new_text)
179
+ if any(status.lower() != "draft" for status in statuses):
180
+ return False
181
+ return CHECKED_CHECKBOX_RE.search(new_text) is None
182
+
183
+
184
+ def extract_command(arguments: dict) -> str | None:
185
+ value = _first(arguments, ("command", "cmd"))
186
+ return str(value) if value else None
187
+
188
+
189
+ def payload_cwd(payload: dict) -> str | None:
190
+ """The harness-reported working directory for this call, if the payload carries one.
191
+
192
+ Claude Code spells it `cwd`, OpenCode spells it `directory`. Neither is a decision
193
+ concept the capability files gate on -- it is only ever used to pick *where* to look
194
+ (a git worktree, an Issue path), never to grant or withhold authority on its own.
195
+ """
196
+ value = _first(payload, ("cwd", "directory"))
197
+ return str(value) if value else None
198
+
199
+
200
+ def resolve_effective_cwd(payload: dict, fallback: Path) -> Path:
201
+ """Prefer the payload's own cwd/directory over the `--cwd` fallback when it names a real directory."""
202
+ candidate = payload_cwd(payload) if isinstance(payload, dict) else None
203
+ if candidate:
204
+ candidate_path = Path(candidate)
205
+ if not candidate_path.is_absolute():
206
+ candidate_path = fallback / candidate_path
207
+ if candidate_path.is_dir():
208
+ return candidate_path.resolve()
209
+ return fallback
210
+
211
+
212
+ def apply_edits(original: str, arguments: dict) -> str | None:
213
+ """Apply Edit/MultiEdit-style `old_string` -> `new_string` edits to `original`, in order.
214
+
215
+ Returns `None` when an edit is malformed or its `old_string` is not literally present --
216
+ the same case in which the real tool call would itself fail -- so callers fall back to
217
+ the coarser text-based check instead of trusting a simulation that could not have happened.
218
+ """
219
+ edits = arguments.get("edits")
220
+ if not isinstance(edits, list):
221
+ old = arguments.get("old_string", arguments.get("oldString"))
222
+ new = arguments.get("new_string", arguments.get("newString"))
223
+ if not isinstance(old, str) or not isinstance(new, str):
224
+ return None
225
+ replace_all = arguments.get("replace_all", arguments.get("replaceAll"))
226
+ edits = [{"old_string": old, "new_string": new, "replace_all": replace_all}]
227
+ text = original
228
+ for edit in edits:
229
+ if not isinstance(edit, dict):
230
+ return None
231
+ old = edit.get("old_string", edit.get("oldString"))
232
+ new = edit.get("new_string", edit.get("newString"))
233
+ if not isinstance(old, str) or not isinstance(new, str):
234
+ return None
235
+ if old not in text:
236
+ return None
237
+ replace_all = bool(edit.get("replace_all", edit.get("replaceAll")))
238
+ count = -1 if replace_all else 1
239
+ text = text.replace(old, new, count)
240
+ return text
241
+
242
+
243
+ def decide(payload: dict, cwd: Path) -> Decision:
244
+ """Evaluate one tool-invocation payload against the protected rule set."""
245
+ if not isinstance(payload, dict):
246
+ return Decision(True)
247
+ name = tool_name(payload)
248
+ arguments = tool_input(payload)
249
+ normalized_name = re.sub(r"[^a-z]", "", name)
250
+
251
+ if normalized_name in MODIFYING_TOOLS:
252
+ path = extract_path(payload, arguments)
253
+ if not path:
254
+ return Decision(True)
255
+ basename = Path(path).name
256
+ if basename.lower() == "roadmap.md":
257
+ return Decision(False, "roadmap-protected", path)
258
+ if ISSUE_FILE_RE.match(basename):
259
+ target = Path(path)
260
+ target = target if target.is_absolute() else cwd / target
261
+ if normalized_name in {"write", "multiedit"}:
262
+ # The draft exemption only ever applies to *creating* a new Issue file.
263
+ # Once the target exists, its Status/checkbox fields are already under
264
+ # protection, and the new content must fall through to the same
265
+ # STATUS_LINE_RE / CHECKBOX_LINE_RE checks any other edit would face --
266
+ # never exempted just because the new content, read alone, looks draft-safe.
267
+ if not target.exists() and is_draft_safe(extract_new_text(arguments)):
268
+ return Decision(True)
269
+ if normalized_name in {"edit", "multiedit"} and target.exists():
270
+ # Compare the whole file before/after applying the edit in memory, so a
271
+ # value-only edit (e.g. 'ready-for-agent' -> 'done', or '[ ]' -> '[x]'
272
+ # without the '- ' scaffolding) is caught even though neither its
273
+ # old_string nor its new_string alone spells 'Status:' or a full checkbox line.
274
+ try:
275
+ original = target.read_text(encoding="utf-8")
276
+ except OSError:
277
+ original = None
278
+ if original is not None:
279
+ updated = apply_edits(original, arguments)
280
+ if updated is not None:
281
+ if STATUS_LINE_RE.findall(original) != STATUS_LINE_RE.findall(updated):
282
+ return Decision(False, "issue-status-protected", path)
283
+ if CHECKBOX_FULL_LINE_RE.findall(original) != CHECKBOX_FULL_LINE_RE.findall(updated):
284
+ return Decision(False, "issue-checkbox-protected", path)
285
+ return Decision(True)
286
+ changed = extract_text(arguments)
287
+ if STATUS_LINE_RE.search(changed):
288
+ return Decision(False, "issue-status-protected", path)
289
+ if CHECKBOX_LINE_RE.search(changed):
290
+ return Decision(False, "issue-checkbox-protected", path)
291
+ return Decision(True)
292
+
293
+ if normalized_name in BASH_TOOLS:
294
+ command = extract_command(arguments)
295
+ if not command:
296
+ return Decision(True)
297
+ # The only remaining Bash rule (docs/adr/0005): a linear substring scan, never
298
+ # tokenised, so it stays fast on an arbitrarily large command. no-force-push and
299
+ # no-test-skip-commit are enforced by the git hooks themselves; this only refuses
300
+ # a command that would disable that layer.
301
+ for token in HOOK_DISABLING_SUBSTRINGS:
302
+ if token in command:
303
+ return Decision(False, "hook-bypass-protected", command.strip()[:200])
304
+ lowered = command.lower()
305
+ for token in HOOK_DISABLING_SUBSTRINGS_CASE_INSENSITIVE:
306
+ if token in lowered:
307
+ return Decision(False, "hook-bypass-protected", command.strip()[:200])
308
+ return Decision(True)
309
+
310
+ return Decision(True)
311
+
312
+
313
+ def resolve_run(payload: dict, override: str | None, state_root_override: str | None, cwd: Path) -> tuple[str | None, str | None]:
314
+ """Resolve (Run ID, state root) for recording: explicit override, environment, marker, payload.
315
+
316
+ `--run-id` and `$GANTRY_RUN_ID` are authoritative -- a caller that names a Run means it. The
317
+ worktree's current-Run marker (`runlog.read_marker`, written by the round workflow into the
318
+ worktree's own git directory, and the same fallback the git hooks use) comes *before* the
319
+ payload's session ID, because a harness session ID is not a Gantry Run ID: Claude Code sends a
320
+ UUID that no Run log will ever be keyed by, so preferring it over a marker naming a Run whose
321
+ log does exist would silently drop every denial in the configuration the pack ships. A session
322
+ ID is only consulted when nothing else names a Run, and then only when its Run log already
323
+ exists -- a Run ID that resolves to no log records nothing at all.
324
+ """
325
+ root = state_root_override or os.environ.get("GANTRY_STATE_ROOT") or None
326
+ candidate = override or os.environ.get("GANTRY_RUN_ID")
327
+ if candidate and runlog.RUN_ID_RE.fullmatch(str(candidate)):
328
+ return str(candidate), root
329
+ marker = runlog.read_marker(cwd)
330
+ if marker:
331
+ marked_root = marker.get("stateRoot")
332
+ return marker["run"], root or (marked_root if isinstance(marked_root, str) else None)
333
+ session = _first(payload, ("session_id", "sessionID", "sessionId"))
334
+ if session and runlog.RUN_ID_RE.fullmatch(str(session)) and run_log_exists(str(session), root, cwd):
335
+ return str(session), root
336
+ return None, root
337
+
338
+
339
+ def run_log_exists(run_id: str, state_root: str | None, cwd: Path) -> bool:
340
+ try:
341
+ return runlog.run_log_path(runlog.state_root(state_root), runlog.unit_id(cwd), run_id).exists()
342
+ except (runlog.EventError, OSError, ValueError):
343
+ return False
344
+
345
+
346
+
347
+
348
+ def record(cwd: Path, state_root: str | None, run_id: str, event: dict) -> None:
349
+ """Best-effort append; a logging failure never changes the hook's decision."""
350
+ try:
351
+ payload = runlog.validate_event(event)
352
+ unit = runlog.unit_id(cwd)
353
+ root = runlog.state_root(state_root)
354
+ path = runlog.run_log_path(root, unit, run_id)
355
+ if not path.exists():
356
+ return
357
+ runlog.append_event(path, payload)
358
+ except (runlog.EventError, OSError, ValueError):
359
+ return
360
+
361
+
362
+ _CAPABILITY_CACHE: dict[str, dict] = {}
363
+
364
+
365
+ def load_capability(harness: str) -> dict:
366
+ """Read `capabilities/<harness>.json`; a missing or unreadable file means no declared fields."""
367
+ if harness in _CAPABILITY_CACHE:
368
+ return _CAPABILITY_CACHE[harness]
369
+ capability: dict = {}
370
+ try:
371
+ parsed = json.loads((CAPABILITIES_DIR / f"{harness}.json").read_text(encoding="utf-8"))
372
+ if isinstance(parsed, dict):
373
+ capability = parsed
374
+ except (OSError, json.JSONDecodeError):
375
+ capability = {}
376
+ _CAPABILITY_CACHE[harness] = capability
377
+ return capability
378
+
379
+
380
+ def degradation_fields(event: str, payload: dict) -> list[str]:
381
+ """Declared field names (per the harness's capability file) this event needs that the payload lacks.
382
+
383
+ A concept (e.g. "tool_input") is present as soon as any of the harness's accepted
384
+ spellings for it carries a value, so a payload shaped like another harness's (as the
385
+ OpenCode-forwarded Claude Code shape is in `PreToolUse`-equivalent tests) is not
386
+ penalised for using a different, still-recognised, key.
387
+ """
388
+ harness = HARNESS_BY_EVENT.get(event)
389
+ concepts = EVENT_REQUIRED_CONCEPTS.get(event, ())
390
+ if not harness or not concepts:
391
+ return []
392
+ declared = set(load_capability(harness).get("payload_fields", []))
393
+ concept_field = CONCEPT_FIELD_BY_HARNESS.get(harness, {})
394
+ missing = []
395
+ for concept in concepts:
396
+ field_name = concept_field.get(concept, concept)
397
+ if field_name not in declared:
398
+ continue
399
+ if _first(payload, CONCEPT_ALIASES[concept]) is None:
400
+ missing.append(field_name)
401
+ return missing
402
+
403
+
404
+ def record_degradation(cwd: Path, args: argparse.Namespace, payload: dict, event: str, missing: list[str]) -> None:
405
+ run_id, root = resolve_run(payload, args.run_id, args.state_root, cwd)
406
+ if not run_id:
407
+ return
408
+ record(
409
+ cwd,
410
+ root,
411
+ run_id,
412
+ {
413
+ "ts": now_iso(),
414
+ "run": run_id,
415
+ "event": "hook.degraded",
416
+ "data": {"source": event, "missing": missing, "degraded": True},
417
+ },
418
+ )
419
+
420
+
421
+ def handle_decision_event(payload: dict, args: argparse.Namespace) -> Decision:
422
+ cwd = Path(args.cwd).resolve()
423
+ # decide() looks *where the tool call actually operates* (git worktree, Issue path),
424
+ # which the payload's own cwd/directory field describes more accurately than the
425
+ # invocation-wide --cwd when the two diverge; the run log still keys off --cwd.
426
+ decide_cwd = resolve_effective_cwd(payload, cwd)
427
+ decision = decide(payload, decide_cwd)
428
+ if not decision.allow:
429
+ run_id, root = resolve_run(payload, args.run_id, args.state_root, cwd)
430
+ if run_id:
431
+ record(
432
+ cwd,
433
+ root,
434
+ run_id,
435
+ {
436
+ "ts": now_iso(),
437
+ "run": run_id,
438
+ "event": "hook.denied",
439
+ "data": {"rule": decision.rule, "path": decision.path},
440
+ },
441
+ )
442
+ return decision
443
+
444
+
445
+ def handle_subagent_event(payload: dict, args: argparse.Namespace, event: str) -> None:
446
+ cwd = Path(args.cwd).resolve()
447
+ run_id, root = resolve_run(payload if isinstance(payload, dict) else {}, args.run_id, args.state_root, cwd)
448
+ if not run_id:
449
+ return
450
+ role = None
451
+ if isinstance(payload, dict):
452
+ role = _first(payload, ("role", "subagent_type", "agent_type", "subagentType", "agentType", "description"))
453
+ record(
454
+ cwd,
455
+ root,
456
+ run_id,
457
+ {
458
+ "ts": now_iso(),
459
+ "run": run_id,
460
+ "event": event,
461
+ "data": {"role": str(role) if role else "unknown"},
462
+ },
463
+ )
464
+
465
+
466
+ def handle_compaction_event(payload: dict, args: argparse.Namespace) -> None:
467
+ cwd = Path(args.cwd).resolve()
468
+ run_id, root = resolve_run(payload if isinstance(payload, dict) else {}, args.run_id, args.state_root, cwd)
469
+ if not run_id:
470
+ return
471
+ source = None
472
+ if isinstance(payload, dict):
473
+ source = _first(payload, ("trigger", "reason", "source"))
474
+ record(
475
+ cwd,
476
+ root,
477
+ run_id,
478
+ {
479
+ "ts": now_iso(),
480
+ "run": run_id,
481
+ "event": "compaction",
482
+ "data": {"source": str(source) if source else "unknown"},
483
+ },
484
+ )
485
+
486
+
487
+ def read_payload() -> dict | None:
488
+ """Parse standard input; `None` means empty, undecodable, or not a JSON object.
489
+
490
+ A `None` payload is never recordable -- no event, degraded or otherwise, is ever
491
+ written for it, and the hook still exits 0 (allow).
492
+ """
493
+ raw = sys.stdin.read()
494
+ if not raw.strip():
495
+ return None
496
+ try:
497
+ payload = json.loads(raw)
498
+ except json.JSONDecodeError:
499
+ return None
500
+ return payload if isinstance(payload, dict) else None
501
+
502
+
503
+ def main() -> int:
504
+ parser = argparse.ArgumentParser(description=__doc__)
505
+ parser.add_argument("event", help="harness hook event name, e.g. PreToolUse or SubagentStop")
506
+ parser.add_argument("--cwd", default=".", help="repository or worktree the payload applies to")
507
+ parser.add_argument("--state-root", help="override ~/.gantry/state")
508
+ parser.add_argument(
509
+ "--run-id",
510
+ help="override the Run ID (defaults to $GANTRY_RUN_ID, then this worktree's current-Run "
511
+ "marker, then a payload session ID that already has a Run log)",
512
+ )
513
+ parser.add_argument("--json", action="store_true", help="emit the decision as a compact JSON object")
514
+ args = parser.parse_args()
515
+
516
+ payload = read_payload()
517
+ cwd = Path(args.cwd).resolve()
518
+
519
+ def allow() -> int:
520
+ if args.json:
521
+ print(json.dumps({"decision": "allow"}, separators=(",", ":")))
522
+ else:
523
+ print("allow")
524
+ return 0
525
+
526
+ if payload is None:
527
+ # Empty, undecodable, or non-object stdin. A Run ID resolvable from --run-id,
528
+ # $GANTRY_RUN_ID or this worktree's current-Run marker (never from the payload,
529
+ # since there is none) still gets a hook.degraded event naming the missing payload;
530
+ # with no Run ID at all, nothing is recorded.
531
+ record_degradation(cwd, args, {}, args.event, ["payload"])
532
+ return allow()
533
+
534
+ missing = degradation_fields(args.event, payload)
535
+ if missing:
536
+ record_degradation(cwd, args, payload, args.event, missing)
537
+ return allow()
538
+
539
+ if args.event in DECISION_EVENTS:
540
+ decision = handle_decision_event(payload, args)
541
+ if decision.allow:
542
+ return allow()
543
+ message = f"deny: {decision.rule} {decision.path}"
544
+ if args.json:
545
+ print(json.dumps({"decision": "deny", "rule": decision.rule, "path": decision.path}, separators=(",", ":")))
546
+ else:
547
+ print(message)
548
+ print(message, file=sys.stderr)
549
+ return 2
550
+
551
+ if args.event in SUBAGENT_START_EVENTS:
552
+ handle_subagent_event(payload, args, "subagent.started")
553
+ return allow()
554
+
555
+ if args.event in SUBAGENT_STOP_EVENTS:
556
+ handle_subagent_event(payload, args, "subagent.stopped")
557
+ return allow()
558
+
559
+ if args.event in COMPACTION_EVENTS:
560
+ handle_compaction_event(payload, args)
561
+ return allow()
562
+
563
+ # Unknown payload shapes degrade to recording nothing and granting no authority.
564
+ return allow()
565
+
566
+
567
+ if __name__ == "__main__":
568
+ sys.exit(main())
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """Draft recurring lesson candidates from recorded Run-log refutations and review findings.
3
+
4
+ The Learner reads no source other than the permitted Run-log events named on the command
5
+ line: `refutation` and `review.finding`. It never opens `AGENTS.md`, `CONTEXT.md`, a template
6
+ or the repository policy, and it never writes anything; a lesson candidate is a proposal for
7
+ the operator, not an injected change.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).parent))
17
+ from runlog import read_valid_events # noqa: E402
18
+
19
+ LESSON_EVENTS = {"refutation", "review.finding"}
20
+ DEFAULT_TARGET = "AGENTS.md (Gantry section)"
21
+
22
+
23
+ def lesson_message(event: dict) -> str | None:
24
+ data = event.get("data")
25
+ if not isinstance(data, dict):
26
+ return None
27
+ message = data.get("message")
28
+ if isinstance(message, str) and message.strip():
29
+ return message.strip()
30
+ return None
31
+
32
+
33
+ def collect_lesson_events(paths: list[Path]) -> list[dict]:
34
+ """Read only the permitted refutation and review-finding events from the given Run logs."""
35
+ events: list[dict] = []
36
+ for path in paths:
37
+ for event in read_valid_events(path):
38
+ if event.get("event") in LESSON_EVENTS:
39
+ events.append(event)
40
+ return events
41
+
42
+
43
+ def draft_candidates(paths: list[Path], target: str = DEFAULT_TARGET) -> list[dict]:
44
+ """Group recurring refutations/review findings across Issues or attempts into candidates.
45
+
46
+ Recurrence is counted per occurrence (each matching event), not per distinct Issue: two
47
+ refutation events sharing a message on the same Issue (e.g. two correction attempts) count
48
+ as two occurrences, just as the same message on two different Issues does.
49
+ """
50
+ order: list[str] = []
51
+ occurrences_by_message: dict[str, list[str]] = {}
52
+ for event in collect_lesson_events(paths):
53
+ message = lesson_message(event)
54
+ issue = event.get("issue")
55
+ if not message or not isinstance(issue, str):
56
+ continue
57
+ if message not in occurrences_by_message:
58
+ occurrences_by_message[message] = []
59
+ order.append(message)
60
+ occurrences_by_message[message].append(issue)
61
+
62
+ candidates = []
63
+ for message in order:
64
+ occurrences = occurrences_by_message[message]
65
+ if len(occurrences) < 2:
66
+ continue
67
+ candidates.append(
68
+ {
69
+ "lesson": message,
70
+ "evidence": [f"{issue}: {message}" for issue in occurrences],
71
+ "target": target,
72
+ }
73
+ )
74
+ return candidates
75
+
76
+
77
+ def main() -> int:
78
+ parser = argparse.ArgumentParser(description=__doc__)
79
+ parser.add_argument("runlog", nargs="+", help="one or more Run-log JSONL file paths")
80
+ parser.add_argument("--target", default=DEFAULT_TARGET, help="proposed target for every candidate")
81
+ parser.add_argument("--json", action="store_true", help="emit machine-readable output")
82
+ args = parser.parse_args()
83
+ paths = [Path(value) for value in args.runlog]
84
+ candidates = draft_candidates(paths, args.target)
85
+ payload = {"candidates": candidates}
86
+ if args.json:
87
+ print(json.dumps(payload))
88
+ elif candidates:
89
+ for candidate in candidates:
90
+ print(f"- {candidate['lesson']} -> {candidate['target']}")
91
+ for item in candidate["evidence"]:
92
+ print(f" {item}")
93
+ else:
94
+ print("no recurring lesson candidates")
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ sys.exit(main())