uni-harness 0.1.2 → 0.2.1

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,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # ════════════════════════════════════════════════════════════════
3
3
  # SessionStart context injection (startup|resume|clear|compact)
4
- # (1) If an in-progress checkpoint (progress.json) exists, surface
4
+ # (1) If an in-progress checkpoint (.harness/state/progress.json,
5
+ # legacy: ./progress.json) exists, surface
5
6
  # it so completed steps aren't repeated. Re-injected after
6
7
  # compaction too.
7
8
  # (2) If tool failures have piled up in the last 7 days of logs,
@@ -36,15 +37,21 @@ try:
36
37
  except Exception:
37
38
  pass
38
39
 
39
- # (1) in-progress checkpoint
40
+ # (1) in-progress checkpoint (.harness/state/; legacy installs kept it at the root)
40
41
  try:
41
- with open(os.path.join(proj, "progress.json")) as f:
42
- ckpt = json.load(f)
43
- if ckpt.get("status") not in ("completed", "abandoned"):
42
+ ckpt, where = None, None
43
+ for rel in (".harness/state/progress.json", "progress.json"):
44
+ p = os.path.join(proj, rel)
45
+ if os.path.exists(p):
46
+ with open(p) as f:
47
+ ckpt = json.load(f)
48
+ where = os.path.dirname(rel) or "."
49
+ break
50
+ if ckpt and ckpt.get("status") not in ("completed", "abandoned"):
44
51
  parts.append(
45
52
  "[harness memory] An in-progress checkpoint exists. Before starting "
46
- "work, read progress.json / plan.md / decisions.jsonl, do not repeat "
47
- "completed steps, and resume from the next step.\n"
53
+ f"work, read progress.json / plan.md / decisions.jsonl in {where}/, "
54
+ "do not repeat completed steps, and resume from the next step.\n"
48
55
  f"Checkpoint summary: {json.dumps(ckpt, ensure_ascii=False)[:1500]}")
49
56
  except Exception:
50
57
  pass
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  name: checkpoint
3
- description: Save current work state to plan.md / decisions.jsonl / progress.json checkpoints. Use after completing a meaningful step, before pausing a long task, or when the user invokes /checkpoint.
3
+ description: Save current work state to .harness/state/ checkpoints (plan.md / decisions.jsonl / progress.json). Use after completing a meaningful step, before pausing a long task, or when the user invokes /checkpoint.
4
4
  ---
5
5
 
6
6
  # Checkpoint — Saving Work State
7
7
 
8
- The filesystem is the memory. Maintain these three files at the project root.
9
- The single test: **if this session died right now, could the next session
10
- pick up where it left off?**
8
+ The filesystem is the memory. Maintain these three files in
9
+ **`.harness/state/`** (created by the installer; gitignored by default
10
+ remove the `.gitignore` entry if you want decisions.jsonl in version
11
+ control). The single test: **if this session died right now, could the
12
+ next session pick up where it left off?**
11
13
 
12
14
  ## Procedure
13
15
 
14
- 1. **plan.md** — update if the plan changed. Format:
16
+ 1. **`.harness/state/plan.md`** — update if the plan changed. Format:
15
17
  ```markdown
16
18
  # Plan: [task title]
17
19
  - [x] completed step
@@ -19,13 +21,13 @@ pick up where it left off?**
19
21
  - [ ] later step
20
22
  ```
21
23
 
22
- 2. **decisions.jsonl** — append only *settled decisions* from this step,
24
+ 2. **`.harness/state/decisions.jsonl`** — append only *settled decisions* from this step,
23
25
  one per line (no exploratory reasoning or transient facts):
24
26
  ```json
25
27
  {"ts": "ISO8601", "decision": "what was decided", "why": "one-line rationale", "alternatives_rejected": ["rejected option"]}
26
28
  ```
27
29
 
