@4pm/cli 1.17.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.
@@ -1,177 +1,23 @@
1
1
  ---
2
- description: Autonomous cycle — pull a request from USER_TODO, do ONE small approved task, test it, merge into ${ai_dev_branch}$. (The token gate is checked by autonomous-tick.sh BEFORE Claude is invoked.)
3
- allowed-tools: Bash(*), Read(*), Edit(*), Write(*), Glob(*), Grep(*)
2
+ description: (Retired — ADR-0319) The autonomous cycle now runs through the 4PM cli, not `claude -p /auto-cycle`.
4
3
  ---
5
4
 
6
- # /auto-cycle — one autonomous work cycle
5
+ # /auto-cycle — retired (ADR-0319)
7
6
 
8
- You are running **unattended** (headless, triggered by cron every ~10 min via
9
- `.claude/hooks/autonomous-tick.sh`). Do **exactly one full cycle** with the steps below, then **stop**.
10
- The integration branch is named **`${ai_dev_branch}$`** (the placeholder is resolved at runtime — see
11
- `AI_PLACEHOLDER.md`); a task branch is **merged straight into `${ai_dev_branch}$`** (no PR).
7
+ The autonomous work cycle is **no longer** a `claude -p /auto-cycle` slash command. Under **ADR-0319**
8
+ it runs through the 4PM cli instead:
12
9
 
13
- > Survival rule: **each cycle does exactly ONE small task** to avoid running out of tokens mid-way. If a
14
- > step fails, log it as a row in the **Incidents** table of `AI_DONE.md`, **clean the lock (Step 8)**, then stop — don't
15
- > push on.
16
- >
17
- > **Lock (`.claude/.autonomous.lock`):** whether the cycle ends normally or on error, you MUST delete
18
- > the lock file before stopping so the next cron tick isn't blocked by a stale lock (Step 8) —
19
- > mandatory, even on an early error exit.
20
- >
21
- > **Token gate:** the quota check (session/weekly) moved to the `autonomous-tick.sh` wrapper — it runs
22
- > first and only invokes Claude when quota is sufficient. So by the time `/auto-cycle` starts, the gate
23
- > has already passed; don't re-check at the top.
24
- >
25
- > **Books & templates (MUST compare):** the 5 book files at the repo root — `USER_TODO.md`,
26
- > `AI_TODO.md`, `AI_PROGRESS.md`, `AI_DONE.md`, `USER_QA.md` — have canonical templates in
27
- > `.claude/templates/` (`<NAME>.empty.md` = EMPTY state, `<NAME>.sample.md` = example WITH DATA; see
28
- > `.claude/templates/README.md`). Rules:
29
- > - **Is a file empty / has work?** → compare against `<NAME>.empty.md` (empty ⇔ equal to the empty
30
- > template after trimming trailing whitespace + leading/trailing blank lines). Don't guess by skimming.
31
- > - **When clearing / resetting** a file → overwrite with the **exact** `<NAME>.empty.md`
32
- > (`cp .claude/templates/<NAME>.empty.md <NAME>`). The tick's "has work" gate relies on this match.
33
- > - **When adding data** → keep the header/blockquote, fill in per `<NAME>.sample.md`.
34
- >
35
- > **Language:** write books/docs/code in the language the project's `CLAUDE.md` specifies (default
36
- > English). Don't switch languages on your own.
10
+ - The cron tick (`.claude/hooks/autonomous-tick.sh`) runs **`4pm auto-run`**, which asks the already
11
+ running **`4pm start`** daemon to run **one** cycle through its live WS session — so the cycle reuses
12
+ the cli's profile/quota failover (ADR-0182), token metering (ADR-0072), folder-scope guard (ADR-0181)
13
+ and AI-run timeout (ADR-0243).
14
+ - The cycle **instructions** now live in the cli (`21-apps/31-cli/src/core/autonomous-cycle.ts` →
15
+ `buildAutonomousCyclePrompt`), not in this file. In short: sync the primary repo's current branch →
16
+ analyse **approved** `USER_TODO` requests into `AI_TODO` tasks (unclear ⇒ ask via `USER_QA`) → fold
17
+ **approved** `USER_QA` answers back → take ONE approved task → implement + test on a `task/TSK-…`
18
+ branch → open a **pull request** into the base branch (no direct merge) → update the books.
37
19
 
