@4pm/cli 1.5.15 → 1.5.17-b

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 (53) hide show
  1. package/dist/index.js +19 -11
  2. package/dist/project-sample/.4pm +0 -0
  3. package/dist/project-sample/.claude/.autonomous.approvals.json +1 -0
  4. package/dist/project-sample/.claude/.autonomous.settings.json +24 -0
  5. package/dist/project-sample/.claude/AUTONOMOUS-CRON.md +38 -0
  6. package/dist/project-sample/.claude/AUTONOMOUS.md +52 -0
  7. package/dist/project-sample/.claude/agents/.gitkeep +0 -0
  8. package/dist/project-sample/.claude/agents/long-memory/.gitkeep +0 -0
  9. package/dist/project-sample/.claude/agents/short-memory/.gitkeep +0 -0
  10. package/dist/project-sample/.claude/commands/auto-cycle.md +176 -0
  11. package/dist/project-sample/.claude/hooks/__pycache__/autonomous-history.cpython-312.pyc +0 -0
  12. package/dist/project-sample/.claude/hooks/autonomous-history.py +193 -0
  13. package/dist/project-sample/.claude/hooks/autonomous-tick.sh +356 -0
  14. package/dist/project-sample/.claude/rag/.gitkeep +0 -0
  15. package/dist/project-sample/.claude/settings.json +17 -0
  16. package/dist/project-sample/.claude/skills/check-usage/SKILL.md +40 -0
  17. package/dist/project-sample/.claude/skills/check-usage/check_usage.py +161 -0
  18. package/dist/project-sample/.claude/templates/AI_DONE.empty.md +9 -0
  19. package/dist/project-sample/.claude/templates/AI_DONE.sample.md +7 -0
  20. package/dist/project-sample/.claude/templates/AI_PLACEHOLDER.empty.md +11 -0
  21. package/dist/project-sample/.claude/templates/AI_PLACEHOLDER.sample.md +9 -0
  22. package/dist/project-sample/.claude/templates/AI_PROGRESS.empty.md +5 -0
  23. package/dist/project-sample/.claude/templates/AI_PROGRESS.sample.md +3 -0
  24. package/dist/project-sample/.claude/templates/AI_TODO.empty.md +14 -0
  25. package/dist/project-sample/.claude/templates/AI_TODO.sample.md +10 -0
  26. package/dist/project-sample/.claude/templates/README.md +14 -0
  27. package/dist/project-sample/.claude/templates/USER_QA.empty.md +6 -0
  28. package/dist/project-sample/.claude/templates/USER_QA.sample.md +7 -0
  29. package/dist/project-sample/.claude/templates/USER_TODO.empty.md +6 -0
  30. package/dist/project-sample/.claude/templates/USER_TODO.sample.md +6 -0
  31. package/dist/project-sample/.claude/templates/project.secrets.sample.json +5 -0
  32. package/dist/project-sample/.vscode/extensions.json +6 -0
  33. package/dist/project-sample/.vscode/settings.json +18 -0
  34. package/dist/project-sample/AI_DONE.md +9 -0
  35. package/dist/project-sample/AI_PLACEHOLDER.md +19 -0
  36. package/dist/project-sample/AI_PROGRESS.md +5 -0
  37. package/dist/project-sample/AI_SECURITY.md +30 -0
  38. package/dist/project-sample/AI_TODO.md +14 -0
  39. package/dist/project-sample/CLAUDE.md +0 -0
  40. package/dist/project-sample/USER_QA.md +6 -0
  41. package/dist/project-sample/USER_TODO.md +6 -0
  42. package/dist/project-sample/docs/.gitkeep +0 -0
  43. package/dist/project-sample/project.secrets.json.sample +5 -0
  44. package/dist/project-sample/project.settings.json +8 -0
  45. package/dist/project-sample/reports/.gitkeep +0 -0
  46. package/dist/project-sample/scripts/.gitkeep +0 -0
  47. package/dist/project-sample/src/.gitkeep +0 -0
  48. package/dist/project-sample/tests/IT/README.md +25 -0
  49. package/dist/project-sample/tests/IT/senarios/.gitkeep +0 -0
  50. package/dist/project-sample/tests/IT/tools/.gitkeep +0 -0
  51. package/dist/project-sample/tests/README.md +22 -0
  52. package/dist/project-sample/tests/UT/README.md +18 -0
  53. package/package.json +1 -1
