@4pm/cli 1.14.0 → 1.18.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 +482 -167
- package/dist/project-sample/.claude/.autonomous.settings.json +9 -11
- package/dist/project-sample/.claude/AUTONOMOUS-CRON.md +5 -4
- package/dist/project-sample/.claude/AUTONOMOUS.md +26 -26
- package/dist/project-sample/.claude/commands/auto-cycle.md +16 -170
- package/dist/project-sample/.claude/hooks/autonomous-tick.sh +76 -193
- package/dist/project-sample/.claude/settings.json +1 -3
- package/dist/project-sample/.claude/templates/AI_TODO.empty.md +6 -4
- package/dist/project-sample/.claude/templates/AI_TODO.sample.md +9 -6
- 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 +6 -4
- 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
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Cron tick for the autonomous workflow — install on WSL.
|
|
3
|
-
# .claude/.autonomous.settings.json (paused,
|
|
4
|
-
#
|
|
2
|
+
# Cron tick for the autonomous workflow (ADR-0152 + ADR-0319) — install on WSL. Control flags live in
|
|
3
|
+
# .claude/.autonomous.settings.json (paused, cron_schedule, quiet_hours, max_ticks_per_day, …); read
|
|
4
|
+
# EVERY tick, so changes take effect next tick (cron_schedule auto-syncs into the crontab).
|
|
5
5
|
#
|
|
6
6
|
# 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
7
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
8
|
+
# What changed with ADR-0319: the tick no longer runs `claude -p /auto-cycle` directly. It runs
|
|
9
|
+
# `4pm auto-run`, which asks the ALREADY-RUNNING `4pm start` daemon to run one cycle through its live
|
|
10
|
+
# WS session — so the cycle reuses the cli's profile/quota failover (ADR-0182), token metering
|
|
11
|
+
# (ADR-0072), folder-scope (ADR-0181) and AI-run timeout (ADR-0243). Account selection + the token/quota
|
|
12
|
+
# gate now live in the cli (they need the live usage snapshot), NOT in this shell.
|
|
13
|
+
#
|
|
14
|
+
# This tick keeps only the CHEAP local gates (no token spend): a run lock, pause, quiet-hours,
|
|
15
|
+
# max-ticks/day, and a "has-work" check — so the daemon is only woken when there is approved work.
|
|
16
|
+
#
|
|
17
|
+
# "Busy ⇒ wait for the next tick" via the EXISTENCE of `.autonomous.lock` (not flock):
|
|
18
|
+
# - lock file exists → another tick is running → log "skip" and exit.
|
|
19
|
+
# - it doesn't → create the lock → run one cycle via the daemon.
|
|
20
|
+
# The EXIT trap removes the lock whether the cycle succeeds or dies.
|
|
14
21
|
set -euo pipefail
|
|
15
22
|
|
|
16
23
|
# Project root = two levels above this file (.claude/hooks → .claude → root)
|
|
17
24
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
18
25
|
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
26
|
HIST="$PROJECT_DIR/.claude/.autonomous.histories.json"
|
|
21
27
|
HIST_PY="$PROJECT_DIR/.claude/hooks/autonomous-history.py"
|
|
22
28
|
LOG_DIR="$PROJECT_DIR/.claude/logs"
|
|
@@ -30,80 +36,31 @@ if ! ( set -o noclobber; : > "$LOCK" ) 2>/dev/null; then
|
|
|
30
36
|
echo "$(ts) [skip] busy (.autonomous.lock exists) — waiting for the next tick" >> "$LOG"
|
|
31
37
|
exit 0
|
|
32
38
|
fi
|
|
33
|
-
#
|
|
39
|
+
# The lock is released when this wrapper exits, whether the cycle succeeded, errored, or was skipped.
|
|
34
40
|
trap 'rm -f "$LOCK"' EXIT
|
|
35
41
|
|
|
36
42
|
cd "$PROJECT_DIR"
|
|
37
43
|
|
|
38
|
-
#
|
|
39
|
-
# `
|
|
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
|
|
44
|
+
# cron runs with a minimal PATH and does NOT load ~/.bashrc. Add common WSL install dirs so cron can
|
|
45
|
+
# find the `4pm` binary (native install / npm global) + python3.
|
|
43
46
|
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:$PATH"
|
|
44
47
|
|
|
45
|
-
# --- Python (needed for: reading settings + the
|
|
48
|
+
# --- Python (needed for: reading settings + the has-work gate + history) -----------------------
|
|
46
49
|
PY_BIN="$(command -v python3 || command -v python || true)"
|
|
47
50
|
if [ -z "$PY_BIN" ]; then
|
|
48
51
|
echo "$(ts) [error] python/python3 not found — skipping this tick" >> "$LOG"
|
|
49
52
|
exit 0
|
|
50
53
|
fi
|
|
51
54
|
|
|
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
55
|
# '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
56
|
hist() { "$PY_BIN" "$HIST_PY" "$HIST" "$@"; }
|
|
98
57
|
|
|
99
58
|
# --- Read the autonomous config (.autonomous.settings.json) ------------------------------------
|
|
100
|
-
# User-editable; read EVERY tick so changes take effect next tick.
|
|
101
|
-
# lines to eval; a missing/corrupt file -> use defaults (no break).
|
|
59
|
+
# User-editable; read EVERY tick so changes take effect next tick. A missing/corrupt file -> defaults.
|
|
102
60
|
SETTINGS="$PROJECT_DIR/.claude/.autonomous.settings.json"
|
|
103
61
|
eval "$("$PY_BIN" - "$SETTINGS" <<'PY'
|
|
104
62
|
import json, sys, shlex
|
|
105
|
-
defaults = {"paused": False, "
|
|
106
|
-
"cron_schedule": "*/10 * * * *", "command": "/auto-cycle", "model": "",
|
|
63
|
+
defaults = {"paused": False, "cron_schedule": "*/10 * * * *", "command": "auto-run", "profile": "",
|
|
107
64
|
"quiet_hours": "", "max_ticks_per_day": -1, "stop_on_consecutive_failures": 3,
|
|
108
65
|
"log_retention_days": 14, "notify_webhook": ""}
|
|
109
66
|
try:
|
|
@@ -113,11 +70,8 @@ except Exception:
|
|
|
113
70
|
d = {}
|
|
114
71
|
def g(k): return d.get(k, defaults[k])
|
|
115
72
|
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
73
|
print("CFG_CRON_SCHEDULE=" + shlex.quote(str(g("cron_schedule"))))
|
|
119
|
-
print("
|
|
120
|
-
print("CFG_MODEL=" + shlex.quote(str(g("model"))))
|
|
74
|
+
print("CFG_PROFILE=" + shlex.quote(str(g("profile"))))
|
|
121
75
|
print("CFG_QUIET_HOURS=" + shlex.quote(str(g("quiet_hours"))))
|
|
122
76
|
print("CFG_MAX_TICKS_PER_DAY=" + shlex.quote(str(g("max_ticks_per_day"))))
|
|
123
77
|
print("CFG_STOP_ON_CONSEC_FAILURES=" + shlex.quote(str(g("stop_on_consecutive_failures"))))
|
|
@@ -125,14 +79,10 @@ print("CFG_LOG_RETENTION_DAYS=" + shlex.quote(str(g("log_retention_days"))))
|
|
|
125
79
|
print("CFG_NOTIFY_WEBHOOK=" + shlex.quote(str(g("notify_webhook"))))
|
|
126
80
|
PY
|
|
127
81
|
)"
|
|
128
|
-
# Export CFG_* so the history helper (
|
|
129
|
-
export CFG_CRON_SCHEDULE
|
|
130
|
-
CFG_QUIET_HOURS CFG_MAX_TICKS_PER_DAY CFG_STOP_ON_CONSEC_FAILURES CFG_PAUSED
|
|
82
|
+
# Export CFG_* so the history helper ('record') can read the run config from env.
|
|
83
|
+
export CFG_CRON_SCHEDULE CFG_QUIET_HOURS CFG_MAX_TICKS_PER_DAY CFG_STOP_ON_CONSEC_FAILURES CFG_PAUSED
|
|
131
84
|
|
|
132
85
|
# --- 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
86
|
SELF="$PROJECT_DIR/.claude/hooks/autonomous-tick.sh"
|
|
137
87
|
PREV_SCHED="$(hist get-cron 2>/dev/null || true)"
|
|
138
88
|
if [ "$CFG_CRON_SCHEDULE" != "$PREV_SCHED" ]; then
|
|
@@ -160,17 +110,16 @@ if [ "$CFG_PAUSED" = "1" ]; then
|
|
|
160
110
|
exit 0
|
|
161
111
|
fi
|
|
162
112
|
|
|
163
|
-
# --- Prune old logs by log_retention_days
|
|
113
|
+
# --- Prune old logs by log_retention_days -----------------------------------------------------
|
|
164
114
|
if [ "${CFG_LOG_RETENTION_DAYS:-0}" -gt 0 ] 2>/dev/null; then
|
|
165
115
|
find "$LOG_DIR" -maxdepth 1 -type f -name 'autonomous-tick-*.log' -mtime +"$CFG_LOG_RETENTION_DAYS" -delete 2>/dev/null || true
|
|
166
116
|
fi
|
|
167
117
|
|
|
168
118
|
# --- Quiet hours ------------------------------------------------------------------------------
|
|
169
|
-
# Empty = run all day. 'HH:MM-HH:MM' = skip within the window; supports a window crossing midnight.
|
|
170
119
|
if [ -n "$CFG_QUIET_HOURS" ]; then
|
|
171
120
|
if printf '%s' "$CFG_QUIET_HOURS" | grep -Eq '^[0-9]{1,2}:[0-9]{2}-[0-9]{1,2}:[0-9]{2}$'; then
|
|
172
121
|
q_start="${CFG_QUIET_HOURS%%-*}"; q_end="${CFG_QUIET_HOURS##*-}"
|
|
173
|
-
_min() { echo $(( 10#${1%%:*} * 60 + 10#${1##*:} )); }
|
|
122
|
+
_min() { echo $(( 10#${1%%:*} * 60 + 10#${1##*:} )); }
|
|
174
123
|
qs=$(_min "$q_start"); qe=$(_min "$q_end"); qn=$(_min "$(date +%H:%M)")
|
|
175
124
|
in_q=0
|
|
176
125
|
if [ "$qs" -le "$qe" ]; then
|
|
@@ -187,8 +136,7 @@ if [ -n "$CFG_QUIET_HOURS" ]; then
|
|
|
187
136
|
fi
|
|
188
137
|
fi
|
|
189
138
|
|
|
190
|
-
# --- Max ticks/day
|
|
191
|
-
# -1 = unlimited. Counted per local day, stored under ticks{day,count} in .autonomous.histories.json.
|
|
139
|
+
# --- Max ticks/day ----------------------------------------------------------------------------
|
|
192
140
|
TODAY="$(date +%F)"
|
|
193
141
|
tick_count="$(hist get-ticks "$TODAY" 2>/dev/null || echo 0)"; [ -z "$tick_count" ] && tick_count=0
|
|
194
142
|
if [ "${CFG_MAX_TICKS_PER_DAY:--1}" -gt 0 ] 2>/dev/null && [ "${tick_count:-0}" -ge "$CFG_MAX_TICKS_PER_DAY" ]; then
|
|
@@ -196,141 +144,76 @@ if [ "${CFG_MAX_TICKS_PER_DAY:--1}" -gt 0 ] 2>/dev/null && [ "${tick_count:-0}"
|
|
|
196
144
|
exit 0
|
|
197
145
|
fi
|
|
198
146
|
|
|
199
|
-
# --- "Has work" gate
|
|
200
|
-
# Skip
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
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).
|
|
147
|
+
# --- "Has work" gate --------------------------------------------------------------------------
|
|
148
|
+
# Skip WITHOUT waking the daemon unless there is one of:
|
|
149
|
+
# 1) an APPROVED USER_TODO request (REQ-… approved in .autonomous.approvals.json) — Step 2 analyses it;
|
|
150
|
+
# 2) an APPROVED USER_QA answer (QA-… approved) — Step 3 folds it in;
|
|
151
|
+
# 3) an APPROVED AI_TODO task (TSK-… approved) — Step 4 can take it;
|
|
152
|
+
# 4) AI_PROGRESS.md non-empty (leftover work from a previous tick).
|
|
153
|
+
# Approval is the source of truth in .autonomous.approvals.json (ADR-0152/0319), keyed per row id.
|
|
210
154
|
TPL="$PROJECT_DIR/.claude/templates"
|
|
211
|
-
WORK="$(
|
|
212
|
-
AI_PROGRESS="$PROJECT_DIR/AI_PROGRESS.md" \
|
|
155
|
+
WORK="$(USER_TODO="$PROJECT_DIR/USER_TODO.md" USER_QA="$PROJECT_DIR/USER_QA.md" \
|
|
156
|
+
AI_TODO="$PROJECT_DIR/AI_TODO.md" AI_PROGRESS="$PROJECT_DIR/AI_PROGRESS.md" \
|
|
213
157
|
APPROVALS="$PROJECT_DIR/.claude/.autonomous.approvals.json" \
|
|
214
|
-
|
|
158
|
+
AI_PROGRESS_TPL="$TPL/AI_PROGRESS.empty.md" \
|
|
215
159
|
"$PY_BIN" - <<'PY'
|
|
216
160
|
import os, json
|
|
217
|
-
def
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
while lines and not lines[
|
|
224
|
-
while lines and not lines[-1]: lines.pop()
|
|
161
|
+
def read(p):
|
|
162
|
+
try: return open(p, encoding="utf-8").read()
|
|
163
|
+
except Exception: return ""
|
|
164
|
+
def norm(t):
|
|
165
|
+
lines = [ln.rstrip() for ln in t.splitlines()]
|
|
166
|
+
while lines and not lines[0]: lines.pop(0)
|
|
167
|
+
while lines and not lines[-1]: lines.pop()
|
|
225
168
|
return "\n".join(lines)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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")
|
|
169
|
+
try:
|
|
170
|
+
ap = json.load(open(os.environ["APPROVALS"], encoding="utf-8"))
|
|
171
|
+
approved = {k for k, v in ap.items() if isinstance(v, dict) and v.get("approved") is True}
|
|
172
|
+
except Exception:
|
|
173
|
+
approved = set()
|
|
174
|
+
def approved_in(book_env, prefix):
|
|
175
|
+
text = read(os.environ[book_env])
|
|
176
|
+
return any(i.startswith(prefix) and i in text for i in approved)
|
|
177
|
+
user = approved_in("USER_TODO", "REQ-")
|
|
178
|
+
qa = approved_in("USER_QA", "QA-")
|
|
179
|
+
ai = approved_in("AI_TODO", "TSK-")
|
|
180
|
+
prog = norm(read(os.environ["AI_PROGRESS"])) != norm(read(os.environ["AI_PROGRESS_TPL"]))
|
|
181
|
+
print("WORK" if (user or qa or ai or prog) else "EMPTY")
|
|
253
182
|
PY
|
|
254
183
|
)"
|
|
255
184
|
if [ "$WORK" != "WORK" ]; then
|
|
256
|
-
echo "$(ts) [skip] no
|
|
185
|
+
echo "$(ts) [skip] no approved USER_TODO/USER_QA/AI_TODO work and AI_PROGRESS empty — skipping (daemon not woken)" >> "$LOG"
|
|
257
186
|
exit 0
|
|
258
187
|
fi
|
|
259
188
|
|
|
260
|
-
#
|
|
261
|
-
|
|
262
|
-
if [ -z "$
|
|
263
|
-
echo "$(ts) [error] '
|
|
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"
|
|
189
|
+
# --- Locate the `4pm` cli ---------------------------------------------------------------------
|
|
190
|
+
FOURPM_BIN="${FOURPM_BIN:-$(command -v 4pm || true)}"
|
|
191
|
+
if [ -z "$FOURPM_BIN" ]; then
|
|
192
|
+
echo "$(ts) [error] '4pm' not found on PATH — install the 4PM cli in WSL (e.g. ~/.local/bin/4pm)" >> "$LOG"
|
|
280
193
|
exit 0
|
|
281
194
|
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
195
|
|
|
305
|
-
# Record 1 tick that ACTUALLY
|
|
196
|
+
# Record 1 tick that ACTUALLY runs a cycle (for max_ticks_per_day).
|
|
306
197
|
tick_count=$(( ${tick_count:-0} + 1 ))
|
|
307
198
|
hist set-ticks "$TODAY" "$tick_count" 2>>"$LOG" || true
|
|
308
199
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
200
|
+
# Optional profile pin (settings.profile) — else the cli resolves the single linked profile.
|
|
201
|
+
PROFILE_ARGS=()
|
|
202
|
+
[ -n "${CFG_PROFILE:-}" ] && PROFILE_ARGS=(--profile "$CFG_PROFILE")
|
|
312
203
|
|
|
313
|
-
|
|
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")
|
|
204
|
+
echo "$(ts) [run] 4pm auto-run (tick $tick_count/$TODAY)" >> "$LOG"
|
|
317
205
|
set +e
|
|
318
|
-
"$
|
|
319
|
-
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
|
|
320
|
-
--permission-mode bypassPermissions \
|
|
321
|
-
--dangerously-skip-permissions \
|
|
322
|
-
>> "$LOG" 2>&1
|
|
206
|
+
"$FOURPM_BIN" auto-run ${PROFILE_ARGS[@]+"${PROFILE_ARGS[@]}"} >> "$LOG" 2>&1
|
|
323
207
|
RC=$?
|
|
324
208
|
set -e
|
|
325
209
|
|
|
326
|
-
# --- Record the run + count consecutive failures + auto-stop
|
|
327
|
-
# RC != 0 -> increment; reaching the threshold ->
|
|
328
|
-
# The consecutive-failure count is stored under consecutive_fails in .autonomous.histories.json.
|
|
210
|
+
# --- Record the run + count consecutive failures + auto-stop ----------------------------------
|
|
211
|
+
# RC != 0 (no daemon / dispatch error) -> increment; reaching the threshold -> paused=true. RC == 0 -> reset.
|
|
329
212
|
fails="$(hist get-fails 2>/dev/null || echo 0)"; [ -z "$fails" ] && fails=0
|
|
330
213
|
if [ "$RC" -ne 0 ]; then
|
|
331
|
-
echo "$(ts) [warn]
|
|
214
|
+
echo "$(ts) [warn] 4pm auto-run exited $RC" >> "$LOG"
|
|
332
215
|
fails=$(( ${fails:-0} + 1 )); hist set-fails "$fails" 2>>"$LOG" || true
|
|
333
|
-
hist record "$(ts)" failure "$RC" "
|
|
216
|
+
hist record "$(ts)" failure "$RC" "" "consecutive failure #$fails" 2>>"$LOG" || true
|
|
334
217
|
if [ "${CFG_STOP_ON_CONSEC_FAILURES:-0}" -gt 0 ] 2>/dev/null && [ "$fails" -ge "$CFG_STOP_ON_CONSEC_FAILURES" ]; then
|
|
335
218
|
"$PY_BIN" - "$SETTINGS" <<'PY' 2>>"$LOG" || true
|
|
336
219
|
import json, sys
|
|
@@ -347,10 +230,10 @@ PY
|
|
|
347
230
|
fi
|
|
348
231
|
else
|
|
349
232
|
hist set-fails 0 2>>"$LOG" || true # success → reset the consecutive-failure count
|
|
350
|
-
hist record "$(ts)" success "$RC" "
|
|
233
|
+
hist record "$(ts)" success "$RC" "" "cycle complete" 2>>"$LOG" || true
|
|
351
234
|
fi
|
|
352
235
|
|
|
353
|
-
# notify_webhook: TBD —
|
|
236
|
+
# notify_webhook: TBD — a run summary would be POSTed here if CFG_NOTIFY_WEBHOOK is set.
|
|
354
237
|
|
|
355
238
|
echo "$(ts) [done] tick finished" >> "$LOG"
|
|
356
|
-
# The EXIT trap above removes `.autonomous.lock`
|
|
239
|
+
# The EXIT trap above removes `.autonomous.lock` → the lock is released for the next tick.
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
> Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`
|
|
4
4
|
> (group = one request/batch, task = a small sub-task doable in ~1 cycle).
|
|
5
5
|
> **Priority** = High / Medium / Low.
|
|
6
|
-
> **
|
|
7
|
-
>
|
|
6
|
+
> **Tag** = optional catalog tag(s) (e.g. `UpdateSpecFromDB`) whose action runs from the server down to
|
|
7
|
+
> the project when the task is approved (approval is committed on Save — ADR-0311).
|
|
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.
|
|
8
10
|
> **Depends** = the `TSK-…` ids that must be DONE (present in `AI_DONE.md`) first.
|
|
9
11
|
> `/auto-cycle` only takes tasks that are approved AND have their dependencies met → moves them to
|
|
10
12
|
> `AI_PROGRESS.md`; runs group by group, within a group High → Medium → Low.
|
|
11
13
|
|
|
12
|
-
| ID | Priority |
|
|
13
|
-
|
|
14
|
+
| ID | Priority | Tag | Depends | Group | Task description | Notes |
|
|
15
|
+
|----|----------|-----|---------|-------|------------------|-------|
|
|
14
16
|
| | | | | | | |
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# AI_TODO — Task queue (generated by the AI)
|
|
2
2
|
|
|
3
3
|
> Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`.
|
|
4
|
-
> **Priority** = High / Medium / Low. **
|
|
5
|
-
>
|
|
4
|
+
> **Priority** = High / Medium / Low. **Tag** = optional catalog tag(s) (e.g. `UpdateSpecFromDB`) whose
|
|
5
|
+
> action runs from the server down to the project on approve (committed on Save — ADR-0311).
|
|
6
|
+
> **Approval** lives in `.claude/.autonomous.approvals.json` (ADR-0152), not the table. **Depends** =
|
|
7
|
+
> `TSK-…` ids that must be in `AI_DONE.md` first.
|
|
6
8
|
|
|
7
|
-
| ID | Priority |
|
|
8
|
-
|
|
9
|
-
| TSK-0001-0001 | High |
|
|
10
|
-
| TSK-0001-0002 | Medium |
|
|
9
|
+
| ID | Priority | Tag | Depends | Group | Task description | Notes |
|
|
10
|
+
|----|----------|-----|---------|-------|------------------|-------|
|
|
11
|
+
| TSK-0001-0001 | High | | | Auth | Add login-form validation per docs/auth.md | |
|
|
12
|
+
| TSK-0001-0002 | Medium | | TSK-0001-0001 | Auth | Wire the login API call + error handling | After 0001 |
|
|
13
|
+
| TSK-0001-0003 | Low | UpdateSpecFromDB | | Spec | Sync the project spec into project.spec.json on approve | Server action |
|
|
@@ -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 |
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
> file holds only keys + explanations, so it is **safe to commit** and the AI can read it for context.
|
|
8
8
|
>
|
|
9
9
|
> **Why two files:**
|
|
10
|
-
> - `AI_PLACEHOLDER.md` (this file) — keys + explanations. Safe to commit; not gitignored
|
|
11
|
-
> - `project.secrets.json` — key → real value. **Gitignored**
|
|
12
|
-
>
|
|
13
|
-
>
|
|
10
|
+
> - `AI_PLACEHOLDER.md` (this file) — keys + explanations. Safe to commit; not gitignored.
|
|
11
|
+
> - `project.secrets.json` — key → real value. **Gitignored** (never committed / pushed), so secret
|
|
12
|
+
> values stay on the worker. The AI **can** read this file to use the values while running/testing a
|
|
13
|
+
> task — so enter **test values only**; do **not** put high-sensitivity secrets here. Manage values
|
|
14
|
+
> from the web (Placeholder tab) — they are write-only (never shown back).
|
|
14
15
|
|
|
15
16
|
| Key | Explanation |
|
|
16
17
|
|-----|-------------|
|
|
17
|
-
| ai_dev_branch | The shared dev branch the AI merges tasks into before the main branch. |
|
|
18
18
|
| api_base_url | Base URL of the internal API the AI calls during integration tests. |
|
|
19
19
|
| api_key | Key for the internal API (real value in project.secrets.json, NOT committed). |
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
> Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`
|
|
4
4
|
> (group = one request/batch, task = a small sub-task doable in ~1 cycle).
|
|
5
5
|
> **Priority** = High / Medium / Low.
|
|
6
|
-
> **
|
|
7
|
-
>
|
|
6
|
+
> **Tag** = optional catalog tag(s) (e.g. `UpdateSpecFromDB`) whose action runs from the server down to
|
|
7
|
+
> the project when the task is approved (approval is committed on Save — ADR-0311).
|
|
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.
|
|
8
10
|
> **Depends** = the `TSK-…` ids that must be DONE (present in `AI_DONE.md`) first.
|
|
9
11
|
> `/auto-cycle` only takes tasks that are approved AND have their dependencies met → moves them to
|
|
10
12
|
> `AI_PROGRESS.md`; runs group by group, within a group High → Medium → Low.
|
|
11
13
|
|
|
12
|
-
| ID | Priority |
|
|
13
|
-
|
|
14
|
+
| ID | Priority | Tag | Depends | Group | Task description | Notes |
|
|
15
|
+
|----|----------|-----|---------|-------|------------------|-------|
|
|
14
16
|
| | | | | | | |
|
|
@@ -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
|
+
| | | | | | |
|