uni-harness 0.2.1 → 0.4.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/.claude/hooks/guard-pre-bash.sh +39 -4
- package/.claude/hooks/guard-pre-edit.sh +122 -0
- package/.claude/hooks/observe-log.sh +21 -10
- package/.claude/hooks/pre-compact.sh +53 -0
- package/.claude/hooks/session-end.sh +103 -0
- package/.claude/hooks/session-start.sh +43 -2
- package/.claude/hooks/stop-gate.sh +84 -0
- package/.claude/settings.json +33 -0
- package/.claude/skills/distill/SKILL.md +49 -0
- package/.claude/skills/guide-audit/SKILL.md +14 -0
- package/.claude/skills/harness-init/SKILL.md +5 -1
- package/.claude/skills/ratchet/SKILL.md +31 -0
- package/AGENTS.md +26 -0
- package/README.ja.md +221 -0
- package/README.ko.md +215 -0
- package/README.md +16 -5
- package/README.zh-CN.md +209 -0
- package/bin/cli.js +5 -2
- package/harness/harness_report.py +16 -1
- package/harness/tests/test_hooks.sh +118 -12
- package/harness/tests/test_installer.sh +17 -0
- package/package.json +2 -1
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# ════════════════════════════════════════════════════════════════
|
|
3
3
|
# PreToolUse(Bash) guard — blocks destructive/irreversible commands
|
|
4
|
-
# before they run
|
|
5
|
-
#
|
|
4
|
+
# before they run, and asks before a git commit that would land edits
|
|
5
|
+
# no test run has seen (state-aware gate: the pending_test marker is
|
|
6
|
+
# set by sensor-post-edit.sh and cleared only when stop-gate.sh sees
|
|
7
|
+
# the tests pass).
|
|
8
|
+
# exit 0 + JSON(deny/ask) = intercepted / exit 0 (no output) = normal flow
|
|
6
9
|
# ════════════════════════════════════════════════════════════════
|
|
7
10
|
set -euo pipefail
|
|
8
11
|
|
|
9
12
|
INPUT=$(cat)
|
|
13
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
10
14
|
|
|
11
|
-
python3 - "$INPUT" <<'PYEOF'
|
|
12
|
-
import json, re, sys
|
|
15
|
+
python3 - "$INPUT" "$PROJECT_DIR" <<'PYEOF'
|
|
16
|
+
import json, os, re, sys
|
|
13
17
|
|
|
14
18
|
data = json.loads(sys.argv[1])
|
|
19
|
+
proj = sys.argv[2]
|
|
15
20
|
cmd = (data.get("tool_input") or {}).get("command", "") or ""
|
|
16
21
|
|
|
17
22
|
# DENY: irreversible/destructive patterns (extend per project as needed)
|
|
@@ -41,6 +46,36 @@ for pattern, reason in DENY_PATTERNS:
|
|
|
41
46
|
}))
|
|
42
47
|
sys.exit(0)
|
|
43
48
|
|
|
49
|
+
# State-aware commit gate: committing while the pending_test marker
|
|
50
|
+
# exists means tests have not passed since the last code edit. "ask",
|
|
51
|
+
# not "deny" — WIP commits and doc-only commits are legitimate, so the
|
|
52
|
+
# user decides. Fires only when TEST_CMD is configured (otherwise the
|
|
53
|
+
# marker never clears and this would nag on every commit).
|
|
54
|
+
try:
|
|
55
|
+
sid = data.get("session_id", "")
|
|
56
|
+
marker = os.path.join(proj, ".harness/logs", f"pending_test.{sid}")
|
|
57
|
+
env_path = os.path.join(proj, ".harness/commands.env")
|
|
58
|
+
configured = False
|
|
59
|
+
if os.path.exists(env_path):
|
|
60
|
+
with open(env_path) as f:
|
|
61
|
+
configured = bool(re.search(r'^TEST_CMD=".+"', f.read(), re.M))
|
|
62
|
+
if (configured and sid and os.path.exists(marker)
|
|
63
|
+
and re.search(r"\bgit\s+([a-z-]+\s+)*commit\b", cmd)):
|
|
64
|
+
print(json.dumps({
|
|
65
|
+
"hookSpecificOutput": {
|
|
66
|
+
"hookEventName": "PreToolUse",
|
|
67
|
+
"permissionDecision": "ask",
|
|
68
|
+
"permissionDecisionReason":
|
|
69
|
+
"[harness guard] Code was edited this session but the "
|
|
70
|
+
"test suite has not passed since (pending_test marker "
|
|
71
|
+
"present). Run the TEST command first, or confirm this "
|
|
72
|
+
"commit is intentionally untested (WIP/docs)."
|
|
73
|
+
}
|
|
74
|
+
}))
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
44
79
|
# No match -> exit silently (normal permission flow)
|
|
45
80
|
sys.exit(0)
|
|
46
81
|
PYEOF
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ════════════════════════════════════════════════════════════════
|
|
3
|
+
# PreToolUse(Edit|Write) guard — two checks, both "ask" not "deny":
|
|
4
|
+
# (1) protected paths: .claude/**, .harness/** (except state/),
|
|
5
|
+
# .github/**, .env*, *.config.* — the CLAUDE.md rule "never modify
|
|
6
|
+
# config/harness files without asking", promoted to a gate.
|
|
7
|
+
# (2) test-weakening: edits to test files that add skip markers,
|
|
8
|
+
# remove test functions, or gut assertions. The #1 reward hack is
|
|
9
|
+
# making tests pass by making tests weaker — surface it to the
|
|
10
|
+
# user instead of letting it slide through.
|
|
11
|
+
# Precision-first: fire only on unambiguous signals; fail silent on
|
|
12
|
+
# any parse error.
|
|
13
|
+
# ════════════════════════════════════════════════════════════════
|
|
14
|
+
set -uo pipefail
|
|
15
|
+
|
|
16
|
+
INPUT=$(cat)
|
|
17
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
18
|
+
|
|
19
|
+
python3 - "$INPUT" "$PROJECT_DIR" <<'PYEOF'
|
|
20
|
+
import json, sys, os, re
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
data = json.loads(sys.argv[1])
|
|
24
|
+
except Exception:
|
|
25
|
+
sys.exit(0)
|
|
26
|
+
proj = os.path.realpath(sys.argv[2])
|
|
27
|
+
|
|
28
|
+
ti = data.get("tool_input") or {}
|
|
29
|
+
path = str(ti.get("file_path") or "")
|
|
30
|
+
if not path:
|
|
31
|
+
sys.exit(0)
|
|
32
|
+
|
|
33
|
+
# Only police files inside the project
|
|
34
|
+
rp = os.path.realpath(path if os.path.isabs(path) else os.path.join(proj, path))
|
|
35
|
+
if not (rp + os.sep).startswith(proj + os.sep):
|
|
36
|
+
sys.exit(0)
|
|
37
|
+
rel = os.path.relpath(rp, proj).replace(os.sep, "/")
|
|
38
|
+
base = os.path.basename(rel)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ask(reason):
|
|
42
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
43
|
+
"hookEventName": "PreToolUse",
|
|
44
|
+
"permissionDecision": "ask",
|
|
45
|
+
"permissionDecisionReason": "[harness guard] " + reason,
|
|
46
|
+
}}, ensure_ascii=False))
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── (1) protected paths ──────────────────────────────────────────
|
|
51
|
+
# .harness/state/ is the checkpoint area Claude legitimately writes.
|
|
52
|
+
if not rel.startswith(".harness/state/"):
|
|
53
|
+
protected = None
|
|
54
|
+
if rel.startswith(".claude/"):
|
|
55
|
+
protected = "the harness machinery (.claude/)"
|
|
56
|
+
elif rel.startswith(".harness/"):
|
|
57
|
+
protected = "the harness configuration (.harness/)"
|
|
58
|
+
elif rel.startswith(".github/"):
|
|
59
|
+
protected = "CI configuration (.github/)"
|
|
60
|
+
elif base == ".env" or base.startswith(".env."):
|
|
61
|
+
protected = "an environment file"
|
|
62
|
+
elif ".config." in base:
|
|
63
|
+
protected = "a tool configuration file"
|
|
64
|
+
if protected:
|
|
65
|
+
ask(f"'{rel}' is {protected}. Per the project rules, changes here "
|
|
66
|
+
"need explicit user approval — especially anything that would "
|
|
67
|
+
"weaken tests, lint, or guards. State what you want to change "
|
|
68
|
+
"and why.")
|
|
69
|
+
|
|
70
|
+
# ── (2) test-weakening detection ─────────────────────────────────
|
|
71
|
+
segs = rel.lower().split("/")
|
|
72
|
+
is_test = (
|
|
73
|
+
any(s in ("test", "tests", "__tests__", "spec", "specs") for s in segs[:-1])
|
|
74
|
+
or re.search(r"(^|[._-])(test|spec)s?[._-]", base.lower())
|
|
75
|
+
)
|
|
76
|
+
if not is_test:
|
|
77
|
+
sys.exit(0)
|
|
78
|
+
|
|
79
|
+
tool = data.get("tool_name", "")
|
|
80
|
+
if tool == "Write":
|
|
81
|
+
try:
|
|
82
|
+
with open(rp) as f:
|
|
83
|
+
old = f.read()
|
|
84
|
+
except Exception:
|
|
85
|
+
sys.exit(0) # brand-new test file: nothing to weaken yet
|
|
86
|
+
new = str(ti.get("content") or "")
|
|
87
|
+
elif tool in ("Edit", "MultiEdit"):
|
|
88
|
+
edits = ti.get("edits") or [ti]
|
|
89
|
+
old = "\n".join(str(e.get("old_string") or "") for e in edits)
|
|
90
|
+
new = "\n".join(str(e.get("new_string") or "") for e in edits)
|
|
91
|
+
else:
|
|
92
|
+
sys.exit(0)
|
|
93
|
+
|
|
94
|
+
SKIP_MARKERS = [
|
|
95
|
+
r"@pytest\.mark\.skip", r"@pytest\.mark\.xfail", r"pytest\.skip\(",
|
|
96
|
+
r"@unittest\.skip", r"\.only\(", r"\.skip\(", r"\bxit\(", r"\bxdescribe\(",
|
|
97
|
+
r"\bxtest\(", r"it\.skip", r"describe\.skip", r"test\.skip",
|
|
98
|
+
r"@Disabled", r"t\.Skip\(", r"#\[ignore\]",
|
|
99
|
+
]
|
|
100
|
+
GUT_MARKERS = [r"assert\s+True\b", r"expect\(true\)\.toBe\(true\)", r"assert\s+1\s*==\s*1\b"]
|
|
101
|
+
TEST_FN = re.compile(r"^\s*(def test_|func Test|it\(|test\(|Deno\.test\()", re.M)
|
|
102
|
+
|
|
103
|
+
findings = []
|
|
104
|
+
for pat in SKIP_MARKERS:
|
|
105
|
+
if len(re.findall(pat, new)) > len(re.findall(pat, old)):
|
|
106
|
+
findings.append(f"adds a skip/only marker ({pat.replace(chr(92), '')})")
|
|
107
|
+
break
|
|
108
|
+
for pat in GUT_MARKERS:
|
|
109
|
+
if len(re.findall(pat, new)) > len(re.findall(pat, old)):
|
|
110
|
+
findings.append("adds a trivially-true assertion")
|
|
111
|
+
break
|
|
112
|
+
n_old, n_new = len(TEST_FN.findall(old)), len(TEST_FN.findall(new))
|
|
113
|
+
if n_new < n_old:
|
|
114
|
+
findings.append(f"removes {n_old - n_new} test function(s)")
|
|
115
|
+
|
|
116
|
+
if findings:
|
|
117
|
+
ask(f"this change to test file '{rel}' " + " and ".join(findings) +
|
|
118
|
+
". Weakening tests to make them pass is never an acceptable fix. "
|
|
119
|
+
"If the test itself is genuinely wrong or obsolete, explain that to "
|
|
120
|
+
"the user and get approval first.")
|
|
121
|
+
PYEOF
|
|
122
|
+
exit 0
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
# Observability log — records every tool call as JSONL + tripwires
|
|
4
4
|
# Log: .harness/logs/tool_calls.jsonl
|
|
5
5
|
# Tripwires: (1) warn when per-session tool calls exceed a ceiling
|
|
6
|
-
# (2) same
|
|
6
|
+
# (2) same normalized error 3x in a session -> demand an
|
|
7
|
+
# escalation packet instead of another retry
|
|
7
8
|
# ════════════════════════════════════════════════════════════════
|
|
8
9
|
set -uo pipefail
|
|
9
10
|
|
|
@@ -67,15 +68,25 @@ if n > 0 and n % 100 == 0 and n >= 300:
|
|
|
67
68
|
warnings.append(f"This session has exceeded {n} tool calls. "
|
|
68
69
|
"Re-examine the plan — this may be a runaway loop.")
|
|
69
70
|
|
|
70
|
-
# (2) same
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
# (2) same error 3x this session (normalized, not necessarily consecutive)
|
|
72
|
+
if event == "PostToolUseFailure":
|
|
73
|
+
import re
|
|
74
|
+
def norm(e):
|
|
75
|
+
s = (e.get("error") or "")[:300]
|
|
76
|
+
s = re.sub(r"0x[0-9a-fA-F]+", "N", s) # addresses/ids
|
|
77
|
+
s = re.sub(r"\d+", "N", s) # line numbers, counts
|
|
78
|
+
s = re.sub(r"(/[^\s:'\"]+)+", "PATH", s) # file paths
|
|
79
|
+
return (e.get("tool", ""), s[:120])
|
|
80
|
+
sig = norm(entry)
|
|
81
|
+
count = sum(1 for e in lines
|
|
82
|
+
if e.get("event") == "PostToolUseFailure" and norm(e) == sig)
|
|
83
|
+
if count >= 3 and count % 3 == 0:
|
|
84
|
+
warnings.append(f"[tripwire] The same error has now occurred {count} "
|
|
85
|
+
f"times this session (tool: {entry.get('tool', '')}). "
|
|
86
|
+
"Stop repeating this approach. Write an escalation "
|
|
87
|
+
"packet per the Work Loop section of CLAUDE.md "
|
|
88
|
+
"(decision needed / alternatives tried / cost of "
|
|
89
|
+
"waiting / safest default) and report to the user.")
|
|
79
90
|
|
|
80
91
|
if warnings:
|
|
81
92
|
print(json.dumps({
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ════════════════════════════════════════════════════════════════
|
|
3
|
+
# PreCompact — runs just before context compaction (auto or /compact).
|
|
4
|
+
# Two jobs:
|
|
5
|
+
# (1) snapshot .harness/state/ so whatever checkpoint exists at this
|
|
6
|
+
# moment survives even if the post-compaction session corrupts it
|
|
7
|
+
# (2) record the PreCompact event in the log.
|
|
8
|
+
# Context re-injection after compaction is session-start.sh's job
|
|
9
|
+
# (matcher "compact") — PreCompact hook output supports no
|
|
10
|
+
# hookSpecificOutput/additionalContext channel, only snapshot work.
|
|
11
|
+
# Fail silent: an unconfigured or half-installed harness must never
|
|
12
|
+
# break compaction.
|
|
13
|
+
# ════════════════════════════════════════════════════════════════
|
|
14
|
+
set -uo pipefail
|
|
15
|
+
|
|
16
|
+
INPUT=$(cat)
|
|
17
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
18
|
+
STATE_DIR="$PROJECT_DIR/.harness/state"
|
|
19
|
+
|
|
20
|
+
# (1) snapshot current checkpoints (best effort, keep only the latest)
|
|
21
|
+
if [ -d "$STATE_DIR" ]; then
|
|
22
|
+
mkdir -p "$STATE_DIR/.pre-compact-backup" 2>/dev/null || true
|
|
23
|
+
for f in plan.md progress.json decisions.jsonl; do
|
|
24
|
+
[ -f "$STATE_DIR/$f" ] && cp "$STATE_DIR/$f" "$STATE_DIR/.pre-compact-backup/$f" 2>/dev/null
|
|
25
|
+
done
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# (2) note the event in the log (no JSON output — the PreCompact hook
|
|
29
|
+
# schema accepts no context injection; emitting one fails validation)
|
|
30
|
+
python3 - "$INPUT" "$PROJECT_DIR" <<'PYEOF'
|
|
31
|
+
import json, sys, os, datetime
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(sys.argv[1])
|
|
35
|
+
except Exception:
|
|
36
|
+
data = {}
|
|
37
|
+
proj = sys.argv[2]
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
log_dir = os.path.join(proj, ".harness/logs")
|
|
41
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
42
|
+
with open(os.path.join(log_dir, "tool_calls.jsonl"), "a") as f:
|
|
43
|
+
f.write(json.dumps({
|
|
44
|
+
"ts": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
45
|
+
"session_id": data.get("session_id", ""),
|
|
46
|
+
"event": "PreCompact",
|
|
47
|
+
"tool": "",
|
|
48
|
+
"summary": data.get("trigger", ""),
|
|
49
|
+
}, ensure_ascii=False) + "\n")
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
PYEOF
|
|
53
|
+
exit 0
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ════════════════════════════════════════════════════════════════
|
|
3
|
+
# SessionEnd ledger — one JSONL row per session.
|
|
4
|
+
# Aggregates this session's entries from tool_calls.jsonl into
|
|
5
|
+
# .harness/logs/sessions.jsonl: duration, call/failure/edit counts,
|
|
6
|
+
# stop blocks, and whether edits ended with tests in a clean state.
|
|
7
|
+
# This is the data /guide-audit and harness_report.py read instead of
|
|
8
|
+
# guessing — per-session rollups beat re-scanning the raw log.
|
|
9
|
+
# Also clears this session's marker files (pending_test/stop_blocks)
|
|
10
|
+
# so stale markers never leak into the next session.
|
|
11
|
+
# Fail silent: no logs, no harness, half-installed — always exit 0.
|
|
12
|
+
# ════════════════════════════════════════════════════════════════
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
INPUT=$(cat)
|
|
16
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
17
|
+
|
|
18
|
+
python3 - "$INPUT" "$PROJECT_DIR" <<'PYEOF'
|
|
19
|
+
import json, sys, os, datetime
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(sys.argv[1])
|
|
23
|
+
except Exception:
|
|
24
|
+
data = {}
|
|
25
|
+
proj = sys.argv[2]
|
|
26
|
+
sid = data.get("session_id", "")
|
|
27
|
+
if not sid:
|
|
28
|
+
raise SystemExit(0)
|
|
29
|
+
|
|
30
|
+
log_dir = os.path.join(proj, ".harness/logs")
|
|
31
|
+
log_path = os.path.join(log_dir, "tool_calls.jsonl")
|
|
32
|
+
|
|
33
|
+
calls = failures = edits = 0
|
|
34
|
+
first_ts = last_ts = None
|
|
35
|
+
try:
|
|
36
|
+
with open(log_path) as f:
|
|
37
|
+
for line in f:
|
|
38
|
+
try:
|
|
39
|
+
e = json.loads(line)
|
|
40
|
+
except Exception:
|
|
41
|
+
continue
|
|
42
|
+
if e.get("session_id") != sid:
|
|
43
|
+
continue
|
|
44
|
+
ts = e.get("ts", "")
|
|
45
|
+
first_ts = first_ts or ts
|
|
46
|
+
last_ts = ts or last_ts
|
|
47
|
+
ev = e.get("event", "")
|
|
48
|
+
if ev == "PostToolUse":
|
|
49
|
+
calls += 1
|
|
50
|
+
if e.get("tool") in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
|
|
51
|
+
edits += 1
|
|
52
|
+
elif ev == "PostToolUseFailure":
|
|
53
|
+
calls += 1
|
|
54
|
+
failures += 1
|
|
55
|
+
except Exception:
|
|
56
|
+
raise SystemExit(0)
|
|
57
|
+
|
|
58
|
+
if calls == 0:
|
|
59
|
+
raise SystemExit(0) # nothing happened; no row
|
|
60
|
+
|
|
61
|
+
duration_s = None
|
|
62
|
+
try:
|
|
63
|
+
duration_s = int((datetime.datetime.fromisoformat(last_ts)
|
|
64
|
+
- datetime.datetime.fromisoformat(first_ts)).total_seconds())
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
pending = os.path.join(log_dir, f"pending_test.{sid}")
|
|
69
|
+
blocks_f = os.path.join(log_dir, f"stop_blocks.{sid}")
|
|
70
|
+
stop_blocks = 0
|
|
71
|
+
try:
|
|
72
|
+
with open(blocks_f) as f:
|
|
73
|
+
stop_blocks = int(f.read().strip() or 0)
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
row = {
|
|
78
|
+
"ts": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
79
|
+
"session_id": sid,
|
|
80
|
+
"event": "SessionEnd",
|
|
81
|
+
"reason": data.get("reason", ""),
|
|
82
|
+
"duration_s": duration_s,
|
|
83
|
+
"calls": calls,
|
|
84
|
+
"failures": failures,
|
|
85
|
+
"edits": edits,
|
|
86
|
+
"stop_blocks": stop_blocks,
|
|
87
|
+
# edits happened but the stop-gate never saw them pass -> not clean
|
|
88
|
+
"tests_clean": not os.path.exists(pending),
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
92
|
+
with open(os.path.join(log_dir, "sessions.jsonl"), "a") as f:
|
|
93
|
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
for p in (pending, blocks_f):
|
|
98
|
+
try:
|
|
99
|
+
os.remove(p)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
PYEOF
|
|
103
|
+
exit 0
|
|
@@ -9,17 +9,42 @@
|
|
|
9
9
|
# suggest running /ratchet.
|
|
10
10
|
# (3) If the harness is installed but unconfigured (commands.env has
|
|
11
11
|
# no commands), have Claude offer to run /harness-init.
|
|
12
|
+
# (5) Right after a compaction (source=compact), instruct a checkpoint
|
|
13
|
+
# refresh before resuming — pre-compact.sh snapshotted the state
|
|
14
|
+
# but cannot inject context itself (schema limitation).
|
|
12
15
|
# ════════════════════════════════════════════════════════════════
|
|
13
16
|
set -uo pipefail
|
|
14
17
|
|
|
18
|
+
INPUT=$(cat)
|
|
15
19
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
16
20
|
|
|
17
|
-
python3 - "$PROJECT_DIR" <<'PYEOF'
|
|
21
|
+
python3 - "$INPUT" "$PROJECT_DIR" <<'PYEOF'
|
|
18
22
|
import json, sys, os, datetime
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
try:
|
|
25
|
+
hook_in = json.loads(sys.argv[1])
|
|
26
|
+
except Exception:
|
|
27
|
+
hook_in = {}
|
|
28
|
+
proj = sys.argv[2]
|
|
21
29
|
parts = []
|
|
22
30
|
|
|
31
|
+
# (5) resuming right after compaction -> refresh checkpoint first
|
|
32
|
+
try:
|
|
33
|
+
if hook_in.get("source") == "compact":
|
|
34
|
+
backup = os.path.join(proj, ".harness/state/.pre-compact-backup")
|
|
35
|
+
note = ("[harness] Context was just compacted. Before resuming work, "
|
|
36
|
+
"verify the summary against .harness/state/ (plan.md / "
|
|
37
|
+
"progress.json / decisions.jsonl) and update progress.json "
|
|
38
|
+
"(/checkpoint) so the next step is recorded outside the "
|
|
39
|
+
"conversation.")
|
|
40
|
+
if os.path.isdir(backup):
|
|
41
|
+
note += (" A snapshot of the pre-compaction checkpoint was saved "
|
|
42
|
+
"to .harness/state/.pre-compact-backup/ — consult it if "
|
|
43
|
+
"anything seems lost.")
|
|
44
|
+
parts.append(note)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
23
48
|
# (3) harness installed but not configured -> offer /harness-init
|
|
24
49
|
# (checked first so a fresh install greets the user with setup)
|
|
25
50
|
try:
|
|
@@ -56,6 +81,22 @@ try:
|
|
|
56
81
|
except Exception:
|
|
57
82
|
pass
|
|
58
83
|
|
|
84
|
+
# (4) CLAUDE.md still references the pre-v0.2 checkpoint locations
|
|
85
|
+
# (the installer never edits the user-owned CLAUDE.md — Claude proposes
|
|
86
|
+
# the fix as a diff instead)
|
|
87
|
+
try:
|
|
88
|
+
with open(os.path.join(proj, "CLAUDE.md")) as f:
|
|
89
|
+
cm = f.read()
|
|
90
|
+
if ".harness/state" not in cm and ("progress.json" in cm or "plan.md" in cm):
|
|
91
|
+
parts.append(
|
|
92
|
+
"[harness] This project's CLAUDE.md references checkpoint files "
|
|
93
|
+
"(plan.md / progress.json / decisions.jsonl) at the project root, "
|
|
94
|
+
"but the harness now keeps them in .harness/state/. Propose a "
|
|
95
|
+
"small diff updating those path references in the Work Loop and "
|
|
96
|
+
"Checkpoints sections, and apply it only with the user's approval.")
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
59
100
|
# (2) accumulated failures in the last 7 days -> nudge toward /ratchet
|
|
60
101
|
try:
|
|
61
102
|
cutoff = datetime.datetime.now() - datetime.timedelta(days=7)
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
# stop and feed the output back. Blocking is capped at 3 per
|
|
7
7
|
# session — after that, instruct an escalation packet and let the
|
|
8
8
|
# stop through (prevents infinite loops).
|
|
9
|
+
#
|
|
10
|
+
# Trace rules v1 (.harness/trace.rules, optional): lines of
|
|
11
|
+
# require-before-stop <regex> <message>
|
|
12
|
+
# <regex> must contain no whitespace (use . or \s inside it) — the
|
|
13
|
+
# line is whitespace-split into keyword / regex / message.
|
|
14
|
+
# In a session that edited code, each rule requires at least one
|
|
15
|
+
# logged tool call matching <regex> before the turn may end.
|
|
16
|
+
# An unsatisfied rule blocks the stop ONCE per session (a reminder,
|
|
17
|
+
# not a cage — the marker in trace_notified.<sid> prevents loops).
|
|
18
|
+
# File absent -> feature entirely off (fail silent).
|
|
9
19
|
# ════════════════════════════════════════════════════════════════
|
|
10
20
|
set -uo pipefail
|
|
11
21
|
|
|
@@ -22,6 +32,80 @@ print(json.loads(sys.argv[1]).get("session_id",""))' "$INPUT" 2>/dev/null || ech
|
|
|
22
32
|
MARKER="$LOG_DIR/pending_test.$SESSION_ID"
|
|
23
33
|
COUNTER="$LOG_DIR/stop_blocks.$SESSION_ID"
|
|
24
34
|
|
|
35
|
+
# ── trace rules: require-before-stop ─────────────────────────────
|
|
36
|
+
TRACE_FILE="$PROJECT_DIR/.harness/trace.rules"
|
|
37
|
+
if [ -f "$TRACE_FILE" ] && [ -s "$MARKER" ]; then
|
|
38
|
+
TRACE_OUT=$(python3 - "$SESSION_ID" "$PROJECT_DIR" <<'PYEOF'
|
|
39
|
+
import json, os, re, sys
|
|
40
|
+
|
|
41
|
+
sid, proj = sys.argv[1], sys.argv[2]
|
|
42
|
+
log_dir = os.path.join(proj, ".harness/logs")
|
|
43
|
+
notified_path = os.path.join(log_dir, f"trace_notified.{sid}")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with open(os.path.join(proj, ".harness/trace.rules")) as f:
|
|
47
|
+
raw_rules = f.read().splitlines()
|
|
48
|
+
except Exception:
|
|
49
|
+
raise SystemExit(0)
|
|
50
|
+
|
|
51
|
+
rules = []
|
|
52
|
+
for line in raw_rules:
|
|
53
|
+
line = line.strip()
|
|
54
|
+
if not line or line.startswith("#"):
|
|
55
|
+
continue
|
|
56
|
+
parts = line.split(None, 2)
|
|
57
|
+
if len(parts) == 3 and parts[0] == "require-before-stop":
|
|
58
|
+
rules.append((parts[1], parts[2]))
|
|
59
|
+
if not rules:
|
|
60
|
+
raise SystemExit(0)
|
|
61
|
+
|
|
62
|
+
session_lines = []
|
|
63
|
+
try:
|
|
64
|
+
with open(os.path.join(log_dir, "tool_calls.jsonl")) as f:
|
|
65
|
+
for line in f:
|
|
66
|
+
try:
|
|
67
|
+
if json.loads(line).get("session_id") == sid:
|
|
68
|
+
session_lines.append(line)
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
except Exception:
|
|
72
|
+
raise SystemExit(0)
|
|
73
|
+
|
|
74
|
+
notified = set()
|
|
75
|
+
try:
|
|
76
|
+
with open(notified_path) as f:
|
|
77
|
+
notified = set(f.read().splitlines())
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
for regex, message in rules:
|
|
82
|
+
if regex in notified:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
pat = re.compile(regex)
|
|
86
|
+
except Exception:
|
|
87
|
+
continue # a broken regex must never block work
|
|
88
|
+
if any(pat.search(line) for line in session_lines):
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
with open(notified_path, "a") as f:
|
|
92
|
+
f.write(regex + "\n")
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
print(json.dumps({"decision": "block", "reason":
|
|
96
|
+
f"[harness trace rule] Required step not seen this session "
|
|
97
|
+
f"(no logged tool call matched /{regex}/): {message} "
|
|
98
|
+
f"Do it now, or state explicitly why it does not apply, then stop. "
|
|
99
|
+
f"(This rule blocks only once per session.)"}, ensure_ascii=False))
|
|
100
|
+
break
|
|
101
|
+
PYEOF
|
|
102
|
+
)
|
|
103
|
+
if [ -n "$TRACE_OUT" ]; then
|
|
104
|
+
printf '%s\n' "$TRACE_OUT"
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
107
|
+
fi
|
|
108
|
+
|
|
25
109
|
# No code modified this session -> pass
|
|
26
110
|
[ -s "$MARKER" ] || exit 0
|
|
27
111
|
|
package/.claude/settings.json
CHANGED
|
@@ -11,6 +11,28 @@
|
|
|
11
11
|
"statusMessage": "harness: command safety check"
|
|
12
12
|
}
|
|
13
13
|
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-pre-edit.sh",
|
|
21
|
+
"timeout": 15,
|
|
22
|
+
"statusMessage": "harness: edit safety check"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"PreCompact": [
|
|
28
|
+
{
|
|
29
|
+
"hooks": [
|
|
30
|
+
{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pre-compact.sh",
|
|
33
|
+
"timeout": 15
|
|
34
|
+
}
|
|
35
|
+
]
|
|
14
36
|
}
|
|
15
37
|
],
|
|
16
38
|
"PostToolUse": [
|
|
@@ -71,6 +93,17 @@
|
|
|
71
93
|
}
|
|
72
94
|
]
|
|
73
95
|
}
|
|
96
|
+
],
|
|
97
|
+
"SessionEnd": [
|
|
98
|
+
{
|
|
99
|
+
"hooks": [
|
|
100
|
+
{
|
|
101
|
+
"type": "command",
|
|
102
|
+
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-end.sh",
|
|
103
|
+
"timeout": 15
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
74
107
|
]
|
|
75
108
|
}
|
|
76
109
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: distill
|
|
3
|
+
description: Mine a successfully completed task from the harness logs and distill the reusable procedure into a project skill (success-side ratchet). Use after a nontrivial task succeeded and is likely to recur (/distill [what succeeded]). Proposes only — creating the skill requires user approval.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Distill — Turning Successes into Structure
|
|
7
|
+
|
|
8
|
+
`/ratchet` makes failures permanent structure; `/distill` does the same
|
|
9
|
+
for **successes** (the workflow-memory idea, arXiv:2409.07429). A
|
|
10
|
+
multi-step procedure that worked once and will be needed again should
|
|
11
|
+
become a project skill — not something to rediscover from scratch.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
- The task took multiple nonobvious steps that a future session would
|
|
16
|
+
have to rediscover (env quirks, ordering constraints, project-specific
|
|
17
|
+
commands).
|
|
18
|
+
- The task will plausibly recur (release flow, data migration, fixture
|
|
19
|
+
regeneration, deploy checklist).
|
|
20
|
+
|
|
21
|
+
Do NOT distill one-off work, trivial sequences the code already
|
|
22
|
+
documents, or a procedure that only worked once and remains unverified.
|
|
23
|
+
|
|
24
|
+
## Procedure
|
|
25
|
+
|
|
26
|
+
1. **Locate the trajectory** — from `.harness/logs/tool_calls.jsonl`,
|
|
27
|
+
collect this session's (or the named task's) entries: the commands
|
|
28
|
+
run, files touched, and order. Cross-check `.harness/state/plan.md`
|
|
29
|
+
and `decisions.jsonl` for the decisions that shaped it.
|
|
30
|
+
2. **Separate the reusable from the incidental** — keep steps that will
|
|
31
|
+
repeat (commands, ordering, verification points); drop exploration,
|
|
32
|
+
dead ends, and one-time values. Parameterize what varies (version
|
|
33
|
+
numbers, file names) as `[placeholders]`.
|
|
34
|
+
3. **Attach verification** — every distilled skill must state how to
|
|
35
|
+
check the procedure worked (which command proves success). A recipe
|
|
36
|
+
without a check is how quality drift starts.
|
|
37
|
+
4. **Draft the skill** — propose
|
|
38
|
+
`.claude/skills/<name>/SKILL.md` (YAML frontmatter: name,
|
|
39
|
+
description with clear trigger conditions; body: numbered steps,
|
|
40
|
+
verification, known failure modes from this run).
|
|
41
|
+
5. **Get approval** — show the full draft. Create the file only after
|
|
42
|
+
the user approves. Record the decision in `decisions.jsonl`.
|
|
43
|
+
|
|
44
|
+
## Forbidden
|
|
45
|
+
|
|
46
|
+
- No auto-creating skills without approval.
|
|
47
|
+
- No distilling procedures whose success was never verified.
|
|
48
|
+
- No secrets, tokens, or user-specific paths inside the skill body.
|
|
49
|
+
- One skill per distinct procedure — do not merge unrelated recipes.
|
|
@@ -26,6 +26,20 @@ For each entry in RULES and ANTI-PATTERNS, run five checks:
|
|
|
26
26
|
5. **Freshness** — when was it last seen actually working (blocking or
|
|
27
27
|
catching a violation)? Cite `.harness/logs/tool_calls.jsonl` if evidence
|
|
28
28
|
exists.
|
|
29
|
+
6. **Contract verification** — if `.harness/state/predictions.jsonl`
|
|
30
|
+
exists, check every prediction whose window has elapsed
|
|
31
|
+
(`ts + window_days` in the past) deterministically:
|
|
32
|
+
count `predict_absent` matches among `PostToolUseFailure` entries in
|
|
33
|
+
`.harness/logs/tool_calls.jsonl` with a timestamp after `ts`.
|
|
34
|
+
- **0 matches** → the rule demonstrably worked: verdict *keep*, cite
|
|
35
|
+
the count.
|
|
36
|
+
- **matches at or near `baseline_count`** → the rule failed its
|
|
37
|
+
contract: verdict *delete candidate* or *convert-to-sensor candidate*
|
|
38
|
+
(a rule that didn't stop the failure needs a stronger layer, not
|
|
39
|
+
rewording).
|
|
40
|
+
- Report verified predictions in the output table and propose removing
|
|
41
|
+
their lines from predictions.jsonl (the contract is settled either
|
|
42
|
+
way). This check is grep-and-count, not judgment — show the numbers.
|
|
29
43
|
|
|
30
44
|
## Output Format
|
|
31
45
|
|