@4pm/cli 1.17.0 → 1.19.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.
Files changed (24) hide show
  1. package/dist/index.js +848 -241
  2. package/dist/project-sample/.claude/AUTONOMOUS-CRON.md +16 -23
  3. package/dist/project-sample/.claude/AUTONOMOUS.md +31 -36
  4. package/dist/project-sample/.claude/hooks/autonomous-tick.sh +8 -349
  5. package/dist/project-sample/.claude/settings.json +1 -3
  6. package/dist/project-sample/.claude/templates/AI_TODO.empty.md +2 -2
  7. package/dist/project-sample/.claude/templates/README.md +1 -1
  8. package/dist/project-sample/.claude/templates/USER_QA.empty.md +13 -6
  9. package/dist/project-sample/.claude/templates/USER_QA.sample.md +6 -3
  10. package/dist/project-sample/.claude/templates/USER_TODO.empty.md +13 -6
  11. package/dist/project-sample/.claude/templates/USER_TODO.sample.md +7 -6
  12. package/dist/project-sample/.claude/templates/project.secrets.sample.json +0 -1
  13. package/dist/project-sample/AI_PLACEHOLDER.md +5 -5
  14. package/dist/project-sample/AI_TODO.md +2 -2
  15. package/dist/project-sample/USER_QA.md +13 -6
  16. package/dist/project-sample/USER_TODO.md +13 -6
  17. package/dist/project-sample/project.secrets.json.sample +0 -1
  18. package/package.json +1 -1
  19. package/dist/project-sample/.claude/.autonomous.settings.json +0 -24
  20. package/dist/project-sample/.claude/commands/auto-cycle.md +0 -177
  21. package/dist/project-sample/.claude/hooks/__pycache__/autonomous-history.cpython-312.pyc +0 -0
  22. package/dist/project-sample/.claude/hooks/autonomous-history.py +0 -193
  23. package/dist/project-sample/.claude/skills/check-usage/SKILL.md +0 -40
  24. package/dist/project-sample/.claude/skills/check-usage/check_usage.py +0 -161
@@ -1,38 +1,31 @@
1
- # Set up a 10-minute cron for `autonomous-tick.sh`
1
+ # Set up the cron tick for autonomous mode
2
2
 
3
- How to install the **autonomous cycle** on **WSL (Ubuntu)**: cron calls
4
- [`autonomous-tick.sh`](autonomous-tick.sh) every **10 minutes**; each run does one `claude -p /auto-cycle`
5
- cycle then exits. A file lock (`.claude/.autonomous.lock`) ensures **no two runs overlap** (a later tick
6
- that sees the lock skips).
3
+ How to install the **autonomous cron tick** on **WSL (Ubuntu)**. Under **ADR-0321** the tick is a **dumb**
4
+ one-liner (`exec 4pm auto-run`) — all logic lives in the cli/daemon, and all config lives in
5
+ `~/.4pm/profiles/<name>/autonomous.config.json` (edited from the web **Autonomous → Settings** tab), so
6
+ nothing about the loop is configured in this file or the repo.
7
7
 
8
- > All commands below run in a **WSL shell** (Ubuntu) unless noted as PowerShell/Windows. Call the project
9
- > root `$PROJECT` (e.g. `~/projects/<your-project>`).
8
+ > Run in a **WSL shell** (Ubuntu). Call the project root `$PROJECT` (e.g. `~/projects/<your-project>`).
10
9
 
11
- ## 0. Set a variable for convenience
12
- ```bash
13
- PROJECT="$HOME/projects/<your-project>" # fix to your path
14
- cd "$PROJECT"
15
- ```
16
-
17
- ## 1. Check the required tools
18
- Every line must print a path/version (not "not found"):
10
+ ## 1. Check the tools
19
11
  ```bash
20
12
  command -v cron || echo "missing cron"
21
- command -v claude || echo "missing claude (install natively in WSL)"
13
+ command -v 4pm || echo "missing 4pm cli (install natively in WSL; a '4pm start' daemon must be running)"
22
14
  command -v git || echo "missing git"
23
- command -v python3 || echo "missing python3"
15
+ command -v gh || command -v glab || echo "missing gh/glab (needed for the PR step)"
24
16
  ```
25
17
 
26
- ## 2. Make the tick executable + install the cron line
18
+ ## 2. Install the tick
27
19
  ```bash
28
20
  chmod +x "$PROJECT/.claude/hooks/autonomous-tick.sh"
29
21
  crontab -e
30
22
  # add (fix the path):
31
23
  */10 * * * * /home/<user>/projects/<your-project>/.claude/hooks/autonomous-tick.sh
32
24
  ```
33
- The schedule is also driven by `cron_schedule` in `.claude/.autonomous.settings.json`; once the crontab
34
- line exists, the tick keeps it in sync on later runs.
25
+ After that, set the **schedule** and every other knob from the web **Autonomous → Settings** tab — the
26
+ cli keeps this crontab line's schedule in sync with `cronSchedule`, and **Start/Pause** + **Install/
27
+ Uninstall cron** are driven from the **Overview** tab.
35
28
 
36
- ## 3. Enable / watch
37
- - Set `"paused": false` in `.claude/.autonomous.settings.json` to enable (or use the web Autonomous tab).
38
- - Watch: `tail -f .claude/logs/autonomous-tick-$(date +%F).log`
29
+ ## 3. Watch
30
+ The daemon writes a per-day tick log; view it from the web **Autonomous → Logs** tab (or on the worker
31
+ at `.claude/logs/autonomous-tick-$(date +%F).log`).
@@ -1,52 +1,47 @@
1
1
  # Autonomous mode — how it's assembled
2
2
 
