@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 +289 -16
- package/dist/project-sample/.claude/.autonomous.settings.json +9 -11
- package/dist/project-sample/.claude/AUTONOMOUS-CRON.md +5 -4
- package/dist/project-sample/.claude/AUTONOMOUS.md +26 -26
- package/dist/project-sample/.claude/commands/auto-cycle.md +16 -170
- package/dist/project-sample/.claude/hooks/autonomous-tick.sh +76 -193
- package/dist/project-sample/.claude/settings.json +1 -3
- package/dist/project-sample/.claude/templates/USER_QA.empty.md +13 -6
- package/dist/project-sample/.claude/templates/USER_QA.sample.md +6 -3
- package/dist/project-sample/.claude/templates/USER_TODO.empty.md +13 -6
- package/dist/project-sample/.claude/templates/USER_TODO.sample.md +7 -6
- package/dist/project-sample/.claude/templates/project.secrets.sample.json +0 -1
- package/dist/project-sample/AI_PLACEHOLDER.md +5 -5
- package/dist/project-sample/USER_QA.md +13 -6
- package/dist/project-sample/USER_TODO.md +13 -6
- package/dist/project-sample/project.secrets.json.sample +0 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
40016
|
-
const
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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": "
|
|
11
|
-
"_command": "The
|
|
12
|
-
"
|
|
13
|
-
"
|
|
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
|
|
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
|
|
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
|
|
5
|
-
cycle
|
|
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
|
|
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
|
|
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)
|
|
11
|
-
|
|
|
12
|
-
| `.claude/
|
|
13
|
-
| `.claude/.autonomous.approvals.json` | Approval source of truth (ADR-0152): `{ "<TSK-id>": {approved, by, at} }` — the web
|
|
14
|
-
| `USER_TODO.md`
|
|
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
|
|
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?
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
1.
|
|
26
|
-
2. USER_TODO
|
|
27
|
-
3.
|
|
28
|
-
4.
|
|
29
|
-
5.
|
|
30
|
-
6.
|
|
31
|
-
7.
|
|
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:
|
|
44
|
-
|
|
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
|
-
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|