@@ -0,0 +1,356 @@
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).
5
+ #
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
+ #
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.
14
+ set -euo pipefail
15
+
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
43
+ export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:$PATH"
44
+
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.
File without changes
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
3
+ "model": "claude-opus-4-8",
4
+ "cleanupPeriodDays": 30,
5
+ "env": {
6
+ "XNDP_ENV": "development"
7
+ },
8
+ "permissions": {
9
+ "defaultMode": "bypassPermissions",
10
+ "allow": [],
11
+ "ask": [],
12
+ "deny": [
13
+ "Read(./project.secrets.json)"
14
+ ]
15
+ },
16
+ "enableAllProjectMcpServers": true
17
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: check-usage
3
+ description: Show Claude Code plan usage limits — current session % and reset time, weekly % and reset time (same data as the built-in /usage screen). Use when the user asks "how much usage left", "khi nao reset", "check usage/quota/limit", or invokes /check-usage.
4
+ ---
5
+
6
+ # check-usage — Claude Code plan usage limits
7
+
8
+ Fetches the same data the built-in `/usage` screen shows (session + weekly utilization and
9
+ reset times), by calling the Anthropic OAuth usage endpoint with the local credentials.
10
+
11
+ ## How to run
12
+
13
+ Run the bundled script with the Bash tool. It prints a compact bullet list to the console;
14
+ relay that output to the user.
15
+
16
+ ```bash
17
+ python .claude/skills/check-usage/check_usage.py
18
+ ```
19
+
20
+ On Windows if `python` is missing, try `py .claude/skills/check-usage/check_usage.py`.
21
+
22
+ Add `--json` to dump the raw API response instead of the formatted view:
23
+
24
+ ```bash
25
+ python .claude/skills/check-usage/check_usage.py --json
26
+ ```
27
+
28
+ ## Output file
29
+ Every run (the formatted view, not `--json`) rewrites `./output/last-usage-check.json` next to
30
+ the script: it deletes the previous file then creates a fresh one with this run's result. The
31
+ record has machine-readable fields the autonomous workflow reads back:
32
+ `session_pct`, `weekly_pct`, `session_resets_at`, `weekly_resets_at`, `checked_at`, `plan`,
33
+ plus the human `summary` and the full `raw` API payload.
34
+
35
+ ## Notes
36
+ - Reads the OAuth access token from `~/.claude/.credentials.json` (Claude Code keeps it refreshed).
37
+ - If the call returns HTTP 401, the token expired — tell the user to run the real `/usage` once
38
+ inside Claude Code (or re-login) to refresh credentials, then retry.
39
+ - Reset times are printed in the machine's local timezone with a "con Xh Ym" countdown.
40
+ - Do not print the access token.
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch Claude Code plan-usage limits (same data as the built-in /usage screen).
3
+
4
+ Reads the OAuth access token from ~/.claude/.credentials.json and queries the
5
+ Anthropic usage endpoint, then prints session + weekly utilization and reset times
6
+ in the local timezone.
7
+ """
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import urllib.request
13
+ import urllib.error
14
+ from datetime import datetime, timezone
15
+
16
+ # Windows consoles default to cp1252 which can't encode the emoji/bar glyphs.
17
+ for _s in (sys.stdout, sys.stderr):
18
+ try:
19
+ _s.reconfigure(encoding="utf-8")
20
+ except Exception:
21
+ pass
22
+
23
+ CRED_PATH = os.path.expanduser("~/.claude/.credentials.json")
24
+ URL = "https://api.anthropic.com/api/oauth/usage"
25
+
26
+
27
+ class UsageError(Exception):
28
+ """Recoverable failure while loading the token or calling the usage API.
29
+ Raised (instead of exiting) so the caller can refresh the token and retry once."""
30
+
31
+
32
+ def load_token():
33
+ try:
34
+ with open(CRED_PATH, "r", encoding="utf-8") as f:
35
+ data = json.load(f)
36
+ except FileNotFoundError:
37
+ raise UsageError(f"Credentials not found: {CRED_PATH} (log in to Claude Code first).")
38
+ oauth = data.get("claudeAiOauth", data)
39
+ token = oauth.get("accessToken")
40
+ if not token:
41
+ raise UsageError("No accessToken in credentials.")
42
+ exp = oauth.get("expiresAt")
43
+ if exp and datetime.now(timezone.utc).timestamp() * 1000 > exp:
44
+ print("Warning: token may be expired — run /usage once in Claude Code to refresh.\n",
45
+ file=sys.stderr)
46
+ return token, oauth.get("subscriptionType", "?")
47
+
48
+
49
+ def fetch(token):
50
+ req = urllib.request.Request(URL, headers={
51
+ "Authorization": f"Bearer {token}",
52
+ "anthropic-beta": "oauth-2025-04-20",
53
+ "User-Agent": "claude-cli/2.0.0 (external, cli)",
54
+ })
55
+ try:
56
+ with urllib.request.urlopen(req, timeout=20) as r:
57
+ return json.load(r)
58
+ except urllib.error.HTTPError as e:
59
+ body = e.read().decode("utf-8", "replace")[:300]
60
+ raise UsageError(f"API error HTTP {e.code}: {body}")
61
+ except urllib.error.URLError as e:
62
+ raise UsageError(f"Could not reach API: {e.reason}")
63
+
64
+
65
+ def get_usage():
66
+ """Load the token and query the usage API. Raises UsageError on any failure."""
67
+ token, sub = load_token()
68
+ return fetch(token), sub
69
+
70
+
71
+ def refresh_token():
72
+ """Best-effort token refresh: run `claude /usage`, which refreshes the OAuth
73
+ token on startup. On Windows we go through `wsl` (the credentials this script
74
+ reads live in WSL); on POSIX we call `claude` directly. Output is discarded and
75
+ the call is bounded by a timeout so it can never hang the usage check."""
76
+ cmd = ["wsl", "claude", "/usage"] if sys.platform == "win32" else ["claude", "/usage"]
77
+ print(f"Refreshing token via: {' '.join(cmd)}", file=sys.stderr)
78
+ try:
79
+ subprocess.run(
80
+ cmd, timeout=60,
81
+ stdin=subprocess.DEVNULL,
82
+ stdout=subprocess.DEVNULL,
83
+ stderr=subprocess.DEVNULL,
84
+ )
85
+ except Exception as e:
86
+ print(f"Token refresh attempt failed: {e}", file=sys.stderr)
87
+
88
+
89
+ def fmt_reset(iso):
90
+ if not iso:
91
+ return "?"
92
+ dt = datetime.fromisoformat(iso).astimezone() # -> local tz
93
+ return f"{dt:%a %d/%m %H:%M}"
94
+
95
+
96
+ def save_last_result(record):
97
+ """Delete the previous ./output/last-usage-check.json, then write a fresh one
98
+ holding this run's result. Console output is still printed by the caller."""
99
+ out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
100
+ os.makedirs(out_dir, exist_ok=True)
101
+ out_path = os.path.join(out_dir, "last-usage-check.json")
102
+ # Remove the stale file first, then recreate it from scratch.
103
+ try:
104
+ os.remove(out_path)
105
+ except FileNotFoundError:
106
+ pass
107
+ with open(out_path, "w", encoding="utf-8") as f:
108
+ json.dump(record, f, ensure_ascii=False, indent=2)
109
+ return out_path
110
+
111
+
112
+ def main():
113
+ try:
114
+ data, sub = get_usage()
115
+ except UsageError as e:
116
+ # First attempt failed — try to refresh the token once, then retry once.
117
+ print(f"Usage check failed: {e}", file=sys.stderr)
118
+ refresh_token()
119
+ try:
120
+ data, sub = get_usage()
121
+ except UsageError as e2:
122
+ print(f"Still failing after token refresh — skipping: {e2}", file=sys.stderr)
123
+ sys.exit(1)
124
+
125
+ if "--json" in sys.argv:
126
+ print(json.dumps(data, indent=2))
127
+ return
128
+
129
+ fh = data.get("five_hour") or {}
130
+ sd = data.get("seven_day") or {}
131
+
132
+ lines = [
133
+ f"- Plan: {sub}",
134
+ f"- Session: {fh.get('utilization', 0):.0f}% — reset {fmt_reset(fh.get('resets_at'))}",
135
+ f"- Weekly: {sd.get('utilization', 0):.0f}% — reset {fmt_reset(sd.get('resets_at'))}",
136
+ ]
137
+ extra = data.get("extra_usage") or {}
138
+ if extra.get("is_enabled") and extra.get("used_credits"):
139
+ dp = extra.get("decimal_places", 2)
140
+ lines.append(f"- Extra: {extra['used_credits'] / (10 ** dp):.2f} {extra.get('currency', 'USD')}")
141
+
142
+ summary = "\n".join(lines)
143
+ print(summary) # keep the console output
144
+
145
+ # Persist this run's result so the autonomous workflow can read it back.
146
+ record = {
147
+ "checked_at": datetime.now().astimezone().isoformat(timespec="seconds"),
148
+ "plan": sub,
149
+ "session_pct": fh.get("utilization", 0),
150
+ "weekly_pct": sd.get("utilization", 0),
151
+ "session_resets_at": fh.get("resets_at"),
152
+ "weekly_resets_at": sd.get("resets_at"),
153
+ "summary": summary,
154
+ "raw": data,
155
+ }
156
+ out_path = save_last_result(record)
157
+ print(f"\nSaved result to: {out_path}")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
@@ -0,0 +1,9 @@
1
+ # AI_DONE — Completed tasks + incidents
2
+
3
+ > The autonomous cycle appends completed tasks here (ID, description, timestamp) and logs incidents/notes.
4
+
5
+ ## Done
6
+ _(none)_
7
+
8
+ ## Incidents / notes
9
+ _(none)_
@@ -0,0 +1,7 @@
1
+ # AI_DONE — Completed tasks + incidents
2
+
3
+ ## Done
4
+ - 2026-01-01 10:00 · TSK-0001-0001 · Added login-form validation. Files: src/auth/login.ts.
5
+
6
+ ## Incidents / notes
7
+ - 2026-01-01 10:05 · Cycle only generated tasks; waiting for VERIFY approval.
@@ -0,0 +1,11 @@
1
+ # AI_PLACEHOLDER — Placeholder table (key + explanation)
2
+
3
+ > Lists **placeholders** the AI understands: each has a **key** and an **explanation**. Reference them in
4
+ > docs/config as `${key}$` (e.g. `${ai_dev_branch}$`); the tool substitutes the real **value** at runtime.
5
+ >
6
+ > **Where values live:** the real value of each key is stored in `project.secrets.json` (same folder). This
7
+ > file holds only keys + explanations, so it is **safe to commit** and the AI can read it for context.
8
+
9
+ | Key | Explanation |
10
+ |-----|-------------|
11
+ | ai_dev_branch | The shared dev branch the AI merges tasks into before the main branch. |
@@ -0,0 +1,9 @@
1
+ # AI_PLACEHOLDER — Placeholder table (key + explanation)
2
+
3
+ > Reference placeholders as `${key}$`. Real values live in `project.secrets.json` (not committed).
4
+
5
+ | Key | Explanation |
6
+ |-----|-------------|
7
+ | ai_dev_branch | The shared dev branch the AI merges tasks into before the main branch. |
8
+ | api_base_url | Base URL of the internal API the AI calls during integration tests. |
9
+ | api_key | Key for the internal API (real value in project.secrets.json, NOT committed). |
@@ -0,0 +1,5 @@
1
+ # AI_PROGRESS — Task in progress
2
+
3
+ > The current in-progress task (set when the cycle starts a task, cleared when it finishes).
4
+
5
+ _(no task in progress)_
@@ -0,0 +1,3 @@
1
+ # AI_PROGRESS — Task in progress
2
+
3
+ - Started 2026-01-01 10:00 · TSK-0001-0001 · Add login-form validation.
@@ -0,0 +1,14 @@
1
+ # AI_TODO — Task queue (generated by the AI)
2
+
3
+ > Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`
4
+ > (group = one request/batch, task = a small sub-task doable in ~1 cycle).
5
+ > **Priority** = High / Medium / Low.
6
+ > **Approved**: the source of truth is `.claude/.autonomous.approvals.json` (the user ticks it in the
7
+ > web VERIFY tab — ADR-0152); the Approved column here is display-only, not where the decision is made.
8
+ > **Depends** = the `TSK-…` ids that must be DONE (present in `AI_DONE.md`) first.
9
+ > `/auto-cycle` only takes tasks that are approved AND have their dependencies met → moves them to
10
+ > `AI_PROGRESS.md`; runs group by group, within a group High → Medium → Low.
11
+
12
+ | ID | Priority | Approved | Depends | Group | Task description | Notes |
13
+ |----|----------|----------|---------|-------|------------------|-------|
14
+ | | | | | | | |
@@ -0,0 +1,10 @@
1
+ # AI_TODO — Task queue (generated by the AI)
2
+
3
+ > Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`.
4
+ > **Priority** = High / Medium / Low. **Approved** source of truth = `.claude/.autonomous.approvals.json`
5
+ > (web VERIFY tab). **Depends** = `TSK-…` ids that must be in `AI_DONE.md` first.
6
+
7
+ | ID | Priority | Approved | Depends | Group | Task description | Notes |
8
+ |----|----------|----------|---------|-------|------------------|-------|
9
+ | TSK-0001-0001 | High | ✓ | | Auth | Add login-form validation per docs/auth.md | |
10
+ | TSK-0001-0002 | Medium | | TSK-0001-0001 | Auth | Wire the login API call + error handling | After 0001 |