@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
package/dist/index.js CHANGED
@@ -32916,6 +32916,7 @@ var AI_PROMPT_MAX_LEN = 5e5;
32916
32916
  var COMMAND_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
32917
32917
  var COMMAND_IMAGE_MAX_COUNT = 10;
32918
32918
  var COMMAND_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
32919
+ var COMMAND_IMAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpg|webp|gif)$/;
32919
32920
  function commandImageExt(mime) {
32920
32921
  switch (mime) {
32921
32922
  case "image/png":
@@ -32931,7 +32932,9 @@ function commandImageExt(mime) {
32931
32932
  }
32932
32933
  }
32933
32934
  var commandImageRefSchema = external_exports.object({
32934
- id: external_exports.string().min(1).max(200),
32935
+ // Constrained to `<uuidv4>.<ext>` (the only id the upload mints) so a client-supplied id can never
32936
+ // inject `/` or `..` into the `command-images/{orgId}/{id}` storage key (path-traversal guard).
32937
+ id: external_exports.string().regex(COMMAND_IMAGE_ID_RE),
32935
32938
  placeholder: external_exports.string().min(1).max(40),
32936
32939
  name: external_exports.string().max(255),
32937
32940
  mime: external_exports.enum(COMMAND_IMAGE_MIME_TYPES)
@@ -33904,7 +33907,7 @@ function decryptPayload(key, payloadBase64, nonceBase64) {
33904
33907
  }
33905
33908
 
33906
33909
  // src/core/ws-client.ts
33907
- import { existsSync as existsSync14, mkdirSync as mkdirSync6, rmSync as rmSync5 } from "fs";
33910
+ import { existsSync as existsSync15, mkdirSync as mkdirSync6, rmSync as rmSync5 } from "fs";
33908
33911
  import { randomUUID as randomCommandId } from "crypto";
33909
33912
 
33910
33913
  // src/core/update-scheduler.ts
@@ -37095,6 +37098,7 @@ async function setCommitAuthor(cwd2, machineUsername) {
37095
37098
 
37096
37099
  // src/core/scaffold.ts
37097
37100
  import { cp, mkdir as mkdir8, writeFile as writeFile8 } from "fs/promises";
37101
+ import { existsSync as existsSync12 } from "fs";
37098
37102
  import { execFile as execFile10 } from "child_process";
37099
37103
  import { dirname as dirname8, join as join23, resolve as resolve7 } from "path";
37100
37104
  import { fileURLToPath as fileURLToPath3 } from "url";
@@ -37139,7 +37143,11 @@ async function provisionRepos(target, repos, emit, opts = {}) {
37139
37143
  function sampleDir() {
37140
37144
  if (process.env.SCAFFOLD_SAMPLE_DIR) return process.env.SCAFFOLD_SAMPLE_DIR;
37141
37145
  const here = dirname8(fileURLToPath3(import.meta.url));
37142
- return resolve7(here, "../../project-sample");
37146
+ const bundled = resolve7(here, "project-sample");
37147
+ for (const candidate of [bundled, resolve7(here, "../project-sample"), resolve7(here, "../../project-sample")]) {
37148
+ if (existsSync12(candidate)) return candidate;
37149
+ }
37150
+ return bundled;
37143
37151
  }
37144
37152
  async function scaffoldProject(payload, profileDir2, onProgress) {
37145
37153
  const emit = (step, message) => onProgress?.({ projectId: payload.projectId, step, message });
@@ -37218,7 +37226,7 @@ async function addProject(payload, profileDir2, onProgress) {
37218
37226
  }
37219
37227
 
37220
37228
  // src/ui/slash-commands.ts
37221
- import { existsSync as existsSync12 } from "fs";
37229
+ import { existsSync as existsSync13 } from "fs";
37222
37230
  import { join as join24 } from "path";
37223
37231
  var CONFIG_KEYS = [
37224
37232
  "aiCli",
@@ -37281,7 +37289,7 @@ function runConfig(ctx) {
37281
37289
  ctx.print(`\u2714 ${verb} default config: ${path}`);
37282
37290
  ctx.print("add claude profiles: /config set claudeHome .claude .claude-1 (tried in order)");
37283
37291
  };
37284
- if (existsSync12(path)) {
37292
+ if (existsSync13(path)) {
37285
37293
  ctx.confirm(
37286
37294
  `config exists: ${path} \u2014 replace it with defaults? (y/N)`,
37287
37295
  () => writeDefaults("replaced with")
@@ -37679,7 +37687,7 @@ async function runMemoryCompaction(ai, cwd2, input) {
37679
37687
  }
37680
37688
 
37681
37689
  // src/core/config-sync.ts
37682
- import { existsSync as existsSync13, readFileSync as readFileSync15 } from "fs";
37690
+ import { existsSync as existsSync14, readFileSync as readFileSync15 } from "fs";
37683
37691
  import { join as join25 } from "path";
37684
37692
  var SERVER_MANAGED_KEYS = [
37685
37693
  "physicPath",
@@ -37698,7 +37706,7 @@ var SERVER_MANAGED_KEYS = [
37698
37706
  ];
37699
37707
  function readConfigText(profileDir2) {
37700
37708
  const path = join25(profileDir2, "config.json");
37701
- if (existsSync13(path)) {
37709
+ if (existsSync14(path)) {
37702
37710
  try {
37703
37711
  return readFileSync15(path, "utf8");
37704
37712
  } catch {
@@ -39464,7 +39472,7 @@ ${guardedPrompt}`;
39464
39472
  /** Create the physic folder at an absolute path if missing (best-effort). */
39465
39473
  ensurePhysicFolderPath(folder) {
39466
39474
  try {
39467
- if (!existsSync14(folder)) {
39475
+ if (!existsSync15(folder)) {
39468
39476
  mkdirSync6(folder, { recursive: true });
39469
39477
  this.bus.log(`Created physic project folder: ${folder}`);
39470
39478
  }
@@ -39494,7 +39502,7 @@ ${guardedPrompt}`;
39494
39502
  projects: [],
39495
39503
  version: CLI_VERSION,
39496
39504
  physicPath,
39497
- physicPathExists: physicPath ? existsSync14(physicPath) : void 0,
39505
+ physicPathExists: physicPath ? existsSync15(physicPath) : void 0,
39498
39506
  // Real coarse state (ADR-0152): !paused and a recent tick (was hard-coded false).
39499
39507
  autonomousRunning: physicPath ? isAutonomousRunning(physicPath) : false,
39500
39508
  // The machine-user AI-run limit (0 = unlimited) + profile count so a dispatch can resolve the
@@ -45679,7 +45687,7 @@ var import_react21 = __toESM(require_react(), 1);
45679
45687
  var import_react26 = __toESM(require_react(), 1);
45680
45688
 
45681
45689
  // src/core/input-history.ts
45682
- import { existsSync as existsSync16, readFileSync as readFileSync18, writeFileSync as writeFileSync9 } from "fs";
45690
+ import { existsSync as existsSync17, readFileSync as readFileSync18, writeFileSync as writeFileSync9 } from "fs";
45683
45691
  import { join as join29 } from "path";
45684
45692
  var MAX_HISTORY = 200;
45685
45693
  function historyPath(profileDir2) {
@@ -45687,7 +45695,7 @@ function historyPath(profileDir2) {
45687
45695
  }
45688
45696
  function loadInputHistory(profileDir2) {
45689
45697
  const path = historyPath(profileDir2);
45690
- if (!existsSync16(path)) return [];
45698
+ if (!existsSync17(path)) return [];
45691
45699
  try {
45692
45700
  const data = JSON.parse(readFileSync18(path, "utf8"));
45693
45701
  return Array.isArray(data) ? data.filter((x) => typeof x === "string") : [];
File without changes
@@ -0,0 +1,24 @@
1
+ {
2
+ "_about": "Autonomous workflow config. autonomous-tick.sh READS this file EVERY cron tick, so edits take effect from the next tick. Keys starting with '_' are comments and are ignored by the script.",
3
+ "paused": true,
4
+ "_paused": "true = PAUSED: the tick still runs but skips immediately, without invoking Claude. A quick brake without removing the crontab entry.",
5
+ "max_session_pct": 80,
6
+ "max_weekly_pct": 90,
7
+ "_quota": "Only run when session_pct < max_session_pct AND weekly_pct < max_weekly_pct (read from the usage skill).",
8
+ "cron_schedule": "*/5 * * * *",
9
+ "_cron_schedule": "Standard 5-field cron. When you change this, the tick rewrites the crontab line pointing at autonomous-tick.sh on the next run (only if the crontab already has the autonomous line — install it once manually first).",
10
+ "command": "/auto-cycle",
11
+ "_command": "The headless slash-command each tick invokes.",
12
+ "model": "",
13
+ "_model": "Empty = Claude Code's default model. Or set an id (e.g. 'claude-sonnet-4-6') to force a cheaper/faster model for the autonomous loop.",
14
+ "quiet_hours": "",
15
+ "_quiet_hours": "Empty = run ALL DAY. Or 'HH:MM-HH:MM' (e.g. '00:00-06:00') to SKIP within that window; a window crossing midnight is valid (e.g. '22:00-06:00').",
16
+ "max_ticks_per_day": -1,
17
+ "_max_ticks_per_day": "-1 = NO limit. >0 = cap on the number of ticks that actually invoke Claude in one (local) day. The count is stored under ticks{day,count} in .claude/.autonomous.histories.json.",
18
+ "stop_on_consecutive_failures": 3,
19
+ "_stop_on_consecutive_failures": "After N consecutive FAILED command runs -> set paused=true to stop safely. 0 = off. The count is stored under consecutive_fails in .claude/.autonomous.histories.json (reset on success).",
20
+ "log_retention_days": 14,
21
+ "_log_retention_days": "Logs are written per DAY: .claude/logs/autonomous-tick-YYYY-MM-DD.log. Delete log files older than N days. 0 = keep forever.",
22
+ "notify_webhook": "",
23
+ "_notify_webhook": "TBD — a webhook URL (Slack/Discord) to notify on finish/error. NOT wired yet (read but unused)."
24
+ }
@@ -0,0 +1,38 @@
1
+ # Set up a 10-minute cron for `autonomous-tick.sh`
2
+
3
+ How to install the **autonomous cycle** on **WSL (Ubuntu)**: cron calls
4
+ [`autonomous-tick.sh`](autonomous-tick.sh) every **10 minutes**; each run does one `claude -p /auto-cycle`
5
+ cycle then exits. A file lock (`.claude/.autonomous.lock`) ensures **no two runs overlap** (a later tick
6
+ that sees the lock skips).
7
+
8
+ > All commands below run in a **WSL shell** (Ubuntu) unless noted as PowerShell/Windows. Call the project
9
+ > root `$PROJECT` (e.g. `~/projects/<your-project>`).
10
+
11
+ ## 0. Set a variable for convenience
12
+ ```bash
13
+ PROJECT="$HOME/projects/<your-project>" # fix to your path
14
+ cd "$PROJECT"
15
+ ```
16
+
17
+ ## 1. Check the required tools
18
+ Every line must print a path/version (not "not found"):
19
+ ```bash
20
+ command -v cron || echo "missing cron"
21
+ command -v claude || echo "missing claude (install natively in WSL)"
22
+ command -v git || echo "missing git"
23
+ command -v python3 || echo "missing python3"
24
+ ```
25
+
26
+ ## 2. Make the tick executable + install the cron line
27
+ ```bash
28
+ chmod +x "$PROJECT/.claude/hooks/autonomous-tick.sh"
29
+ crontab -e
30
+ # add (fix the path):
31
+ */10 * * * * /home/<user>/projects/<your-project>/.claude/hooks/autonomous-tick.sh
32
+ ```
33
+ The schedule is also driven by `cron_schedule` in `.claude/.autonomous.settings.json`; once the crontab
34
+ line exists, the tick keeps it in sync on later runs.
35
+
36
+ ## 3. Enable / watch
37
+ - Set `"paused": false` in `.claude/.autonomous.settings.json` to enable (or use the web Autonomous tab).
38
+ - Watch: `tail -f .claude/logs/autonomous-tick-$(date +%F).log`
@@ -0,0 +1,52 @@
1
+ # Autonomous mode — how it's assembled
2
+
3
+ > **Sample project — defines the workflow only, does NOT run on its own.** The files below describe an
4
+ > unattended work loop, intended to run on **WSL** (an isolated environment where the AI can be given
5
+ > full permissions).
6
+
7
+ ## The pieces
8
+ | File | Role |
9
+ |------|------|
10
+ | `.claude/hooks/autonomous-tick.sh` | Cron tick (every ~10 min); a lock so **busy ⇒ skip, idle ⇒ run**; calls `claude -p /auto-cycle`. |
11
+ | `.claude/commands/auto-cycle.md` | Defines **one cycle**: token gate → sync `${ai_dev_branch}$` → generate tasks → do one task → test → merge into `${ai_dev_branch}$`. |
12
+ | `.claude/skills/check-usage/check_usage.py` | The `check-usage` skill: prints token % and writes `output/last-usage-check.json` (deleted + recreated each run) for the token gate to read. |
13
+ | `.claude/.autonomous.approvals.json` | Approval source of truth (ADR-0152): `{ "<TSK-id>": {approved, by, at} }` — the web VERIFY tab writes it; `/auto-cycle` reads it. |
14
+ | `USER_TODO.md` | The user writes requests here; the cycle reads then clears it. |
15
+ | `AI_TODO.md` / `AI_PROGRESS.md` / `AI_DONE.md` | The task books: queue → in progress → done (ID `TSK-{group:0000}-{task:0000}`). |
16
+ | `.claude/templates/<NAME>.{empty,sample}.md` | Canonical templates for the 5 books. The "has work" gate + `/auto-cycle` **compare against `*.empty.md`** to tell empty/has-work and reset correctly (see `README.md` in that folder). |
17
+ | `.claude/settings.json` | The "bypass all" permission profile for autonomous mode (see the note below). |
18
+
19
+ ## Lifecycle (1 tick)
20
+ ```
21
+ cron ~10min → autonomous-tick.sh
22
+ ├─ locked? → log "skip", exit (wait for the next tick)
23
+ └─ idle → claude -p /auto-cycle
24
+ 0. usage: session<80% & weekly<90%? (no → stop)
25
+ 1. checkout/fetch/pull the `${ai_dev_branch}$` branch
26
+ 2. USER_TODO.md → generate tasks into AI_TODO.md (with IDs) → clear USER_TODO.md
27
+ 3. pick one APPROVED task (approvals + dependencies) → AI_PROGRESS.md, remove from AI_TODO.md, commit
28
+ 4. task/TSK-… branch → implement → commit → run the project's tests
29
+ 5. back to `${ai_dev_branch}$` → merge task (resolve conflicts if any) → AI_DONE.md, remove from AI_PROGRESS.md, commit
30
+ 6. report on the `conversation` branch (CONVERSATION.md, ≤50 entries) for later agents
31
+ 7. recheck usage → stop
32
+ ```
33
+
34
+ ## Install on WSL
35
+ ```bash
36
+ chmod +x .claude/hooks/autonomous-tick.sh
37
+ crontab -e
38
+ # add the line (fix /path):
39
+ */10 * * * * /path/to/project/.claude/hooks/autonomous-tick.sh
40
+ # watch:
41
+ tail -f .claude/logs/autonomous-tick-$(date +%F).log
42
+ ```
43
+ Requirements: `claude` logged in (has `~/.claude/.credentials.json`), plus `git` and `python3`, and the
44
+ project's test tooling (per `CLAUDE.md`).
45
+
46
+ ## Note on permissions (important)
47
+ - Claude Code only auto-loads `.claude/settings.json` and `.claude/settings.local.json`. To make a
48
+ full-permission profile take effect, one of:
49
+ 1. The tick already passes `--permission-mode bypassPermissions --dangerously-skip-permissions` (in use).
50
+ 2. Or copy the profile into `.claude/settings.local.json`.
51
+ 3. Or point `CLAUDE_CONFIG_DIR` at the profile dir when running headless.
52
+ - Only enable full permissions in an isolated environment (WSL/CI). Never on a machine with sensitive data.
File without changes
@@ -0,0 +1,176 @@
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(*)
4
+ ---
5
+
6
+ # /auto-cycle — one autonomous work cycle
7
+
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).
12
+
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 in `AI_DONE.md` under "Incidents", **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.
37
+
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. Write the question into `USER_QA.md` ("Q&A" section) per
54
+ `.claude/templates/USER_QA.sample.md`: state the original request, what's unclear, and (if possible)
55
+ options to choose from. Each question has a date + an empty answer slot.
56
+ - **Clear `USER_TODO.md`** back to the empty template (Step 2.4) — do NOT generate tasks this cycle.
57
+ The user will read `USER_QA.md`, clarify, and re-post the request into `USER_TODO.md` for a later cycle.
58
+ - Note in `AI_DONE.md` ("Incidents/notes") that this cycle stopped waiting for an answer, then go to
59
+ Step 8 (clean the lock) and **stop**.
60
+ 3. If the request is clear enough: split it into small tasks doable in ~1 cycle. Write them into
61
+ `AI_TODO.md` per `.claude/templates/AI_TODO.sample.md` (**7-column table:
62
+ `| ID | Priority | Approved | Depends | Group | Task description | Notes |`**), each with an
63
+ **ID `TSK-{groupid:0000}-{taskid:0000}`** (group = one request/batch, task = a sub-task).
64
+ - **`Priority` column**: judge it — `High` / `Medium` / `Low` (default `Medium`).
65
+ - **`Approved` column**: leave BLANK. This is a display-only mirror — the source of truth for approval
66
+ is `.claude/.autonomous.approvals.json` (the user ticks it in the web VERIFY tab — ADR-0152). Do NOT
67
+ fill it in yourself and do NOT read it to decide (see Step 3).
68
+ - **`Depends` column**: if a task must wait for another, list the `TSK-…` ids here (comma-separated);
69
+ leave empty otherwise. Step 3 skips a task whose dependencies aren't in `AI_DONE.md` yet.
70
+
71
+ **ID rules (MANDATORY):**
72
+ - **Each analysis of `USER_TODO.md` → one NEW `groupid`.** All sub-tasks split from that batch share
73
+ this `groupid`, differing only by `taskid`.
74
+ - **`groupid` must be UNIQUE and INCREASING** across all history (including groups already DONE and
75
+ cleared from `AI_TODO.md`). Since the books are cleared each cycle, **check git history** for the
76
+ largest `groupid` ever used, then take `max + 1`:
77
+ ```bash
78
+ MAXG=$( { git log -p -- AI_TODO.md AI_DONE.md AI_PROGRESS.md 2>/dev/null; \
79
+ cat AI_TODO.md AI_DONE.md AI_PROGRESS.md 2>/dev/null; } \
80
+ | grep -oE 'TSK-[0-9]{4}-[0-9]{4}' | sed -E 's/TSK-([0-9]{4}).*/\1/' \
81
+ | sort -rn | head -1 ); MAXG=${MAXG:-0}
82
+ NEWG=$(printf '%04d' $((10#$MAXG + 1)))
83
+ ```
84
+ - **`taskid` starts at `0001` and increases WITHIN the group**: `TSK-{NEWG}-0001`, `TSK-{NEWG}-0002`, …
85
+ 4. **Clear `USER_TODO.md`** by overwriting with the exact empty template
86
+ (`cp .claude/templates/USER_TODO.empty.md USER_TODO.md`) — so old tasks aren't recreated next cycle AND
87
+ the tick's "has work" gate correctly sees it as empty.
88
+
89
+ ## Step 3 — Pick ONE APPROVED task (with satisfied dependencies) and start it
90
+ > **VERIFY gate (MANDATORY):** the source of truth for approval is `.claude/.autonomous.approvals.json`
91
+ > (ADR-0152), NOT the `Approved` column in `AI_TODO.md`. A task is **approved** when approvals has
92
+ > `"<TSK-id>": { "approved": true, … }`. A task not in approvals (or `approved:false`) = NOT permitted →
93
+ > **skip it, leave it queued**.
94
+
95
+ 1. Read `.claude/.autonomous.approvals.json` (JSON `{ "<TSK-id>": {approved, by, at}, … }`; missing file
96
+ ⇒ nothing approved) and `AI_TODO.md`. **Filter tasks meeting BOTH**:
97
+ - **Approved**: `approved === true` in approvals.
98
+ - **Dependencies met**: every `TSK-…` in the `Depends` column is already in `AI_DONE.md` (done). If a
99
+ dependency isn't done yet → **skip** (wait for a later cycle), even if approved.
100
+ Pick the next task: **run group by group** (smallest group with an eligible task first), **within a
101
+ group prefer `Priority` High → Medium → Low**, then line order.
102
+ - **If NO eligible task** (empty, or all waiting for approval / dependencies — including tasks just
103
+ generated in Step 2): **take no task**. Write one line into `AI_DONE.md` ("Incidents/notes")
104
+ (e.g. "this cycle only generated tasks / waiting for VERIFY approval / waiting for dependencies"),
105
+ then go to Step 8 (clean the lock) and **stop**.
106
+ 2. Move that task into `AI_PROGRESS.md` (with a start timestamp, per
107
+ `.claude/templates/AI_PROGRESS.sample.md`), **remove it from `AI_TODO.md`**. If `AI_TODO.md` is now
108
+ empty → `cp .claude/templates/AI_TODO.empty.md AI_TODO.md`.
109
+ 3. Commit on `${ai_dev_branch}$`: `git add -A && git commit -m "chore(auto): start TSK-xxxx-xxxx"`.
110
+
111
+ ## Step 4 — Implement the task on its own branch
112
+ 1. Create the branch: `git checkout -b task/TSK-xxxx-xxxx`.
113
+ 2. Implement the task **following the project's architecture + the conventions in `CLAUDE.md`**. Add or
114
+ update tests as appropriate for the change.
115
+ 3. **Test** by running the project's test command (see `CLAUDE.md` / the project's scripts — e.g.
116
+ `scripts/test.*`, `npm test`, `pnpm test`, `pytest`, …). If the project defines an integration-test /
117
+ evidence harness, use it and keep the produced report/evidence so it can be reviewed later.
118
+ 4. **Wait for the tests to finish** and check the result before continuing.
119
+ 5. Commit (include any produced report/evidence so the integration branch carries it):
120
+ `git add -A && git commit -m "feat(TSK-xxxx-xxxx): <short description>"`.
121
+
122
+ ## Step 5 — Merge into `${ai_dev_branch}$`, update the books
123
+ 1. `git checkout ${ai_dev_branch}$`
124
+ 2. `git merge --no-ff task/TSK-xxxx-xxxx`
125
+ - **On CONFLICT** (parallel agents may have moved the integration branch — MEMO #40): `git status`
126
+ shows `UU` files. **Resolve them yourself**: edit each conflicted file into a correct merged result
127
+ (remove every `<<<<<<< ======= >>>>>>>` marker), `git add <file>`, then `git commit --no-edit` to
128
+ finish the merge. If a conflict is too complex to be sure → `git merge --abort`, write a question
129
+ into `USER_QA.md`, go to Step 8 and stop (don't guess).
130
+ 3. Record the task in `AI_DONE.md` (ID, description, timestamp — per `.claude/templates/AI_DONE.sample.md`).
131
+ **Remove it from `AI_PROGRESS.md`**: if nothing is in progress after removal, reset with
132
+ `cp .claude/templates/AI_PROGRESS.empty.md AI_PROGRESS.md`. Likewise, if `AI_TODO.md` is now empty →
133
+ `cp .claude/templates/AI_TODO.empty.md AI_TODO.md`.
134
+ 4. Commit: `git add -A && git commit -m "chore(auto): finish TSK-xxxx-xxxx, merge into ${ai_dev_branch}$"`.
135
+ 5. (Optional) `git push origin ${ai_dev_branch}$`.
136
+
137
+ ## Step 6 — Recheck the token budget
138
+ 1. Re-run the **check-usage** skill: `python .claude/skills/check-usage/check_usage.py`, then **wait 3s**
139
+ (`sleep 3`) for the result file to be written.
140
+ 2. Print a summary: task done, tokens remaining.
141
+
142
+ ## Step 7 — Commit & push `${ai_dev_branch}$`
143
+ 1. `git checkout ${ai_dev_branch}$`
144
+ 2. `git add -A && git commit -m "chore(auto): update books after the autonomous cycle"` (skip if no change).
145
+ 3. `git push origin ${ai_dev_branch}$`.
146
+
147
+ ## Step 7.5 — Report on the `conversation` branch (share context with later agents — MEMO #40)
148
+ > Multiple Claude instances take different tasks in parallel; a later agent needs to know what an earlier
149
+ > one did. Use a dedicated branch named **`conversation`** holding **only** the file `CONVERSATION.md`
150
+ > (no source or docs), keeping **at most the 50 most recent reports** (trim older ones when over).
151
+
152
+ 1. Save the context (task ID + summary + list of files changed this cycle).
153
+ 2. `git stash -u` if there are uncommitted changes (usually none — the books were committed in Step 7).
154
+ 3. Switch to the conversation branch (create it orphan if missing):
155
+ - `git fetch origin` → `git checkout conversation` (exists) or
156
+ `git checkout --orphan conversation && git rm -rf . 2>/dev/null` (create fresh, clean).
157
+ - `git pull --ff-only origin conversation` (skip if the remote has none).
158
+ 4. Append an entry to the **end** of `CONVERSATION.md`:
159
+ ```
160
+ ## <yyyy-MM-dd HH:mm> · TSK-xxxx-xxxx
161
+ - Did: <short summary>
162
+ - Files: <paths, comma-separated>
163
+ - Merged into: ${ai_dev_branch}$ (<conflict / no conflict>)
164
+ ```
165
+ If the number of `##` entries exceeds **50** → drop the oldest ones down to 50.
166
+ 5. `git add CONVERSATION.md && git commit -m "chore(conversation): TSK-xxxx-xxxx" && git push origin conversation`.
167
+ 6. Return to the integration branch: `git checkout ${ai_dev_branch}$` (and `git stash pop` if you stashed in 2).
168
+
169
+ ## Step 8 — Clean the lock (ALWAYS run, even on error/early exit)
170
+ > This is the **final action of every cycle** — run it whether the cycle succeeded, hit an error, or was
171
+ > blocked at the token gate. Goal: never let a stale lock block the next cron tick.
172
+ 1. Delete the lock file: `rm -f .claude/.autonomous.lock` (run in the project root).
173
+ - The lock IS the file `.autonomous.lock`: `autonomous-tick.sh` creates it at start and treats "the
174
+ file exists" = a run is in progress. Deleting it here releases the lock for the next tick.
175
+ - This is `/auto-cycle`'s responsibility; the wrapper only has a safety-net trap in case the cycle dies.
176
+ 2. **STOP** (the next cron tick will trigger the next cycle).
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Manage .claude/.autonomous.histories.json — the SINGLE file combining the autonomous workflow's state + history.
5
+
6
+ Structure:
7
+ {
8
+ "cron_applied": "*/10 * * * *", # the cron schedule applied last time (to detect a schedule change)
9
+ "consecutive_fails": 0, # number of CONSECUTIVE failed command runs (reset on success)
10
+ "ticks": {"day": "YYYY-MM-DD", "count": N}, # count of ticks that actually invoked Claude today
11
+ "records": [ {...}, ... ] # cron run history, NEWEST LAST, at most 50 records
12
+ }
13
+
14
+ Each record:
15
+ {
16
+ "ts": "YYYY-MM-DD HH:MM:SS",
17
+ "status": "success" | "failure" | "skip",
18
+ "rc": <int|null>, # the command's exit code (null if it didn't run)
19
+ "gate": "<token-gate string>",
20
+ "note": "<short note>",
21
+ "command": "/auto-cycle",
22
+ "tick": "YYYY-MM-DD #N", # day + tick number within the day
23
+ "config": { cron_schedule, model, max_session_pct, max_weekly_pct, quiet_hours,
24
+ max_ticks_per_day, stop_on_consecutive_failures, paused }
25
+ }
26
+
27
+ Usage (the file path is the first arg):
28
+ history.py <file> migrate <cron-applied> <fails> <ticks> # one-time load from 3 old files (paths)
29
+ history.py <file> get-cron
30
+ history.py <file> set-cron <schedule>
31
+ history.py <file> get-fails
32
+ history.py <file> set-fails <n>
33
+ history.py <file> get-ticks <today> # print the count for 'today' (0 if a different day/missing)
34
+ history.py <file> set-ticks <day> <count>
35
+ history.py <file> record <ts> <status> <rc> <gate> <note> # config is read from the CFG_* env vars
36
+
37
+ Philosophy: NEVER break on a missing/corrupt file — always initialize the default skeleton.
38
+ """
39
+ import json
40
+ import os
41
+ import sys
42
+
43
+ MAX_RECORDS = 50
44
+
45
+ SKELETON = {
46
+ "cron_applied": "",
47
+ "consecutive_fails": 0,
48
+ "ticks": {"day": "", "count": 0},
49
+ "records": [],
50
+ }
51
+
52
+
53
+ def load(path):
54
+ try:
55
+ with open(path, encoding="utf-8") as f:
56
+ d = json.load(f)
57
+ if not isinstance(d, dict):
58
+ raise ValueError("not an object")
59
+ except Exception:
60
+ d = {}
61
+ out = dict(SKELETON)
62
+ out.update({k: d[k] for k in SKELETON if k in d})
63
+ if not isinstance(out.get("ticks"), dict):
64
+ out["ticks"] = dict(SKELETON["ticks"])
65
+ if not isinstance(out.get("records"), list):
66
+ out["records"] = []
67
+ return out
68
+
69
+
70
+ def save(path, d):
71
+ tmp = path + ".tmp"
72
+ with open(tmp, "w", encoding="utf-8") as f:
73
+ json.dump(d, f, ensure_ascii=False, indent=2)
74
+ f.write("\n")
75
+ os.replace(tmp, path)
76
+
77
+
78
+ def cfg_from_env():
79
+ def g(k, default=""):
80
+ return os.environ.get(k, default)
81
+ return {
82
+ "cron_schedule": g("CFG_CRON_SCHEDULE"),
83
+ "model": g("CFG_MODEL"),
84
+ "max_session_pct": g("CFG_MAX_SESSION_PCT"),
85
+ "max_weekly_pct": g("CFG_MAX_WEEKLY_PCT"),
86
+ "quiet_hours": g("CFG_QUIET_HOURS"),
87
+ "max_ticks_per_day": g("CFG_MAX_TICKS_PER_DAY"),
88
+ "stop_on_consecutive_failures": g("CFG_STOP_ON_CONSEC_FAILURES"),
89
+ "paused": g("CFG_PAUSED"),
90
+ }
91
+
92
+
93
+ def main():
94
+ if len(sys.argv) < 3:
95
+ sys.stderr.write("missing args: <file> <op> [...]\n")
96
+ return 2
97
+ path = sys.argv[1]
98
+ op = sys.argv[2]
99
+ args = sys.argv[3:]
100
+ d = load(path)
101
+
102
+ if op == "get-cron":
103
+ sys.stdout.write(str(d.get("cron_applied", "")))
104
+ return 0
105
+
106
+ if op == "set-cron":
107
+ d["cron_applied"] = args[0] if args else ""
108
+ save(path, d)
109
+ return 0
110
+
111
+ if op == "get-fails":
112
+ sys.stdout.write(str(int(d.get("consecutive_fails", 0) or 0)))
113
+ return 0
114
+
115
+ if op == "set-fails":
116
+ d["consecutive_fails"] = int(args[0]) if args else 0
117
+ save(path, d)
118
+ return 0
119
+
120
+ if op == "get-ticks":
121
+ today = args[0] if args else ""
122
+ t = d.get("ticks", {})
123
+ sys.stdout.write(str(int(t.get("count", 0) or 0) if t.get("day") == today else 0))
124
+ return 0
125
+
126
+ if op == "set-ticks":
127
+ day = args[0] if len(args) > 0 else ""
128
+ count = int(args[1]) if len(args) > 1 else 0
129
+ d["ticks"] = {"day": day, "count": count}
130
+ save(path, d)
131
+ return 0
132
+
133
+ if op == "record":
134
+ ts = args[0] if len(args) > 0 else ""
135
+ status = args[1] if len(args) > 1 else ""
136
+ rc_raw = args[2] if len(args) > 2 else ""
137
+ gate = args[3] if len(args) > 3 else ""
138
+ note = args[4] if len(args) > 4 else ""
139
+ try:
140
+ rc = int(rc_raw)
141
+ except (ValueError, TypeError):
142
+ rc = None
143
+ t = d.get("ticks", {})
144
+ rec = {
145
+ "ts": ts,
146
+ "status": status,
147
+ "rc": rc,
148
+ "gate": gate,
149
+ "note": note,
150
+ "command": os.environ.get("CFG_COMMAND", ""),
151
+ "tick": "%s #%s" % (t.get("day", ""), t.get("count", 0)),
152
+ "config": cfg_from_env(),
153
+ }
154
+ d["records"].append(rec)
155
+ if len(d["records"]) > MAX_RECORDS:
156
+ d["records"] = d["records"][-MAX_RECORDS:]
157
+ save(path, d)
158
+ return 0
159
+
160
+ if op == "migrate":
161
+ # Only load when histories.json has no real data yet (don't overwrite live data).
162
+ cron_p = args[0] if len(args) > 0 else ""
163
+ fails_p = args[1] if len(args) > 1 else ""
164
+ ticks_p = args[2] if len(args) > 2 else ""
165
+
166
+ def read_txt(p):
167
+ try:
168
+ with open(p, encoding="utf-8") as f:
169
+ return f.read().strip()
170
+ except Exception:
171
+ return ""
172
+
173
+ if cron_p and not d.get("cron_applied"):
174
+ v = read_txt(cron_p)
175
+ if v:
176
+ d["cron_applied"] = v
177
+ if fails_p and not d.get("consecutive_fails"):
178
+ v = read_txt(fails_p)
179
+ if v.isdigit():
180
+ d["consecutive_fails"] = int(v)
181
+ if ticks_p and not d.get("ticks", {}).get("day"):
182
+ parts = read_txt(ticks_p).split()
183
+ if len(parts) == 2 and parts[1].isdigit():
184
+ d["ticks"] = {"day": parts[0], "count": int(parts[1])}
185
+ save(path, d)
186
+ return 0
187
+
188
+ sys.stderr.write("invalid op: %s\n" % op)
189
+ return 2
190
+
191
+
192
+ if __name__ == "__main__":
193
+ sys.exit(main())