3
- > **Sample project — defines the workflow only, does NOT run on its own.** The files below describe an
4
- > unattended work loop, intended to run on **WSL** (an isolated environment where the AI can be given
5
- > full permissions).
3
+ > **Sample project — describes the workflow only; it does NOT run on its own.** Intended for an isolated
4
+ > environment (WSL / a per-project container) where the AI can be given full permissions.
5
+ >
6
+ > **ADR-0321: the autonomous LOGIC lives in the 4PM cli, not in this repo.** The project repo carries
7
+ > only a **dumb** cron tick and the **data** books — never the algorithm — so the logic can't be read
8
+ > from, or tampered with in, a checkout. The **config** is not in the repo either: it lives in the
9
+ > **profile dir** (`~/.4pm/profiles/<name>/autonomous.config.json`) and is edited from the web
10
+ > **Autonomous → Settings** tab.
6
11
 
7
12
  ## The pieces
8
- | File | Role |
9
- |------|------|
10
- | `.claude/hooks/autonomous-tick.sh` | Cron tick (every ~10 min); a lock so **busy ⇒ skip, idle ⇒ run**; calls `claude -p /auto-cycle`. |
11
- | `.claude/commands/auto-cycle.md` | Defines **one cycle**: token gate → sync `${ai_dev_branch}$` → generate tasks → do one task → test → merge into `${ai_dev_branch}$`. |
12
- | `.claude/skills/check-usage/check_usage.py` | The `check-usage` skill: prints token % and writes `output/last-usage-check.json` (deleted + recreated each run) for the token gate to read. |
13
- | `.claude/.autonomous.approvals.json` | Approval source of truth (ADR-0152): `{ "<TSK-id>": {approved, by, at} }` — the web VERIFY tab writes it; `/auto-cycle` reads it. |
14
- | `USER_TODO.md` | The user writes requests here; the cycle reads then clears it. |
15
- | `AI_TODO.md` / `AI_PROGRESS.md` / `AI_DONE.md` | The task books: queue → in progress → done (ID `TSK-{group:0000}-{task:0000}`). |
16
- | `.claude/templates/<NAME>.{empty,sample}.md` | Canonical templates for the 5 books. The "has work" gate + `/auto-cycle` **compare against `*.empty.md`** to tell empty/has-work and reset correctly (see `README.md` in that folder). |
17
- | `.claude/settings.json` | The "bypass all" permission profile for autonomous mode (see the note below). |
13
+ | Where | Role |
14
+ |-------|------|
15
+ | `.claude/hooks/autonomous-tick.sh` | **Dumb** cron tick — its only job is `exec 4pm auto-run`. No gates, no schedule sync, no lock, no settings. |
16
+ | `4pm auto-run` (cli) | Asks the running **`4pm start`** daemon to run **one** cycle over the control socket (token-authenticated — ADR-0320/0321). |
17
+ | cli daemon (`runAutonomousCycle`) | Owns **all** logic: reads `autonomous.config.json`; the gates (paused / quiet-hours / max-ticks / **has-work** / **quota**); cron schedule sync; run histories + auto-pause; serialize one cycle at a time; model; usage via the live snapshot (ADR-0072). Runs the cycle as a write-capable agent (bypass — ADR-0271: branch + PR). |
18
+ | `~/.4pm/profiles/<name>/autonomous.config.json` | The config knobs (paused, cronSchedule, quietHours, maxTicksPerDay, stopOnConsecutiveFailures, logRetentionDays, model, **maxSessionPct**, **maxWeeklyPct**). Clean JSON — the web Settings Form labels + explains each field. **Outside the repo.** |
19
+ | `USER_TODO.md` / `USER_QA.md` / `AI_TODO.md` / `AI_PROGRESS.md` / `AI_DONE.md` | The data books (content-only tables — ADR-0320). |
20
+ | `.claude/.autonomous.approvals.json` · `.autonomous.authors.json` | Per-row approver / writer (ADR-0320) — project data. `.autonomous.histories.json` = runtime state (gitignored). |
18
21
 
19
22
  ## Lifecycle (1 tick)
20
23
  ```
21
- cron ~10min → autonomous-tick.sh
22
- ├─ locked? → log "skip", exit (wait for the next tick)
23
- └─ idle → claude -p /auto-cycle
24
- 0. usage: session<80% & weekly<90%? (no → stop)
25
- 1. checkout/fetch/pull the `${ai_dev_branch}$` branch
26
- 2. USER_TODO.md → generate tasks into AI_TODO.md (with IDs) → clear USER_TODO.md
27
- 3. pick one APPROVED task (approvals + dependencies) → AI_PROGRESS.md, remove from AI_TODO.md, commit
28
- 4. task/TSK-… branch → implement → commit → run the project's tests
29
- 5. back to `${ai_dev_branch}$` → merge task (resolve conflicts if any) → AI_DONE.md, remove from AI_PROGRESS.md, commit
30
- 6. report on the `conversation` branch (CONVERSATION.md, ≤50 entries) for later agents
31
- 7. recheck usage → stop
24
+ cron → autonomous-tick.sh → `4pm auto-run` → the running daemon:
25
+ ├─ paused / quiet-hours / max-ticks / has-work / quota over caps? → log "skip", done
26
+ └─ run ONE cycle (write-capable agent):
27
+ sync the primary repo's branch → analyse APPROVED USER_TODO → fold APPROVED USER_QA →
28
+ one approved task → implement + test → open a PULL REQUEST into the base branch → update books
29
+ └─ record history (N consecutive failures → auto-pause); serialized (one cycle at a time)
32
30
  ```
33
31
 
34
32
  ## Install on WSL
35
33
  ```bash
36
34
  chmod +x .claude/hooks/autonomous-tick.sh
37
35
  crontab -e
38
- # add the line (fix /path):
36
+ # add (fix /path):
39
37
  */10 * * * * /path/to/project/.claude/hooks/autonomous-tick.sh
40
- # watch:
41
- tail -f .claude/logs/autonomous-tick-$(date +%F).log
42
38
  ```
