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.
@@ -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
- # exit 0 + JSON(deny) = blocked / exit 0 (no output) = normal flow
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 failure 3x in a row -> tell Claude to stop and pivot
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 failure 3x in a row
71
- fails = [e for e in lines if e.get("event") == "PostToolUseFailure"][-3:]
72
- if len(fails) == 3:
73
- sigs = {(e.get("tool", ""), (e.get("error") or "")[:80]) for e in fails}
74
- if len(sigs) == 1:
75
- warnings.append("[tripwire] The same tool failed with the same error "
76
- "3 times in a row. Stop repeating this approach; write "
77
- "an escalation packet per the Work Loop section of "
78
- "CLAUDE.md and report to the user.")
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
- proj = sys.argv[1]
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
 
@@ -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