38
- ---
39
-
40
- ## Step 1 — Sync the `${ai_dev_branch}$` branch
41
- 1. If not on `${ai_dev_branch}$`:
42
- - `git fetch origin`
43
- - If it doesn't exist yet: `git checkout -b ${ai_dev_branch}$ origin/${ai_dev_branch}$` (if the remote
44
- has it) or `git checkout -b ${ai_dev_branch}$ main` (create from main).
45
- - Otherwise: `git checkout ${ai_dev_branch}$`.
46
- 2. `git pull --ff-only origin ${ai_dev_branch}$` (skip if the remote has no such branch yet).
47
-
48
- ## Step 2 — Intake user requests → generate tasks
49
- 1. Read `USER_TODO.md` and **compare with `.claude/templates/USER_TODO.empty.md`**. If **equal** (only
50
- the empty template remains) → **no** new request → skip task generation.
51
- 2. **If anything is unclear / needs a user decision** (ambiguous, missing info, contradictory, or the
52
- user said "ask if unclear, don't decide on your own"):
53
- - **Do NOT guess** and generate tasks. Append a **row** to `USER_QA.md` per
54
- `.claude/templates/USER_QA.sample.md` (columns `Date | Original request | Question / options | Answer`):
55
- fill the date, the original request, and what's unclear + (if possible) options to choose from; leave
56
- the **Answer** column blank for the user.
57
- - **Clear `USER_TODO.md`** back to the empty template (Step 2.4) — do NOT generate tasks this cycle.
58
- The user will read `USER_QA.md`, clarify, and re-post the request into `USER_TODO.md` for a later cycle.
59
- - Append a row to the **Incidents** table in `AI_DONE.md` that this cycle stopped waiting for an answer, then go to
60
- Step 8 (clean the lock) and **stop**.
61
- 3. If the request is clear enough: split it into small tasks doable in ~1 cycle. Write them into
62
- `AI_TODO.md` per `.claude/templates/AI_TODO.sample.md` (**7-column table:
63
- `| ID | Priority | Approved | Depends | Group | Task description | Notes |`**), each with an
64
- **ID `TSK-{groupid:0000}-{taskid:0000}`** (group = one request/batch, task = a sub-task).
65
- - **`Priority` column**: judge it — `High` / `Medium` / `Low` (default `Medium`).
66
- - **`Approved` column**: leave BLANK. This is a display-only mirror — the source of truth for approval
67
- is `.claude/.autonomous.approvals.json` (the user ticks it in the web VERIFY tab — ADR-0152). Do NOT
68
- fill it in yourself and do NOT read it to decide (see Step 3).
69
- - **`Depends` column**: if a task must wait for another, list the `TSK-…` ids here (comma-separated);
70
- leave empty otherwise. Step 3 skips a task whose dependencies aren't in `AI_DONE.md` yet.
71
-
72
- **ID rules (MANDATORY):**
73
- - **Each analysis of `USER_TODO.md` → one NEW `groupid`.** All sub-tasks split from that batch share
74
- this `groupid`, differing only by `taskid`.
75
- - **`groupid` must be UNIQUE and INCREASING** across all history (including groups already DONE and
76
- cleared from `AI_TODO.md`). Since the books are cleared each cycle, **check git history** for the
77
- largest `groupid` ever used, then take `max + 1`:
78
- ```bash
79
- MAXG=$( { git log -p -- AI_TODO.md AI_DONE.md AI_PROGRESS.md 2>/dev/null; \
80
- cat AI_TODO.md AI_DONE.md AI_PROGRESS.md 2>/dev/null; } \
81
- | grep -oE 'TSK-[0-9]{4}-[0-9]{4}' | sed -E 's/TSK-([0-9]{4}).*/\1/' \
82
- | sort -rn | head -1 ); MAXG=${MAXG:-0}
83
- NEWG=$(printf '%04d' $((10#$MAXG + 1)))
84
- ```
85
- - **`taskid` starts at `0001` and increases WITHIN the group**: `TSK-{NEWG}-0001`, `TSK-{NEWG}-0002`, …
86
- 4. **Clear `USER_TODO.md`** by overwriting with the exact empty template
87
- (`cp .claude/templates/USER_TODO.empty.md USER_TODO.md`) — so old tasks aren't recreated next cycle AND
88
- the tick's "has work" gate correctly sees it as empty.
89
-
90
- ## Step 3 — Pick ONE APPROVED task (with satisfied dependencies) and start it
91
- > **VERIFY gate (MANDATORY):** the source of truth for approval is `.claude/.autonomous.approvals.json`
92
- > (ADR-0152), NOT the `Approved` column in `AI_TODO.md`. A task is **approved** when approvals has
93
- > `"<TSK-id>": { "approved": true, … }`. A task not in approvals (or `approved:false`) = NOT permitted →
94
- > **skip it, leave it queued**.
95
-
96
- 1. Read `.claude/.autonomous.approvals.json` (JSON `{ "<TSK-id>": {approved, by, at}, … }`; missing file
97
- ⇒ nothing approved) and `AI_TODO.md`. **Filter tasks meeting BOTH**:
98
- - **Approved**: `approved === true` in approvals.
99
- - **Dependencies met**: every `TSK-…` in the `Depends` column is already in `AI_DONE.md` (done). If a
100
- dependency isn't done yet → **skip** (wait for a later cycle), even if approved.
101
- Pick the next task: **run group by group** (smallest group with an eligible task first), **within a
102
- group prefer `Priority` High → Medium → Low**, then line order.
103
- - **If NO eligible task** (empty, or all waiting for approval / dependencies — including tasks just
104
- generated in Step 2): **take no task**. Append a row to the **Incidents** table in `AI_DONE.md`
105
- (e.g. "this cycle only generated tasks / waiting for VERIFY approval / waiting for dependencies"),
106
- then go to Step 8 (clean the lock) and **stop**.
107
- 2. Move that task into `AI_PROGRESS.md` (with a start timestamp, per
108
- `.claude/templates/AI_PROGRESS.sample.md`), **remove it from `AI_TODO.md`**. If `AI_TODO.md` is now
109
- empty → `cp .claude/templates/AI_TODO.empty.md AI_TODO.md`.
110
- 3. Commit on `${ai_dev_branch}$`: `git add -A && git commit -m "chore(auto): start TSK-xxxx-xxxx"`.
111
-
112
- ## Step 4 — Implement the task on its own branch
113
- 1. Create the branch: `git checkout -b task/TSK-xxxx-xxxx`.
114
- 2. Implement the task **following the project's architecture + the conventions in `CLAUDE.md`**. Add or
115
- update tests as appropriate for the change.
116
- 3. **Test** by running the project's test command (see `CLAUDE.md` / the project's scripts — e.g.
117
- `scripts/test.*`, `npm test`, `pnpm test`, `pytest`, …). If the project defines an integration-test /
118
- evidence harness, use it and keep the produced report/evidence so it can be reviewed later.
119
- 4. **Wait for the tests to finish** and check the result before continuing.
120
- 5. Commit (include any produced report/evidence so the integration branch carries it):
121
- `git add -A && git commit -m "feat(TSK-xxxx-xxxx): <short description>"`.
122
-
123
- ## Step 5 — Merge into `${ai_dev_branch}$`, update the books
124
- 1. `git checkout ${ai_dev_branch}$`
125
- 2. `git merge --no-ff task/TSK-xxxx-xxxx`
126
- - **On CONFLICT** (parallel agents may have moved the integration branch — MEMO #40): `git status`
127
- shows `UU` files. **Resolve them yourself**: edit each conflicted file into a correct merged result
128
- (remove every `<<<<<<< ======= >>>>>>>` marker), `git add <file>`, then `git commit --no-edit` to
129
- finish the merge. If a conflict is too complex to be sure → `git merge --abort`, write a question
130
- into `USER_QA.md`, go to Step 8 and stop (don't guess).
131
- 3. Record the task by **appending a row to the Done table** in `AI_DONE.md` (Timestamp, ID, Task description, Files, Notes — per `.claude/templates/AI_DONE.sample.md`; keep the table header intact).
132
- **Remove it from `AI_PROGRESS.md`**: if nothing is in progress after removal, reset with
133
- `cp .claude/templates/AI_PROGRESS.empty.md AI_PROGRESS.md`. Likewise, if `AI_TODO.md` is now empty →
134
- `cp .claude/templates/AI_TODO.empty.md AI_TODO.md`.
135
- 4. Commit: `git add -A && git commit -m "chore(auto): finish TSK-xxxx-xxxx, merge into ${ai_dev_branch}$"`.
136
- 5. (Optional) `git push origin ${ai_dev_branch}$`.
137
-
138
- ## Step 6 — Recheck the token budget
139
- 1. Re-run the **check-usage** skill: `python .claude/skills/check-usage/check_usage.py`, then **wait 3s**
140
- (`sleep 3`) for the result file to be written.
141
- 2. Print a summary: task done, tokens remaining.
142
-
143
- ## Step 7 — Commit & push `${ai_dev_branch}$`
144
- 1. `git checkout ${ai_dev_branch}$`
145
- 2. `git add -A && git commit -m "chore(auto): update books after the autonomous cycle"` (skip if no change).
146
- 3. `git push origin ${ai_dev_branch}$`.
147
-
148
- ## Step 7.5 — Report on the `conversation` branch (share context with later agents — MEMO #40)
149
- > Multiple Claude instances take different tasks in parallel; a later agent needs to know what an earlier
150
- > one did. Use a dedicated branch named **`conversation`** holding **only** the file `CONVERSATION.md`
151
- > (no source or docs), keeping **at most the 50 most recent reports** (trim older ones when over).
152
-
153
- 1. Save the context (task ID + summary + list of files changed this cycle).
154
- 2. `git stash -u` if there are uncommitted changes (usually none — the books were committed in Step 7).
155
- 3. Switch to the conversation branch (create it orphan if missing):
156
- - `git fetch origin` → `git checkout conversation` (exists) or
157
- `git checkout --orphan conversation && git rm -rf . 2>/dev/null` (create fresh, clean).
158
- - `git pull --ff-only origin conversation` (skip if the remote has none).
159
- 4. Append an entry to the **end** of `CONVERSATION.md`:
160
- ```
161
- ## <yyyy-MM-dd HH:mm> · TSK-xxxx-xxxx
162
- - Did: <short summary>
163
- - Files: <paths, comma-separated>
164
- - Merged into: ${ai_dev_branch}$ (<conflict / no conflict>)
165
- ```
166
- If the number of `##` entries exceeds **50** → drop the oldest ones down to 50.
167
- 5. `git add CONVERSATION.md && git commit -m "chore(conversation): TSK-xxxx-xxxx" && git push origin conversation`.
168
- 6. Return to the integration branch: `git checkout ${ai_dev_branch}$` (and `git stash pop` if you stashed in 2).
20
+ Approval is the source of truth in `.claude/.autonomous.approvals.json` (ADR-0152), keyed per row id
21
+ (`REQ-…` / `QA-…` / `TSK-…`), set from the web AI-content grids.
169
22
 
170
- ## Step 8 — Clean the lock (ALWAYS run, even on error/early exit)
171
- > This is the **final action of every cycle** — run it whether the cycle succeeded, hit an error, or was
172
- > blocked at the token gate. Goal: never let a stale lock block the next cron tick.
173
- 1. Delete the lock file: `rm -f .claude/.autonomous.lock` (run in the project root).
174
- - The lock IS the file `.autonomous.lock`: `autonomous-tick.sh` creates it at start and treats "the
175
- file exists" = a run is in progress. Deleting it here releases the lock for the next tick.
176
- - This is `/auto-cycle`'s responsibility; the wrapper only has a safety-net trap in case the cycle dies.
177
- 2. **STOP** (the next cron tick will trigger the next cycle).
23
+ This file is kept only as a pointer; it is not executed.
@@ -1,22 +1,28 @@
1
1
  #!/usr/bin/env bash
2
- # Cron tick for the autonomous workflow — install on WSL. The schedule & control flags live in
3
- # .claude/.autonomous.settings.json (paused, max_session_pct, max_weekly_pct, cron_schedule, …);
4
- # read EVERY tick, so changes take effect on the next tick (cron_schedule auto-syncs into the crontab).
2
+ # 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
- # "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.
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
- # Safety net: ensure the lock is cleaned when the wrapper exits, even if Claude dies silently.
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
- # 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
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 token gate) ------------------------------------
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. Python prints CFG_*=<shlex-quoted>
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, "max_session_pct": 80, "max_weekly_pct": 90,
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("CFG_COMMAND=" + shlex.quote(str(g("command"))))
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 (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
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 (logs are per-day in $LOG_DIR) -----------------------
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##*:} )); } # "HH:MM" -> minutes (10# forces base 10)
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 (max_ticks_per_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 (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).
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="$(AI_TODO="$PROJECT_DIR/AI_TODO.md" USER_TODO="$PROJECT_DIR/USER_TODO.md" \
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
- USER_TODO_TPL="$TPL/USER_TODO.empty.md" AI_PROGRESS_TPL="$TPL/AI_PROGRESS.empty.md" \
158
+ AI_PROGRESS_TPL="$TPL/AI_PROGRESS.empty.md" \
215
159
  "$PY_BIN" - <<'PY'
216
160
  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()
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
- 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")
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 new request (USER_TODO), approved task (AI_TODO), or in-progress work (AI_PROGRESS) — skipping (no Claude call)" >> "$LOG"
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
- # 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"
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 calls Claude (for max_ticks_per_day) — stored in ticks{day,count}.
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
- 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
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
- # 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")
204
+ echo "$(ts) [run] 4pm auto-run (tick $tick_count/$TODAY)" >> "$LOG"
317
205
  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
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 (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.
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] $CFG_COMMAND exited $RC" >> "$LOG"
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" "$GATE" "consecutive failure #$fails" 2>>"$LOG" || true
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" "$GATE" "cycle complete" 2>>"$LOG" || true
233
+ hist record "$(ts)" success "$RC" "" "cycle complete" 2>>"$LOG" || true
351
234
  fi
352
235
 
353
- # notify_webhook: TBD — this is where a run summary would be POSTed to a webhook if CFG_NOTIFY_WEBHOOK is set.
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` if /auto-cycle didn't → the lock is released.
239
+ # The EXIT trap above removes `.autonomous.lock` → the lock is released for the next tick.
@@ -9,9 +9,7 @@
9
9
  "defaultMode": "bypassPermissions",
10
10
  "allow": [],
11
11
  "ask": [],
12
- "deny": [
13
- "Read(./project.secrets.json)"
14
- ]
12
+ "deny": []
15
13
  },
16
14
  "enableAllProjectMcpServers": true
17
15
  }
@@ -1,9 +1,16 @@
1
1
  # USER_QA — Questions & answers
2
2
 
3
- > When a request is unclear, the AI adds a **row** here (date, the original request, what's unclear +
4
- > options) instead of guessing. Fill the **Answer** column, then re-post the clarified request into
5
- > `USER_TODO.md` — per `.claude/templates/USER_QA.sample.md`.
3
+ > When a request is unclear, the AI adds a **row** here (instead of guessing) with an
4
+ > **ID `QA-{groupid:0000}-{qaid:0000}`**: the original request and what's unclear + options, leaving the
5
+ > `Answer` blank. You fill the `Answer`; the autonomous cycle folds it back into a re-analysis **only after
6
+ > the row is approved**. Approval + authorship (who answered / who approved, with dates) live in JSON
7
+ > sidecars, not in this table (ADR-0320): `.claude/.autonomous.approvals.json` +
8
+ > `.claude/.autonomous.authors.json` — the web USER_QA grid shows them and enforces four-eyes (the person
9
+ > who answered can't approve their own answer, except ADMIN). See `.claude/templates/USER_QA.sample.md`.
10
+ >
11
+ > **Columns (content only):** `ID` · `Group` · `Depends` (comma-separated `QA-…`) · `Original request` ·
12
+ > `Question / options` · `Answer`.
6
13
 
7
- | Date | Original request | Question / options | Answer |
8
- |------|------------------|--------------------|--------|
9
- | | | | |
14
+ | ID | Group | Depends | Original request | Question / options | Answer |
15
+ |----|-------|---------|------------------|--------------------|--------|
16
+ | | | | | | |