43
- Requirements: `claude` logged in (has `~/.claude/.credentials.json`), plus `git` and `python3`, and the
44
- project's test tooling (per `CLAUDE.md`).
39
+ Requirements: the **`4pm`** cli on PATH with a **running `4pm start` daemon** serving this project, plus
40
+ `git` and `gh`/`glab` for the PR step. The **schedule** and every other knob are set from the web
41
+ Autonomous → Settings tab; the cli keeps the crontab line in sync with `cronSchedule`.
45
42
 
46
- ## Note on permissions (important)
47
- - Claude Code only auto-loads `.claude/settings.json` and `.claude/settings.local.json`. To make a
48
- full-permission profile take effect, one of:
49
- 1. The tick already passes `--permission-mode bypassPermissions --dangerously-skip-permissions` (in use).
50
- 2. Or copy the profile into `.claude/settings.local.json`.
51
- 3. Or point `CLAUDE_CONFIG_DIR` at the profile dir when running headless.
52
- - Only enable full permissions in an isolated environment (WSL/CI). Never on a machine with sensitive data.
43
+ ## Permissions
44
+ The daemon runs the cycle as a write-capable agent (`--permission-mode bypassPermissions`, ADR-0271),
45
+ bounded by the folder-scope guard (ADR-0181) + the AI-run timeout (ADR-0243). Only enable full
46
+ permissions in an isolated environment (WSL / a per-project container). Never on a machine with
47
+ sensitive data.
@@ -1,356 +1,15 @@
1
1
  #!/usr/bin/env bash
2
- # Cron tick for the autonomous workflow — install on WSL. The schedule & control flags live in
3
- # .claude/.autonomous.settings.json (paused, max_session_pct, max_weekly_pct, cron_schedule, …);
4
- # read EVERY tick, so changes take effect on the next tick (cron_schedule auto-syncs into the crontab).
2
+ # Autonomous cron tick (ADR-0321). Its ONLY job is to invoke the 4PM cli — there is NO autonomous logic
3
+ # here. All decisions (config, paused/quiet-hours/max-ticks/has-work/quota gates, cron schedule sync,
4
+ # run histories, serialization, model, usage checks) live in the cli/daemon, so the logic can't be read
5
+ # from or tampered with in the project repo. `4pm auto-run` asks the running `4pm start` daemon to run
6
+ # one cycle through its live session.
5
7
  #
6
8
  # Install once: crontab -e → */10 * * * * /path/to/project/.claude/hooks/autonomous-tick.sh
7
- # After that, change the cadence/priority by editing cron_schedule in .autonomous.settings.json.
8
- #
9
- # "Busy ⇒ wait for the next tick" via the EXISTENCE of the file `.autonomous.lock` (not flock):
10
- # - If the lock file exists → another tick is running → log "skip" and exit.
11
- # - If it doesn't → create the lock → call Claude headless to run /auto-cycle.
12
- # Removing the lock is /auto-cycle's job (on success or error). The trap below is only a SAFETY NET:
13
- # if the process dies before /auto-cycle removes it, the wrapper cleans it up on exit.
9
+ # (the cli keeps this crontab line's schedule in sync with autonomous.config.json).
14
10
  set -euo pipefail
15
11
 
16
- # Project root = two levels above this file (.claude/hooks → .claude → root)
17
- PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
18
- LOCK="$PROJECT_DIR/.claude/.autonomous.lock"
19
- # State + cron history COMBINED into ONE JSON file (replacing the 3 old cron-applied/fails/ticks files).
20
- HIST="$PROJECT_DIR/.claude/.autonomous.histories.json"
21
- HIST_PY="$PROJECT_DIR/.claude/hooks/autonomous-history.py"
22
- LOG_DIR="$PROJECT_DIR/.claude/logs"
23
- mkdir -p "$LOG_DIR"
24
- LOG="$LOG_DIR/autonomous-tick-$(date +%F).log" # per-DAY log (so log_retention_days is meaningful)
25
- ts() { date '+%F %T'; }
26
-
27
- # Create the lock atomically: `noclobber` makes `> file` FAIL if the file exists, closing the race
28
- # where two ticks both pass a separate "does it exist?" check.
29
- if ! ( set -o noclobber; : > "$LOCK" ) 2>/dev/null; then
30
- echo "$(ts) [skip] busy (.autonomous.lock exists) — waiting for the next tick" >> "$LOG"
31
- exit 0
32
- fi
33
- # Safety net: ensure the lock is cleaned when the wrapper exits, even if Claude dies silently.
34
- trap 'rm -f "$LOCK"' EXIT
35
-
36
- cd "$PROJECT_DIR"
37
-
38
- # IMPORTANT: cron runs with a minimal PATH (/usr/bin:/bin) and does NOT load ~/.bashrc/~/.profile.
39
- # `claude` is installed NATIVELY in WSL. Add common WSL install dirs so cron can find the claude binary:
40
- # - $HOME/.local/bin : native installer (curl … | sh)
41
- # - $HOME/.npm-global/bin : npm global with a custom prefix
42
- # - /usr/local/bin : default npm global when installed with sudo
12
+ # cron has a minimal PATH and does not load ~/.bashrc; add common WSL install dirs so `4pm` is found.
43
13
  export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:$PATH"
44
14
 
