@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.
package/dist/index.js CHANGED
@@ -28873,7 +28873,11 @@ var en_default = {
28873
28873
  "slash.claudeCmd.usage": "usage: /claude-cmd /context (forwards to the AI CLI)",
28874
28874
  "start.connecting": "Connecting to {server} (profile: {profile})\u2026",
28875
28875
  "start.recovered": "Recovered from an internal error \u2014 reconnecting: {message}",
28876
- "attach.attached": 'Attached to profile "{profile}" \u2014 the worker runs headless; input is forwarded.'
28876
+ "attach.attached": 'Attached to profile "{profile}" \u2014 the worker runs headless; input is forwarded.',
28877
+ "autoRun.done": "Autonomous cycle complete.",
28878
+ "autoRun.failed": "Autonomous cycle failed to dispatch: {note}",
28879
+ "autoRun.noDaemon": 'No running daemon for profile "{profile}" \u2014 start it first: 4pm start --profile {profile}',
28880
+ "autoRun.timeout": "Autonomous cycle exceeded the time limit \u2014 aborting."
28877
28881
  };
28878
28882
 
28879
28883
  // src/i18n/messages/vi.json
@@ -29051,7 +29055,11 @@ var vi_default = {
29051
29055
  "slash.claudeCmd.usage": "c\xE1ch d\xF9ng: /claude-cmd /context (chuy\u1EC3n ti\u1EBFp t\u1EDBi AI CLI)",
29052
29056
  "start.connecting": "\u0110ang k\u1EBFt n\u1ED1i t\u1EDBi {server} (h\u1ED3 s\u01A1: {profile})\u2026",
29053
29057
  "start.recovered": "\u0110\xE3 kh\xF4i ph\u1EE5c sau l\u1ED7i n\u1ED9i b\u1ED9 \u2014 \u0111ang k\u1EBFt n\u1ED1i l\u1EA1i: {message}",
29054
- "attach.attached": '\u0110\xE3 g\u1EAFn v\xE0o h\u1ED3 s\u01A1 "{profile}" \u2014 worker ch\u1EA1y kh\xF4ng giao di\u1EC7n; \u0111\u1EA7u v\xE0o \u0111\u01B0\u1EE3c chuy\u1EC3n ti\u1EBFp.'
29058
+ "attach.attached": '\u0110\xE3 g\u1EAFn v\xE0o h\u1ED3 s\u01A1 "{profile}" \u2014 worker ch\u1EA1y kh\xF4ng giao di\u1EC7n; \u0111\u1EA7u v\xE0o \u0111\u01B0\u1EE3c chuy\u1EC3n ti\u1EBFp.',
29059
+ "autoRun.done": "Chu k\u1EF3 t\u1EF1 \u0111\u1ED9ng \u0111\xE3 ho\xE0n t\u1EA5t.",
29060
+ "autoRun.failed": "Kh\xF4ng \u0111i\u1EC1u ph\u1ED1i \u0111\u01B0\u1EE3c chu k\u1EF3 t\u1EF1 \u0111\u1ED9ng: {note}",
29061
+ "autoRun.noDaemon": 'Kh\xF4ng c\xF3 ti\u1EBFn tr\xECnh n\u1EC1n n\xE0o cho h\u1ED3 s\u01A1 "{profile}" \u2014 h\xE3y kh\u1EDFi \u0111\u1ED9ng tr\u01B0\u1EDBc: 4pm start --profile {profile}',
29062
+ "autoRun.timeout": "Chu k\u1EF3 t\u1EF1 \u0111\u1ED9ng v\u01B0\u1EE3t qu\xE1 gi\u1EDBi h\u1EA1n th\u1EDDi gian \u2014 \u0111ang h\u1EE7y."
29055
29063
  };
29056
29064
 
29057
29065
  // src/i18n/messages/ja.json
@@ -29229,7 +29237,11 @@ var ja_default = {
29229
29237
  "slash.claudeCmd.usage": "\u4F7F\u3044\u65B9: /claude-cmd /context (AI CLI \u306B\u8EE2\u9001)",
29230
29238
  "start.connecting": "{server} \u306B\u63A5\u7D9A\u4E2D\uFF08\u30D7\u30ED\u30D5\u30A1\u30A4\u30EB: {profile}\uFF09\u2026",
29231
29239
  "start.recovered": "\u5185\u90E8\u30A8\u30E9\u30FC\u304B\u3089\u56DE\u5FA9\u3057\u307E\u3057\u305F \u2014 \u518D\u63A5\u7D9A\u4E2D: {message}",
29232
- "attach.attached": "\u30D7\u30ED\u30D5\u30A1\u30A4\u30EB\u300C{profile}\u300D\u306B\u30A2\u30BF\u30C3\u30C1\u3057\u307E\u3057\u305F \u2014 worker \u306F\u30D8\u30C3\u30C9\u30EC\u30B9\u3067\u52D5\u4F5C\u3057\u3001\u5165\u529B\u306F\u8EE2\u9001\u3055\u308C\u307E\u3059\u3002"
29240
+ "attach.attached": "\u30D7\u30ED\u30D5\u30A1\u30A4\u30EB\u300C{profile}\u300D\u306B\u30A2\u30BF\u30C3\u30C1\u3057\u307E\u3057\u305F \u2014 worker \u306F\u30D8\u30C3\u30C9\u30EC\u30B9\u3067\u52D5\u4F5C\u3057\u3001\u5165\u529B\u306F\u8EE2\u9001\u3055\u308C\u307E\u3059\u3002",
29241
+ "autoRun.done": "\u81EA\u5F8B\u30B5\u30A4\u30AF\u30EB\u304C\u5B8C\u4E86\u3057\u307E\u3057\u305F\u3002",
29242
+ "autoRun.failed": "\u81EA\u5F8B\u30B5\u30A4\u30AF\u30EB\u306E\u30C7\u30A3\u30B9\u30D1\u30C3\u30C1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {note}",
29243
+ "autoRun.noDaemon": '\u30D7\u30ED\u30D5\u30A1\u30A4\u30EB "{profile}" \u306E\u30C7\u30FC\u30E2\u30F3\u304C\u5B9F\u884C\u3055\u308C\u3066\u3044\u307E\u305B\u3093 \u2014 \u5148\u306B\u8D77\u52D5\u3057\u3066\u304F\u3060\u3055\u3044: 4pm start --profile {profile}',
29244
+ "autoRun.timeout": "\u81EA\u5F8B\u30B5\u30A4\u30AF\u30EB\u304C\u5236\u9650\u6642\u9593\u3092\u8D85\u3048\u307E\u3057\u305F \u2014 \u4E2D\u6B62\u3057\u307E\u3059\u3002"
29233
29245
  };
