@4pm/cli 1.14.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
 
@@ -37098,10 +37114,32 @@ var taskApproveRequestSchema = external_exports.object({
37098
37114
  taskId: external_exports.string().min(1).max(64),
37099
37115
  approved: external_exports.boolean()
37100
37116
  });
37117
+ var taskApproveEntrySchema = external_exports.object({
37118
+ taskId: external_exports.string().min(1).max(64),
37119
+ tags: external_exports.array(external_exports.string().min(1).max(64)).max(16).default([])
37120
+ });
37121
+ var taskApproveBatchRequestSchema = external_exports.object({
37122
+ approvals: external_exports.array(taskApproveEntrySchema).max(500).default([]),
37123
+ unapprovals: external_exports.array(external_exports.string().min(1).max(64)).max(500).default([])
37124
+ });
37101
37125
  var autonomousWriteRequestSchema = external_exports.discriminatedUnion("kind", [
37102
37126
  external_exports.object({ kind: external_exports.literal("settings"), settings: external_exports.string().max(64 * 1024) }),
37103
37127
  external_exports.object({ kind: external_exports.literal("approvals"), taskId: external_exports.string().min(1).max(64), approved: external_exports.boolean() }),
37128
+ // Batched approvals (ADR-0311): commit many approve/unapprove ids in ONE approvals-file write, so a
37129
+ // Save's coupled batch lands atomically. Constructed by the server after its tag actions succeed.
37130
+ external_exports.object({
37131
+ kind: external_exports.literal("approvalsBatch"),
37132
+ approve: external_exports.array(external_exports.string().min(1).max(64)).max(500),
37133
+ unapprove: external_exports.array(external_exports.string().min(1).max(64)).max(500)
37134
+ }),
37104
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
+ }),
37105
37143
  external_exports.object({ kind: external_exports.literal("cron"), action: external_exports.enum(["install", "uninstall"]) })
37106
37144
  ]);
37107
37145
  var autonomousLogsQuerySchema = external_exports.object({
@@ -37288,11 +37326,14 @@ var repoSpecSchema = external_exports.object({
37288
37326
  role: external_exports.string().max(60).optional().default(""),
37289
37327
  /** Free-form description of what this repo holds (optional). */
37290
37328
  desc: external_exports.string().max(500).optional().default(""),
37291
- /** Exactly one repo of the project is the primary. */
37329
+ /** Exactly one repo of the project is the primary (the root); the rest are submodules. */
37292
37330
  primary: external_exports.boolean().optional().default(false),
37293
37331
  /** Clone url — `https` or `ssh` (required; ADR-0172). */
37294
37332
  url: external_exports.string().max(500).regex(GIT_URL_RE, "must be an https or ssh git url"),
37295
- /** Sub-repo subfolder under the target root (empty ⇒ derived from role/name). */
37333
+ /**
37334
+ * Folder under the root: **empty for the primary** (the root itself), **non-empty and unique for a
37335
+ * submodule** — the path `git submodule add` attaches it at (ADR-0316).
37336
+ */
37296
37337
  subdir: external_exports.string().max(120).optional().default(""),
37297
37338
  /**
37298
37339
  * Primary branch to clone / check out (ADR-0292). Empty ⇒ the repo's default branch. The
@@ -37301,9 +37342,38 @@ var repoSpecSchema = external_exports.object({
37301
37342
  */
37302
37343
  branch: external_exports.string().max(200).optional().default("")
37303
37344
  });
37304
- function repoName(url) {
37305
- const last = (url || "").trim().replace(/\/+$/, "").split(/[/:]/).pop() ?? "";
37306
- return last.replace(/\.git$/i, "");
37345
+ function validateProjectRepos(repos, ctx, path = ["repos"]) {
37346
+ const list = repos ?? [];
37347
+ const issue = (message) => ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path, message });
37348
+ if (list.length < 1) {
37349
+ issue("a project must declare at least one repo");
37350
+ return;
37351
+ }
37352
+ const primaries = list.filter((r) => r.primary);
37353
+ if (primaries.length !== 1) {
37354
+ issue("exactly one repo must be primary");
37355
+ }
37356
+ for (const r of list) {
37357
+ if (r.primary) {
37358
+ if ((r.subdir ?? "").trim() !== "") issue("the primary repo must be at the root (no subfolder)");
37359
+ } else if ((r.subdir ?? "").trim() === "") {
37360
+ issue("each submodule needs a folder path");
37361
+ }
37362
+ }
37363
+ const seenDir = /* @__PURE__ */ new Set();
37364
+ for (const r of list) {
37365
+ const dir2 = (r.subdir ?? "").trim().toLowerCase();
37366
+ if (!dir2) continue;
37367
+ if (seenDir.has(dir2)) issue("submodule folder paths must be unique");
37368
+ seenDir.add(dir2);
37369
+ }
37370
+ const seenUrl = /* @__PURE__ */ new Set();
37371
+ for (const r of list) {
37372
+ const url = (r.url ?? "").trim().toLowerCase();
37373
+ if (!url) continue;
37374
+ if (seenUrl.has(url)) issue("repo URLs must be unique");
37375
+ seenUrl.add(url);
37376
+ }
37307
37377
  }
37308
37378
  var specFieldEnvelopeSchema = external_exports.object({
37309
37379
  id: external_exports.string().min(1),
@@ -37335,16 +37405,7 @@ var projectSpecSchema = external_exports.object({
37335
37405
  });
37336
37406
  }
37337
37407
  }
37338
- if (v2.repos && v2.repos.length > 0) {
37339
- const primaries = v2.repos.filter((r) => r.primary).length;
37340
- if (primaries !== 1) {
37341
- ctx.addIssue({
37342
- code: external_exports.ZodIssueCode.custom,
37343
- path: ["repos"],
37344
- message: "exactly one primary repo is required"
37345
- });
37346
- }
37347
- }
37408
+ validateProjectRepos(v2.repos, ctx);
37348
37409
  });