45
- # --- Python (needed for: reading settings + the token gate) ------------------------------------
46
- PY_BIN="$(command -v python3 || command -v python || true)"
47
- if [ -z "$PY_BIN" ]; then
48
- echo "$(ts) [error] python/python3 not found — skipping this tick" >> "$LOG"
49
- exit 0
50
- fi
51
-
52
- # --- Claude config dir (CLAUDE_CONFIG_DIR) from project.settings.json (MULTIPLE accounts) --------
53
- # claudeConfigDir is a LIST of absolute paths, tried in fallback order. Pick the FIRST account whose
54
- # token is still valid; if none is valid, pick the first account WITH credentials and mark it for
55
- # refresh (calling 'claude /usage' once CLAUDE_BIN is known). Empty/missing -> keep the default (~/.claude).
56
- SEL="$("$PY_BIN" - "$PROJECT_DIR/project.settings.json" <<'PY'
57
- import json, os, sys, time
58
- def exp_at(d):
59
- try:
60
- o = json.load(open(os.path.join(d, ".credentials.json"), encoding="utf-8")).get("claudeAiOauth", {})
61
- return o.get("expiresAt")
62
- except Exception:
63
- return None
64
- try:
65
- s = json.load(open(sys.argv[1], encoding="utf-8"))
66
- dirs = s.get("claudeConfigDir", []) if isinstance(s, dict) else []
67
- except Exception:
68
- dirs = []
69
- if isinstance(dirs, str):
70
- dirs = [dirs]
71
- dirs = [str(x).strip() for x in dirs if str(x).strip().startswith("/")]
72
- now = time.time() * 1000
73
- chosen, expired = "", "0"
74
- for d in dirs: # prefer an account whose token is still valid
75
- e = exp_at(d)
76
- if e and e > now:
77
- chosen = d
78
- break
79
- else:
80
- for d in dirs: # none valid -> first account with credentials (will refresh)
81
- if exp_at(d) is not None:
82
- chosen, expired = d, "1"
83
- break
84
- print(chosen)
85
- print(expired)
86
- PY
87
- )"
88
- CFG_CLAUDE_DIR="$(printf '%s\n' "$SEL" | sed -n 1p)"
89
- CFG_CLAUDE_EXPIRED="$(printf '%s\n' "$SEL" | sed -n 2p)"
90
- if [ -n "$CFG_CLAUDE_DIR" ]; then
91
- export CLAUDE_CONFIG_DIR="$CFG_CLAUDE_DIR"
92
- echo "$(ts) [cfg] CLAUDE_CONFIG_DIR=$CFG_CLAUDE_DIR (expired=$CFG_CLAUDE_EXPIRED)" >> "$LOG"
93
- fi
94
-
95
- # 'hist' wrapper: every read/write of .autonomous.histories.json goes through the Python helper.
96
- # (get-cron/set-cron, get-fails/set-fails, get-ticks/set-ticks, record — see autonomous-history.py)
97
- hist() { "$PY_BIN" "$HIST_PY" "$HIST" "$@"; }
98
-
99
- # --- Read the autonomous config (.autonomous.settings.json) ------------------------------------
100
- # User-editable; read EVERY tick so changes take effect next tick. Python prints CFG_*=<shlex-quoted>
101
- # lines to eval; a missing/corrupt file -> use defaults (no break).
102
- SETTINGS="$PROJECT_DIR/.claude/.autonomous.settings.json"
103
- eval "$("$PY_BIN" - "$SETTINGS" <<'PY'
104
- import json, sys, shlex
105
- defaults = {"paused": False, "max_session_pct": 80, "max_weekly_pct": 90,
106
- "cron_schedule": "*/10 * * * *", "command": "/auto-cycle", "model": "",
107
- "quiet_hours": "", "max_ticks_per_day": -1, "stop_on_consecutive_failures": 3,
108
- "log_retention_days": 14, "notify_webhook": ""}
109
- try:
110
- d = json.load(open(sys.argv[1], encoding="utf-8"))
111
- if not isinstance(d, dict): d = {}
112
- except Exception:
113
- d = {}
114
- def g(k): return d.get(k, defaults[k])
115
- print("CFG_PAUSED=" + ("1" if bool(g("paused")) else "0"))
116
- print("CFG_MAX_SESSION_PCT=" + shlex.quote(str(g("max_session_pct"))))
117
- print("CFG_MAX_WEEKLY_PCT=" + shlex.quote(str(g("max_weekly_pct"))))
118
- print("CFG_CRON_SCHEDULE=" + shlex.quote(str(g("cron_schedule"))))
119
- print("CFG_COMMAND=" + shlex.quote(str(g("command"))))
120
- print("CFG_MODEL=" + shlex.quote(str(g("model"))))
121
- print("CFG_QUIET_HOURS=" + shlex.quote(str(g("quiet_hours"))))
122
- print("CFG_MAX_TICKS_PER_DAY=" + shlex.quote(str(g("max_ticks_per_day"))))
123
- print("CFG_STOP_ON_CONSEC_FAILURES=" + shlex.quote(str(g("stop_on_consecutive_failures"))))
124
- print("CFG_LOG_RETENTION_DAYS=" + shlex.quote(str(g("log_retention_days"))))
125
- print("CFG_NOTIFY_WEBHOOK=" + shlex.quote(str(g("notify_webhook"))))
126
- PY
127
- )"
128
- # Export CFG_* so the history helper (autonomous-history.py 'record') can read the run config from env.
129
- export CFG_CRON_SCHEDULE CFG_COMMAND CFG_MODEL CFG_MAX_SESSION_PCT CFG_MAX_WEEKLY_PCT \
130
- CFG_QUIET_HOURS CFG_MAX_TICKS_PER_DAY CFG_STOP_ON_CONSEC_FAILURES CFG_PAUSED
131
-
132
- # --- Sync the crontab when cron_schedule changes ----------------------------------------------
133
- # Compare with the last-applied schedule (cron_applied in .autonomous.histories.json); if different,
134
- # rewrite the crontab LINE pointing at this script. Safe: only self-edit when the crontab already has
135
- # the autonomous line (or one was applied before) -> avoid creating a crontab during a manual run.
136
- SELF="$PROJECT_DIR/.claude/hooks/autonomous-tick.sh"
137
- PREV_SCHED="$(hist get-cron 2>/dev/null || true)"
138
- if [ "$CFG_CRON_SCHEDULE" != "$PREV_SCHED" ]; then
139
- if command -v crontab >/dev/null 2>&1; then
140
- CUR="$(crontab -l 2>/dev/null || true)"
141
- if printf '%s\n' "$CUR" | grep -qF "$SELF" || [ -n "$PREV_SCHED" ]; then
142
- NEWTAB="$( { printf '%s\n' "$CUR" | grep -vF "$SELF" || true; echo "$CFG_CRON_SCHEDULE $SELF"; } )"
143
- if printf '%s\n' "$NEWTAB" | crontab - 2>>"$LOG"; then
144
- hist set-cron "$CFG_CRON_SCHEDULE" 2>>"$LOG" || true
145
- echo "$(ts) [cron] synced schedule -> '$CFG_CRON_SCHEDULE'" >> "$LOG"
146
- else
147
- echo "$(ts) [warn] could not write crontab (keeping the old schedule)" >> "$LOG"
148
- fi
149
- else
150
- echo "$(ts) [cron] crontab has no autonomous line — skipping auto-install (install it once manually first)" >> "$LOG"
151
- fi
152
- else
153
- echo "$(ts) [cron] no 'crontab' on PATH — skipping schedule sync" >> "$LOG"
154
- fi
155
- fi
156
-
157
- # --- Pause flag -------------------------------------------------------------------------------
158
- if [ "$CFG_PAUSED" = "1" ]; then
159
- echo "$(ts) [skip] paused=true in .autonomous.settings.json — skipping this tick" >> "$LOG"
160
- exit 0
161
- fi
162
-
163
- # --- Prune old logs by log_retention_days (logs are per-day in $LOG_DIR) -----------------------
164
- if [ "${CFG_LOG_RETENTION_DAYS:-0}" -gt 0 ] 2>/dev/null; then
165
- find "$LOG_DIR" -maxdepth 1 -type f -name 'autonomous-tick-*.log' -mtime +"$CFG_LOG_RETENTION_DAYS" -delete 2>/dev/null || true
166
- fi
167
-
168
- # --- Quiet hours ------------------------------------------------------------------------------
169
- # Empty = run all day. 'HH:MM-HH:MM' = skip within the window; supports a window crossing midnight.
170
- if [ -n "$CFG_QUIET_HOURS" ]; then
171
- if printf '%s' "$CFG_QUIET_HOURS" | grep -Eq '^[0-9]{1,2}:[0-9]{2}-[0-9]{1,2}:[0-9]{2}$'; then
172
- q_start="${CFG_QUIET_HOURS%%-*}"; q_end="${CFG_QUIET_HOURS##*-}"
173
- _min() { echo $(( 10#${1%%:*} * 60 + 10#${1##*:} )); } # "HH:MM" -> minutes (10# forces base 10)
174
- qs=$(_min "$q_start"); qe=$(_min "$q_end"); qn=$(_min "$(date +%H:%M)")
175
- in_q=0
176
- if [ "$qs" -le "$qe" ]; then
177
- { [ "$qn" -ge "$qs" ] && [ "$qn" -lt "$qe" ]; } && in_q=1
178
- else
179
- { [ "$qn" -ge "$qs" ] || [ "$qn" -lt "$qe" ]; } && in_q=1 # window crossing midnight
180
- fi
181
- if [ "$in_q" = 1 ]; then
182
- echo "$(ts) [skip] within quiet_hours ($CFG_QUIET_HOURS) — skipping this tick" >> "$LOG"
183
- exit 0
184
- fi
185
- else
186
- echo "$(ts) [warn] quiet_hours has a bad format ('$CFG_QUIET_HOURS') — ignoring the check" >> "$LOG"
187
- fi
188
- fi
189
-
190
- # --- Max ticks/day (max_ticks_per_day) --------------------------------------------------------
191
- # -1 = unlimited. Counted per local day, stored under ticks{day,count} in .autonomous.histories.json.
192
- TODAY="$(date +%F)"
193
- tick_count="$(hist get-ticks "$TODAY" 2>/dev/null || echo 0)"; [ -z "$tick_count" ] && tick_count=0
194
- if [ "${CFG_MAX_TICKS_PER_DAY:--1}" -gt 0 ] 2>/dev/null && [ "${tick_count:-0}" -ge "$CFG_MAX_TICKS_PER_DAY" ]; then
195
- echo "$(ts) [skip] reached max_ticks_per_day=$CFG_MAX_TICKS_PER_DAY ($tick_count ticks today) — skipping" >> "$LOG"
196
- exit 0
197
- fi
198
-
199
- # --- "Has work" gate (USER_TODO / APPROVED AI_TODO / AI_PROGRESS) -------------------------------
200
- # Skip IMMEDIATELY (WITHOUT calling Claude — /auto-cycle would still cost tokens just to start + stop)
201
- # UNLESS there is one of three kinds of work:
202
- # 1) USER_TODO.md has a new request → /auto-cycle will generate tasks (Step 2);
203
- # 2) AI_TODO.md has AT LEAST 1 APPROVED task (approved in .autonomous.approvals.json) → Step 3 can take
204
- # it. Tasks not approved DON'T count as work (VERIFY gate: wait for the user to approve them first);
205
- # 3) AI_PROGRESS.md is non-empty → work left over from a previous tick to continue.
206
- #
207
- # "EMPTY" (USER_TODO / AI_PROGRESS) = COMPARE TO THE TEMPLATE: the content (normalized: trim trailing
208
- # whitespace + leading/trailing blank lines) EQUALS the empty template in .claude/templates/. AI_TODO is
209
- # judged separately via approvals (a full AI_TODO of unapproved tasks != the empty template but has NO work).
210
- TPL="$PROJECT_DIR/.claude/templates"
211
- WORK="$(AI_TODO="$PROJECT_DIR/AI_TODO.md" USER_TODO="$PROJECT_DIR/USER_TODO.md" \
212
- AI_PROGRESS="$PROJECT_DIR/AI_PROGRESS.md" \
213
- APPROVALS="$PROJECT_DIR/.claude/.autonomous.approvals.json" \
214
- USER_TODO_TPL="$TPL/USER_TODO.empty.md" AI_PROGRESS_TPL="$TPL/AI_PROGRESS.empty.md" \
215
- "$PY_BIN" - <<'PY'
216
- import os, json
217
- def norm(p):
218
- # Read + normalize; return None if the file can't be read.
219
- try:
220
- lines = [ln.rstrip() for ln in open(p, encoding="utf-8").read().splitlines()]
221
- except Exception:
222
- return None
223
- while lines and not lines[0]: lines.pop(0)
224
- while lines and not lines[-1]: lines.pop()
225
- return "\n".join(lines)
226
- def has_work(live, tpl):
227
- nv = norm(live)
228
- if nv is None: # no live file -> treat as NO work (safe, don't call Claude)
229
- return False
230
- nt = norm(tpl)
231
- if nt is None: # no template -> can't compare -> any non-empty content counts as work
232
- return bool(nv)
233
- return nv != nt # differs from the empty template = has work
234
- def has_approved(aitodo_p, approvals_p):
235
- # At least 1 APPROVED task still in the queue (ADR-0152): the approval source is
236
- # .autonomous.approvals.json ("<TSK-id>": {approved:true,...}) — the ✓ column is no longer read.
237
- try:
238
- ap = json.load(open(approvals_p, encoding="utf-8"))
239
- approved = {k for k, v in ap.items() if isinstance(v, dict) and v.get("approved") is True}
240
- except Exception:
241
- return False
242
- if not approved:
243
- return False
244
- try:
245
- todo = open(aitodo_p, encoding="utf-8").read()
246
- except Exception:
247
- return False
248
- return any(tid in todo for tid in approved) # approved AND still present in AI_TODO
249
- user = has_work(os.environ["USER_TODO"], os.environ["USER_TODO_TPL"])
250
- prog = has_work(os.environ["AI_PROGRESS"], os.environ["AI_PROGRESS_TPL"])
251
- ai = has_approved(os.environ["AI_TODO"], os.environ["APPROVALS"])
252
- print("WORK" if (user or prog or ai) else "EMPTY")
253
- PY
254
- )"
255
- if [ "$WORK" != "WORK" ]; then
256
- echo "$(ts) [skip] no new request (USER_TODO), approved task (AI_TODO), or in-progress work (AI_PROGRESS) — skipping (no Claude call)" >> "$LOG"
257
- exit 0
258
- fi
259
-
260
- # Allow an override via env; if still not found, log a clear error and skip.
261
- CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude || true)}"
262
- if [ -z "$CLAUDE_BIN" ]; then
263
- echo "$(ts) [error] 'claude' not found on PATH — check the claude install in WSL (e.g. ~/.local/bin/claude)" >> "$LOG"
264
- exit 0
265
- fi
266
-
267
- # The chosen account's token expired -> try to refresh with 'claude /usage' (startup refresh).
268
- # (Switching accounts was done in the CLAUDE_CONFIG_DIR selection above.) Best-effort, doesn't block.
269
- if [ -n "${CLAUDE_CONFIG_DIR:-}" ] && [ "${CFG_CLAUDE_EXPIRED:-0}" = "1" ]; then
270
- echo "$(ts) [cfg] token expired -> refreshing with 'claude /usage'" >> "$LOG"
271
- "$CLAUDE_BIN" /usage >/dev/null 2>&1 || true
272
- fi
273
-
274
- # --- Token gate (moved from /auto-cycle Step 0 to here) ---------------------------------------
275
- # Reason: check quota BEFORE calling Claude so we don't spend tokens just to start + self-stop when
276
- # quota is already high. Thresholds from settings: session_pct < max_session_pct AND weekly_pct < max_weekly_pct.
277
- USAGE_JSON="$PROJECT_DIR/.claude/skills/check-usage/output/last-usage-check.json"
278
- if ! "$PY_BIN" "$PROJECT_DIR/.claude/skills/check-usage/check_usage.py" >> "$LOG" 2>&1; then
279
- echo "$(ts) [skip] check_usage.py failed (can't read quota) — skipping to be safe" >> "$LOG"
280
- exit 0
281
- fi
282
- GATE="$(MAXS="$CFG_MAX_SESSION_PCT" MAXW="$CFG_MAX_WEEKLY_PCT" "$PY_BIN" - "$USAGE_JSON" <<'PY'
283
- import json, os, sys
284
- try:
285
- d = json.load(open(sys.argv[1], encoding="utf-8"))
286
- s = float(d.get("session_pct", 100))
287
- w = float(d.get("weekly_pct", 100))
288
- maxs = float(os.environ.get("MAXS", "80"))
289
- maxw = float(os.environ.get("MAXW", "90"))
290
- except Exception as e:
291
- print(f"ERR {e}")
292
- sys.exit(0)
293
- print(f"{'OK' if (s < maxs and w < maxw) else 'HIGH'} session={s:.0f}% weekly={w:.0f}% (max {maxs:.0f}/{maxw:.0f})")
294
- PY
295
- )"
296
- case "$GATE" in
297
- OK*) echo "$(ts) [gate] tokens ok ($GATE) — continue" >> "$LOG" ;;
298
- HIGH*) echo "$(ts) [skip] tokens high ($GATE) — skipping this tick" >> "$LOG"
299
- hist record "$(ts)" skip "" "$GATE" "tokens high — skipped" 2>>"$LOG" || true; exit 0 ;;
300
- *) echo "$(ts) [skip] can't read usage ($GATE) — skipping to be safe" >> "$LOG"
301
- hist record "$(ts)" skip "" "$GATE" "can't read usage" 2>>"$LOG" || true; exit 0 ;;
302
- esac
303
- # -----------------------------------------------------------------------------------------------
304
-
305
- # Record 1 tick that ACTUALLY calls Claude (for max_ticks_per_day) — stored in ticks{day,count}.
306
- tick_count=$(( ${tick_count:-0} + 1 ))
307
- hist set-ticks "$TODAY" "$tick_count" 2>>"$LOG" || true
308
-
309
- echo "$(ts) [run] starting $CFG_COMMAND (tick $tick_count/$TODAY)" >> "$LOG"
310
- # No GUI window (cron has no desktop session). Everything goes to "$LOG"; watch it live with:
311
- # tail -f .claude/logs/autonomous-tick-$(date +%F).log
312
-
313
- # Headless: -p runs one cycle then exits. No permission prompts (unattended on WSL).
314
- # Force the model if settings has 'model' (empty array is safe under set -u via ${arr[@]+...}).
315
- MODEL_ARGS=()
316
- [ -n "$CFG_MODEL" ] && MODEL_ARGS=(--model "$CFG_MODEL")
317
- set +e
318
- "$CLAUDE_BIN" -p "$CFG_COMMAND" \
319
- ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
320
- --permission-mode bypassPermissions \
321
- --dangerously-skip-permissions \
322
- >> "$LOG" 2>&1
323
- RC=$?
324
- set -e
325
-
326
- # --- Record the run + count consecutive failures + auto-stop (stop_on_consecutive_failures) ----
327
- # RC != 0 -> increment; reaching the threshold -> set paused=true (needs a manual resume). RC == 0 -> reset.
328
- # The consecutive-failure count is stored under consecutive_fails in .autonomous.histories.json.
329
- fails="$(hist get-fails 2>/dev/null || echo 0)"; [ -z "$fails" ] && fails=0
330
- if [ "$RC" -ne 0 ]; then
331
- echo "$(ts) [warn] $CFG_COMMAND exited $RC" >> "$LOG"
332
- fails=$(( ${fails:-0} + 1 )); hist set-fails "$fails" 2>>"$LOG" || true
333
- hist record "$(ts)" failure "$RC" "$GATE" "consecutive failure #$fails" 2>>"$LOG" || true
334
- if [ "${CFG_STOP_ON_CONSEC_FAILURES:-0}" -gt 0 ] 2>/dev/null && [ "$fails" -ge "$CFG_STOP_ON_CONSEC_FAILURES" ]; then
335
- "$PY_BIN" - "$SETTINGS" <<'PY' 2>>"$LOG" || true
336
- import json, sys
337
- p = sys.argv[1]
338
- try:
339
- d = json.load(open(p, encoding="utf-8"))
340
- d["paused"] = True
341
- with open(p, "w", encoding="utf-8") as f:
342
- json.dump(d, f, ensure_ascii=False, indent=2); f.write("\n")
343
- except Exception:
344
- pass
345
- PY
346
- echo "$(ts) [stop] $fails consecutive failures >= $CFG_STOP_ON_CONSEC_FAILURES → set paused=true (resume manually)" >> "$LOG"
347
- fi
348
- else
349
- hist set-fails 0 2>>"$LOG" || true # success → reset the consecutive-failure count
350
- hist record "$(ts)" success "$RC" "$GATE" "cycle complete" 2>>"$LOG" || true
351
- fi
352
-
353
- # notify_webhook: TBD — this is where a run summary would be POSTed to a webhook if CFG_NOTIFY_WEBHOOK is set.
354
-
355
- echo "$(ts) [done] tick finished" >> "$LOG"
356
- # The EXIT trap above removes `.autonomous.lock` if /auto-cycle didn't → the lock is released.
15
+ exec 4pm auto-run
@@ -9,9 +9,7 @@
9
9
  "defaultMode": "bypassPermissions",