29234
29246
 
29235
29247
  // src/i18n/messages/zh.json
@@ -29407,7 +29419,11 @@ var zh_default = {
29407
29419
  "slash.claudeCmd.usage": "\u7528\u6CD5\uFF1A/claude-cmd /context (\u8F6C\u53D1\u7ED9 AI CLI)",
29408
29420
  "start.connecting": "\u6B63\u5728\u8FDE\u63A5\u5230 {server}\uFF08\u914D\u7F6E\u6587\u4EF6\uFF1A{profile}\uFF09\u2026",
29409
29421
  "start.recovered": "\u5DF2\u4ECE\u5185\u90E8\u9519\u8BEF\u4E2D\u6062\u590D \u2014 \u6B63\u5728\u91CD\u65B0\u8FDE\u63A5\uFF1A{message}",
29410
- "attach.attached": "\u5DF2\u9644\u52A0\u5230\u914D\u7F6E\u6587\u4EF6 \u201C{profile}\u201D \u2014 worker \u4EE5\u65E0\u754C\u9762\u65B9\u5F0F\u8FD0\u884C\uFF1B\u8F93\u5165\u5C06\u88AB\u8F6C\u53D1\u3002"
29422
+ "attach.attached": "\u5DF2\u9644\u52A0\u5230\u914D\u7F6E\u6587\u4EF6 \u201C{profile}\u201D \u2014 worker \u4EE5\u65E0\u754C\u9762\u65B9\u5F0F\u8FD0\u884C\uFF1B\u8F93\u5165\u5C06\u88AB\u8F6C\u53D1\u3002",
29423
+ "autoRun.done": "\u81EA\u4E3B\u5FAA\u73AF\u5DF2\u5B8C\u6210\u3002",
29424
+ "autoRun.failed": "\u81EA\u4E3B\u5FAA\u73AF\u8C03\u5EA6\u5931\u8D25\uFF1A{note}",
29425
+ "autoRun.noDaemon": "\u914D\u7F6E\u6587\u4EF6 \u201C{profile}\u201D \u6CA1\u6709\u6B63\u5728\u8FD0\u884C\u7684\u5B88\u62A4\u8FDB\u7A0B \u2014 \u8BF7\u5148\u542F\u52A8\uFF1A4pm start --profile {profile}",
29426
+ "autoRun.timeout": "\u81EA\u4E3B\u5FAA\u73AF\u8D85\u8FC7\u65F6\u95F4\u9650\u5236 \u2014 \u6B63\u5728\u4E2D\u6B62\u3002"
29411
29427
  };
29412
29428
 
29413
29429
  // src/i18n/messages/index.ts
@@ -32706,7 +32722,7 @@ async function checkAndUpdate(serverUrl, autoUpdate) {
32706
32722
 
32707
32723
  // src/core/ws-client.ts
32708
32724
  init_wrapper();
32709
- import { randomUUID as randomUUID2 } from "crypto";
32725
+ import { randomUUID as randomUUID3 } from "crypto";
32710
32726
  import { basename as basename7, join as join28 } from "path";
32711
32727
  import { existsSync as existsSync16, mkdirSync as mkdirSync7 } from "fs";
32712
32728
 
@@ -37117,6 +37133,13 @@ var autonomousWriteRequestSchema = external_exports.discriminatedUnion("kind", [
37117
37133
  unapprove: external_exports.array(external_exports.string().min(1).max(64)).max(500)
37118
37134
  }),
37119
37135
  external_exports.object({ kind: external_exports.literal("userTodo"), content: external_exports.string().min(1).max(16 * 1024) }),
37136
+ // Traced book save (ADR-0320): the cli diffs rows by id + stamps `.autonomous.authors.json` so
37137
+ // authorship is server-filled, not a spoofable `.md` cell. `by`/author are added server-side.
37138
+ external_exports.object({
37139
+ kind: external_exports.literal("bookSave"),
37140
+ book: external_exports.enum(["userTodo", "userQa", "aiTodo"]),
37141
+ content: external_exports.string().max(256 * 1024)
37142
+ }),
37120
37143
  external_exports.object({ kind: external_exports.literal("cron"), action: external_exports.enum(["install", "uninstall"]) })
37121
37144
  ]);
