@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.
- package/.agents/skills/gantry/SKILL.md +166 -0
- package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
- package/.agents/skills/gantry/capabilities/codex.json +14 -0
- package/.agents/skills/gantry/capabilities/opencode.json +15 -0
- package/.agents/skills/gantry/dashboard/static/app.js +100 -0
- package/.agents/skills/gantry/dashboard/static/index.html +16 -0
- package/.agents/skills/gantry/dashboard/static/style.css +74 -0
- package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
- package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
- package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
- package/.agents/skills/gantry/hooks/git/pre-push +123 -0
- package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
- package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
- package/.agents/skills/gantry/reference/round-workflow.md +755 -0
- package/.agents/skills/gantry/schemas/critic.json +93 -0
- package/.agents/skills/gantry/schemas/implementer.json +52 -0
- package/.agents/skills/gantry/schemas/learner.json +35 -0
- package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
- package/.agents/skills/gantry/schemas/planner.json +64 -0
- package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
- package/.agents/skills/gantry/schemas/reviewer.json +52 -0
- package/.agents/skills/gantry/scripts/acceptance.py +66 -0
- package/.agents/skills/gantry/scripts/budget.py +162 -0
- package/.agents/skills/gantry/scripts/cleanup.py +186 -0
- package/.agents/skills/gantry/scripts/common.py +361 -0
- package/.agents/skills/gantry/scripts/dashboard.py +233 -0
- package/.agents/skills/gantry/scripts/frontier.py +192 -0
- package/.agents/skills/gantry/scripts/gates.py +401 -0
- package/.agents/skills/gantry/scripts/guard.py +568 -0
- package/.agents/skills/gantry/scripts/learner.py +99 -0
- package/.agents/skills/gantry/scripts/result.py +104 -0
- package/.agents/skills/gantry/scripts/roadmap.py +212 -0
- package/.agents/skills/gantry/scripts/runlog.py +491 -0
- package/.agents/skills/gantry/scripts/setup.py +139 -0
- package/.agents/skills/gantry/scripts/spec.py +252 -0
- package/.agents/skills/gantry/templates/issue.md +32 -0
- package/.agents/skills/gantry/templates/prd.md +26 -0
- package/.agents/skills/gantry/templates/spec.md +48 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
- package/.agents/skills/gantry-setup/SKILL.md +30 -0
- package/LICENSE +201 -0
- package/README.md +437 -0
- package/bin/gantry.mjs +45 -0
- package/package.json +36 -0
- 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())
|