10
10
  "allow": [],
11
11
  "ask": [],
12
- "deny": [
13
- "Read(./project.secrets.json)"
14
- ]
12
+ "deny": []
15
13
  },
16
14
  "enableAllProjectMcpServers": true
17
15
  }
@@ -6,9 +6,9 @@
6
6
  > **Tag** = optional catalog tag(s) (e.g. `UpdateSpecFromDB`) whose action runs from the server down to
7
7
  > the project when the task is approved (approval is committed on Save — ADR-0311).
8
8
  > **Approval** is NOT a table column — it lives in `.claude/.autonomous.approvals.json` (ADR-0152), set
9
- > from the web AI Todo grid; `/auto-cycle` reads that file, never the table.
9
+ > from the web AI Todo grid; the autonomous cli reads that file, never the table.
10
10
  > **Depends** = the `TSK-…` ids that must be DONE (present in `AI_DONE.md`) first.
11
- > `/auto-cycle` only takes tasks that are approved AND have their dependencies met → moves them to
11
+ > the autonomous cli only takes tasks that are approved AND have their dependencies met → moves them to
12
12
  > `AI_PROGRESS.md`; runs group by group, within a group High → Medium → Low.
13
13
 
14
14
  | ID | Priority | Tag | Depends | Group | Task description | Notes |
