simplicio-loop 1.0.2__py3-none-any.whl
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.
- simplicio_loop/__init__.py +8 -0
- simplicio_loop/_bundle/hooks/hooks.claude.json +20 -0
- simplicio_loop/_bundle/hooks/hooks.json +12 -0
- simplicio_loop/_bundle/hooks/learn_stop.py +38 -0
- simplicio_loop/_bundle/hooks/loop_capture.py +67 -0
- simplicio_loop/_bundle/hooks/loop_stop.py +205 -0
- simplicio_loop/_bundle/hooks/orient_clamp.py +167 -0
- simplicio_loop/_bundle/hooks/orient_rewrite.py +96 -0
- simplicio_loop/_bundle/skills/simplicio-compress/SKILL.md +86 -0
- simplicio_loop/_bundle/skills/simplicio-learn/SKILL.md +70 -0
- simplicio_loop/_bundle/skills/simplicio-loop/SKILL.md +108 -0
- simplicio_loop/_bundle/skills/simplicio-orient/SKILL.md +188 -0
- simplicio_loop/_bundle/skills/simplicio-review/SKILL.md +94 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/SKILL.md +213 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/references/azure-devops-adapter.md +69 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/references/extension-points.md +60 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/references/orchestration.md +131 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/references/quality-safety-delivery.md +121 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/references/standing-loop-247.md +117 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/references/token-economy.md +175 -0
- simplicio_loop/_bundle/skills/simplicio-tasks/references/web-evidence.md +93 -0
- simplicio_loop/cli.py +76 -0
- simplicio_loop-1.0.2.dist-info/METADATA +75 -0
- simplicio_loop-1.0.2.dist-info/RECORD +28 -0
- simplicio_loop-1.0.2.dist-info/WHEEL +5 -0
- simplicio_loop-1.0.2.dist-info/entry_points.txt +2 -0
- simplicio_loop-1.0.2.dist-info/licenses/LICENSE +21 -0
- simplicio_loop-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""simplicio-loop — The Universal Looping AI Orchestrator.
|
|
2
|
+
|
|
3
|
+
A runtime-agnostic super-plugin (6 skills + loop/token hooks) that drains any
|
|
4
|
+
queue of work end-to-end on any LLM/runtime. This package ships the skills and
|
|
5
|
+
hooks and installs them into a runtime's skills location.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.2"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"Stop": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/loop_stop.py\"" },
|
|
7
|
+
{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/learn_stop.py\"" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"PreToolUse": [
|
|
12
|
+
{
|
|
13
|
+
"matcher": "Bash",
|
|
14
|
+
"hooks": [
|
|
15
|
+
{ "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/orient_rewrite.py\"" }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"afterAgentResponse": [
|
|
5
|
+
{ "command": "python3 ./hooks/loop_capture.py" }
|
|
6
|
+
],
|
|
7
|
+
"stop": [
|
|
8
|
+
{ "command": "python3 ./hooks/loop_stop.py", "loop_limit": null },
|
|
9
|
+
{ "command": "python3 ./hooks/learn_stop.py" }
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""simplicio-learn — session-end trigger (Stop / SubagentStop, fail-open).
|
|
3
|
+
|
|
4
|
+
Drops a lightweight marker so the next simplicio-tasks tick (or the human) runs the
|
|
5
|
+
`simplicio-learn` retrospective on the just-finished run. It does NOT itself mine the
|
|
6
|
+
transcript (that's the skill's job, where the model can judge signal) — it only signals
|
|
7
|
+
"there is a finished run worth learning from", with the transcript path if the host gave one.
|
|
8
|
+
|
|
9
|
+
Fail-open and silent: never blocks stopping, never errors out loud.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
LEARN_DIR = os.path.join(".orchestrator", "learn")
|
|
17
|
+
QUEUE = os.path.join(LEARN_DIR, "pending.jsonl")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
try:
|
|
22
|
+
raw = sys.stdin.read()
|
|
23
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
24
|
+
os.makedirs(LEARN_DIR, exist_ok=True)
|
|
25
|
+
rec = {
|
|
26
|
+
"at": int(time.time()),
|
|
27
|
+
"transcript": data.get("transcript_path") or data.get("transcriptPath"),
|
|
28
|
+
"session": data.get("session_id") or data.get("sessionId"),
|
|
29
|
+
}
|
|
30
|
+
with open(QUEUE, "a", encoding="utf-8") as f:
|
|
31
|
+
f.write(json.dumps(rec) + "\n")
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
sys.exit(0) # never block the stop
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""simplicio-loop — capture hook (Cursor `afterAgentResponse`).
|
|
3
|
+
|
|
4
|
+
Runs after every agent turn. Detects an evidence-backed completion-promise and
|
|
5
|
+
raises the `done` flag for the stop hook to act on. Detection and termination are
|
|
6
|
+
split on purpose (the ralph-loop two-hook pattern): this script NEVER stops the
|
|
7
|
+
loop, it only raises the flag. Fire-and-forget, always exit 0.
|
|
8
|
+
|
|
9
|
+
On Claude Code (which has no separate post-response hook) this script is unused —
|
|
10
|
+
`loop_stop.py` folds capture in by reading the transcript. Harmless if wired anyway.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
LOOP_DIR = os.path.join(".orchestrator", "loop")
|
|
18
|
+
SCRATCHPAD = os.path.join(LOOP_DIR, "scratchpad.md")
|
|
19
|
+
DONE_FLAG = os.path.join(LOOP_DIR, "done")
|
|
20
|
+
LAST_RESP = os.path.join(LOOP_DIR, "last_response.txt")
|
|
21
|
+
|
|
22
|
+
PROMISE_RE = re.compile(r"<promise>\s*(.*?)\s*</promise>", re.IGNORECASE | re.DOTALL)
|
|
23
|
+
EVIDENCE_RE = re.compile(
|
|
24
|
+
r"(https?://\S+/pull/\d+)|(\b(pass|passed|passing|green|ok)\b)|([\w./-]+:\d+)|([✓✅])",
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main():
|
|
30
|
+
try:
|
|
31
|
+
raw = sys.stdin.read()
|
|
32
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
33
|
+
resp = data.get("text", "") or ""
|
|
34
|
+
if not resp:
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
# Stash the response so a unified stop hook can read it cross-runtime.
|
|
37
|
+
try:
|
|
38
|
+
os.makedirs(LOOP_DIR, exist_ok=True)
|
|
39
|
+
with open(LAST_RESP, "w", encoding="utf-8") as f:
|
|
40
|
+
f.write(resp)
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
if not os.path.exists(SCRATCHPAD):
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
with open(SCRATCHPAD, encoding="utf-8") as f:
|
|
46
|
+
content = f.read()
|
|
47
|
+
m_prom = re.search(r"^completion_promise:\s*(.*)$", content, re.M)
|
|
48
|
+
if not m_prom:
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
promise = m_prom.group(1).strip().strip('"')
|
|
51
|
+
if promise in ("", "null"):
|
|
52
|
+
sys.exit(0)
|
|
53
|
+
evidence_required = "evidence_required: false" not in content.lower()
|
|
54
|
+
m = PROMISE_RE.search(resp)
|
|
55
|
+
if m and m.group(1).strip() == promise:
|
|
56
|
+
if (not evidence_required) or EVIDENCE_RE.search(resp):
|
|
57
|
+
try:
|
|
58
|
+
open(DONE_FLAG, "w").close() # raise the flag; stop hook acts
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
sys.exit(0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""simplicio-loop — stop hook (cross-runtime, cross-platform).
|
|
3
|
+
|
|
4
|
+
Fires when an agent turn ends. Decides whether to RE-FEED the goal (continue the
|
|
5
|
+
Ralph loop) or let the agent STOP. Works under Claude Code (Stop hook) and Cursor
|
|
6
|
+
(stop hook); detects the runtime from env and emits the matching control object.
|
|
7
|
+
|
|
8
|
+
SAFETY: fail-open. On ANY error, ambiguity, or missing state, ALLOW STOP — a buggy
|
|
9
|
+
hook must never trap the agent in an endless loop. The real guards are the
|
|
10
|
+
`max_iterations` cap and the $ budget kill-switch, never this script's cleverness.
|
|
11
|
+
|
|
12
|
+
State (single source of truth): .orchestrator/loop/scratchpad.md (+ sibling `done` flag)
|
|
13
|
+
Reads stdin JSON from the host (Claude: {transcript_path,...}; Cursor: {text,...}).
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
LOOP_DIR = os.path.join(".orchestrator", "loop")
|
|
21
|
+
SCRATCHPAD = os.path.join(LOOP_DIR, "scratchpad.md")
|
|
22
|
+
DONE_FLAG = os.path.join(LOOP_DIR, "done")
|
|
23
|
+
LAST_RESP = os.path.join(LOOP_DIR, "last_response.txt")
|
|
24
|
+
STOP_SIGNAL = os.path.join(".orchestrator", "STOP")
|
|
25
|
+
BUDGET = os.path.join(".orchestrator", "loop-budget.json")
|
|
26
|
+
|
|
27
|
+
EVIDENCE_RE = re.compile(
|
|
28
|
+
r"(https?://\S+/pull/\d+)" # a PR URL
|
|
29
|
+
r"|(\b(pass|passed|passing|green|ok)\b)" # a gate verdict
|
|
30
|
+
r"|([\w./-]+:\d+)" # a file:line receipt
|
|
31
|
+
r"|([✓✅])",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
PROMISE_RE = re.compile(r"<promise>\s*(.*?)\s*</promise>", re.IGNORECASE | re.DOTALL)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def allow_stop():
|
|
38
|
+
"""Emit nothing actionable → the agent is allowed to stop. Always exit 0."""
|
|
39
|
+
sys.exit(0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cleanup_and_stop():
|
|
43
|
+
for p in (SCRATCHPAD, DONE_FLAG, LAST_RESP):
|
|
44
|
+
try:
|
|
45
|
+
if os.path.exists(p):
|
|
46
|
+
os.remove(p)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
allow_stop()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_stdin_json():
|
|
53
|
+
try:
|
|
54
|
+
raw = sys.stdin.read()
|
|
55
|
+
return json.loads(raw) if raw.strip() else {}
|
|
56
|
+
except Exception:
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_frontmatter(text):
|
|
61
|
+
"""Return (meta dict, body str) or (None, None) on corruption."""
|
|
62
|
+
if not text.startswith("---"):
|
|
63
|
+
return None, None
|
|
64
|
+
parts = text.split("---", 2)
|
|
65
|
+
if len(parts) < 3:
|
|
66
|
+
return None, None
|
|
67
|
+
meta = {}
|
|
68
|
+
for line in parts[1].splitlines():
|
|
69
|
+
if ":" in line:
|
|
70
|
+
k, _, v = line.partition(":")
|
|
71
|
+
meta[k.strip()] = v.strip().strip('"')
|
|
72
|
+
return meta, parts[2].strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def last_assistant_text(stdin):
|
|
76
|
+
# Cursor passes the response text inline.
|
|
77
|
+
if isinstance(stdin.get("text"), str):
|
|
78
|
+
return stdin["text"]
|
|
79
|
+
# Cursor capture hook may have stashed it.
|
|
80
|
+
if os.path.exists(LAST_RESP):
|
|
81
|
+
try:
|
|
82
|
+
with open(LAST_RESP, encoding="utf-8") as f:
|
|
83
|
+
return f.read()
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
# Claude passes a transcript path (JSONL); read the last assistant message.
|
|
87
|
+
tp = stdin.get("transcript_path")
|
|
88
|
+
if tp and os.path.exists(tp):
|
|
89
|
+
try:
|
|
90
|
+
txt = ""
|
|
91
|
+
with open(tp, encoding="utf-8") as f:
|
|
92
|
+
for line in f:
|
|
93
|
+
try:
|
|
94
|
+
ev = json.loads(line)
|
|
95
|
+
except Exception:
|
|
96
|
+
continue
|
|
97
|
+
if ev.get("role") == "assistant" or ev.get("type") == "assistant":
|
|
98
|
+
msg = ev.get("message", ev)
|
|
99
|
+
content = msg.get("content", "")
|
|
100
|
+
if isinstance(content, list):
|
|
101
|
+
content = " ".join(
|
|
102
|
+
c.get("text", "") for c in content if isinstance(c, dict)
|
|
103
|
+
)
|
|
104
|
+
txt = content or txt
|
|
105
|
+
return txt
|
|
106
|
+
except OSError:
|
|
107
|
+
return ""
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def budget_halted():
|
|
112
|
+
try:
|
|
113
|
+
if not os.path.exists(BUDGET):
|
|
114
|
+
return False
|
|
115
|
+
with open(BUDGET, encoding="utf-8") as f:
|
|
116
|
+
b = json.load(f)
|
|
117
|
+
if str(b.get("state", "")).lower() == "halted":
|
|
118
|
+
return True
|
|
119
|
+
ceiling = float(b.get("daily_usd_ceiling", 0) or 0)
|
|
120
|
+
spent = float(b.get("spent_usd_today", 0) or 0)
|
|
121
|
+
return ceiling > 0 and spent >= ceiling
|
|
122
|
+
except Exception:
|
|
123
|
+
return False # fail-open: budget unreadable ≠ trap
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def emit_refeed(followup):
|
|
127
|
+
"""Emit the re-feed in BOTH schemas; each runtime reads its own key."""
|
|
128
|
+
out = {
|
|
129
|
+
"followup_message": followup, # Cursor
|
|
130
|
+
"decision": "block", # Claude Code Stop hook
|
|
131
|
+
"reason": followup,
|
|
132
|
+
}
|
|
133
|
+
sys.stdout.write(json.dumps(out))
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main():
|
|
138
|
+
try:
|
|
139
|
+
# Explicit STOP signal beats everything.
|
|
140
|
+
if os.path.exists(STOP_SIGNAL):
|
|
141
|
+
cleanup_and_stop()
|
|
142
|
+
# (1) No active loop.
|
|
143
|
+
if not os.path.exists(SCRATCHPAD):
|
|
144
|
+
allow_stop()
|
|
145
|
+
with open(SCRATCHPAD, encoding="utf-8") as f:
|
|
146
|
+
content = f.read()
|
|
147
|
+
meta, body = parse_frontmatter(content)
|
|
148
|
+
# (2) Corrupt state.
|
|
149
|
+
if meta is None:
|
|
150
|
+
cleanup_and_stop()
|
|
151
|
+
try:
|
|
152
|
+
iteration = int(meta.get("iteration", "1"))
|
|
153
|
+
max_iter = int(meta.get("max_iterations", "0"))
|
|
154
|
+
except ValueError:
|
|
155
|
+
cleanup_and_stop()
|
|
156
|
+
promise = meta.get("completion_promise", "null")
|
|
157
|
+
promise = None if promise in (None, "null", "") else promise
|
|
158
|
+
evidence_required = str(meta.get("evidence_required", "true")).lower() != "false"
|
|
159
|
+
|
|
160
|
+
stdin = read_stdin_json()
|
|
161
|
+
resp = last_assistant_text(stdin)
|
|
162
|
+
|
|
163
|
+
# Completion detection (capture folded in for single-hook runtimes like Claude).
|
|
164
|
+
if promise and resp:
|
|
165
|
+
m = PROMISE_RE.search(resp)
|
|
166
|
+
if m and m.group(1).strip() == promise.strip():
|
|
167
|
+
has_evidence = bool(EVIDENCE_RE.search(resp))
|
|
168
|
+
if (not evidence_required) or has_evidence:
|
|
169
|
+
cleanup_and_stop() # (3) promise fulfilled → stop
|
|
170
|
+
# promise without evidence → ignore, keep looping
|
|
171
|
+
# (3') Cursor capture may have raised the flag.
|
|
172
|
+
if os.path.exists(DONE_FLAG):
|
|
173
|
+
cleanup_and_stop()
|
|
174
|
+
# (4) Iteration cap.
|
|
175
|
+
if max_iter > 0 and iteration >= max_iter:
|
|
176
|
+
cleanup_and_stop()
|
|
177
|
+
# (5) Budget halted.
|
|
178
|
+
if budget_halted():
|
|
179
|
+
cleanup_and_stop()
|
|
180
|
+
# (6) Continue: bump iteration in place, re-feed the goal body.
|
|
181
|
+
nxt = iteration + 1
|
|
182
|
+
new_content = re.sub(
|
|
183
|
+
r"^iteration:\s*\d+", "iteration: %d" % nxt, content, count=1, flags=re.M
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
tmp = SCRATCHPAD + ".tmp"
|
|
187
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
188
|
+
f.write(new_content)
|
|
189
|
+
os.replace(tmp, SCRATCHPAD)
|
|
190
|
+
except OSError:
|
|
191
|
+
allow_stop() # can't persist progress → don't risk an unbounded loop
|
|
192
|
+
promise_hint = (
|
|
193
|
+
" To finish: output <promise>%s</promise> ONLY when genuinely true AND "
|
|
194
|
+
"backed by a passing gate." % promise
|
|
195
|
+
if promise
|
|
196
|
+
else ""
|
|
197
|
+
)
|
|
198
|
+
header = "[simplicio-loop iteration %d.%s]" % (nxt, promise_hint)
|
|
199
|
+
emit_refeed(header + "\n\n" + (body or ""))
|
|
200
|
+
except Exception:
|
|
201
|
+
allow_stop() # fail-open, always
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""simplicio-orient — command clamp wrapper (rtk core, portable).
|
|
3
|
+
|
|
4
|
+
Run a dev command and return its output REDUCED before it reaches the model context:
|
|
5
|
+
success-collapse · dedup-with-counts · signal-tiered caps · tee-cache on failure.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 hooks/orient_clamp.py -- <command> [args...]
|
|
9
|
+
python3 hooks/orient_clamp.py --json -- <command> [args...] # machine summary
|
|
10
|
+
|
|
11
|
+
Works on Windows/macOS/Linux (pure Python, no shell-specific syntax). Safe and
|
|
12
|
+
fail-open: on ANY internal error it prints the raw output and propagates the REAL
|
|
13
|
+
exit code — it can never turn "task works" into "task dead".
|
|
14
|
+
|
|
15
|
+
Config (optional): .orchestrator/orient.toml
|
|
16
|
+
[tee] mode = "failures" | "always" | "never" (default failures)
|
|
17
|
+
[hooks] exclude_commands = ["curl", "wget", "playwright", "ssh", "vim", "less"]
|
|
18
|
+
Excluded commands run RAW (streaming/interactive/binary must not be filtered).
|
|
19
|
+
"""
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
try: # Windows consoles default to cp1252 — force UTF-8 so arbitrary output never crashes.
|
|
27
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
28
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
CAP_ERRORS = 20
|
|
33
|
+
CAP_WARNINGS = 10
|
|
34
|
+
CAP_LIST = 20
|
|
35
|
+
TEE_DIR = os.path.join(".orchestrator", "tee")
|
|
36
|
+
CONFIG = os.path.join(".orchestrator", "orient.toml")
|
|
37
|
+
DEFAULT_EXCLUDES = ["curl", "wget", "playwright", "ssh", "vim", "less", "top", "htop"]
|
|
38
|
+
|
|
39
|
+
ERR_RE = re.compile(r"\b(error|fail(ed|ure)?|panic|exception|fatal|traceback)\b", re.I)
|
|
40
|
+
WARN_RE = re.compile(r"\bwarn(ing)?\b", re.I)
|
|
41
|
+
CLEAN_RE = re.compile(
|
|
42
|
+
r"^(ok|done|success|up.to.date|no changes|nothing to commit|pass(ed|ing)?)", re.I
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_config():
|
|
47
|
+
mode, excludes = "failures", list(DEFAULT_EXCLUDES)
|
|
48
|
+
try:
|
|
49
|
+
if os.path.exists(CONFIG):
|
|
50
|
+
txt = open(CONFIG, encoding="utf-8").read()
|
|
51
|
+
m = re.search(r'mode\s*=\s*"(\w+)"', txt)
|
|
52
|
+
if m:
|
|
53
|
+
mode = m.group(1)
|
|
54
|
+
m = re.search(r"exclude_commands\s*=\s*\[(.*?)\]", txt, re.S)
|
|
55
|
+
if m:
|
|
56
|
+
excludes = re.findall(r'"([^"]+)"', m.group(1)) or excludes
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return mode, excludes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_excluded(cmd, excludes):
|
|
63
|
+
joined = " ".join(cmd).lower()
|
|
64
|
+
return any(joined.startswith(x.lower()) or (" " + x.lower() + " ") in (" " + joined + " ")
|
|
65
|
+
for x in excludes)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def tee_write(cmd, raw):
|
|
69
|
+
try:
|
|
70
|
+
os.makedirs(TEE_DIR, exist_ok=True)
|
|
71
|
+
slug = re.sub(r"[^a-z0-9]+", "_", " ".join(cmd).lower())[:40].strip("_")
|
|
72
|
+
path = os.path.join(TEE_DIR, "%d_%s.log" % (int(time.time()), slug or "cmd"))
|
|
73
|
+
with open(path, "w", encoding="utf-8", errors="replace") as f:
|
|
74
|
+
f.write(raw)
|
|
75
|
+
return path
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def dedup_counts(lines):
|
|
81
|
+
out, prev, n = [], None, 0
|
|
82
|
+
for ln in lines:
|
|
83
|
+
if ln == prev:
|
|
84
|
+
n += 1
|
|
85
|
+
else:
|
|
86
|
+
if prev is not None:
|
|
87
|
+
out.append(prev if n == 1 else "%s x%d" % (prev, n))
|
|
88
|
+
prev, n = ln, 1
|
|
89
|
+
if prev is not None:
|
|
90
|
+
out.append(prev if n == 1 else "%s ×%d" % (prev, n))
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def clamp(raw, exit_code):
|
|
95
|
+
lines = raw.splitlines()
|
|
96
|
+
err = [l for l in lines if ERR_RE.search(l)]
|
|
97
|
+
warn = [l for l in lines if WARN_RE.search(l) and l not in err]
|
|
98
|
+
# success-collapse: clean exit, no error/warning → one line
|
|
99
|
+
if exit_code == 0 and not err and not warn:
|
|
100
|
+
first = next((l for l in lines if l.strip()), "")
|
|
101
|
+
if not lines or CLEAN_RE.search(first.strip()) or len(lines) <= 1:
|
|
102
|
+
return (first.strip() or "ok"), False
|
|
103
|
+
kept = dedup_counts([l for l in lines if l.strip()])[:CAP_LIST]
|
|
104
|
+
clipped = len(kept) < len([l for l in lines if l.strip()])
|
|
105
|
+
return "\n".join(kept), clipped
|
|
106
|
+
# has errors/warnings → keep signal, dedup
|
|
107
|
+
body = dedup_counts(err)[:CAP_ERRORS] + dedup_counts(warn)[:CAP_WARNINGS]
|
|
108
|
+
clipped = len(err) > CAP_ERRORS or len(warn) > CAP_WARNINGS
|
|
109
|
+
if not body: # underflow-safe: fall back to a tail of raw
|
|
110
|
+
body = [l for l in lines if l.strip()][-CAP_ERRORS:]
|
|
111
|
+
clipped = clipped or len(lines) > CAP_ERRORS
|
|
112
|
+
return "\n".join(body), clipped
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
argv = sys.argv[1:]
|
|
117
|
+
as_json = False
|
|
118
|
+
if argv and argv[0] == "--json":
|
|
119
|
+
as_json, argv = True, argv[1:]
|
|
120
|
+
if not argv or argv[0] != "--" or len(argv) < 2:
|
|
121
|
+
sys.stderr.write("usage: orient_clamp.py [--json] -- <command> [args...]\n")
|
|
122
|
+
sys.exit(2)
|
|
123
|
+
cmd = argv[1:]
|
|
124
|
+
mode, excludes = load_config()
|
|
125
|
+
|
|
126
|
+
# Excluded → run raw, stream through unchanged.
|
|
127
|
+
if is_excluded(cmd, excludes):
|
|
128
|
+
try:
|
|
129
|
+
return sys.exit(subprocess.call(cmd))
|
|
130
|
+
except Exception as e:
|
|
131
|
+
sys.stderr.write("orient_clamp passthrough error: %s\n" % e)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
|
|
136
|
+
raw = (proc.stdout or "") + (proc.stderr or "")
|
|
137
|
+
code = proc.returncode
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
sys.stderr.write("orient_clamp: command not found: %s\n" % cmd[0])
|
|
140
|
+
sys.exit(127)
|
|
141
|
+
except Exception:
|
|
142
|
+
# fail-open: re-run inheriting stdio so the user still gets the real result
|
|
143
|
+
try:
|
|
144
|
+
sys.exit(subprocess.call(cmd))
|
|
145
|
+
except Exception:
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
reduced, clipped = clamp(raw, code)
|
|
149
|
+
tee_path = None
|
|
150
|
+
if mode == "always" or (mode == "failures" and (code != 0 or (clipped and code != 0))):
|
|
151
|
+
tee_path = tee_write(cmd, raw)
|
|
152
|
+
|
|
153
|
+
if as_json:
|
|
154
|
+
import json
|
|
155
|
+
print(json.dumps({
|
|
156
|
+
"exit": code, "reduced": reduced, "tee": tee_path,
|
|
157
|
+
"raw_chars": len(raw), "reduced_chars": len(reduced),
|
|
158
|
+
}))
|
|
159
|
+
else:
|
|
160
|
+
print(reduced)
|
|
161
|
+
if tee_path:
|
|
162
|
+
print("[full output: %s]" % tee_path)
|
|
163
|
+
sys.exit(code) # propagate the REAL exit code
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""simplicio-orient — auto-rewrite hook (PreToolUse, best-effort, fail-open).
|
|
3
|
+
|
|
4
|
+
Transparently routes a bare heavy READ-ONLY command through `orient_clamp.py` so its
|
|
5
|
+
output is reduced before it reaches context — rtk's `init -g` idea. Guarantees adoption
|
|
6
|
+
across the main agent AND subagents at zero token overhead, where the host supports
|
|
7
|
+
PreToolUse input rewriting (newer Claude Code; Cursor).
|
|
8
|
+
|
|
9
|
+
CONSERVATIVE BY DESIGN (safety > savings):
|
|
10
|
+
• Only wraps a small allowlist of read-only commands (git status/log/diff/show, ls,
|
|
11
|
+
rg, cat/type, and known test/build runners).
|
|
12
|
+
• NEVER touches writes, excluded commands, or compound commands (&&, ||, |, ;, >, <,
|
|
13
|
+
backticks, $()) — those run unchanged.
|
|
14
|
+
• Fail-open: on ANY error or unknown protocol, allow the command unchanged. If the host
|
|
15
|
+
ignores the rewrite, the command simply runs raw — never broken.
|
|
16
|
+
|
|
17
|
+
Disabled unless wired explicitly (see hooks/README.md). Honest note: where a runtime
|
|
18
|
+
cannot rewrite tool input, this no-ops; use `orient_clamp.py` as a manual wrapper instead.
|
|
19
|
+
"""
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
CLAMP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "orient_clamp.py")
|
|
26
|
+
CONFIG = os.path.join(".orchestrator", "orient.toml")
|
|
27
|
+
DEFAULT_EXCLUDES = ["curl", "wget", "playwright", "ssh", "vim", "less", "top", "htop"]
|
|
28
|
+
|
|
29
|
+
# read-only, output-heavy commands worth clamping (prefix match on the first token(s))
|
|
30
|
+
ALLOW = [
|
|
31
|
+
"git status", "git log", "git diff", "git show", "git branch",
|
|
32
|
+
"ls", "ll", "dir", "rg ", "grep ", "cat ", "type ", "tree",
|
|
33
|
+
"cargo check", "cargo test", "cargo clippy", "cargo build",
|
|
34
|
+
"npm test", "npm run", "pnpm test", "yarn test", "jest", "vitest",
|
|
35
|
+
"go test", "go build", "go vet", "pytest", "python -m pytest",
|
|
36
|
+
"mvn ", "gradle ", "tsc", "eslint", "ruff", "golangci-lint",
|
|
37
|
+
]
|
|
38
|
+
UNSAFE = re.compile(r"[|&;><`]|\$\(|>>|<<") # compound / redirect / substitution
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def allow_unchanged():
|
|
42
|
+
# Emit a permissive decision that does not modify anything; host runs the command raw.
|
|
43
|
+
print(json.dumps({
|
|
44
|
+
"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}
|
|
45
|
+
}))
|
|
46
|
+
sys.exit(0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_excludes():
|
|
50
|
+
excludes = list(DEFAULT_EXCLUDES)
|
|
51
|
+
try:
|
|
52
|
+
if os.path.exists(CONFIG):
|
|
53
|
+
m = re.search(r"exclude_commands\s*=\s*\[(.*?)\]",
|
|
54
|
+
open(CONFIG, encoding="utf-8").read(), re.S)
|
|
55
|
+
if m:
|
|
56
|
+
excludes = re.findall(r'"([^"]+)"', m.group(1)) or excludes
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return excludes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
try:
|
|
64
|
+
raw = sys.stdin.read()
|
|
65
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
66
|
+
ti = data.get("tool_input", data.get("toolInput", {})) or {}
|
|
67
|
+
cmd = (ti.get("command") or ti.get("cmd") or "").strip()
|
|
68
|
+
if not cmd:
|
|
69
|
+
allow_unchanged()
|
|
70
|
+
low = cmd.lower()
|
|
71
|
+
# already wrapped, unsafe-shape, or excluded → leave raw
|
|
72
|
+
if "orient_clamp" in low or UNSAFE.search(cmd):
|
|
73
|
+
allow_unchanged()
|
|
74
|
+
if any(low.startswith(x.lower()) or (" " + x.lower()) in (" " + low)
|
|
75
|
+
for x in load_excludes()):
|
|
76
|
+
allow_unchanged()
|
|
77
|
+
if not any(low.startswith(a.lower()) for a in ALLOW):
|
|
78
|
+
allow_unchanged()
|
|
79
|
+
# eligible → rewrite to route through the clamp wrapper
|
|
80
|
+
new_cmd = 'python3 "%s" -- %s' % (CLAMP, cmd)
|
|
81
|
+
out = {
|
|
82
|
+
"hookSpecificOutput": {
|
|
83
|
+
"hookEventName": "PreToolUse",
|
|
84
|
+
"permissionDecision": "allow",
|
|
85
|
+
"updatedInput": {"command": new_cmd}, # newer Claude Code
|
|
86
|
+
},
|
|
87
|
+
"updatedInput": {"command": new_cmd}, # alt schema; ignored if unknown
|
|
88
|
+
}
|
|
89
|
+
print(json.dumps(out))
|
|
90
|
+
sys.exit(0)
|
|
91
|
+
except Exception:
|
|
92
|
+
allow_unchanged() # fail-open, always
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|