28
- 3. **progress.json** — rewrite in full:
30
+ 3. **`.harness/state/progress.json`** — rewrite in full:
29
31
  ```json
30
32
  {
31
33
  "task_id": "task identifier",
@@ -40,6 +42,11 @@ pick up where it left off?**
40
42
 
41
43
  ## Rules
42
44
 
45
+ - Legacy layout: if plan.md / progress.json / decisions.jsonl sit at the
46
+ project root (installs before v0.2), propose moving them into
47
+ `.harness/state/` — ask first, since a root plan.md might be the
48
+ project's own document rather than the harness's.
49
+
43
50
  - When the task is fully done, set `status: "completed"`. The SessionStart
44
51
  hook does not inject completed checkpoints — forget this closing step and
45
52
  the next session will read stale state.
package/CLAUDE.md CHANGED
@@ -31,7 +31,8 @@
31
31
 
32
32
  ## Work Loop
33
33
 
34
- - For any task with 3+ steps, write the plan to `plan.md` before starting.
34
+ - For any task with 3+ steps, write the plan to `.harness/state/plan.md`
35
+ before starting.
35
36
  - Retry ceiling: at most 3 fix attempts per goal. Beyond that, stop and
36
37
  write an escalation packet.
37
38
  - Escalation packet format (report to the user):
@@ -45,10 +46,11 @@
45
46
 
46
47
  ## Checkpoints
47
48
 
48
- - At session start, if `progress.json` (checkpoint) exists, read it first
49
- and do not repeat completed steps. (The SessionStart hook will remind you.)
49
+ - At session start, if `.harness/state/progress.json` (checkpoint) exists,
50
+ read it first and do not repeat completed steps. (The SessionStart hook
51
+ will remind you.)
50
52
  - After completing each meaningful step, run `/checkpoint` to update
51
- plan.md / decisions.jsonl / progress.json.
53
+ plan.md / decisions.jsonl / progress.json in `.harness/state/`.
52
54
  - Keep transient facts and exploratory reasoning out of checkpoints. Record
53
55
  only settled decisions in decisions.jsonl.
54
56
 
package/README.md CHANGED
@@ -56,7 +56,6 @@ anything you've customized is skipped (listed, with `--force` to override).
56
56
  | `.claude/hooks/guard-pre-bash.sh` | Blocks destructive commands and verification bypasses (`--no-verify`) before execution |
57
57
  | `.claude/hooks/session-start.sh` | Re-injects in-progress checkpoints at session start/resume/compaction; nudges `/ratchet` when failures pile up |
58
58
  | `.claude/hooks/observe-log.sh` | Logs every tool call as JSONL + tripwires (same failure 3x, call surge) |
59
- | `.claude/agents/TEMPLATE.md` | Custom agent template (inactive until you uncomment `name`; example: fresh-context code reviewer) |
60
59
  | `harness/harness_report.py` | Health scorecard from the logs |
61
60
  | `harness/tests/` | Self-tests for the hooks and the installer |
62
61
  | `bin/cli.js` | The installer (init / update / doctor / uninstall) |
@@ -66,7 +65,7 @@ Skills:
66
65
  | Skill | Role |
67
66
  |---|---|
68
67
  | `/harness-init` | Scan the repo → fill PROJECT section & commands.env (once, at install) |
69
- | `/checkpoint` | Save state to plan.md / decisions.jsonl / progress.json |
68
+ | `/checkpoint` | Save state to `.harness/state/` (plan.md / decisions.jsonl / progress.json) |
70
69
  | `/ratchet [mistake]` | Reproduce → classify → propose rule/sensor/permission → verify (no args: diagnose the logs) |
71
70
  | `/guide-audit` | Audit CLAUDE.md rules — keep / delete / convert-to-sensor (monthly) |
72
71
 
package/bin/cli.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * npx uni-harness uninstall [dir] --yes remove machinery files
10
10
  *
11
11
  * File boundary (the core contract of this installer):
12
- * machinery (managed) — .claude/hooks/, .claude/skills/, .claude/agents/,
13
- * harness/ → init installs them, update refreshes
12
+ * machinery (managed) — .claude/hooks/, .claude/skills/, harness/
13
+ * → init installs them, update refreshes
14
14
  * them. Hashes are recorded in
15
15
  * .harness/kit-manifest.json so files the user has
16
16
  * modified are NEVER overwritten (unless --force).
@@ -26,7 +26,7 @@ const crypto = require('crypto');
26
26
  const { spawnSync } = require('child_process');
27
27
 
28
28
  const PKG_ROOT = path.resolve(__dirname, '..');
29
- const MACHINERY_DIRS = ['.claude/hooks', '.claude/skills', '.claude/agents', 'harness'];
29
+ const MACHINERY_DIRS = ['.claude/hooks', '.claude/skills', 'harness'];
30
30
  // Files that live in these dirs but belong to the kit repository only.
31
31
  // test_installer.sh tests bin/cli.js, which is never installed into target
32
32
  // projects — shipping it there guarantees a failing test suite.
@@ -103,6 +103,24 @@ function runHookTests(target) {
103
103
 
104
104
  const log = (s) => process.stdout.write(s + '\n');
105
105
 
106
+ // runtime dirs: logs (observability) and state (checkpoints — plan.md,
107
+ // progress.json, decisions.jsonl). Both gitignored by default; users who
108
+ // want decisions.jsonl in version control can drop the state entry.
109
+ // Called by init AND update so upgrades pick up newly added dirs/entries.
110
+ function ensureRuntimeDirs(target) {
111
+ fs.mkdirSync(path.join(target, '.harness/logs'), { recursive: true });
112
+ fs.mkdirSync(path.join(target, '.harness/state'), { recursive: true });
113
+ const gi = path.join(target, '.gitignore');
114
+ const giBody = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
115
+ const giLines = giBody.split('\n').map((l) => l.trim().replace(/\/$/, ''));
116
+ const giAdd = ['.harness/logs', '.harness/state'].filter((d) => !giLines.includes(d));
117
+ if (giAdd.length) {
118
+ fs.writeFileSync(gi, giBody + (giBody && !giBody.endsWith('\n') ? '\n' : '') +
119
+ giAdd.map((d) => d + '/\n').join(''));
120
+ log(' ~ .gitignore (+' + giAdd.map((d) => d + '/').join(', +') + ')');
121
+ }
122
+ }
123
+
106
124
  // ── settings.json merge (init only) ──────────────────────────────
107
125
  // Hooks: append only the entries whose hook script is not yet registered
108
126
  // for that event. Everything the user already has is left untouched.
@@ -162,6 +180,36 @@ function mergeSettings(target) {
162
180
  }
163
181
  }
164
182
 
183
+ // pre-v0.2 checkpoints lived at the project root. Migrate them into
184
+ // .harness/state/, but ONLY files whose content is recognizably the
185
+ // harness's own checkpoint format — a root plan.md may well be the
186
+ // project's own document, and those are never touched.
187
+ function migrateLegacyState(target) {
188
+ const isOurs = {
189
+ 'progress.json': (s) => {
190
+ try { const j = JSON.parse(s); return !!j && typeof j === 'object' && 'status' in j && ('task_id' in j || 'next_step' in j); }
191
+ catch { return false; }
192
+ },
193
+ 'decisions.jsonl': (s) => {
194
+ const lines = s.trim().split('\n');
195
+ return lines.length > 0 && lines.every((l) => {
196
+ try { return 'decision' in JSON.parse(l); } catch { return false; }
197
+ });
198
+ },
199
+ 'plan.md': (s) => /^# Plan:/m.test(s),
200
+ };
201
+ for (const [name, test] of Object.entries(isOurs)) {
202
+ const src = path.join(target, name);
203
+ const dst = path.join(target, '.harness/state', name);
204
+ if (!fs.existsSync(src) || fs.existsSync(dst)) continue;
205
+ let body;
206
+ try { body = fs.readFileSync(src, 'utf8'); } catch { continue; }
207
+ if (!test(body)) { log(' ! ' + name + ' left at the root (not in harness checkpoint format)'); continue; }
208
+ fs.renameSync(src, dst);
209
+ log(' > ' + name + ' -> .harness/state/' + name + ' (migrated)');
210
+ }
211
+ }
212
+
165
213
  // ── init ─────────────────────────────────────────────────────────
166
214
  function init(target, force) {
167
215
  const files = machineryFiles();
@@ -193,13 +241,7 @@ function init(target, force) {
193
241
  }
194
242
  mergeSettings(target);
195
243
 
196
- fs.mkdirSync(path.join(target, '.harness/logs'), { recursive: true });
197
- const gi = path.join(target, '.gitignore');
198
- const giBody = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
199
- if (!giBody.split('\n').some((l) => l.trim().replace(/\/$/, '') === '.harness/logs')) {
200
- fs.writeFileSync(gi, giBody + (giBody && !giBody.endsWith('\n') ? '\n' : '') + '.harness/logs/\n');
201
- log(' ~ .gitignore (+.harness/logs/)');
202
- }
244
+ ensureRuntimeDirs(target);
203
245
 
204
246
  writeManifest(target, files);
205
247
 
@@ -253,14 +295,31 @@ function update(target, force) {
253
295
  const dst = path.join(target, rel);
254
296
  if (fs.existsSync(dst) && sha256(dst) === manifest.files[rel]) {
255
297
  fs.unlinkSync(dst);
298
+ try { fs.rmdirSync(path.dirname(dst)); } catch { /* keep if not empty */ }
256
299
  log(' - ' + rel + ' (removed from kit)');
257
300
  changed++;
258
301
  }
259
302
  }
260
303
 
261
304
  writeManifest(target, files);
305
+ ensureRuntimeDirs(target);
306
+ migrateLegacyState(target);
262
307
  log(`\n${changed} file(s) updated (v${manifest.kitVersion} -> v${KIT_VERSION}). ` +
263
308
  'CLAUDE.md / commands.env / settings.json were not touched.');
309
+
310
+ // pre-v0.2 installs kept checkpoints at the project root and their
311
+ // (owned, never-touched) CLAUDE.md still says so — tell, don't touch
312
+ try {
313
+ const cm = fs.readFileSync(path.join(target, 'CLAUDE.md'), 'utf8');
314
+ if (cm.includes('progress.json') && !cm.includes('.harness/state')) {
315
+ log('\nNote: checkpoint files (plan.md / progress.json / decisions.jsonl)');
316
+ log('now live in .harness/state/ instead of the project root. Recognized');
317
+ log('checkpoint files were migrated automatically (see above). Your');
318
+ log('CLAUDE.md still references the old locations — update its Work Loop');
319
+ log('and Checkpoints sections. Until then the SessionStart hook still');
320
+ log('reads a root progress.json as a fallback.');
321
+ }
322
+ } catch { /* no CLAUDE.md — nothing to migrate */ }
264
323
  if (skipped.length) {
265
324
  log('Skipped files with local modifications (use --force to overwrite):');
266
325
  for (const s of skipped) log(' ! ' + s);
@@ -102,12 +102,18 @@ echo "session-start.sh"
102
102
  fresh_project
103
103
  OUT=$(bash "$HOOKS/session-start.sh")
104
104
  check "silent without checkpoint" "EMPTY" "$OUT"
105
- echo '{"task_id":"t1","status":"in_progress","next_step":"do X"}' > "$TMP/progress.json"
105
+ mkdir -p "$TMP/.harness/state"
106
+ echo '{"task_id":"t1","status":"in_progress","next_step":"do X"}' > "$TMP/.harness/state/progress.json"
106
107
  OUT=$(bash "$HOOKS/session-start.sh")
107
- check "injects in-progress checkpoint" "progress.json" "$OUT"
108
- echo '{"task_id":"t1","status":"completed"}' > "$TMP/progress.json"
108
+ check "injects in-progress checkpoint" ".harness/state" "$OUT"
109
+ echo '{"task_id":"t1","status":"completed"}' > "$TMP/.harness/state/progress.json"
109
110
  OUT=$(bash "$HOOKS/session-start.sh")
110
111
  check "skips completed checkpoint" "EMPTY" "$OUT"
112
+ rm "$TMP/.harness/state/progress.json"
113
+ # legacy layout (pre-v0.2): checkpoint at the project root still read
114
+ echo '{"task_id":"t1","status":"in_progress","next_step":"do X"}' > "$TMP/progress.json"
115
+ OUT=$(bash "$HOOKS/session-start.sh")
116
+ check "reads legacy root checkpoint" "progress.json" "$OUT"
111
117
  rm "$TMP/progress.json"
112
118
  NOW=$(python3 -c 'import datetime; print(datetime.datetime.now().isoformat(timespec="seconds"))')
113
119
  for _ in 1 2 3; do
@@ -38,6 +38,8 @@ check "installs CLAUDE.md" "yes" "$([ -f "$TGT/CLAUDE.md" ] && echo yes)"
38
38
  check "installs settings.json" "yes" "$([ -f "$TGT/.claude/settings.json" ] && echo yes)"
39
39
  check "writes manifest" "yes" "$([ -f "$TGT/.harness/kit-manifest.json" ] && echo yes)"
40
40
  check "gitignores logs" ".harness/logs/" "$(cat "$TGT/.gitignore")"
41
+ check "creates state dir" "yes" "$([ -d "$TGT/.harness/state" ] && echo yes)"
42
+ check "gitignores state" ".harness/state/" "$(cat "$TGT/.gitignore")"
41
43
  check "init runs hook tests" "Hook tests passed" "$OUT"
42
44
  check "prints next step" "/harness-init" "$OUT"
43
45
  # regression: test_installer.sh tests bin/cli.js, which only exists in the
@@ -101,6 +103,29 @@ OUT=$(node "$CLI" update "$TGT" 2>&1)
101
103
  check "update removes stale kit-only file" "removed from kit" "$OUT"
102
104
  check "stale file is gone" "EMPTY" "$([ -f "$TGT/harness/tests/test_installer.sh" ] && echo left)"
103
105
 
106
+ # update on a pre-v0.2 install must also add the new gitignore entry
107
+ sed -i.bak '/.harness\/state/d' "$TGT/.gitignore" && rm -f "$TGT/.gitignore.bak"
108
+ OUT=$(node "$CLI" update "$TGT" 2>&1)
109
+ check "update re-adds state gitignore entry" ".harness/state/" "$(cat "$TGT/.gitignore")"
110
+
111
+ # pre-v0.2 CLAUDE.md pointing checkpoints at the project root -> migration
112
+ # note (told, never touched); the current template must NOT trigger it
113
+ printf '# old project\n- read progress.json first\n' > "$TGT/CLAUDE.md"
114
+ # legacy root checkpoints: harness-format files are migrated, foreign ones kept
115
+ echo '{"task_id":"t1","status":"in_progress","next_step":"do X"}' > "$TGT/progress.json"
116
+ printf '{"ts":"2026-01-01","decision":"use X","why":"y"}\n' > "$TGT/decisions.jsonl"
117
+ printf '# Roadmap\nnot a harness plan\n' > "$TGT/plan.md"
118
+ OUT=$(node "$CLI" update "$TGT" 2>&1)
119
+ check "prints checkpoint migration note" "now live in .harness/state/" "$OUT"
120
+ check "migrates harness-format progress.json" "yes" "$([ -f "$TGT/.harness/state/progress.json" ] && [ ! -f "$TGT/progress.json" ] && echo yes)"
121
+ check "migrates harness-format decisions.jsonl" "yes" "$([ -f "$TGT/.harness/state/decisions.jsonl" ] && echo yes)"
122
+ check "keeps foreign plan.md at the root" "yes" "$([ -f "$TGT/plan.md" ] && [ ! -f "$TGT/.harness/state/plan.md" ] && echo yes)"
123
+ rm -f "$TGT/plan.md" "$TGT/.harness/state/progress.json" "$TGT/.harness/state/decisions.jsonl"
124
+ check "migration note does not edit CLAUDE.md" "# old project" "$(cat "$TGT/CLAUDE.md")"
125
+ cp "$ROOT/CLAUDE.md" "$TGT/CLAUDE.md" # restore for the doctor section
126
+ OUT=$(node "$CLI" update "$TGT" 2>&1)
127
+ check "no migration note for current template" "EMPTY" "$(printf '%s' "$OUT" | grep -F 'now live in' || true)"
128
+
104
129
  # ── doctor ───────────────────────────────────────────────────────
105
130
  echo "doctor"
106
131
  OUT=$(node "$CLI" doctor "$TGT" 2>&1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uni-harness",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Agent harness kit for Claude Code — installs verification sensors, destructive-command guards, checkpoint recovery, observability logs, and ratchet skills into your project",
5
5
  "bin": {
6
6
  "uni-harness": "bin/cli.js"
@@ -1,64 +0,0 @@
1
- ---
2
- # ── Custom agent template (this file itself is inactive) ────────
3
- # Claude Code treats a file without a `name` in its frontmatter as
4
- # documentation and ignores it. To create an agent: copy this file →
5
- # uncomment and fill the fields below → replace the body with that
6
- # agent's system prompt.
7
- #
8
- # name: lowercase-hyphen identifier (becomes the call name) [required]
9
- # description: WHEN to delegate to this agent — the main agent
10
- # reads this sentence to decide auto-delegation [required]
11
- # tools: allowed tools (omit to inherit all)
12
- # disallowedTools: explicit denials (take precedence over tools)
13
- # model: sonnet | opus | haiku (omit to inherit)
14
- #
15
- # name: code-reviewer
16
- # description: Reviews code changes in a fresh context, without seeing the implementation process. Use after finishing a feature or fix, before commit/PR. Judges plan compliance and defects, and reports.
17
- # tools: Read, Grep, Glob, Bash
18
- #
19
- # Note: creating the first active agent in agents/ requires one session
20
- # restart. The harness hooks and permissions apply to agent tool calls too.
21
- # ────────────────────────────────────────────────────────────────
22
- ---
23
-
24
- The body below is the system prompt of the example (fresh-context code
25
- reviewer). Replace it entirely when creating a new agent.
26
-
27
- ---
28
-
29
- You are a code reviewer. You see only the result, not the implementer's
30
- reasoning — that is your value. Find what the implementer missed, without
31
- inheriting their assumptions.
32
-
33
- ## Procedure
34
-
35
- 1. Read the full change via `git diff` (or the scope you were given).
36
- 2. If `plan.md` exists, read it and check the change against the plan and
37
- requirements.
38
- 3. Read enough surrounding code to judge each change in context.
39
- 4. If `.harness/commands.env` has a TEST_CMD, run it and check the result.
40
-
41
- ## Judging Criteria (in priority order)
42
-
43
- 1. **Compliance**: implemented differently from the plan/requirements, or
44
- requirements missing
45
- 2. **Defects**: code that produces wrong results or crashes on real input —
46
- report only what you can pair with a concrete failure scenario
47
- 3. **Verification gaps**: changed behavior that no test covers
48
- 4. **Simplification**: duplication replaceable by existing code (only when
49
- confident)
50
-
51
- ## Rules
52
-
53
- - Report only. Never modify code.
54
- - Anchor every finding to `file:line` with evidence. If it's speculation,
55
- say so.
56
- - If there is nothing to report, say so. Never invent findings to fill space.
57
- - No style preferences — that's the linter's job.
58
-
59
- ## Output Format
60
-
61
- - **Verdict**: approvable / needs changes (one-line rationale)
62
- - **Findings**: ordered by severity, each with location, problem, evidence,
63
- failure scenario
64
- - **Scope checked**: files read, verification commands run and their results