@@ -2,7 +2,7 @@
2
2
 
3
3
  Canonical templates for the 5 "book" files used by the autonomous loop. Each book has two templates:
4
4
 
5
- - `<NAME>.empty.md` — the EMPTY state. The tick's "has work" gate + `/auto-cycle` compare a live book
5
+ - `<NAME>.empty.md` — the EMPTY state. The cli's "has-work" gate + the autonomous cli compare a live book
6
6
  against this (equal ⇒ empty). When clearing/resetting a book, overwrite it with **exactly** this file
7
7
  (`cp .claude/templates/<NAME>.empty.md <NAME>`).
8
8
  - `<NAME>.sample.md` — an example WITH DATA, showing the expected format when adding entries.
@@ -1,9 +1,16 @@
1
1
  # USER_QA — Questions & answers
2
2
 
3
- > When a request is unclear, the AI adds a **row** here (date, the original request, what's unclear +
4
- > options) instead of guessing. Fill the **Answer** column, then re-post the clarified request into
5
- > `USER_TODO.md` — per `.claude/templates/USER_QA.sample.md`.
3
+ > When a request is unclear, the AI adds a **row** here (instead of guessing) with an
4
+ > **ID `QA-{groupid:0000}-{qaid:0000}`**: the original request and what's unclear + options, leaving the
5
+ > `Answer` blank. You fill the `Answer`; the autonomous cycle folds it back into a re-analysis **only after
6
+ > the row is approved**. Approval + authorship (who answered / who approved, with dates) live in JSON
7
+ > sidecars, not in this table (ADR-0320): `.claude/.autonomous.approvals.json` +
8
+ > `.claude/.autonomous.authors.json` — the web USER_QA grid shows them and enforces four-eyes (the person
9
+ > who answered can't approve their own answer, except ADMIN). See `.claude/templates/USER_QA.sample.md`.
10
+ >
11
+ > **Columns (content only):** `ID` · `Group` · `Depends` (comma-separated `QA-…`) · `Original request` ·
12
+ > `Question / options` · `Answer`.
6
13
 
7
- | Date | Original request | Question / options | Answer |
8
- |------|------------------|--------------------|--------|
9
- | | | | |
14
+ | ID | Group | Depends | Original request | Question / options | Answer |
15
+ |----|-------|---------|------------------|--------------------|--------|
16
+ | | | | | | |
@@ -1,5 +1,8 @@
1
1
  # USER_QA — Questions & answers
2
2
 
3
- | Date | Original request | Question / options | Answer |
4
- |------|------------------|--------------------|--------|
5
- | 2026-01-01 | Add a login screen | Email/password only, or also OAuth? (a) email+password, (b) also Google, (c) also GitHub | |
3
+ > The AI asks; you answer + approve. The cycle re-analyses **only after the row is approved**. Approver/
4
+ > date and answerer/date live in the JSON sidecars (ADR-0320), not here; the web grid shows them.
5
+
6
+ | ID | Group | Depends | Original request | Question / options | Answer |
7
+ |----|-------|---------|------------------|--------------------|--------|
8
+ | QA-0001-0001 | Auth | | Add a login screen | Email/password only, or also OAuth? (a) email+password, (b) also Google, (c) also GitHub | (a) email+password |
@@ -1,9 +1,16 @@
1
1
  # USER_TODO — User requests
2
2
 
3
- > Write what you want the AI to do (in the project's language — default English), **one request per row**
4
- > in the table below. The autonomous cycle reads this, generates tasks into `AI_TODO.md`, then clears this
5
- > file back to the empty template — per `.claude/templates/USER_TODO.sample.md`.
3
+ > Write what you want the AI to do (in the project's language — default English), **one request per row**.
4
+ > Each row has an **ID `REQ-{groupid:0000}-{reqid:0000}`** (group = one batch of related requests) used to
5
+ > approve it and to reference it from `Depends`. The autonomous cycle analyses a request into tasks in
6
+ > `AI_TODO.md` **only after the row is approved**. Approval + authorship are kept in JSON sidecars, not in
7
+ > this table (ADR-0320): `.claude/.autonomous.approvals.json` (who/when approved) and
8
+ > `.claude/.autonomous.authors.json` (who/when wrote the row) — the web USER_TODO grid shows them and
9
+ > enforces four-eyes (the writer can't approve their own row, except ADMIN). See
10
+ > `.claude/templates/USER_TODO.sample.md`.
11
+ >
12
+ > **Columns (content only):** `ID` · `Group` · `Depends` (comma-separated `REQ-…`) · `Request`.
6
13
 
7
- | # | Request | Notes |
8
- |---|---------|-------|
9
- | | | |
14
+ | ID | Group | Depends | Request |
15
+ |----|-------|---------|---------|
16
+ | | | | |
@@ -1,9 +1,10 @@
1
1
  # USER_TODO — User requests
2
2
 
3
- > Write what you want the AI to do, one request per row. The autonomous cycle reads this, generates tasks,
4
- > then clears it.
3
+ > One request per row with an **ID `REQ-{groupid:0000}-{reqid:0000}`**. The cycle analyses a request into
4
+ > `AI_TODO.md` tasks **only after the row is approved**. Approver/date and writer/date live in the JSON
5
+ > sidecars (ADR-0320), not here; the web grid shows them.
5
6
 
6
- | # | Request | Notes |
7
- |---|---------|-------|
8
- | 1 | Add a login screen with email + password, validating inputs and showing errors | |
9
- | 2 | Wire it to the existing auth API and handle the error responses | After #1 |
7
+ | ID | Group | Depends | Request |
8
+ |----|-------|---------|---------|
9
+ | REQ-0001-0001 | Auth | | Add a login screen with email + password, validating inputs and showing errors |
10
+ | REQ-0001-0002 | Auth | REQ-0001-0001 | Wire it to the existing auth API and handle the error responses |