37122
37145
  var autonomousLogsQuerySchema = external_exports.object({
@@ -39873,6 +39896,7 @@ var run4 = promisify4(execFile4);
39873
39896
  var SETTINGS_REL = ".claude/.autonomous.settings.json";
39874
39897
  var HISTORIES_REL = ".claude/.autonomous.histories.json";
39875
39898
  var APPROVALS_REL = ".claude/.autonomous.approvals.json";
39899
+ var AUTHORS_REL = ".claude/.autonomous.authors.json";
39876
39900
  var TICK_REL = ".claude/hooks/autonomous-tick.sh";
39877
39901
  var LOG_DIR_REL = ".claude/logs";
39878
39902
  var BOOK_FILES = {
@@ -39901,6 +39925,35 @@ function parseJson(text) {
39901
39925
  function tableCell(value) {
39902
39926
  return value.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
39903
39927
  }
39928
+ async function readJsonMap(path) {
39929
+ return parseJson(await readText(path, "{}")) ?? {};
39930
+ }
39931
+ function entryBy(map, id) {
39932
+ const e = map[id];
39933
+ return e && typeof e === "object" && typeof e.by === "string" ? e.by : null;
39934
+ }
39935
+ function tableRowsById(md) {
39936
+ const rows = /* @__PURE__ */ new Map();
39937
+ let headerSeen = false;
39938
+ let inTable = false;
39939
+ for (const line of md.split(/\r?\n/)) {
39940
+ const s3 = line.trim();
39941
+ if (!s3.startsWith("|")) {
39942
+ if (inTable) break;
39943
+ continue;
39944
+ }
39945
+ inTable = true;
39946
+ if (!headerSeen) {
39947
+ headerSeen = true;
39948
+ continue;
39949
+ }
39950
+ if (/^\|[\s:|-]+\|?$/.test(s3)) continue;
39951
+ const cells = s3.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
39952
+ const id = cells[0] ?? "";
39953
+ if (id) rows.set(id, cells.join(""));
39954
+ }
39955
+ return rows;
39956
+ }
39904
39957
  function tickScript(root) {
39905
39958
  return join15(root, TICK_REL);
39906
39959
  }
@@ -39955,16 +40008,17 @@ function isAutonomousRunning(root) {
39955
40008
  }
39956
40009
  }
39957
40010
  async function readAutonomous(root) {
39958
- const [settings, approvals, status, ...books] = await Promise.all([
40011
+ const [settings, approvals, authors, status, ...books] = await Promise.all([
39959
40012
  readText(join15(root, SETTINGS_REL)),
39960
40013
  readText(join15(root, APPROVALS_REL), "{}"),
40014
+ readText(join15(root, AUTHORS_REL), "{}"),
39961
40015
  getAutonomousStatus(root),
39962
40016
  ...Object.values(BOOK_FILES).map((f2) => readText(join15(root, f2)))
39963
40017
  ]);
39964
40018
  const keys = Object.keys(BOOK_FILES);
39965
40019
  const bookMap = {};
39966
40020
  keys.forEach((k2, i) => bookMap[k2] = books[i] ?? "");
39967
- return { settings, status, books: bookMap, approvals };
40021
+ return { settings, status, books: bookMap, approvals, authors };
39968
40022
  }
39969
40023
  async function readAutonomousLogs(root, date) {
39970
40024
  const day = date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -39996,26 +40050,57 @@ async function writeAutonomous(root, req, by) {
39996
40050
  break;
39997
40051
  }
39998
40052
  case "approvals": {
40053
+ if (req.approved && !req.byIsAdmin) {
40054
+ const authors = await readJsonMap(join15(root, AUTHORS_REL));
40055
+ if (entryBy(authors, req.taskId) === by) {
40056
+ return { ok: false, code: "APPROVAL_SELF", failedId: req.taskId, error: "self-approval blocked" };
40057
+ }
40058
+ }
39999
40059
  const map = parseJson(await readText(join15(root, APPROVALS_REL), "{}")) ?? {};
40000
- if (req.approved) map[req.taskId] = { approved: true, by, at: (/* @__PURE__ */ new Date()).toISOString() };
40060
+ if (req.approved) map[req.taskId] = { approved: true, by, byLabel: req.byLabel ?? by, at: (/* @__PURE__ */ new Date()).toISOString() };
40001
40061
  else delete map[req.taskId];
40002
40062
  await writeFile2(join15(root, APPROVALS_REL), JSON.stringify(map, null, 2) + "\n", "utf8");
40003
40063
  break;
40004
40064
  }
40005
40065
  case "approvalsBatch": {
40066
+ if (!req.byIsAdmin) {
40067
+ const authors = await readJsonMap(join15(root, AUTHORS_REL));
40068
+ const selfId = req.approve.find((id) => entryBy(authors, id) === by);
40069
+ if (selfId) return { ok: false, code: "APPROVAL_SELF", failedId: selfId, error: "self-approval blocked" };
40070
+ }
40006
40071
  const map = parseJson(await readText(join15(root, APPROVALS_REL), "{}")) ?? {};
40007
40072
  const at2 = (/* @__PURE__ */ new Date()).toISOString();
40008
- for (const taskId of req.approve) map[taskId] = { approved: true, by, at: at2 };
40073
+ const byLabel = req.byLabel ?? by;
40074
+ for (const taskId of req.approve) map[taskId] = { approved: true, by, byLabel, at: at2 };
40009
40075
  for (const taskId of req.unapprove) delete map[taskId];
40010
40076
  await writeFile2(join15(root, APPROVALS_REL), JSON.stringify(map, null, 2) + "\n", "utf8");
40011
40077
  break;
40012
40078
  }
40013
40079
  case "userTodo": {
40014
40080
  const cur = await readText(join15(root, BOOK_FILES.userTodo));
40015
- const trace = `posted by ${tableCell(by)} at ${(/* @__PURE__ */ new Date()).toISOString()}`;
40016
- const row = `| | ${tableCell(req.content)} | ${trace} |
40081
+ const ids = [...cur.matchAll(/REQ-(\d{4})-(\d{4})/g)];
40082
+ const maxGroup = ids.reduce((m2, g2) => Math.max(m2, Number(g2[1])), 0);
40083
+ const id = `REQ-${String(maxGroup + 1).padStart(4, "0")}-0001`;
40084
+ const row = `| ${id} | | | ${tableCell(req.content)} |
40017
40085
  `;
40018
40086
  await writeFile2(join15(root, BOOK_FILES.userTodo), cur.replace(/\s*$/, "\n") + row, "utf8");
40087
+ const authors = await readJsonMap(join15(root, AUTHORS_REL));
40088
+ authors[id] = { by, byLabel: req.byLabel ?? by, at: (/* @__PURE__ */ new Date()).toISOString() };
40089
+ await writeFile2(join15(root, AUTHORS_REL), JSON.stringify(authors, null, 2) + "\n", "utf8");
40090
+ break;
40091
+ }
40092
+ case "bookSave": {
40093
+ const file = BOOK_FILES[req.book];
40094
+ const prev = tableRowsById(await readText(join15(root, file)));
40095
+ const next = tableRowsById(req.content);
40096
+ const authors = await readJsonMap(join15(root, AUTHORS_REL));
40097
+ const at2 = (/* @__PURE__ */ new Date()).toISOString();
40098
+ const byLabel = req.byLabel ?? by;
40099
+ for (const [id, cells] of next) {
40100
+ if (prev.get(id) !== cells) authors[id] = { by, byLabel, at: at2 };
40101
+ }
40102
+ await writeFile2(join15(root, file), req.content, "utf8");
40103
+ await writeFile2(join15(root, AUTHORS_REL), JSON.stringify(authors, null, 2) + "\n", "utf8");
40019
40104
  break;
40020
40105
  }
40021
40106
  case "cron": {
@@ -41351,6 +41436,102 @@ function rejectByReview(ctx, commandId, origin, prompt, reasons) {
41351
41436
  });
41352
41437
  }
41353
41438
 
41439
+ // src/core/autonomous-cycle.ts
41440
+ import { randomUUID as randomUUID2 } from "crypto";
41441
+ function buildAutonomousCyclePrompt() {
41442
+ return `You are running UNATTENDED as the 4PM autonomous cycle (headless, one tick). Do EXACTLY ONE small
41443
+ work cycle with the steps below, then STOP. Keep each cycle to ONE small task so you never run out of
41444
+ tokens mid-way. If a step fails, append a row to the **Incidents** table of \`AI_DONE.md\` and STOP \u2014
41445
+ do not push on. Write all books/docs/code in the language the project's \`CLAUDE.md\` specifies (default
41446
+ English).
41447
+
41448
+ ## Books & approval (MUST read the approvals file)
41449
+ The five "book" files at the project root \u2014 \`USER_TODO.md\`, \`USER_QA.md\`, \`AI_TODO.md\`,
41450
+ \`AI_PROGRESS.md\`, \`AI_DONE.md\` \u2014 have canonical templates in \`.claude/templates/\` (\`<NAME>.empty.md\`
41451
+ = EMPTY, \`<NAME>.sample.md\` = example WITH DATA). To tell empty/has-work, COMPARE a book to its
41452
+ \`<NAME>.empty.md\` (equal after trimming \u21D2 empty). When clearing a book, overwrite it with EXACTLY the
41453
+ empty template (\`cp .claude/templates/<NAME>.empty.md <NAME>\`).
41454
+
41455
+ The single source of truth for approval is \`.claude/.autonomous.approvals.json\` \u2014 a map
41456
+ \`{ "<ID>": { "approved": true, "by": "...", "at": "..." } }\` (the user ticks rows in the web grids). A
41457
+ row is APPROVED only when its ID has \`"approved": true\`. Row IDs: \`USER_TODO\` = \`REQ-\u2026\`, \`USER_QA\`
41458
+ = \`QA-\u2026\`, \`AI_TODO\` = \`TSK-\u2026\`. NEVER act on an unapproved row.
41459
+
41460
+ ## Step 1 \u2014 Base branch (from the spec, ADR-0292)
41461
+ The project root IS the primary repo (ADR-0314). Its currently checked-out branch is the base branch
41462
+ (4PM cloned it with \`-b <branch>\`): \`BASE=$(git rev-parse --abbrev-ref HEAD)\`. Sync it:
41463
+ \`git fetch origin\` then \`git pull --ff-only origin "$BASE"\` (skip the pull if the remote has no such
41464
+ branch yet). Do NOT invent a separate integration/dev branch.
41465
+
41466
+ ## Step 2 \u2014 Intake APPROVED user requests \u2192 tasks
41467
+ Read \`USER_TODO.md\` (content-only columns \`ID | Group | Depends | Request\`; who/when wrote or approved a
41468
+ row lives in \`.claude/.autonomous.authors.json\` / \`.autonomous.approvals.json\`, ADR-0320 \u2014 never add
41469
+ provenance columns to the table). For each row that is APPROVED (its \`REQ-\u2026\` in the approvals file) AND
41470
+ whose \`Depends\` \`REQ-\u2026\` are already handled:
41471
+ - If the request is CLEAR: split it into small tasks and append them to \`AI_TODO.md\` (7 columns
41472
+ \`ID | Priority | Tag | Depends | Group | Task description | Notes\`), each with an ID
41473
+ \`TSK-{groupid:0000}-{taskid:0000}\`. **groupid** must be UNIQUE + INCREASING across all history \u2014 find
41474
+ the largest ever used in git history of \`AI_TODO.md\`/\`AI_DONE.md\`/\`AI_PROGRESS.md\`, then use max+1;
41475
+ **taskid** starts at 0001 within the group. Leave \`Tag\` blank unless a catalog tag applies; leave
41476
+ \`Priority\` = High/Medium/Low (default Medium). Then REMOVE the analysed request row from
41477
+ \`USER_TODO.md\` (if it becomes empty, reset it to the empty template).
41478
+ - If the request is UNCLEAR (ambiguous/missing info/contradictory): do NOT guess. Append a row to
41479
+ \`USER_QA.md\` (content-only columns \`ID | Group | Depends | Original request | Question / options |
41480
+ Answer\`) with a new \`QA-{groupid:0000}-{qaid:0000}\` id, the original request and the question +
41481
+ options; leave \`Answer\` blank for the user. Remove the request row from \`USER_TODO.md\`. (Do NOT
41482
+ generate tasks for it.)
41483
+ An unapproved request row is LEFT in place (wait for approval).
41484
+
41485
+ ## Step 3 \u2014 Fold APPROVED answers back (USER_QA)
41486
+ For each \`USER_QA.md\` row that has an \`Answer\` AND is APPROVED (its \`QA-\u2026\` in the approvals file):
41487
+ re-analyse using the answer \u2014 append the resulting tasks to \`AI_TODO.md\` (same ID rules as Step 2) \u2014
41488
+ then clear that QA row. An unanswered or unapproved QA row is left in place.
41489
+
41490
+ ## Step 4 \u2014 Pick ONE approved task and start it
41491
+ Read \`AI_TODO.md\` + the approvals file. Filter tasks that are BOTH approved (\`TSK-\u2026\` in approvals) AND
41492
+ have every \`Depends\` \`TSK-\u2026\` already in \`AI_DONE.md\`. Run group by group (smallest eligible group
41493
+ first), within a group High \u2192 Medium \u2192 Low, then line order. If NO eligible task: append a row to the
41494
+ \`AI_DONE.md\` Incidents table (e.g. "only generated tasks / waiting for approval / waiting for deps")
41495
+ and STOP. Otherwise move the chosen task into \`AI_PROGRESS.md\` (with a start timestamp), remove it from
41496
+ \`AI_TODO.md\`, and commit on the base branch: \`git commit -am "chore(auto): start TSK-\u2026"\`.
41497
+
41498
+ ## Step 5 \u2014 Implement on a task branch + test
41499
+ \`git checkout -b task/TSK-\u2026\`. Implement following the project's architecture + \`CLAUDE.md\`; add/update
41500
+ tests. Run the project's test command (per \`CLAUDE.md\` / the project's scripts); WAIT for it to finish
41501
+ and check the result. Keep any produced report/evidence. Commit: \`git commit -am "feat(TSK-\u2026): <desc>"\`.
41502
+
41503
+ ## Step 6 \u2014 Pull request into the base branch (NO direct merge \u2014 ADR-0319/0271)
41504
+ 1. Rebase onto the freshest base to minimise conflicts: \`git fetch origin\` then
41505
+ \`git rebase origin/"$BASE"\` (on conflict you can't resolve confidently, \`git rebase --abort\`, write a
41506
+ \`USER_QA.md\` row, and STOP).
41507
+ 2. Push the task branch: \`git push -u origin task/TSK-\u2026\`.
41508
+ 3. Open a PR into the base branch with \`gh\` (GitHub) or \`glab\` (GitLab) \u2014 e.g.
41509
+ \`gh pr create --base "$BASE" --head task/TSK-\u2026 --title "TSK-\u2026: <desc>" --body "<summary>"\`. Do NOT
41510
+ merge it and do NOT fast-forward the base yourself \u2014 a human reviews the PR. If no remote / no
41511
+ \`gh\`/\`glab\` auth is available, keep the local task-branch commit and append an Incidents row saying
41512
+ the PR could not be opened.
41513
+
41514
+ ## Step 7 \u2014 Update the books
41515
+ On the base branch, append a row to the \`AI_DONE.md\` Done table (Timestamp, ID, Task description, Files,
41516
+ Notes \u2014 include the PR link if opened) and REMOVE the task from \`AI_PROGRESS.md\` (reset it to the empty
41517
+ template if nothing is in progress). Commit + push the base branch:
41518
+ \`git commit -am "chore(auto): finish TSK-\u2026 (PR opened)" && git push origin "$BASE"\` (a push rejected as
41519
+ non-fast-forward \u21D2 \`git pull --rebase origin "$BASE"\` then push again).
41520
+
41521
+ ## Step 8 \u2014 Stop
41522
+ Print a one-line summary (task id + PR link or incident). STOP \u2014 the next cron tick runs the next cycle.
41523
+ The cron tick owns the run lock; you do NOT manage \`.claude/.autonomous.lock\`.`;
41524
+ }
41525
+ async function runAutonomousCycle(ctx) {
41526
+ const commandId = randomUUID2();
41527
+ try {
41528
+ await runAiPrompt(ctx, buildAutonomousCyclePrompt(), commandId, "local", false, void 0, void 0, false, true);
41529
+ ctx.bus.autonomousDone(true);
41530
+ } catch (err) {
41531
+ ctx.bus.autonomousDone(false, err instanceof Error ? err.message : String(err));
41532
+ }
41533
+ }
41534
+
41354
41535
  // src/core/fs-browse.ts
41355
41536
  import { readdir as readdir2 } from "fs/promises";
41356
41537
  import { resolve as resolve2, sep as sep2 } from "path";
@@ -43367,6 +43548,7 @@ var WsClient = class {
43367
43548
  );
43368
43549
  this.hctx = this.buildHandlerCtx();
43369
43550
  context.bus.onLocalSubmit((input) => void runLocalCommand(this.hctx, input));
43551
+ context.bus.onAutonomousRun(() => void runAutonomousCycle(this.hctx));
43370
43552
  context.bus.onReconnect(() => this.reconnectNow());
43371
43553
  context.bus.onTranscript((entry) => this.emitConsole({ kind: "add", entry: toDtoEntry(entry) }));
43372
43554
  context.bus.onTranscriptUpdate(
@@ -43916,7 +44098,7 @@ var WsClient = class {
43916
44098
  }
43917
44099
  const { payload, nonce } = encryptPayload(this.sessionKey, data);
43918
44100
  const envelope = {
43919
- id: randomUUID2(),
44101
+ id: randomUUID3(),
43920
44102
  replyTo,
43921
44103
  channel,
43922
44104
  wsToken: this.wsToken,
@@ -43937,7 +44119,7 @@ var WsClient = class {
43937
44119
  }
43938
44120
  const { payload, nonce } = encryptPayload(this.sessionKey, data);
43939
44121
  const envelope = {
43940
- id: randomUUID2(),
44122
+ id: randomUUID3(),
43941
44123
  replyTo: null,
43942
44124
  channel,
43943
44125
  wsToken: this.wsToken,
@@ -44326,7 +44508,7 @@ function releaseInstanceLock(profileDir2) {
44326
44508
 
44327
44509
  // src/core/session-bus.ts
44328
44510
  import { EventEmitter } from "events";
44329
- import { randomUUID as randomUUID3 } from "crypto";
44511
+ import { randomUUID as randomUUID4 } from "crypto";
44330
44512
  var REPLAY_CAP = 500;
44331
44513
  var SessionBus = class {
44332
44514
  emitter = new EventEmitter();
@@ -44357,7 +44539,7 @@ var SessionBus = class {
44357
44539
  /** Producer → consumers: push one transcript entry. Returns its id (for `updateEntry`). */
44358
44540
  push(input) {
44359
44541
  const entry = {
44360
- id: randomUUID3(),
44542
+ id: randomUUID4(),
44361
44543
  level: "info",
44362
44544
  ...input,
44363
44545
  ts: Date.now()
@@ -44588,6 +44770,28 @@ var SessionBus = class {
44588
44770
  this.emitter.on("reconnect", fn);
44589
44771
  return () => this.emitter.off("reconnect", fn);
44590
44772
  }
44773
+ /**
44774
+ * Trigger → session: run ONE autonomous cycle (ADR-0319). Sent by `4pm auto-run` over the control
44775
+ * socket so the cron tick runs the cycle through THIS daemon's live WS session — reusing its
44776
+ * profile-failover, token metering (ADR-0072) and folder-scope (ADR-0181) — instead of a raw `claude -p`.
44777
+ */
44778
+ submitAutonomous() {
44779
+ this.emitter.emit("autonomous-run");
44780
+ }
44781
+ /** Subscribe to autonomous-cycle triggers. */
44782
+ onAutonomousRun(fn) {
44783
+ this.emitter.on("autonomous-run", fn);
44784
+ return () => this.emitter.off("autonomous-run", fn);
44785
+ }
44786
+ /** Session → trigger: the autonomous cycle settled (ok + an optional note). */
44787
+ autonomousDone(ok, note) {
44788
+ this.emitter.emit("autonomous-done", ok, note);
44789
+ }
44790
+ /** Subscribe to autonomous-cycle completion. */
44791
+ onAutonomousDone(fn) {
44792
+ this.emitter.on("autonomous-done", fn);
44793
+ return () => this.emitter.off("autonomous-done", fn);
44794
+ }
44591
44795
  };
44592
44796
 
44593
44797
  // src/core/control-server.ts
@@ -44650,7 +44854,9 @@ function startControlServer(bus, profileDir2, info) {
44650
44854
  bus.onWorker((worker) => broadcast({ t: "worker", worker })),
44651
44855
  bus.onProject((project) => broadcast({ t: "project", project })),
44652
44856
  bus.onUsage((usage) => broadcast({ t: "usage", usage })),
44653
- bus.onTokens((total) => broadcast({ t: "tokens", total }))
44857
+ bus.onTokens((total) => broadcast({ t: "tokens", total })),
44858
+ // Report an autonomous cycle's completion back to the `4pm auto-run` client (ADR-0319).
44859
+ bus.onAutonomousDone((ok, note) => broadcast({ t: "autonomousDone", ok, note }))
44654
44860
  ];
44655
44861
  const server = createServer((sock) => {
44656
44862
  clients.add(sock);
@@ -44673,6 +44879,7 @@ function startControlServer(bus, profileDir2, info) {
44673
44879
  for (const frame of parse(chunk)) {
44674
44880
  if (frame.t === "submit") bus.submitLocal(frame.input);
44675
44881
  else if (frame.t === "reconnect") bus.requestReconnect();
44882
+ else if (frame.t === "autonomousRun") bus.submitAutonomous();
44676
44883
  }
44677
44884
  });
44678
44885
  const drop = () => {
@@ -51209,6 +51416,62 @@ async function runAttach(profileDir2, profileName) {
51209
51416
  }
51210
51417
  }
51211
51418
 
51419
+ // src/commands/auto-run.ts
51420
+ import { connect as connect3 } from "net";
51421
+ import { join as join34 } from "path";
51422
+ var AUTO_RUN_TIMEOUT_MS = 3 * 60 * 60 * 1e3;
51423
+ async function runAutoRun(profileDir2, profileName) {
51424
+ initI18n(readProfileConfig(profileDir2).locale);
51425
+ const socketPath = join34(profileDir2, CONTROL_SOCKET_FILE);
51426
+ const code = await new Promise((resolve10) => {
51427
+ let settled = false;
51428
+ const done = (c) => {
51429
+ if (settled) return;
51430
+ settled = true;
51431
+ clearTimeout(timer);
51432
+ try {
51433
+ socket.destroy();
51434
+ } catch {
51435
+ }
51436
+ resolve10(c);
51437
+ };
51438
+ const socket = connect3(socketPath);
51439
+ socket.setEncoding("utf8");
51440
+ const timer = setTimeout(() => {
51441
+ console.error(t("autoRun.timeout"));
51442
+ done(1);
51443
+ }, AUTO_RUN_TIMEOUT_MS);
51444
+ timer.unref?.();
51445
+ socket.on("connect", () => {
51446
+ try {
51447
+ socket.write(encodeFrame({ t: "autonomousRun" }));
51448
+ } catch {
51449
+ done(1);
51450
+ }
51451
+ });
51452
+ const parse = createFrameParser();
51453
+ socket.on("data", (chunk) => {
51454
+ for (const frame of parse(chunk)) {
51455
+ if (frame.t === "transcript") {
51456
+ if (frame.entry.text) console.log(frame.entry.text);
51457
+ } else if (frame.t === "update") {
51458
+ if (frame.text) console.log(frame.text);
51459
+ } else if (frame.t === "autonomousDone") {
51460
+ if (frame.ok) console.log(t("autoRun.done"));
51461
+ else console.error(t("autoRun.failed", { note: frame.note ?? "" }));
51462
+ done(frame.ok ? 0 : 1);
51463
+ }
51464
+ }
51465
+ });
51466
+ socket.on("error", () => {
51467
+ console.error(t("autoRun.noDaemon", { profile: profileName }));
51468
+ done(1);
51469
+ });
51470
+ socket.on("close", () => done(1));
51471
+ });
51472
+ process.exitCode = code;
51473
+ }
51474
+
51212
51475
  // src/commands/ai-login.ts
51213
51476
  import { spawn as spawn11 } from "child_process";
51214
51477
  import { mkdirSync as mkdirSync8 } from "fs";
@@ -51433,6 +51696,15 @@ async function main() {
51433
51696
  await runAttach(profileDir(name), name);
51434
51697
  break;
51435
51698
  }
51699
+ case "auto-run": {
51700
+ const name = await pickLinkedProfile(explicitProfile, "auto-run");
51701
+ if (!name) {
51702
+ process.exitCode = 1;
51703
+ break;
51704
+ }
51705
+ await runAutoRun(profileDir(name), name);
51706
+ break;
51707
+ }
51436
51708
  case "ai-login": {
51437
51709
  const name = await pickLinkedProfile(explicitProfile, "ai-login");
51438
51710
  if (!name) {
@@ -51473,6 +51745,7 @@ async function main() {
51473
51745
  " 4pm link Pair with the server (pairing \u2192 confirmation code)",
51474
51746
  " 4pm start Connect WS, receive commands from the server",
51475
51747
  " 4pm attach Open the TUI against a running headless daemon (ADR-0192)",
51748
+ " 4pm auto-run Run one autonomous cycle via the running daemon (cron \u2014 ADR-0319)",
51476
51749
  " 4pm ai-login Log into a profile's AI CLI (claude/codex) in place (ADR-0199)",
51477
51750
  " 4pm unlink Delete a link (pick a profile if several)",
51478
51751
  " 4pm version Show the installed version (+ latest from the server)",
@@ -1,22 +1,20 @@
1
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.",
2
+ "_about": "Autonomous workflow config (ADR-0152 + ADR-0319). 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
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).",
4
+ "_paused": "true = PAUSED: the tick still runs but skips immediately, without waking the daemon. A quick brake without removing the crontab entry.",
8
5
  "cron_schedule": "*/5 * * * *",
9
6
  "_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.",
7
+ "command": "auto-run",
8
+ "_command": "The cli subcommand each tick runs. `4pm auto-run` asks the running `4pm start` daemon to run one cycle through its live session (ADR-0319).",
9
+ "profile": "",
10
+ "_profile": "Optional profile name for `4pm auto-run --profile <name>`. Empty = the cli resolves the single linked profile (set this only on a worker serving multiple projects).",
11
+ "_quota_moved": "The session/weekly token gate + account selection moved OUT of this shell into the cli (ADR-0319) — they need the daemon's live usage snapshot. Configure quota-based profile failover in the cli's own config, not here.",
14
12
  "quiet_hours": "",
15
13
  "_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
14
  "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.",
15
+ "_max_ticks_per_day": "-1 = NO limit. >0 = cap on the number of ticks that actually wake the daemon in one (local) day. The count is stored under ticks{day,count} in .claude/.autonomous.histories.json.",
18
16
  "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).",
17
+ "_stop_on_consecutive_failures": "After N consecutive FAILED runs (no daemon / dispatch error) -> set paused=true to stop safely. 0 = off. The count is stored under consecutive_fails in .claude/.autonomous.histories.json (reset on success).",
20
18
  "log_retention_days": 14,
21
19
  "_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
20
  "notify_webhook": "",
@@ -1,9 +1,9 @@
1
1
  # Set up a 10-minute cron for `autonomous-tick.sh`
2
2
 
3
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).
4
+ [`autonomous-tick.sh`](autonomous-tick.sh) every **10 minutes**; each run does one **`4pm auto-run`**
5
+ cycle (the running `4pm start` daemon executes it — ADR-0319) then exits. A file lock
6
+ (`.claude/.autonomous.lock`) ensures **no two runs overlap** (a later tick that sees the lock skips).
7
7
 
8
8
  > All commands below run in a **WSL shell** (Ubuntu) unless noted as PowerShell/Windows. Call the project
9
9
  > root `$PROJECT` (e.g. `~/projects/<your-project>`).
@@ -18,9 +18,10 @@ cd "$PROJECT"
18
18
  Every line must print a path/version (not "not found"):
19
19
  ```bash
20
20
  command -v cron || echo "missing cron"
21
- command -v claude || echo "missing claude (install natively in WSL)"
21
+ command -v 4pm || echo "missing 4pm cli (install natively in WSL; a '4pm start' daemon must be running)"
22
22
  command -v git || echo "missing git"
23
23
  command -v python3 || echo "missing python3"
24
+ command -v gh || command -v glab || echo "missing gh/glab (needed for the PR step)"
24
25
  ```
25
26
 
26
27
  ## 2. Make the tick executable + install the cron line
@@ -1,34 +1,36 @@
1
1
  # Autonomous mode — how it's assembled
2
2
 
3
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).
4
+ > unattended work loop (ADR-0152 + **ADR-0319**), intended to run on **WSL** (an isolated environment
5
+ > where the AI can be given full permissions). Under ADR-0319 the cycle runs **through the 4PM cli**,
6
+ > not a raw `claude -p`.
6
7
 
7
8
  ## The pieces
8
9
  | File | Role |
9
10
  |------|------|
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. |
11
+ | `.claude/hooks/autonomous-tick.sh` | Cron tick (every ~5–10 min): a run lock + the CHEAP local gates (pause / quiet-hours / max-ticks / **has-work**), then runs **`4pm auto-run`**. No token spend when there's no approved work. |
12
+ | `4pm auto-run` (cli) | Asks the running **`4pm start`** daemon to run **one** cycle over the control socket; the cycle rides the daemon's live WS session (failover ADR-0182, metering ADR-0072, folder-scope ADR-0181, timeout ADR-0243). |
13
+ | cli `buildAutonomousCyclePrompt` | The cli-owned cycle instructions (was `.claude/commands/auto-cycle.md`, now retired): sync branch → analyse **approved** USER_TODO → fold **approved** USER_QA → do ONE approved task → **PR** into the base branch. |
14
+ | `.claude/.autonomous.approvals.json` | Approval source of truth (ADR-0152): `{ "<REQ/QA/TSK-id>": {approved, by, at} }` — the web AI-content grids write it; the cycle reads it. |
15
+ | `USER_TODO.md` / `USER_QA.md` | User requests / the AI's questions back — each row approved before the cycle acts on it (ADR-0319). |
15
16
  | `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/templates/<NAME>.{empty,sample}.md` | Canonical templates for the books. The has-work gate + the cycle **compare against `*.empty.md`** to tell empty/has-work and reset correctly. |
17
18
  | `.claude/settings.json` | The "bypass all" permission profile for autonomous mode (see the note below). |
18
19
 
19
20
  ## Lifecycle (1 tick)
20
21
  ```
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
22
+ cron ~5–10min → autonomous-tick.sh
23
+ ├─ locked? / paused? / quiet-hours? / max-ticks? → log "skip", exit
24
+ ├─ has-work? (approved USER_TODO/USER_QA/AI_TODO, or AI_PROGRESS non-empty) — no → skip (daemon NOT woken)
25
+ └─ 4pm auto-run → the running daemon runs ONE cycle:
26
+ 1. sync the primary repo's current branch (base branch — ADR-0292)
27
+ 2. analyse APPROVED USER_TODO requests → AI_TODO tasks (unclear ⇒ ask via USER_QA)
28
+ 3. fold APPROVED USER_QA answers back into AI_TODO
29
+ 4. take ONE approved task (deps met) → AI_PROGRESS, commit
30
+ 5. implement + test on task/TSK-… branch
31
+ 6. rebase + push + open a PULL REQUEST into the base branch (no direct merge)
32
+ 7. update the books (AI_DONE), commit + push the base branch
33
+ └─ record success/failure (N consecutive failures → pause); the EXIT trap releases the lock
32
34
  ```
33
35
 
34
36
  ## Install on WSL
@@ -40,13 +42,11 @@ crontab -e
40
42
  # watch:
41
43
  tail -f .claude/logs/autonomous-tick-$(date +%F).log
42
44
  ```
43
- Requirements: `claude` logged in (has `~/.claude/.credentials.json`), plus `git` and `python3`, and the
44
- project's test tooling (per `CLAUDE.md`).
45
+ Requirements: the **`4pm`** cli on PATH with a **running `4pm start` daemon** serving this project (it
46
+ holds the AI credentials + WS session), plus `git`, `python3`, and `gh`/`glab` for the PR step.
45
47
 
46
48
  ## 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.
49
+ - The daemon runs the cycle as a **write-capable agent** (`--permission-mode bypassPermissions`,
50
+ ADR-0271) so file + git/`gh`/`glab` writes run headless. It stays bounded by the folder-scope guard
51
+ (ADR-0181) + the AI-run timeout (ADR-0243).
52
52
  - Only enable full permissions in an isolated environment (WSL/CI). Never on a machine with sensitive data.