37349
37410
  var createFromSpecRequestSchema = external_exports.object({
37350
37411
  projectId: external_exports.string().uuid(),
@@ -37354,14 +37415,7 @@ var addRepoRequestSchema = external_exports.object({
37354
37415
  projectId: external_exports.string().uuid(),
37355
37416
  repos: external_exports.array(repoSpecSchema).min(1)
37356
37417
  }).superRefine((v2, ctx) => {
37357
- const primaries = v2.repos.filter((r) => r.primary).length;
37358
- if (primaries !== 1) {
37359
- ctx.addIssue({
37360
- code: external_exports.ZodIssueCode.custom,
37361
- path: ["repos"],
37362
- message: "Exactly one repo must be primary"
37363
- });
37364
- }
37418
+ validateProjectRepos(v2.repos, ctx);
37365
37419
  });
37366
37420
  var addProjectRepoRequestSchema = external_exports.object({
37367
37421
  /** Clone url — `https` or `ssh` (required; ADR-0172). */
@@ -37439,7 +37493,7 @@ var activityHeartbeatSchema = external_exports.object({
37439
37493
  });
37440
37494
 
37441
37495
  // src/core/scaffold.ts
37442
- import { cp, mkdir, readdir, rename, rm, writeFile } from "fs/promises";
37496
+ import { cp, mkdir, rm, writeFile } from "fs/promises";
37443
37497
  import { existsSync as existsSync4 } from "fs";
37444
37498
  import { execFile as execFile2 } from "child_process";
37445
37499
  import { dirname as dirname3, join as join6, resolve } from "path";
@@ -37524,89 +37578,111 @@ function reposOf(spec) {
37524
37578
  const repos = spec?.repos;
37525
37579
  return Array.isArray(repos) ? repos : [];
37526
37580
  }
37527
- function folderName(repo) {
37528
- const raw = (repo.subdir || repo.role || repoName(repo.url ?? "") || "repo").trim();
37529
- return raw.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
37530
- }
37531
- function repoFolders(repos) {
37532
- const used = /* @__PURE__ */ new Set();
37533
- return repos.map((r) => {
37534
- const base = folderName(r);
37535
- let name = base;
37536
- let n = 2;
37537
- while (used.has(name)) name = `${base}-${n++}`;
37538
- used.add(name);
37539
- return name;
37540
- });
37581
+ function singleRepo(repos) {
37582
+ return repos.find((r) => r.primary) ?? repos[0] ?? null;
37583
+ }
37584
+ function submodulesOf(repos) {
37585
+ const primary = singleRepo(repos);
37586
+ return repos.filter((r) => r !== primary && !!r.url && !!(r.subdir ?? "").trim());
37587
+ }
37588
+ async function attachSubmodules(root, submodules, emit) {
37589
+ if (submodules.length === 0) return;
37590
+ if (!existsSync4(join6(root, ".git"))) return;
37591
+ let added = 0;
37592
+ for (const sub of submodules) {
37593
+ const dir2 = (sub.subdir ?? "").trim();
37594
+ if (!dir2 || !sub.url) continue;
37595
+ if (existsSync4(join6(root, dir2, ".git")) || existsSync4(join6(root, ".git", "modules", dir2))) {
37596
+ emit("submodule", `Submodule ${dir2} already present \u2014 updating\u2026`);
37597
+ try {
37598
+ await run2("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37599
+ } catch {
37600
+ }
37601
+ continue;
37602
+ }
37603
+ emit("submodule", `Adding submodule ${sub.url} \u2192 ${dir2}\u2026`);
37604
+ const b2 = (sub.branch ?? "").trim();
37605
+ try {
37606
+ await run2("git", ["submodule", "add", ...b2 ? ["-b", b2] : [], sub.url, dir2], { cwd: root, timeout: 12e4 });
37607
+ added++;
37608
+ } catch (err) {
37609
+ const msg = err instanceof Error ? err.message : String(err);
37610
+ if (/already exists|already registered|in the index/i.test(msg)) {
37611
+ try {
37612
+ await run2("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37613
+ added++;
37614
+ } catch {
37615
+ }
37616
+ } else {
37617
+ throw new Error(`git submodule add ${dir2} failed: ${msg}`);
37618
+ }
37619
+ }
37620
+ }
37621
+ if (added > 0) await commitAndPushSubmodules(root, submodules, emit);
37622
+ }
37623
+ async function commitAndPushSubmodules(root, submodules, emit) {
37624
+ const dirs = submodules.map((s3) => (s3.subdir ?? "").trim()).filter(Boolean);
37625
+ emit("submodule", "Committing .gitmodules\u2026");
37626
+ await run2("git", ["add", ".gitmodules", ...dirs], { cwd: root, timeout: 6e4 });
37627
+ const staged = (await run2("git", ["diff", "--cached", "--name-only"], { cwd: root, timeout: 3e4 })).stdout.trim();
37628
+ if (!staged) return;
37629
+ const commitArgs = ["commit", "-m", "chore: add git submodules (4PM)"];
37630
+ try {
37631
+ await run2("git", commitArgs, { cwd: root, timeout: 6e4 });
37632
+ } catch {
37633
+ await run2("git", ["-c", "user.name=4PM", "-c", "user.email=noreply@4pm.app", ...commitArgs], { cwd: root, timeout: 6e4 });
37634
+ }
37635
+ emit("submodule", "Pushing .gitmodules to the primary remote\u2026");
37636
+ try {
37637
+ await run2("git", ["push"], { cwd: root, timeout: 12e4 });
37638
+ } catch (err) {
37639
+ const msg = err instanceof Error ? err.message : String(err);
37640
+ emit("submodule-push-failed", `Submodules committed locally but the push failed: ${msg}`);
37641
+ }
37541
37642
  }
37542
37643
  function cloneArgs(url, dest, branch) {
37543
37644
  const b2 = (branch ?? "").trim();
37544
37645
  return b2 ? ["clone", "-b", b2, url, dest] : ["clone", url, dest];
37545
37646
  }
37546
- async function updateRepo(dir2, label, branch, emit) {
37647
+ async function updateRepo(dir2, branch, emit) {
37547
37648
  const b2 = (branch ?? "").trim();
37548
- emit("git", `Updating ${label} (fetch + ${b2 ? `checkout ${b2} + ` : ""}fast-forward pull)\u2026`);
37649
+ emit("git", `Updating repository (fetch + ${b2 ? `checkout ${b2} + ` : ""}fast-forward pull)\u2026`);
37549
37650
  await run2("git", ["fetch", "origin", "--prune"], { cwd: dir2, timeout: 12e4 });
37550
37651
  if (b2) await run2("git", ["checkout", b2], { cwd: dir2, timeout: 6e4 });
37551
37652
  await run2("git", ["pull", "--ff-only"], { cwd: dir2, timeout: 12e4 });
37552
37653
  }
37553
- function repoDir(target, repo, folder, legacyRoot) {
37554
- if (repo.primary && legacyRoot) return target;
37555
- return join6(target, folder);
37556
- }
37557
- async function provisionRepos(target, repos, emit, opts = {}) {
37654
+ async function provisionRepo(target, repo, emit, opts = {}) {
37558
37655
  const mode = opts.mode ?? "clone";
37559
- const legacyRoot = existsSync4(join6(target, ".git"));
37560
- const folders = repoFolders(repos);
37561
- const foldersToKeep = new Set(
37562
- repos.map((r, i) => r.primary && legacyRoot ? null : folders[i]).filter((f2) => !!f2)
37563
- );
37564
- for (const [i, repo] of repos.entries()) {
37565
- const folder = folders[i];
37566
- const atRoot = !!repo.primary && legacyRoot;
37567
- const dir2 = repoDir(target, repo, folder, legacyRoot);
37568
- const label = repo.primary ? "primary repo" : `repo ${folder}`;
37569
- if (!repo.url) {
37570
- if (!existsSync4(join6(dir2, ".git"))) {
37571
- emit("git", `Initializing ${label}\u2026`);
37572
- await mkdir(dir2, { recursive: true });
37573
- await run2("git", ["init"], { cwd: dir2, timeout: 2e4 });
37574
- }
37575
- continue;
37656
+ if (!repo.url) {
37657
+ if (!existsSync4(join6(target, ".git"))) {
37658
+ emit("git", "Initializing repository\u2026");
37659
+ await mkdir(target, { recursive: true });
37660
+ await run2("git", ["init"], { cwd: target, timeout: 2e4 });
37576
37661
  }
37577
- if (existsSync4(join6(dir2, ".git"))) {
37578
- if (mode === "sync") {
37579
- await updateRepo(dir2, label, repo.branch, emit);
37580
- } else if (mode === "force" && atRoot) {
37581
- emit("git", `Re-cloning ${repo.url} (force)\u2026`);
37582
- const tmp = `${target}.4pm-reclone`;
37583
- await rm(tmp, { recursive: true, force: true });
37584
- await run2("git", cloneArgs(repo.url, tmp, repo.branch), { timeout: 12e4 });
37585
- for (const entry of await readdir(target)) {
37586
- if (foldersToKeep.has(entry)) continue;
37587
- await rm(join6(target, entry), { recursive: true, force: true });
37588
- }
37589
- for (const entry of await readdir(tmp)) {
37590
- if (foldersToKeep.has(entry)) continue;
37591
- await rename(join6(tmp, entry), join6(target, entry));
37592
- }
37593
- await rm(tmp, { recursive: true, force: true });
37594
- } else if (mode === "force") {
37595
- emit("git", `Re-cloning ${repo.url} (force)\u2026`);
37596
- await rm(dir2, { recursive: true, force: true });
37597
- await run2("git", cloneArgs(repo.url, dir2, repo.branch), { timeout: 12e4 });
37598
- } else {
37599
- emit("git", `${label} already present \u2014 skipping clone.`);
37600
- }
37601
- continue;
37662
+ return;
37663
+ }
37664
+ if (existsSync4(join6(target, ".git"))) {
37665
+ if (mode === "sync") {
37666
+ await updateRepo(target, repo.branch, emit);
37667
+ } else if (mode === "force") {
37668
+ emit("git", `Re-cloning ${repo.url} (force)\u2026`);
37669
+ await rm(target, { recursive: true, force: true });
37670
+ await run2("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37671
+ } else {
37672
+ emit("git", "Repository already present \u2014 skipping clone.");
37602
37673
  }
37603
- emit("git", `Cloning ${repo.url}\u2026`);
37604
- await run2("git", cloneArgs(repo.url, dir2, repo.branch), { timeout: 12e4 });
37674
+ return;
37605
37675
  }
37676
+ emit("git", `Cloning ${repo.url}\u2026`);
37677
+ await run2("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37606
37678
  }
37607
37679
  async function ensureReposCloned(physicRoot, repos, emit) {
37608
- if (repos.length === 0) return;
37609
- await provisionRepos(physicRoot, repos, emit ?? (() => void 0));
37680
+ const list = repos;
37681
+ const repo = singleRepo(list);
37682
+ if (!repo) return;
37683
+ const step = emit ?? (() => void 0);
37684
+ await provisionRepo(physicRoot, repo, step);
37685
+ await attachSubmodules(physicRoot, submodulesOf(list), step);
37610
37686
  }
37611
37687
  function sampleDir() {
37612
37688
  if (process.env.SCAFFOLD_SAMPLE_DIR) return process.env.SCAFFOLD_SAMPLE_DIR;
@@ -37627,17 +37703,12 @@ async function scaffoldProject(payload, profileDir2, onProgress) {
37627
37703
  const root = process.env.SCAFFOLD_ROOT ? resolve(process.env.SCAFFOLD_ROOT) : resolve(profileDir2);
37628
37704
  const target = join6(root, payload.projectName);
37629
37705
  await mkdir(target, { recursive: true });
37630
- const repos = reposOf(payload.spec);
37631
- if (repos.length > 0) {
37632
- emit("git", "Cloning repositories\u2026");
37633
- await provisionRepos(target, repos, emit);
37634
- const folders = repoFolders(repos);
37635
- for (let i = 0; i < repos.length; i++) {
37636
- await scaffoldRepo(join6(target, folders[i]), folders[i], payload.spec, emit);
37637
- }
37638
- } else {
37639
- await scaffoldRepo(target, payload.projectName, payload.spec, emit);
37640
- }
37706
+ const repo = singleRepo(reposOf(payload.spec));
37707
+ if (!repo) throw new Error("A project must declare one repo (ADR-0314).");
37708
+ emit("git", "Cloning repository\u2026");
37709
+ await provisionRepo(target, repo, emit);
37710
+ await scaffoldRepo(target, payload.projectName, payload.spec, emit);
37711
+ await attachSubmodules(target, submodulesOf(reposOf(payload.spec)), emit);
37641
37712
  emit("version", "Writing .4pm/.4pm.json\u2026");
37642
37713
  await writeTemplateMarker(target);
37643
37714
  onProgress?.({ projectId: payload.projectId, step: "done", message: "Scaffold complete.", done: true });
@@ -37723,12 +37794,13 @@ async function addProject(payload, profileDir2, onProgress) {
37723
37794
  const root = process.env.SCAFFOLD_ROOT ? resolve(process.env.SCAFFOLD_ROOT) : resolve(profileDir2);
37724
37795
  const target = join6(root, payload.projectName);
37725
37796
  await mkdir(target, { recursive: true });
37726
- const repos = payload.repos ?? [];
37797
+ const repo = singleRepo(payload.repos ?? []);
37727
37798
  const mode = payload.mode ?? "clone";
37728
- if (repos.length > 0) await provisionRepos(target, repos, emit, { mode });
37729
- for (const sub of payload.scaffoldRepos ?? []) {
37730
- await scaffoldRepo(join6(target, sub), sub, payload.spec, emit);
37799
+ if (repo) await provisionRepo(target, repo, emit, { mode });
37800
+ if (payload.scaffoldRepos && payload.scaffoldRepos.length > 0) {
37801
+ await scaffoldRepo(target, payload.projectName, payload.spec, emit);
37731
37802
  }
37803
+ await attachSubmodules(target, submodulesOf(payload.repos ?? []), emit);
37732
37804
  onProgress?.({ projectId: payload.projectId, step: "done", message: "Project added.", done: true });
37733
37805
  return { ok: true, path: target };
37734
37806
  } catch (err) {
@@ -38227,7 +38299,7 @@ function decryptPayload(key, payloadBase64, nonceBase64) {
38227
38299
  import { spawn as spawn2 } from "child_process";
38228
38300
 
38229
38301
  // src/common/logger/logger.ts
38230
- import { appendFile, mkdir as mkdir2, readdir as readdir2, unlink } from "fs/promises";
38302
+ import { appendFile, mkdir as mkdir2, readdir, unlink } from "fs/promises";
38231
38303
  import { readdirSync as readdirSync2, readFileSync as readFileSync6, statSync } from "fs";
38232
38304
  import { join as join7 } from "path";
38233
38305
  var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
@@ -38287,7 +38359,7 @@ var CliLogger = class {
38287
38359
  async cleanupOldLogs() {
38288
38360
  const dir2 = this.dir;
38289
38361
  if (!dir2) return;
38290
- const files = await readdir2(dir2).catch(() => []);
38362
+ const files = await readdir(dir2).catch(() => []);
38291
38363
  const cutoff = Date.now() - this.retentionDays * DAY_MS;
38292
38364
  for (const file of files) {
38293
38365
  const match = /^cli-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(file);
@@ -38823,7 +38895,6 @@ var ONE_SHOT_DISALLOWED_CLAUDE_TOOLS = [
38823
38895
  "WebSearch",
38824
38896
  "WebFetch",
38825
38897
  "Skill",
38826
- "SlashCommand",
38827
38898
  "ToolSearch",
38828
38899
  "TaskCreate",
38829
38900
  "TaskGet",
@@ -39564,7 +39635,7 @@ var DOC_EXTENSIONS = [".md", ".mdx", ".txt"];
39564
39635
  function kbRoot(profileDir2) {
39565
39636
  return join14(profileDir2, "support-kb");
39566
39637
  }
39567
- function repoDir2(profileDir2, url, branch) {
39638
+ function repoDir(profileDir2, url, branch) {
39568
39639
  const key = createHash3("sha1").update(`${url}#${branch}`).digest("hex").slice(0, 16);
39569
39640
  return join14(kbRoot(profileDir2), key);
39570
39641
  }
@@ -39574,7 +39645,7 @@ function authedUrl(url, token) {
39574
39645
  return url;
39575
39646
  }
39576
39647
  async function ensureRepo(profileDir2, repo) {
39577
- const dir2 = repoDir2(profileDir2, repo.url, repo.branch);
39648
+ const dir2 = repoDir(profileDir2, repo.url, repo.branch);
39578
39649
  if (existsSync10(join14(dir2, ".git"))) return dir2;
39579
39650
  const url = authedUrl(repo.url, repo.token);
39580
39651
  const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
@@ -39825,6 +39896,7 @@ var run4 = promisify4(execFile4);
39825
39896
  var SETTINGS_REL = ".claude/.autonomous.settings.json";
39826
39897
  var HISTORIES_REL = ".claude/.autonomous.histories.json";
39827
39898
  var APPROVALS_REL = ".claude/.autonomous.approvals.json";
39899
+ var AUTHORS_REL = ".claude/.autonomous.authors.json";
39828
39900
  var TICK_REL = ".claude/hooks/autonomous-tick.sh";
39829
39901
  var LOG_DIR_REL = ".claude/logs";
39830
39902
  var BOOK_FILES = {
@@ -39853,6 +39925,35 @@ function parseJson(text) {
39853
39925
  function tableCell(value) {
39854
39926
  return value.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
39855
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
+ }
39856
39957
  function tickScript(root) {
39857
39958
  return join15(root, TICK_REL);
39858
39959
  }
@@ -39907,16 +40008,17 @@ function isAutonomousRunning(root) {
39907
40008
  }
39908
40009
  }
39909
40010
  async function readAutonomous(root) {
39910
- const [settings, approvals, status, ...books] = await Promise.all([
40011
+ const [settings, approvals, authors, status, ...books] = await Promise.all([
39911
40012
  readText(join15(root, SETTINGS_REL)),
39912
40013
  readText(join15(root, APPROVALS_REL), "{}"),
40014
+ readText(join15(root, AUTHORS_REL), "{}"),
39913
40015
  getAutonomousStatus(root),
39914
40016
  ...Object.values(BOOK_FILES).map((f2) => readText(join15(root, f2)))
39915
40017
  ]);
39916
40018
  const keys = Object.keys(BOOK_FILES);
39917
40019
  const bookMap = {};
39918
40020
  keys.forEach((k2, i) => bookMap[k2] = books[i] ?? "");
39919
- return { settings, status, books: bookMap, approvals };
40021
+ return { settings, status, books: bookMap, approvals, authors };
39920
40022
  }
39921
40023
  async function readAutonomousLogs(root, date) {
39922
40024
  const day = date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -39948,18 +40050,57 @@ async function writeAutonomous(root, req, by) {
39948
40050
  break;
39949
40051
  }
39950
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
+ }
39951
40059
  const map = parseJson(await readText(join15(root, APPROVALS_REL), "{}")) ?? {};
39952
- 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() };
39953
40061
  else delete map[req.taskId];
39954
40062
  await writeFile2(join15(root, APPROVALS_REL), JSON.stringify(map, null, 2) + "\n", "utf8");
39955
40063
  break;
39956
40064
  }
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
+ }
40071
+ const map = parseJson(await readText(join15(root, APPROVALS_REL), "{}")) ?? {};
40072
+ const at2 = (/* @__PURE__ */ new Date()).toISOString();
40073
+ const byLabel = req.byLabel ?? by;
40074
+ for (const taskId of req.approve) map[taskId] = { approved: true, by, byLabel, at: at2 };
40075
+ for (const taskId of req.unapprove) delete map[taskId];
40076
+ await writeFile2(join15(root, APPROVALS_REL), JSON.stringify(map, null, 2) + "\n", "utf8");
40077
+ break;
40078
+ }
39957
40079
  case "userTodo": {
39958
40080
  const cur = await readText(join15(root, BOOK_FILES.userTodo));
39959
- const trace = `posted by ${tableCell(by)} at ${(/* @__PURE__ */ new Date()).toISOString()}`;
39960
- 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)} |
39961
40085
  `;
39962
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");
39963
40104
  break;
39964
40105
  }
39965
40106
  case "cron": {
@@ -40828,8 +40969,10 @@ function handleCommandChannels(ctx, message, payload) {
40828
40969
  text: `$ ${dispatch.cmd} ${dispatch.args.join(" ")}`.trimEnd()
40829
40970
  });
40830
40971
  ctx.bus.startBusy(dispatch.cmd);
40972
+ const cwd2 = dispatch.path ?? ctx.physicRoot ?? readProfileConfig(ctx.profileDir).physicPath ?? process.cwd();
40973
+ const dispatchInRoot = { ...dispatch, path: cwd2 };
40831
40974
  const cmdStartMs = Date.now();
40832
- void runCommand(dispatch, (out) => {
40975
+ void runCommand(dispatchInRoot, (out) => {
40833
40976
  if (out.chunk) {
40834
40977
  ctx.bus.push({ source: "server", kind: "out", text: out.chunk });
40835
40978
  appendCommandOutput(out.commandId, out.chunk);
@@ -41293,8 +41436,104 @@ function rejectByReview(ctx, commandId, origin, prompt, reasons) {
41293
41436
  });
41294
41437
  }
41295
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
+
41296
41535
  // src/core/fs-browse.ts
41297
- import { readdir as readdir3 } from "fs/promises";
41536
+ import { readdir as readdir2 } from "fs/promises";
41298
41537
  import { resolve as resolve2, sep as sep2 } from "path";
41299
41538
  async function listDir(path, root) {
41300
41539
  if (!root) return { path: "", entries: [] };
@@ -41302,7 +41541,7 @@ async function listDir(path, root) {
41302
41541
  const requested = resolve2(base, path && path.trim() ? path : ".");
41303
41542
  const target = requested === base || requested.startsWith(base + sep2) ? requested : base;
41304
41543
  try {
41305
- const dirents = await readdir3(target, { withFileTypes: true });
41544
+ const dirents = await readdir2(target, { withFileTypes: true });
41306
41545
  const entries2 = dirents.map((d) => ({
41307
41546
  name: d.name,
41308
41547
  type: d.isDirectory() ? "dir" : "file"
@@ -41370,7 +41609,7 @@ async function writeWorkerFile(root, relPath, content) {
41370
41609
  }
41371
41610
 
41372
41611
  // src/core/fs-mutate.ts
41373
- import { mkdir as mkdir4, writeFile as writeFile4, rename as rename2, rm as rm2, stat } from "fs/promises";
41612
+ import { mkdir as mkdir4, writeFile as writeFile4, rename, rm as rm2, stat } from "fs/promises";
41374
41613
  import { basename as basename3, dirname as dirname5, resolve as resolve5, sep as sep5 } from "path";
41375
41614
  var MAX_BYTES3 = 2 * 1024 * 1024;
41376
41615
  function resolveInRoot3(root, rel) {
@@ -41425,7 +41664,7 @@ async function mutateFs(root, req) {
41425
41664
  if (to2 !== resolve5(root) && !to2.startsWith(resolve5(root) + sep5)) return escaped(req.to ?? "");
41426
41665
  if (await exists(to2)) return { ok: false, path: to2, error: "destination already exists" };
41427
41666
  await mkdir4(dirname5(to2), { recursive: true });
41428
- await rename2(from, to2);
41667
+ await rename(from, to2);
41429
41668
  return { ok: true, path: to2 };
41430
41669
  }
41431
41670
  case "delete": {
@@ -41724,7 +41963,6 @@ async function manageSshKey(op, profileDir2) {
41724
41963
  }
41725
41964
 
41726
41965
  // src/core/git-history.ts
41727
- import { readdir as readdir4 } from "fs/promises";
41728
41966
  import { execFile as execFile9 } from "child_process";
41729
41967
  import { resolve as resolve8, sep as sep8 } from "path";
41730
41968
  import { promisify as promisify9 } from "util";
@@ -41752,36 +41990,21 @@ async function git(cwd2, args) {
41752
41990
  async function gitRepos(root) {
41753
41991
  if (!root) return { repos: [] };
41754
41992
  const base = resolve8(root);
41755
- const found = /* @__PURE__ */ new Set();
41993
+ const top = (await git(base, ["rev-parse", "--show-toplevel"])).trim();
41994
+ if (!top || !(top === base || top.startsWith(base + sep8))) return { repos: [] };
41756
41995
  const repos = [];
41757
- const add = async (dir2) => {
41758
- const inside = await git(dir2, ["rev-parse", "--show-toplevel"]);
41759
- const top = inside.trim();
41760
- if (!top || found.has(top) || !(top === base || top.startsWith(base + sep8))) return;
41761
- found.add(top);
41762
- const subdir = top === base ? "" : top.slice(base.length + 1);
41763
- const remote = (await git(top, ["remote", "get-url", "origin"])).trim();
41764
- repos.push({ subdir, name: subdir || "", remote: remote || null });
41765
- };
41766
- await add(base);
41767
- try {
41768
- const lvl1 = await readdir4(base, { withFileTypes: true });
41769
- for (const d of lvl1) {
41770
- if (!d.isDirectory() || d.name === ".git" || d.name === "node_modules") continue;
41771
- const p1 = resolve8(base, d.name);
41772
- await add(p1);
41773
- try {
41774
- const lvl2 = await readdir4(p1, { withFileTypes: true });
41775
- for (const d2 of lvl2) {
41776
- if (!d2.isDirectory() || d2.name === ".git" || d2.name === "node_modules") continue;
41777
- await add(resolve8(p1, d2.name));
41778
- }
41779
- } catch {
41780
- }
41781
- }
41782
- } catch {
41996
+ const rootRemote = (await git(base, ["remote", "get-url", "origin"])).trim();
41997
+ repos.push({ subdir: "", name: "", remote: rootRemote || null });
41998
+ const subOut = await git(base, ["submodule", "status"]);
41999
+ for (const line of subOut.split("\n")) {
42000
+ const m2 = line.trim().match(/^[-+U ]?[0-9a-f]{7,40}\s+(\S+)/);
42001
+ const subPath = m2?.[1];
42002
+ if (!subPath) continue;
42003
+ const abs = resolve8(base, subPath);
42004
+ if (!(abs === base || abs.startsWith(base + sep8))) continue;
42005
+ const remote = (await git(abs, ["remote", "get-url", "origin"])).trim();
42006
+ repos.push({ subdir: subPath, name: subPath, remote: remote || null });
41783
42007
  }
41784
- repos.sort((a, b2) => a.subdir.localeCompare(b2.subdir));
41785
42008
  return { repos };
41786
42009
  }
41787
42010
  async function gitLog(root, repo, skip, limit) {
@@ -42304,7 +42527,7 @@ function handleToolsChannels(ctx, message, payload) {
42304
42527
  }
42305
42528
 
42306
42529
  // src/core/agents.ts
42307
- import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir7, readdir as readdir5, rm as rm4 } from "fs/promises";
42530
+ import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir7, readdir as readdir3, rm as rm4 } from "fs/promises";
42308
42531
  import { join as join21 } from "path";
42309
42532
  var AGENTS_REL = ".claude/agents";
42310
42533
  var SKILLS_REL = ".claude/skills";
@@ -42329,7 +42552,7 @@ async function listAgents(root) {
42329
42552
  const subagents = [];
42330
42553
  const skills = [];
42331
42554
  try {
42332
- for (const e of await readdir5(join21(root, AGENTS_REL), { withFileTypes: true })) {
42555
+ for (const e of await readdir3(join21(root, AGENTS_REL), { withFileTypes: true })) {
42333
42556
  if (!e.isFile() || !e.name.endsWith(".md") || MEMORY_DIRS.has(e.name)) continue;
42334
42557
  const fm = frontmatter(await readText2(join21(root, AGENTS_REL, e.name)));
42335
42558
  subagents.push({ name: e.name.replace(/\.md$/, ""), description: fm.description });
@@ -42337,7 +42560,7 @@ async function listAgents(root) {
42337
42560
  } catch {
42338
42561
  }
42339
42562
  try {
42340
- for (const e of await readdir5(join21(root, SKILLS_REL), { withFileTypes: true })) {
42563
+ for (const e of await readdir3(join21(root, SKILLS_REL), { withFileTypes: true })) {
42341
42564
  if (!e.isDirectory()) continue;
42342
42565
  const fm = frontmatter(await readText2(join21(root, SKILLS_REL, e.name, "SKILL.md")));
42343
42566
  skills.push({ name: e.name, description: fm.description });
@@ -42489,7 +42712,7 @@ async function writeAgentTools(root, scope, permissions) {
42489
42712
 
42490
42713
  // src/core/packages.ts
42491
42714
  import { createHash as createHash4 } from "crypto";
42492
- import { readFile as readFile10, writeFile as writeFile9, mkdir as mkdir9, readdir as readdir6, rm as rm5, stat as stat3 } from "fs/promises";
42715
+ import { readFile as readFile10, writeFile as writeFile9, mkdir as mkdir9, readdir as readdir4, rm as rm5, stat as stat3 } from "fs/promises";
42493
42716
  import { dirname as dirname9, join as join24, relative } from "path";
42494
42717
  var AGENTS_REL2 = ".claude/agents";
42495
42718
  var SKILLS_REL2 = ".claude/skills";
@@ -42509,7 +42732,7 @@ async function listFilesRel(dir2) {
42509
42732
  async function walk2(d) {
42510
42733
  let entries2;
42511
42734
  try {
42512
- entries2 = await readdir6(d, { withFileTypes: true });
42735
+ entries2 = await readdir4(d, { withFileTypes: true });
42513
42736
  } catch {
42514
42737
  return;
42515
42738
  }
@@ -42677,7 +42900,7 @@ async function removePackage(root, slug) {
42677
42900
  }
42678
42901
 
42679
42902
  // src/core/graph.ts
42680
- import { readFile as readFile11, readdir as readdir7 } from "fs/promises";
42903
+ import { readFile as readFile11, readdir as readdir5 } from "fs/promises";
42681
42904
  import { join as join25, relative as relative2, dirname as dirname10, resolve as resolve9, extname as extname2, basename as basename6 } from "path";
42682
42905
  var MAX_FILES = 2e3;
42683
42906
  var MAX_NODES = 3e3;
@@ -42690,7 +42913,7 @@ async function walk(root, accept) {
42690
42913
  const dir2 = stack.pop();
42691
42914
  let entries2;
42692
42915
  try {
42693
- entries2 = await readdir7(dir2, { withFileTypes: true });
42916
+ entries2 = await readdir5(dir2, { withFileTypes: true });
42694
42917
  } catch {
42695
42918
  continue;
42696
42919
  }
@@ -43325,6 +43548,7 @@ var WsClient = class {
43325
43548
  );
43326
43549
  this.hctx = this.buildHandlerCtx();
43327
43550
  context.bus.onLocalSubmit((input) => void runLocalCommand(this.hctx, input));
43551
+ context.bus.onAutonomousRun(() => void runAutonomousCycle(this.hctx));
43328
43552
  context.bus.onReconnect(() => this.reconnectNow());
43329
43553
  context.bus.onTranscript((entry) => this.emitConsole({ kind: "add", entry: toDtoEntry(entry) }));
43330
43554
  context.bus.onTranscriptUpdate(
@@ -43874,7 +44098,7 @@ var WsClient = class {
43874
44098
  }
43875
44099
  const { payload, nonce } = encryptPayload(this.sessionKey, data);
43876
44100
  const envelope = {
43877
- id: randomUUID2(),
44101
+ id: randomUUID3(),
43878
44102
  replyTo,
43879
44103
  channel,
43880
44104
  wsToken: this.wsToken,
@@ -43895,7 +44119,7 @@ var WsClient = class {
43895
44119
  }
43896
44120
  const { payload, nonce } = encryptPayload(this.sessionKey, data);
43897
44121
  const envelope = {
43898
- id: randomUUID2(),
44122
+ id: randomUUID3(),
43899
44123
  replyTo: null,
43900
44124
  channel,
43901
44125
  wsToken: this.wsToken,
@@ -44284,7 +44508,7 @@ function releaseInstanceLock(profileDir2) {
44284
44508
 
44285
44509
  // src/core/session-bus.ts
44286
44510
  import { EventEmitter } from "events";
44287
- import { randomUUID as randomUUID3 } from "crypto";
44511
+ import { randomUUID as randomUUID4 } from "crypto";
44288
44512
  var REPLAY_CAP = 500;
44289
44513
  var SessionBus = class {
44290
44514
  emitter = new EventEmitter();
@@ -44315,7 +44539,7 @@ var SessionBus = class {
44315
44539
  /** Producer → consumers: push one transcript entry. Returns its id (for `updateEntry`). */
44316
44540
  push(input) {
44317
44541
  const entry = {
44318
- id: randomUUID3(),
44542
+ id: randomUUID4(),
44319
44543
  level: "info",
44320
44544
  ...input,
44321
44545
  ts: Date.now()
@@ -44546,6 +44770,28 @@ var SessionBus = class {
44546
44770
  this.emitter.on("reconnect", fn);
44547
44771
  return () => this.emitter.off("reconnect", fn);
44548
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
+ }
44549
44795
  };
44550
44796
 
44551
44797
  // src/core/control-server.ts
@@ -44608,7 +44854,9 @@ function startControlServer(bus, profileDir2, info) {
44608
44854
  bus.onWorker((worker) => broadcast({ t: "worker", worker })),
44609
44855
  bus.onProject((project) => broadcast({ t: "project", project })),
44610
44856
  bus.onUsage((usage) => broadcast({ t: "usage", usage })),
44611
- 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 }))
44612
44860
  ];
44613
44861
  const server = createServer((sock) => {
44614
44862
  clients.add(sock);
@@ -44631,6 +44879,7 @@ function startControlServer(bus, profileDir2, info) {
44631
44879
  for (const frame of parse(chunk)) {
44632
44880
  if (frame.t === "submit") bus.submitLocal(frame.input);
44633
44881
  else if (frame.t === "reconnect") bus.requestReconnect();
44882
+ else if (frame.t === "autonomousRun") bus.submitAutonomous();
44634
44883
  }
44635
44884
  });
44636
44885
  const drop = () => {
@@ -51167,6 +51416,62 @@ async function runAttach(profileDir2, profileName) {
51167
51416
  }
51168
51417
  }
51169
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
+
51170
51475
  // src/commands/ai-login.ts
51171
51476
  import { spawn as spawn11 } from "child_process";
51172
51477
  import { mkdirSync as mkdirSync8 } from "fs";
@@ -51391,6 +51696,15 @@ async function main() {
51391
51696
  await runAttach(profileDir(name), name);
51392
51697
  break;
51393
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
+ }
51394
51708
  case "ai-login": {
51395
51709
  const name = await pickLinkedProfile(explicitProfile, "ai-login");
51396
51710
  if (!name) {
@@ -51431,6 +51745,7 @@ async function main() {
51431
51745
  " 4pm link Pair with the server (pairing \u2192 confirmation code)",
51432
51746
  " 4pm start Connect WS, receive commands from the server",
51433
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)",
51434
51749
  " 4pm ai-login Log into a profile's AI CLI (claude/codex) in place (ADR-0199)",
51435
51750
  " 4pm unlink Delete a link (pick a profile if several)",
51436
51751
  " 4pm version Show the installed version (+ latest from the server)",