jeffy-loop 1.23.0__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.
jeffy_loop/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.23.0"
jeffy_loop/cli.py ADDED
@@ -0,0 +1,334 @@
1
+ """Console entry point for the Jeffy Loop.
2
+
3
+ The engine is a Claude Code skill plus a Stop hook, both shipped inside this
4
+ package under jeffy_loop/skills. This module is the Python-native equivalent of
5
+ install.sh and install.ps1: it checks the prerequisites, copies the two skill
6
+ folders into the user's Claude Code skills directory, and registers the Stop
7
+ hook in settings.json with the same idempotence and timeout rules.
8
+ """
9
+
10
+ import contextlib
11
+ import json
12
+ import os
13
+ import shutil
14
+ import stat
15
+ import sys
16
+ from importlib import metadata, resources
17
+ from pathlib import Path
18
+
19
+ DISTRIBUTION = "jeffy-loop"
20
+ SKILL_NAMES = ("jeffy", "cancel-jeffy")
21
+ HOOK_FRAGMENT = "skills/jeffy/hooks/stop-hook.sh"
22
+ HOOK_TIMEOUT = 1800
23
+
24
+
25
+ def package_version():
26
+ try:
27
+ return metadata.version(DISTRIBUTION)
28
+ except metadata.PackageNotFoundError:
29
+ from jeffy_loop import __version__
30
+
31
+ return __version__
32
+
33
+
34
+ def engine_version(skills_root):
35
+ hook = skills_root / "jeffy" / "hooks" / "stop-hook.sh"
36
+ if not hook.is_file():
37
+ raise SystemExit(f"jeffy: packaged Stop hook not found at {hook}")
38
+ for line in hook.read_text(encoding="utf-8").splitlines():
39
+ if line.startswith("JEFFY_VERSION="):
40
+ return line.split("=", 1)[1].strip().strip('"')
41
+ raise SystemExit(f"jeffy: no JEFFY_VERSION line in {hook}")
42
+
43
+
44
+ def claude_home():
45
+ return Path.home() / ".claude"
46
+
47
+
48
+ def which(name):
49
+ return shutil.which(name) is not None
50
+
51
+
52
+ def check_prerequisites():
53
+ ok = True
54
+ if which("claude"):
55
+ print("[OK] Claude Code CLI found")
56
+ else:
57
+ print("[MISSING] Claude Code CLI. Install it first: https://claude.com/claude-code")
58
+ ok = False
59
+ if which("jq"):
60
+ print("[OK] jq found")
61
+ elif os.name == "nt":
62
+ print(
63
+ "[MISSING] jq. Install it manually: winget install jqlang.jq "
64
+ "(or see https://jqlang.github.io/jq/download/)"
65
+ )
66
+ ok = False
67
+ else:
68
+ print(
69
+ "[MISSING] jq. Install it with your package manager "
70
+ "(brew install jq / sudo apt install jq)"
71
+ )
72
+ ok = False
73
+ return ok
74
+
75
+
76
+ def relative_files(root):
77
+ return sorted(
78
+ p.relative_to(root) for p in root.rglob("*") if p.is_file()
79
+ )
80
+
81
+
82
+ def install_skills(skills_root):
83
+ for name in SKILL_NAMES:
84
+ src = skills_root / name
85
+ if not (src / "SKILL.md").is_file():
86
+ raise SystemExit(f"jeffy: packaged skills/{name}/SKILL.md not found at {src}")
87
+ dest = claude_home() / "skills" / name
88
+ dest.mkdir(parents=True, exist_ok=True)
89
+ shutil.copytree(src, dest, dirs_exist_ok=True)
90
+ # A copy over the top never removes. Files the shipped tree no longer
91
+ # carries are removed, and nothing else.
92
+ for rel in relative_files(dest):
93
+ if not (src / rel).exists():
94
+ (dest / rel).unlink()
95
+ print(f"[OK] removed {dest / rel} (no longer shipped)")
96
+ print(f"[OK] /{name} skill installed to {dest}")
97
+
98
+
99
+ def make_hook_executable():
100
+ if os.name == "nt":
101
+ return
102
+ hook = claude_home() / "skills" / "jeffy" / "hooks" / "stop-hook.sh"
103
+ if hook.is_file():
104
+ mode = hook.stat().st_mode
105
+ hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
106
+
107
+
108
+ def hook_command():
109
+ hook = claude_home() / "skills" / "jeffy" / "hooks" / "stop-hook.sh"
110
+ path = str(hook)
111
+ if os.name == "nt":
112
+ # The command runs under bash (Git Bash on Windows), as install.ps1 writes it.
113
+ path = path.replace("\\", "/")
114
+ return f'bash "{path}"'
115
+
116
+
117
+ def matching_hooks(settings):
118
+ hooks = settings.get("hooks")
119
+ if not isinstance(hooks, dict):
120
+ return []
121
+ stop = hooks.get("Stop")
122
+ if not isinstance(stop, list):
123
+ return []
124
+ found = []
125
+ for entry in stop:
126
+ if not isinstance(entry, dict):
127
+ continue
128
+ inner = entry.get("hooks")
129
+ if not isinstance(inner, list):
130
+ continue
131
+ for hook in inner:
132
+ if isinstance(hook, dict) and HOOK_FRAGMENT in str(hook.get("command", "")):
133
+ found.append(hook)
134
+ return found
135
+
136
+
137
+ def write_settings(path, settings):
138
+ path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
139
+
140
+
141
+ def register_hook():
142
+ settings_path = claude_home() / "settings.json"
143
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
144
+ if settings_path.is_file() and settings_path.stat().st_size > 0:
145
+ try:
146
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
147
+ except (ValueError, UnicodeDecodeError):
148
+ print(
149
+ f"[FAILED] {settings_path} is not valid JSON; fix it, then "
150
+ "re-run this installer to register the hook."
151
+ )
152
+ return False
153
+ if not isinstance(settings, dict):
154
+ print(
155
+ f"[FAILED] {settings_path} is not valid JSON; fix it, then "
156
+ "re-run this installer to register the hook."
157
+ )
158
+ return False
159
+ else:
160
+ settings = {}
161
+
162
+ found = matching_hooks(settings)
163
+ if found:
164
+ # A pre-1.2 registration lacks the timeout field and a 1.2-1.14
165
+ # registration carries 600s, which the verify bound can exceed (P1-58).
166
+ # Either shape moves to 1800s exactly once.
167
+ stale = [h for h in found if "timeout" not in h or h["timeout"] == 600]
168
+ if not stale:
169
+ print(f"[OK] Jeffy Stop hook already registered in {settings_path}")
170
+ return True
171
+ for hook in stale:
172
+ hook["timeout"] = HOOK_TIMEOUT
173
+ write_settings(settings_path, settings)
174
+ print(
175
+ f"[OK] Jeffy Stop hook registration upgraded to an 1800s timeout in {settings_path}"
176
+ )
177
+ return True
178
+
179
+ entry = {
180
+ "hooks": [
181
+ {"type": "command", "command": hook_command(), "timeout": HOOK_TIMEOUT}
182
+ ]
183
+ }
184
+ hooks = settings.setdefault("hooks", {})
185
+ if not isinstance(hooks, dict):
186
+ print(
187
+ f"[FAILED] could not update {settings_path}; add the Stop hook entry "
188
+ "by hand (see README) and re-run to verify."
189
+ )
190
+ return False
191
+ stop = hooks.setdefault("Stop", [])
192
+ if not isinstance(stop, list):
193
+ print(
194
+ f"[FAILED] could not update {settings_path}; add the Stop hook entry "
195
+ "by hand (see README) and re-run to verify."
196
+ )
197
+ return False
198
+ stop.append(entry)
199
+ write_settings(settings_path, settings)
200
+ print(f"[OK] Jeffy Stop hook registered in {settings_path} (1800s timeout)")
201
+ return True
202
+
203
+
204
+ def cmd_install(skills_root):
205
+ print("Jeffy installer")
206
+ print("")
207
+ ok = check_prerequisites()
208
+ install_skills(skills_root)
209
+ make_hook_executable()
210
+ if not register_hook():
211
+ ok = False
212
+ print("")
213
+ if ok:
214
+ print("Done. Start a new Claude Code session in any project and run: /jeffy")
215
+ return 0
216
+ print("Skills installed, but fix the items above, then re-run this installer to verify.")
217
+ return 1
218
+
219
+
220
+ def cmd_uninstall():
221
+ for name in SKILL_NAMES:
222
+ dest = claude_home() / "skills" / name
223
+ if dest.is_dir():
224
+ shutil.rmtree(dest)
225
+ print(f"[OK] removed {dest}")
226
+ else:
227
+ print(f"[OK] {dest} was not installed")
228
+
229
+ settings_path = claude_home() / "settings.json"
230
+ if not (settings_path.is_file() and settings_path.stat().st_size > 0):
231
+ print(f"[OK] no {settings_path} to clean")
232
+ return 0
233
+ try:
234
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
235
+ except (ValueError, UnicodeDecodeError):
236
+ print(
237
+ f"[FAILED] {settings_path} is not valid JSON; fix it, then re-run "
238
+ "to remove the hook."
239
+ )
240
+ return 1
241
+ if not isinstance(settings, dict):
242
+ print(
243
+ f"[FAILED] {settings_path} is not valid JSON; fix it, then re-run "
244
+ "to remove the hook."
245
+ )
246
+ return 1
247
+
248
+ hooks = settings.get("hooks")
249
+ stop = hooks.get("Stop") if isinstance(hooks, dict) else None
250
+ if not isinstance(stop, list):
251
+ print(f"[OK] no Jeffy Stop hook in {settings_path}")
252
+ return 0
253
+
254
+ removed = 0
255
+ kept_entries = []
256
+ for entry in stop:
257
+ if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
258
+ kept_entries.append(entry)
259
+ continue
260
+ kept_hooks = []
261
+ for hook in entry["hooks"]:
262
+ if isinstance(hook, dict) and HOOK_FRAGMENT in str(hook.get("command", "")):
263
+ removed += 1
264
+ else:
265
+ kept_hooks.append(hook)
266
+ if kept_hooks:
267
+ entry["hooks"] = kept_hooks
268
+ kept_entries.append(entry)
269
+ elif not entry["hooks"]:
270
+ kept_entries.append(entry)
271
+ if removed == 0:
272
+ print(f"[OK] no Jeffy Stop hook in {settings_path}")
273
+ return 0
274
+ if kept_entries:
275
+ hooks["Stop"] = kept_entries
276
+ else:
277
+ del hooks["Stop"]
278
+ write_settings(settings_path, settings)
279
+ print(f"[OK] removed the Jeffy Stop hook from {settings_path}")
280
+ return 0
281
+
282
+
283
+ def cmd_version(skills_root):
284
+ pkg = package_version()
285
+ engine = engine_version(skills_root)
286
+ print(f"jeffy-loop {pkg} (engine JEFFY_VERSION {engine} from the packaged stop-hook.sh)")
287
+ if pkg != engine:
288
+ print(
289
+ f"jeffy: version mismatch. The package says {pkg} and the packaged "
290
+ f"stop-hook.sh says {engine}. The package was built without the "
291
+ "release bump; bump JEFFY_VERSION in skills/jeffy/hooks/stop-hook.sh "
292
+ "or the version in pyproject.toml so they agree, then rebuild.",
293
+ file=sys.stderr,
294
+ )
295
+ return 2
296
+ return 0
297
+
298
+
299
+ def cmd_help():
300
+ print("jeffy install install both skills and register the Stop hook in settings.json")
301
+ print("jeffy uninstall remove both skills and the Jeffy Stop hook entry")
302
+ print("jeffy version print the package version and the packaged engine version")
303
+ print("jeffy path print the packaged skills directory")
304
+ return 0
305
+
306
+
307
+ def main():
308
+ args = sys.argv[1:]
309
+ cmd = args[0] if args else "help"
310
+
311
+ if cmd in ("help", "--help", "-h"):
312
+ return cmd_help()
313
+ if cmd == "uninstall":
314
+ return cmd_uninstall()
315
+ if cmd not in ("install", "version", "--version", "path"):
316
+ print(f"jeffy: unknown command: {cmd}", file=sys.stderr)
317
+ cmd_help()
318
+ return 2
319
+
320
+ with contextlib.ExitStack() as stack:
321
+ root = stack.enter_context(resources.as_file(resources.files("jeffy_loop")))
322
+ skills_root = Path(root) / "skills"
323
+ if not skills_root.is_dir():
324
+ raise SystemExit(f"jeffy: packaged skills directory not found at {skills_root}")
325
+ if cmd == "path":
326
+ print(skills_root)
327
+ return 0
328
+ if cmd in ("version", "--version"):
329
+ return cmd_version(skills_root)
330
+ return cmd_install(skills_root)
331
+
332
+
333
+ if __name__ == "__main__":
334
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: cancel-jeffy
3
+ description: Use when the user runs /cancel-jeffy to stop the active Jeffy improvement loop in the current project
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Cancel Jeffy
8
+
9
+ Stop the Jeffy loop in this project by removing the loop state file the Stop hook reads.
10
+
11
+ 1. Project root is the directory Claude Code was started in, not wherever the shell currently sits: the Bash tool's cwd persists across calls and may have drifted into a subdirectory that holds its own orphaned state file from another session. Always check state files with absolute paths anchored at the project root, never bare relative ones.
12
+ 2. Check `<project-root>/.claude/jeffy-loop.local.md`. If it exists, read its frontmatter and report its session_id, iteration, and started_at, then delete the file and confirm the loop is cancelled. Once the file is gone the Stop hook lets the session end normally.
13
+ 3. Also check `<project-root>/.claude/ralph-loop.local.md`: it belongs to the ralph-loop plugin's engine or a pre-2.0 Jeffy. If it exists, report the same frontmatter fields and delete it too, so no legacy loop keeps re-feeding.
14
+ 4. If neither file exists, report that no Jeffy loop is active in this project and stop.
15
+ 5. Remind the user that PLAN.md, BACKLOG.md, and JOURNAL.md are untouched: the loop picks up where it left off the next time they run /jeffy.
@@ -0,0 +1,155 @@
1
+ ---
2
+ name: jeffy
3
+ description: Use when the user runs /jeffy to start an autonomous Jeffy improvement loop on the current project
4
+ disable-model-invocation: true
5
+ argument-hint: "[N] [--highs] [focus...]"
6
+ ---
7
+
8
+ # Jeffy
9
+
10
+ Bootstrap per-project Jeffy state files, write the loop state file, and begin iteration 1. The loop engine is Jeffy's own Stop hook, shipped with this skill at hooks/stop-hook.sh: at every turn end it reads `.claude/jeffy-loop.local.md` at the project root, and while that file names this session and budget remains it re-feeds the iteration prompt; it deletes the file and lets the session end when the budget is spent, the completion promise fires, or one of its own gates ends the run - a stall, an oscillation, a time ceiling, the sweep fail-fast, or an audit or refill inside the closing window. The hook anchors itself to CLAUDE_PROJECT_DIR, which hooks receive fixed at the directory Claude Code was started in, so shell cwd drift mid-iteration cannot kill the loop. The hook is registered machine-wide in `~/.claude/settings.json` by the installer but exits immediately in any session whose project has no state file. This skill only sets up and launches; the hook implements the loop mechanics.
11
+
12
+ Project root means the directory Claude Code was started in - the session's primary working directory shown in the environment, which is stable regardless of shell drift. Resolve it to an absolute path with forward slashes and use it everywhere below; never trust the shell's current cwd, which persists across Bash calls and may have drifted into a subdirectory.
13
+
14
+ ## Arguments
15
+
16
+ Parse $ARGUMENTS: if the first token is an integer, it is the iteration budget N, default 10, or 5 on a `--highs` launch. Next parse the mode flag and the ceiling flags wherever they appear in the remainder. `--highs` takes no value and selects High-hunt mode for this run: the loop audits the whole surface, fixes only Highs, audits again once no High is open, and closes the first time an audit finds none - no sweep obligation, no Medium or Low queue, no evaluator gate, no closing extension and no convergence claim; a hunt is not a convergence and its receipt is the pull request its Highs produce. The ceiling flags each take one value: `--max-time <duration>` sets `max_wall_clock_seconds`, `--max-iter-time <duration>` sets `max_iteration_seconds`, and `--max-context <N>` sets `max_context_growth`; a duration is a bare number of seconds or a number suffixed `s`, `m`, or `h` (`45m`, `2h`, `900s`, `900`), and `0` means off, the default for all three. A flag missing its value, an unparseable duration, or an unrecognized `--` token is a refusal naming the token, never silently folded into the focus - a documented ceiling that becomes free text is a ceiling that is silently off. Whatever text remains after the budget and the flags is the focus directive for this run. If the token after the optional budget is exactly `enhance`, refuse the launch and stop: Enhance mode was removed in v1.11.0 - the defect loop is the product - and the last release that carries it is v1.10.0. On a fresh project, iteration 1 is always consumed by the audit that generates the backlog, so N=2 executes exactly one task; if the user picks N below 5, proceed but note that larger budgets make materially more progress.
17
+
18
+ ## Step 1: Pre-flight
19
+
20
+ If any check fails, stop and report the exact fix needed. Do no other work first.
21
+
22
+ 1. Hook dependency: `which jq`. If missing the Stop hook cannot parse its input and the loop cannot run. Suggest `winget install jqlang.jq` (Windows), `brew install jq` (macOS), or `sudo apt-get install jq` (Debian/Ubuntu) and stop.
23
+ 2. Hook install: locate Jeffy's Stop hook script, substituting the absolute home directory and using forward slashes even on Windows (`~` is not expanded by this tool). Glob for `<home>/.claude/skills/jeffy/hooks/stop-hook.sh`. If it is missing, the install is broken or predates the self-owned engine; tell the user to re-run the installer (install.sh or install.ps1) and stop. If it exists, the hook must also be registered: read `<home>/.claude/settings.json` and confirm some Stop hook command contains `skills/jeffy/hooks/stop-hook.sh`. If the registration is missing, tell the user to re-run the installer, which registers it, and stop. When the project root itself carries `skills/jeffy/hooks/stop-hook.sh` - a checkout of Jeffy, the one project where the engine under development is also the engine driving the loop - compare that file with the installed one byte for byte and say so in one line when they differ: the loop is driven by the installed copy, so whatever the tree has fixed since is not in force, and the suite grades a file the run never executes. Compare content, never JEFFY_VERSION, which is equal across a divergence by construction because a development tree carries the next release's fixes under the current version string. This never blocks and never prompts: an unmatched pair is the ordinary state of a tree mid-change, and the remedy - re-running install.sh - belongs to the operator and must not be taken mid-run, since it would swap the engine between iterations.
24
+ 3. Session identity: `echo "$CLAUDE_CODE_SESSION_ID"` must print a non-empty id. If empty, the state file would be written without session scoping and the loop would capture every Claude session in this project. Stop.
25
+ 4. Existing loop state: if `.claude/jeffy-loop.local.md` exists at the project root, do not assume a loop is active. Read its frontmatter and compare its `session_id` with the current session id:
26
+ - Equal: this session already has a loop running. Stop.
27
+ - Different or missing: either another live session owns it or, far more often, it is an orphan from a closed session. The hook deletes the file only when its own session's run ends - at the budget, the promise, or one of its gates - so a crashed or closed session leaks it forever. Report the file's session_id, started_at, and iteration, then ask the user: if no other session is running Jeffy in this project, confirm deletion and continue; otherwise stop. Never delete the file without explicit confirmation.
28
+ Also check for a legacy `.claude/ralph-loop.local.md` at the project root: it belongs to the ralph-loop plugin's engine (or a pre-2.0 Jeffy). If present, a ralph-loop-driven loop may still be active in another session; report it and ask the user to cancel or delete it before launching, so two engines never interleave in one project.
29
+ 5. Checkpoint baseline: if the project is a git repository (`git rev-parse --is-inside-work-tree` succeeds), run `git status --porcelain` and ignore any path under `.jeffy/metrics/`. That file is the engine's own telemetry, written at the end of every turn including the one that ends a run - so the last write of any completed run necessarily lands after that run's final checkpoint and leaves the path modified with no iteration left to commit it. It is never the user's work, the next checkpoint sweeps it up, and treating it as a dirty tree stops every unattended second round dead: the question goes to a headless session that cannot answer it. If what remains prints anything, tell the user: the loop ends every iteration with a local checkpoint commit made with `git add -A`, so these uncommitted changes would be swept into the first checkpoint. Ask them to choose: commit or stash first (then relaunch), proceed anyway (their changes ride along in the first jeffy checkpoint), or abort. Never proceed silently past a dirty tree. When every modified path is a symlink in the index (`git ls-files -s` reports mode `120000` for each), say so explicitly: the likely cause is a cross-filesystem tree - Windows git cannot stat symlinks over `\\wsl.localhost` and reports them all modified - and the fix is running the loop from a git that lives on the same filesystem as the tree, not committing or stashing. If the project is not a git repository, note once that checkpoints, salvage, the ratchet, the verify-gate revert, and the stall check degrade to journal-only discipline, and continue.
30
+ 6. Verify command lint: if `PLAN.md` exists at the project root and its `## Verify command` section carries a `Command: ` line whose payload is neither `none` nor an unfilled `<...>` placeholder, sanitize that payload exactly as the hook does - trim the surrounding whitespace, then strip one wrapping pair of backticks when both ends carry one and nothing between them does - and run `bash -n` over the result. If it does not parse, report the exact defect (the first `bash -n` error line) and the exact corrected `Command: ` line to write into PLAN.md, then stop. Apply the same refusal when the payload contains a pipe and its final pipeline stage is a pager or truncator - `head`, `tail`, `less`, `more`, or `cat` - because the pipeline's exit status is then the truncator's, not the suite's, and a failing suite reports green; name the offending stage and tell the user to drop it. The hook executes that line verbatim at the converged stop, so a malformed line costs a rejected declaration at the end of a run instead of one message at its start. A missing PLAN.md, a section carrying no `Command: ` line, a payload of `none`, and a payload still wearing the template's `<first audit fills this in>` placeholder are all fine and stop nothing: the hook skips its own check on the first three, and the placeholder is the line the first audit exists to fill, so linting it would hard-stop every relaunch whose bootstrapped PLAN.md never reached that audit.
31
+ 7. Line-ending safety: if the project is a git repository and the platform is not Windows (or the project root is a Linux or WSL filesystem path), run `git config core.autocrlf`. If it prints `true` or `input`, refuse to launch: the loop's verify-gate revert path runs `git checkout`, which would rewrite every text file in the tree to CRLF and break the build while looking like a clean revert. Report the exact fix - run `git config core.autocrlf false` at the project root - and stop.
32
+ 8. Repository scope: if the project is a git repository, compare `git rev-parse --show-toplevel` with the project root. When they differ, the project is a subdirectory of a larger repository, so every checkpoint's `git add -A` stages changes across the whole parent tree. State both paths plainly and ask the user whether to proceed, exactly as the dirty-tree check does; working in a subdirectory is legitimate, so never refuse outright.
33
+ 9. Nested Jeffy project: glob for `*/.claude/jeffy-loop.local.md` and `*/PLAN.md` below the project root. If a nested directory carries Jeffy state files, surface its path and ask whether the user meant to launch there instead: launching above an existing Jeffy project sweeps that project's state files into this project's checkpoints.
34
+ 10. Budget echo: print one line the operator has to see before the state file is written - the resolved N, the project root, the base HEAD - and, when JOURNAL.md exists, two figures derived from its headings: the N of the most recent `## iter i/N` heading, and the count of distinct run ids across JOURNAL-archive.md and JOURNAL.md, so the line reads `Budget: N=<N> at <short HEAD> in <root>; run <k+1> of this project (previous run N=<n>)`. Derive them with `grep -h '^## iter [0-9]' JOURNAL.md | tail -n 1 | sed -n 's|^## iter [0-9]*/\([0-9]*\).*|\1|p'` and `cat JOURNAL-archive.md JOURNAL.md 2>/dev/null | grep '^## iter [0-9]' | cut -d'|' -f2 | tr -d ' ' | sort -u | wc -l`. The second reads the run id as a field rather than through a substitution, because the heading is pipe-delimited and a sed whose own delimiter appears three times inside its pattern is read as a truncated expression: written that way it printed an error and returned 0, so the line reported run 1 of every project forever. Both commands anchor on `[0-9]` so the grammar example in the journal template's preamble is not counted as an entry. Never ask and never stop on it: a headless session cannot answer a question and the campaign driver owns the budget; the line exists so a run launched at the wrong N, or a fifth run on a four-run pre-registration, is seen at launch rather than found afterwards.
35
+
36
+ ## Step 2: Bootstrap state files
37
+
38
+ Create each file at the project root only if it is missing. The default contents live in this skill's references directory and are copied with cp, never read into context or retyped: the templates are large static payloads and the copy is byte-exact.
39
+
40
+ First resolve REF, the absolute path of this skill's references directory. Glob for `<home>/.claude/skills/jeffy/references/iteration-prompt.txt`, substituting the absolute home directory with forward slashes as in pre-flight check 2. REF is the directory of the match. If nothing matches, stop and report a broken install: the references directory is missing, so re-run the installer. Substitute the resolved REF below and wherever later steps say REF.
41
+
42
+ Mode guard: when PLAN.md already exists at the project root, read the first word of its `## Mode` section body. If it reads Enhance, refuse the launch: Enhance mode was removed in v1.11.0, and its ledger ranks work by impact rather than severity, so its state files must not continue under the standard rules. Tell the user to archive those state files first - commit them and delete them, or keep them on a separate branch or checkout - and relaunch standard, or to run v1.10.0, the last release that carries the mode; then stop. The same guard keys on the launch mode both ways: a `--highs` launch over a PLAN.md whose first Mode word is not High-hunt refuses, because a standard ledger carries Mediums a hunt must not work and a standard Converged history a hunt does not extend, and a launch without `--highs` over a PLAN.md whose first Mode word is High-hunt refuses, because a hunt ledger and its Hunted history mean nothing to the standard closing rule; both refusals name the same archive step and stop. Any other mode proceeds and reuses the existing state files exactly as any relaunch does; a PLAN.md with no `## Mode` section is a user-authored plan and is treated as standard.
43
+
44
+ ```bash
45
+ REF="<resolved references dir>"
46
+ PR="<PROJECT_ROOT>"
47
+ PLAN_TPL=plan-default.md
48
+ BACKLOG_TPL=backlog-default.md
49
+ # On a --highs launch the two templates are the hunt ones instead:
50
+ # PLAN_TPL=plan-highs.md
51
+ # BACKLOG_TPL=backlog-highs.md
52
+ [ -f "$PR/PLAN.md" ] || cp "$REF/$PLAN_TPL" "$PR/PLAN.md"
53
+ [ -f "$PR/BACKLOG.md" ] || cp "$REF/$BACKLOG_TPL" "$PR/BACKLOG.md"
54
+ [ -f "$PR/JOURNAL.md" ] || cp "$REF/journal-default.md" "$PR/JOURNAL.md"
55
+ ```
56
+
57
+ On a `--highs` launch set PLAN_TPL to plan-highs.md and BACKLOG_TPL to backlog-highs.md as the commented lines show, before the copies run. The standard templates define Improvement mode (PLAN.md with the Goal, Operating envelope, Method, severity rubric, and Definition of done), the BACKLOG.md ledger sections (Now, Next, Later, Proposed, Settled classes, Declined, Converged), and the append-only JOURNAL.md heading grammar. The hunt templates define High-hunt mode (the same envelope, inventory, Verify command and rubric, a Definition of done that is one fresh audit finding no High) and a ledger of Highs only (Now, Proposed, Declined, Hunted); JOURNAL.md is shared, and its grammar carries the `hunted` status a hunt's closing entry takes. Edit the copies in the project to customize one run; edit the templates in references/ only to change every future run.
58
+
59
+ All work happens directly in the current project folder on the current branch. When the project is a git repository, every iteration ends in a local checkpoint commit made with git add -A and a message prefixed jeffy:. The checkpoint is the loop's revert and recovery unit; nothing is ever pushed and no branches are created - the user reviews with git log and squashes if they want one commit. Because the checkpoint uses git add -A, also do this during bootstrap: if the project is a git repository and `git check-ignore -q .claude/jeffy-loop.local.md` fails, append `.claude/jeffy-loop.local.md` to the project's .gitignore (creating the file if needed) so the transient session-scoped loop state can never be committed.
60
+
61
+ ## Step 3: Launch the loop
62
+
63
+ Verify bound first: when PLAN.md exists at the project root and carries a `Command: ` line under `## Verify command`, look there for a labeled line reading `Verify duration: <N>s` (a measured figure earlier runs record). The launcher computes no bound of its own: it resolves the same chain the hook resolves when the state file carries no key - `verify_timeout_seconds`, else `Verify duration` x3 floored at 240s, else 240s, capped at 1740s because the installer registers the hook with an 1800s timeout. Found: resolve that chain against the measured seconds and add a `verify_timeout_seconds: <bound>` line to the frontmatter written below, so the Stop hook's converged-stop verify re-run inherits a bound sized to this suite across relaunches. Absent, with a `Command: ` line present: ask the user one question - roughly how long does the verify command run? Resolve the chain against their answer: if it lands on the floor, write no line, because the hook applies that same floor by default; if it lands above the floor, write `verify_timeout_seconds: <that resolved bound>`. This threshold is the chain's rather than a separate one, so it moved when the chain became the single statement of the bound: the replaced text wrote no line for any answer under four minutes, where the chain writes one for any answer above eighty seconds, always wider than the floor it replaces and never narrower. An answer of a minute still writes no line, because three times it lands on the floor. No PLAN.md yet, or no `Command: ` line: write no line and move on; the first run measures, records `Verify duration:` in PLAN.md, and every later launch inherits it from there.
64
+
65
+ Write the loop state file yourself, at the project root, with an absolute path. If focus text was given, sanitize it first: remove double quotes, backticks, dollar signs, and newlines, which would break the heredoc or the frontmatter, then substitute it below; with no focus, leave the value empty (the line stays, its value blank). Substitute PROJECT_ROOT, REF (resolved in Step 2), and N. `base_head` records the commit the run starts on, or `none` outside a repository; the Stop hook uses it to tell a genuine convergence ratchet, which re-declares a tree an earlier run certified, from a run that did the work itself and typed RATCHET over it. The heredoc terminator EOF must stay at column 0.
66
+
67
+ ```bash
68
+ PR="<PROJECT_ROOT>"
69
+ REF="<resolved references dir>"
70
+ mkdir -p "$PR/.claude"
71
+ cat > "$PR/.claude/jeffy-loop.local.md" <<EOF
72
+ ---
73
+ session_id: $CLAUDE_CODE_SESSION_ID
74
+ iteration: 1
75
+ max_iterations: <N>
76
+ prompt_path: $REF/<iteration-prompt-highs.txt on a --highs launch, else iteration-prompt.txt>
77
+ focus: <sanitized focus, or empty>
78
+ completion_promise: <JEFFY HUNT COMPLETE on a --highs launch, else JEFFY CONVERGED>
79
+ <mode: highs on a --highs launch, else omit this line entirely>
80
+ started_at: $(date -u +%Y-%m-%dT%H:%M:%SZ)
81
+ run_started_at: $(date +%s)
82
+ iteration_started_at: $(date +%s)
83
+ max_wall_clock_seconds: <seconds from --max-time, else 0>
84
+ max_iteration_seconds: <seconds from --max-iter-time, else 0>
85
+ max_context_growth: <multiple from --max-context, else 0>
86
+ sandboxed: <yes|no|unknown from hooks/lib/detect-sandbox.sh>
87
+ base_head: $(git -C "$PR" rev-parse HEAD 2>/dev/null || echo none)
88
+ <verify_timeout_seconds line when derived above, else omit this line entirely>
89
+ ---
90
+ Jeffy loop state. Session-scoped and transient: the Stop hook deletes it when
91
+ the run ends. Cancel with /cancel-jeffy, or delete this file to end the loop.
92
+ EOF
93
+ grep -n "session_id\|iteration:" "$PR/.claude/jeffy-loop.local.md"
94
+ ```
95
+
96
+ **Blast radius.** Run `bash <REF>/../hooks/lib/detect-sandbox.sh` at launch
97
+ and record its answer as `sandboxed` in the state file. When it answers `no`,
98
+ the launch banner carries one further line: *"Not sandboxed: this run has
99
+ whatever access this shell has - credentials, SSH keys, tokens - and an
100
+ unattended agent usually runs with permissions relaxed. See SECURITY.md."*
101
+ On any other answer, say nothing. **It never blocks and never prompts.**
102
+ The loop does not widen its own mandate, and it has no business narrowing the
103
+ operator's either; what it owes them is one honest sentence about what is
104
+ reachable, said once, before the run rather than after it.
105
+
106
+ **Context pressure.** The engine re-feeds one session, so context accumulates
107
+ within a run, and the corpus prices that: later runs of long targets re-filed
108
+ findings earlier runs had already swept and scored clean. The hook measures it
109
+ from the transcript the harness names on its stdin - the thing itself, rather
110
+ than an iteration ordinal standing in for it - as a multiple of this run's own
111
+ first measurement, which calibrates to the project instead of to a constant
112
+ invented here. `--max-context <N>` sets `max_context_growth`; past N times the
113
+ opening size, the re-feed carries a CONTEXT PRESSURE note recommending the run
114
+ finish its current task and close, so the next one reads the state files with a
115
+ clean window. **It is advice and never a stop**: the closing rule governs, and
116
+ a pre-registered budget is never cut short by it. Off unless set, and the
117
+ measured growth is reported in the run state either way.
118
+
119
+ **Time ceilings.** A turn budget counts turns, and a turn is unbounded in
120
+ time, so the state file carries two optional ceilings the Stop hook enforces
121
+ at every turn end. Both are **0 (off) unless the launch sets them**, and that
122
+ default is deliberate: this engine publishes no figure it has not measured,
123
+ and the right ceiling belongs to the project rather than to the tool. For
124
+ reference when choosing one, rounds of ten iterations in the published corpus
125
+ run roughly 60 to 130 minutes. `--max-time <duration>` sets
126
+ `max_wall_clock_seconds` and ends the run out of time the way exhaustion ends
127
+ it out of turns; `--max-iter-time <duration>` sets `max_iteration_seconds`,
128
+ after which a long iteration draws an ITERATION OVERRUN note and two
129
+ consecutive overruns end the run. Accept `45m`, `2h`, `900s` or a bare
130
+ integer of seconds, and `0` as an explicit opt-out. Neither ceiling can cut a
131
+ turn short - the hook fires after it - and neither preempts the closing
132
+ extension or a converged declaration. Whether a ceiling is set or not, the
133
+ run state line reports elapsed wall time every iteration, so a run can see
134
+ its own clock.
135
+
136
+ Verify the write: the grep output must show the current session id and `iteration: 1`. If the session id line is empty or wrong, delete the file, report the failure, and stop. The state file carries the mode as three keys the Stop hook reads: `mode: highs` (absent on a standard launch, where every hook path is the one it always was), the promise phrase, and the prompt path. The iteration prompt itself is a single line stored at `$REF/iteration-prompt.txt`, or `$REF/iteration-prompt-highs.txt` for a hunt, which is the standard prompt with the convergence machinery deleted so every shared sentence stays byte-identical; the hook reads it from disk at every turn end and JSON-encodes it with jq, so its content never needs to be injected through the shell. Never edit iteration-prompt.txt casually: the loop's journal grammar, checkpoint discipline, run report, and closing rule all live in it, and it must stay a single line.
137
+
138
+ Then announce the launch in one line - Jeffy v<version>, N iterations, the mode and its promise phrase (Improvement with JEFFY CONVERGED, or High-hunt with JEFFY HUNT COMPLETE), the focus if one was given, and the absolute path of the verify wrapper, `<REF>/../hooks/lib/quiet-verify.sh`, because the iteration prompt names it by its repository-relative path and a target project has no such path, and the absolute path of the Stop hook, `<REF>/../hooks/stop-hook.sh`, because the iteration prompt runs it in lint mode before every gate invocation - reading the version from the installed hook with `sed -n 's/^JEFFY_VERSION="\(.*\)"/\1/p' <home>/.claude/skills/jeffy/hooks/stop-hook.sh`, so every run's transcript opens by naming the engine version a bug report needs.
139
+
140
+ ## Step 4: Begin iteration 1
141
+
142
+ Read "<REF>/iteration-prompt.txt" now, or "<REF>/iteration-prompt-highs.txt" on a `--highs` launch (REF as resolved in Step 2), its only in-context load, and immediately start following it yourself. Do not wait for input. Every later turn end triggers the Stop hook, which re-feeds the same prompt until N iterations complete, the promise fires, or the state file is deleted.
143
+
144
+ ## Operational notes
145
+
146
+ - Cancel: run /cancel-jeffy (or delete `.claude/jeffy-loop.local.md`).
147
+ - Permission prompts pause the loop. Unattended runs need test and file tools allowlisted, or acceptEdits mode. Never allowlist push or force operations for a loop.
148
+ - A user message sent mid-loop gets answered and then the Stop hook re-feeds the iteration prompt, so a side question flows straight into the next iteration. The turn it consumed counts against the iteration budget, because the budget counts turns.
149
+ - Prefer several small runs over one large budget. The hook re-feeds the same session, so context accumulates across iterations within a run; the state files persist between runs and convergence is sticky, so two runs of 5 beat one run of 10. The clean context is the whole point, and it only arrives with a new session: relaunching /jeffy in the session that just finished a run keeps every accumulated token and forfeits the benefit entirely. Close the session and start a new one in the same directory; the state files on disk carry the run forward, nothing is lost.
150
+ - Edit PLAN.md or BACKLOG.md between iterations, not while one is running: a mid-iteration edit can collide with the loop's own in-flight edit. The Proposed section of BACKLOG.md is the designed channel for decisions.
151
+ - Checkpoints: every iteration ends in a local commit prefixed jeffy:. Review a run with git log --oneline, revert a bad iteration by reverting its checkpoint, and squash the run into one commit if you want tidy history. Nothing is ever pushed.
152
+ - One Jeffy loop per project at a time. The state file is transient; a crashed or closed session can leave it behind, which pre-flight check 4 handles. Orphans can also hide in subdirectories if a session was ever started there; they are inert for the hook, which anchors at the project root, but confuse relative-path checks, so always inspect state files with absolute paths.
153
+ - Git hygiene: `.claude/jeffy-loop.local.md` is transient, session-scoped state that must never be committed. Bootstrap appends it to the target project's `.gitignore` automatically, because the checkpoint's git add -A would otherwise sweep it in. The three state files (PLAN.md, BACKLOG.md, JOURNAL.md) are meant to persist between runs and are committed by the checkpoints; that is intentional, they are the loop's memory.
154
+ - If a loop ever dies silently mid-run (turn ends, no re-feed, state file frozen at its last iteration), the likely causes are: the hook was installed or registered after this session started (start a fresh session and relaunch); or the jeffy skills folder was moved or removed, so the state file's prompt_path went stale - the hook then ends the loop with a message to stderr, and re-running /jeffy relaunches with the new path.
155
+ - When a run ends, the loop closes with a run report; JOURNAL.md and the checkpoint commits in git log hold the full record.