@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.
- package/dist/index.js +848 -241
- package/dist/project-sample/.claude/AUTONOMOUS-CRON.md +16 -23
- package/dist/project-sample/.claude/AUTONOMOUS.md +31 -36
- package/dist/project-sample/.claude/hooks/autonomous-tick.sh +8 -349
- package/dist/project-sample/.claude/settings.json +1 -3
- package/dist/project-sample/.claude/templates/AI_TODO.empty.md +2 -2
- package/dist/project-sample/.claude/templates/README.md +1 -1
- package/dist/project-sample/.claude/templates/USER_QA.empty.md +13 -6
- package/dist/project-sample/.claude/templates/USER_QA.sample.md +6 -3
- package/dist/project-sample/.claude/templates/USER_TODO.empty.md +13 -6
- package/dist/project-sample/.claude/templates/USER_TODO.sample.md +7 -6
- package/dist/project-sample/.claude/templates/project.secrets.sample.json +0 -1
- package/dist/project-sample/AI_PLACEHOLDER.md +5 -5
- package/dist/project-sample/AI_TODO.md +2 -2
- package/dist/project-sample/USER_QA.md +13 -6
- package/dist/project-sample/USER_TODO.md +13 -6
- package/dist/project-sample/project.secrets.json.sample +0 -1
- package/package.json +1 -1
- package/dist/project-sample/.claude/.autonomous.settings.json +0 -24
- package/dist/project-sample/.claude/commands/auto-cycle.md +0 -177
- package/dist/project-sample/.claude/hooks/__pycache__/autonomous-history.cpython-312.pyc +0 -0
- package/dist/project-sample/.claude/hooks/autonomous-history.py +0 -193
- package/dist/project-sample/.claude/skills/check-usage/SKILL.md +0 -40
- package/dist/project-sample/.claude/skills/check-usage/check_usage.py +0 -161
|
@@ -1,38 +1,31 @@
|
|
|
1
|
-
# Set up
|
|
1
|
+
# Set up the cron tick for autonomous mode
|
|
2
2
|
|
|
3
|
-
How to install the **autonomous
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
>
|
|
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
|
-
##
|
|
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
|
|
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
|
|
15
|
+
command -v gh || command -v glab || echo "missing gh/glab (needed for the PR step)"
|
|
24
16
|
```
|
|
25
17
|
|
|
26
|
-
## 2.
|
|
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
|
-
|
|
34
|
-
|
|
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.
|
|
37
|
-
|
|
38
|
-
|
|
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 —
|
|
4
|
-
>
|
|
5
|
-
>
|
|
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
|
-
|
|
|
9
|
-
|
|
10
|
-
| `.claude/hooks/autonomous-tick.sh` |
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
| `USER_TODO.md`
|
|
15
|
-
|
|
|
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
|
|
22
|
-
├─
|
|
23
|
-
└─
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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:
|
|
44
|
-
|
|
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
|
-
##
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
#
|
|
3
|
-
# .
|
|
4
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
@@ -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;
|
|
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
|
-
>
|
|
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
|
|
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 (
|
|
4
|
-
>
|
|
5
|
-
> `
|
|
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
|
-
|
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
>
|
|
5
|
-
>
|
|
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
|
-
|
|
|
8
|
-
|
|
9
|
-
| | | |
|
|
14
|
+
| ID | Group | Depends | Request |
|
|
15
|
+
|----|-------|---------|---------|
|
|
16
|
+
| | | | |
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# USER_TODO — User requests
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
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
|
-
|
|
|
7
|
-
|
|
8
|
-
|
|
|
9
|
-
|
|
|
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 |
|