@4pm/cli 1.14.0 → 1.17.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
@@ -37098,9 +37098,24 @@ var taskApproveRequestSchema = external_exports.object({
37098
37098
  taskId: external_exports.string().min(1).max(64),
37099
37099
  approved: external_exports.boolean()
37100
37100
  });
37101
+ var taskApproveEntrySchema = external_exports.object({
37102
+ taskId: external_exports.string().min(1).max(64),
37103
+ tags: external_exports.array(external_exports.string().min(1).max(64)).max(16).default([])
37104
+ });
37105
+ var taskApproveBatchRequestSchema = external_exports.object({
37106
+ approvals: external_exports.array(taskApproveEntrySchema).max(500).default([]),
37107
+ unapprovals: external_exports.array(external_exports.string().min(1).max(64)).max(500).default([])
37108
+ });
37101
37109
  var autonomousWriteRequestSchema = external_exports.discriminatedUnion("kind", [
37102
37110
  external_exports.object({ kind: external_exports.literal("settings"), settings: external_exports.string().max(64 * 1024) }),
37103
37111
  external_exports.object({ kind: external_exports.literal("approvals"), taskId: external_exports.string().min(1).max(64), approved: external_exports.boolean() }),
37112
+ // Batched approvals (ADR-0311): commit many approve/unapprove ids in ONE approvals-file write, so a
37113
+ // Save's coupled batch lands atomically. Constructed by the server after its tag actions succeed.
37114
+ external_exports.object({
37115
+ kind: external_exports.literal("approvalsBatch"),
37116
+ approve: external_exports.array(external_exports.string().min(1).max(64)).max(500),
37117
+ unapprove: external_exports.array(external_exports.string().min(1).max(64)).max(500)
37118
+ }),
37104
37119
  external_exports.object({ kind: external_exports.literal("userTodo"), content: external_exports.string().min(1).max(16 * 1024) }),
37105
37120
  external_exports.object({ kind: external_exports.literal("cron"), action: external_exports.enum(["install", "uninstall"]) })
37106
37121
  ]);
@@ -37288,11 +37303,14 @@ var repoSpecSchema = external_exports.object({
37288
37303
  role: external_exports.string().max(60).optional().default(""),
37289
37304
  /** Free-form description of what this repo holds (optional). */
37290
37305
  desc: external_exports.string().max(500).optional().default(""),
37291
- /** Exactly one repo of the project is the primary. */
37306
+ /** Exactly one repo of the project is the primary (the root); the rest are submodules. */
37292
37307
  primary: external_exports.boolean().optional().default(false),
37293
37308
  /** Clone url — `https` or `ssh` (required; ADR-0172). */
37294
37309
  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). */
37310
+ /**
37311
+ * Folder under the root: **empty for the primary** (the root itself), **non-empty and unique for a
37312
+ * submodule** — the path `git submodule add` attaches it at (ADR-0316).
37313
+ */
37296
37314
  subdir: external_exports.string().max(120).optional().default(""),
37297
37315
  /**
37298
37316
  * Primary branch to clone / check out (ADR-0292). Empty ⇒ the repo's default branch. The
@@ -37301,9 +37319,38 @@ var repoSpecSchema = external_exports.object({
37301
37319
  */
37302
37320
  branch: external_exports.string().max(200).optional().default("")
37303
37321
  });
37304
- function repoName(url) {
37305
- const last = (url || "").trim().replace(/\/+$/, "").split(/[/:]/).pop() ?? "";
37306
- return last.replace(/\.git$/i, "");
37322
+ function validateProjectRepos(repos, ctx, path = ["repos"]) {
37323
+ const list = repos ?? [];
37324
+ const issue = (message) => ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path, message });
37325
+ if (list.length < 1) {
37326
+ issue("a project must declare at least one repo");
37327
+ return;
37328
+ }
37329
+ const primaries = list.filter((r) => r.primary);
37330
+ if (primaries.length !== 1) {
37331
+ issue("exactly one repo must be primary");
37332
+ }
37333
+ for (const r of list) {
37334
+ if (r.primary) {
37335
+ if ((r.subdir ?? "").trim() !== "") issue("the primary repo must be at the root (no subfolder)");
37336
+ } else if ((r.subdir ?? "").trim() === "") {
37337
+ issue("each submodule needs a folder path");
37338
+ }
37339
+ }
37340
+ const seenDir = /* @__PURE__ */ new Set();
37341
+ for (const r of list) {
37342
+ const dir2 = (r.subdir ?? "").trim().toLowerCase();
37343
+ if (!dir2) continue;
37344
+ if (seenDir.has(dir2)) issue("submodule folder paths must be unique");
37345
+ seenDir.add(dir2);
37346
+ }
37347
+ const seenUrl = /* @__PURE__ */ new Set();
37348
+ for (const r of list) {
37349
+ const url = (r.url ?? "").trim().toLowerCase();
37350
+ if (!url) continue;
37351
+ if (seenUrl.has(url)) issue("repo URLs must be unique");
37352
+ seenUrl.add(url);
37353
+ }
37307
37354
  }
37308
37355
  var specFieldEnvelopeSchema = external_exports.object({
37309
37356
  id: external_exports.string().min(1),
@@ -37335,16 +37382,7 @@ var projectSpecSchema = external_exports.object({
37335
37382
  });
37336
37383
  }
37337
37384
  }
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
- }
37385
+ validateProjectRepos(v2.repos, ctx);
37348
37386
  });
37349
37387
  var createFromSpecRequestSchema = external_exports.object({
37350
37388
  projectId: external_exports.string().uuid(),
@@ -37354,14 +37392,7 @@ var addRepoRequestSchema = external_exports.object({
37354
37392
  projectId: external_exports.string().uuid(),
37355
37393
  repos: external_exports.array(repoSpecSchema).min(1)
37356
37394
  }).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
- }
37395
+ validateProjectRepos(v2.repos, ctx);
37365
37396
  });
37366
37397
  var addProjectRepoRequestSchema = external_exports.object({
37367
37398
  /** Clone url — `https` or `ssh` (required; ADR-0172). */
@@ -37439,7 +37470,7 @@ var activityHeartbeatSchema = external_exports.object({
37439
37470
  });
37440
37471
 
37441
37472
  // src/core/scaffold.ts
37442
- import { cp, mkdir, readdir, rename, rm, writeFile } from "fs/promises";
37473
+ import { cp, mkdir, rm, writeFile } from "fs/promises";
37443
37474
  import { existsSync as existsSync4 } from "fs";
37444
37475
  import { execFile as execFile2 } from "child_process";
37445
37476
  import { dirname as dirname3, join as join6, resolve } from "path";
@@ -37524,89 +37555,111 @@ function reposOf(spec) {
37524
37555
  const repos = spec?.repos;
37525
37556
  return Array.isArray(repos) ? repos : [];
37526
37557
  }
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
- });
37558
+ function singleRepo(repos) {
37559
+ return repos.find((r) => r.primary) ?? repos[0] ?? null;
37560
+ }
37561
+ function submodulesOf(repos) {
37562
+ const primary = singleRepo(repos);
37563
+ return repos.filter((r) => r !== primary && !!r.url && !!(r.subdir ?? "").trim());
37564
+ }
37565
+ async function attachSubmodules(root, submodules, emit) {
37566
+ if (submodules.length === 0) return;
37567
+ if (!existsSync4(join6(root, ".git"))) return;
37568
+ let added = 0;
37569
+ for (const sub of submodules) {
37570
+ const dir2 = (sub.subdir ?? "").trim();
37571
+ if (!dir2 || !sub.url) continue;
37572
+ if (existsSync4(join6(root, dir2, ".git")) || existsSync4(join6(root, ".git", "modules", dir2))) {
37573
+ emit("submodule", `Submodule ${dir2} already present \u2014 updating\u2026`);
37574
+ try {
37575
+ await run2("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37576
+ } catch {
37577
+ }
37578
+ continue;
37579
+ }
37580
+ emit("submodule", `Adding submodule ${sub.url} \u2192 ${dir2}\u2026`);
37581
+ const b2 = (sub.branch ?? "").trim();
37582
+ try {
37583
+ await run2("git", ["submodule", "add", ...b2 ? ["-b", b2] : [], sub.url, dir2], { cwd: root, timeout: 12e4 });
37584
+ added++;
37585
+ } catch (err) {
37586
+ const msg = err instanceof Error ? err.message : String(err);
37587
+ if (/already exists|already registered|in the index/i.test(msg)) {
37588
+ try {
37589
+ await run2("git", ["submodule", "update", "--init", "--", dir2], { cwd: root, timeout: 12e4 });
37590
+ added++;
37591
+ } catch {
37592
+ }
37593
+ } else {
37594
+ throw new Error(`git submodule add ${dir2} failed: ${msg}`);
37595
+ }
37596
+ }
37597
+ }
37598
+ if (added > 0) await commitAndPushSubmodules(root, submodules, emit);
37599
+ }
37600
+ async function commitAndPushSubmodules(root, submodules, emit) {
37601
+ const dirs = submodules.map((s3) => (s3.subdir ?? "").trim()).filter(Boolean);
37602
+ emit("submodule", "Committing .gitmodules\u2026");
37603
+ await run2("git", ["add", ".gitmodules", ...dirs], { cwd: root, timeout: 6e4 });
37604
+ const staged = (await run2("git", ["diff", "--cached", "--name-only"], { cwd: root, timeout: 3e4 })).stdout.trim();
37605
+ if (!staged) return;
37606
+ const commitArgs = ["commit", "-m", "chore: add git submodules (4PM)"];
37607
+ try {
37608
+ await run2("git", commitArgs, { cwd: root, timeout: 6e4 });
37609
+ } catch {
37610
+ await run2("git", ["-c", "user.name=4PM", "-c", "user.email=noreply@4pm.app", ...commitArgs], { cwd: root, timeout: 6e4 });
37611
+ }
37612
+ emit("submodule", "Pushing .gitmodules to the primary remote\u2026");
37613
+ try {
37614
+ await run2("git", ["push"], { cwd: root, timeout: 12e4 });
37615
+ } catch (err) {
37616
+ const msg = err instanceof Error ? err.message : String(err);
37617
+ emit("submodule-push-failed", `Submodules committed locally but the push failed: ${msg}`);
37618
+ }
37541
37619
  }
37542
37620
  function cloneArgs(url, dest, branch) {
37543
37621
  const b2 = (branch ?? "").trim();
37544
37622
  return b2 ? ["clone", "-b", b2, url, dest] : ["clone", url, dest];
37545
37623
  }
37546
- async function updateRepo(dir2, label, branch, emit) {
37624
+ async function updateRepo(dir2, branch, emit) {
37547
37625
  const b2 = (branch ?? "").trim();
37548
- emit("git", `Updating ${label} (fetch + ${b2 ? `checkout ${b2} + ` : ""}fast-forward pull)\u2026`);
37626
+ emit("git", `Updating repository (fetch + ${b2 ? `checkout ${b2} + ` : ""}fast-forward pull)\u2026`);
37549
37627
  await run2("git", ["fetch", "origin", "--prune"], { cwd: dir2, timeout: 12e4 });
37550
37628
  if (b2) await run2("git", ["checkout", b2], { cwd: dir2, timeout: 6e4 });
37551
37629
  await run2("git", ["pull", "--ff-only"], { cwd: dir2, timeout: 12e4 });
37552
37630
  }
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 = {}) {
37631
+ async function provisionRepo(target, repo, emit, opts = {}) {
37558
37632
  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;
37633
+ if (!repo.url) {
37634
+ if (!existsSync4(join6(target, ".git"))) {
37635
+ emit("git", "Initializing repository\u2026");
37636
+ await mkdir(target, { recursive: true });
37637
+ await run2("git", ["init"], { cwd: target, timeout: 2e4 });
37576
37638
  }
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;
37639
+ return;
37640
+ }
37641
+ if (existsSync4(join6(target, ".git"))) {
37642
+ if (mode === "sync") {
37643
+ await updateRepo(target, repo.branch, emit);
37644
+ } else if (mode === "force") {
37645
+ emit("git", `Re-cloning ${repo.url} (force)\u2026`);
37646
+ await rm(target, { recursive: true, force: true });
37647
+ await run2("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37648
+ } else {
37649
+ emit("git", "Repository already present \u2014 skipping clone.");
37602
37650
  }
37603
- emit("git", `Cloning ${repo.url}\u2026`);
37604
- await run2("git", cloneArgs(repo.url, dir2, repo.branch), { timeout: 12e4 });
37651
+ return;
37605
37652
  }
37653
+ emit("git", `Cloning ${repo.url}\u2026`);
37654
+ await run2("git", cloneArgs(repo.url, target, repo.branch), { timeout: 12e4 });
37606
37655
  }
37607
37656
  async function ensureReposCloned(physicRoot, repos, emit) {
37608
- if (repos.length === 0) return;
37609
- await provisionRepos(physicRoot, repos, emit ?? (() => void 0));
37657
+ const list = repos;
37658
+ const repo = singleRepo(list);
37659
+ if (!repo) return;
37660
+ const step = emit ?? (() => void 0);
37661
+ await provisionRepo(physicRoot, repo, step);
37662
+ await attachSubmodules(physicRoot, submodulesOf(list), step);
37610
37663
  }
37611
37664
  function sampleDir() {
37612
37665
  if (process.env.SCAFFOLD_SAMPLE_DIR) return process.env.SCAFFOLD_SAMPLE_DIR;
@@ -37627,17 +37680,12 @@ async function scaffoldProject(payload, profileDir2, onProgress) {
37627
37680
  const root = process.env.SCAFFOLD_ROOT ? resolve(process.env.SCAFFOLD_ROOT) : resolve(profileDir2);
37628
37681
  const target = join6(root, payload.projectName);
37629
37682
  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
- }
37683
+ const repo = singleRepo(reposOf(payload.spec));
37684
+ if (!repo) throw new Error("A project must declare one repo (ADR-0314).");
37685
+ emit("git", "Cloning repository\u2026");
37686
+ await provisionRepo(target, repo, emit);
37687
+ await scaffoldRepo(target, payload.projectName, payload.spec, emit);
37688
+ await attachSubmodules(target, submodulesOf(reposOf(payload.spec)), emit);
37641
37689
  emit("version", "Writing .4pm/.4pm.json\u2026");
37642
37690
  await writeTemplateMarker(target);
37643
37691
  onProgress?.({ projectId: payload.projectId, step: "done", message: "Scaffold complete.", done: true });
@@ -37723,12 +37771,13 @@ async function addProject(payload, profileDir2, onProgress) {
37723
37771
  const root = process.env.SCAFFOLD_ROOT ? resolve(process.env.SCAFFOLD_ROOT) : resolve(profileDir2);
37724
37772
  const target = join6(root, payload.projectName);
37725
37773
  await mkdir(target, { recursive: true });
37726
- const repos = payload.repos ?? [];
37774
+ const repo = singleRepo(payload.repos ?? []);
37727
37775
  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);
37776
+ if (repo) await provisionRepo(target, repo, emit, { mode });
37777
+ if (payload.scaffoldRepos && payload.scaffoldRepos.length > 0) {
37778
+ await scaffoldRepo(target, payload.projectName, payload.spec, emit);
37731
37779
  }
37780
+ await attachSubmodules(target, submodulesOf(payload.repos ?? []), emit);
37732
37781
  onProgress?.({ projectId: payload.projectId, step: "done", message: "Project added.", done: true });
37733
37782
  return { ok: true, path: target };
37734
37783
  } catch (err) {
@@ -38227,7 +38276,7 @@ function decryptPayload(key, payloadBase64, nonceBase64) {
38227
38276
  import { spawn as spawn2 } from "child_process";
38228
38277
 
38229
38278
  // src/common/logger/logger.ts
38230
- import { appendFile, mkdir as mkdir2, readdir as readdir2, unlink } from "fs/promises";
38279
+ import { appendFile, mkdir as mkdir2, readdir, unlink } from "fs/promises";
38231
38280
  import { readdirSync as readdirSync2, readFileSync as readFileSync6, statSync } from "fs";
38232
38281
  import { join as join7 } from "path";
38233
38282
  var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
@@ -38287,7 +38336,7 @@ var CliLogger = class {
38287
38336
  async cleanupOldLogs() {
38288
38337
  const dir2 = this.dir;
38289
38338
  if (!dir2) return;
38290
- const files = await readdir2(dir2).catch(() => []);
38339
+ const files = await readdir(dir2).catch(() => []);
38291
38340
  const cutoff = Date.now() - this.retentionDays * DAY_MS;
38292
38341
  for (const file of files) {
38293
38342
  const match = /^cli-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(file);
@@ -38823,7 +38872,6 @@ var ONE_SHOT_DISALLOWED_CLAUDE_TOOLS = [
38823
38872
  "WebSearch",
38824
38873
  "WebFetch",
38825
38874
  "Skill",
38826
- "SlashCommand",
38827
38875
  "ToolSearch",
38828
38876
  "TaskCreate",
38829
38877
  "TaskGet",
@@ -39564,7 +39612,7 @@ var DOC_EXTENSIONS = [".md", ".mdx", ".txt"];
39564
39612
  function kbRoot(profileDir2) {
39565
39613
  return join14(profileDir2, "support-kb");
39566
39614
  }
39567
- function repoDir2(profileDir2, url, branch) {
39615
+ function repoDir(profileDir2, url, branch) {
39568
39616
  const key = createHash3("sha1").update(`${url}#${branch}`).digest("hex").slice(0, 16);
39569
39617
  return join14(kbRoot(profileDir2), key);
39570
39618
  }
@@ -39574,7 +39622,7 @@ function authedUrl(url, token) {
39574
39622
  return url;
39575
39623
  }
39576
39624
  async function ensureRepo(profileDir2, repo) {
39577
- const dir2 = repoDir2(profileDir2, repo.url, repo.branch);
39625
+ const dir2 = repoDir(profileDir2, repo.url, repo.branch);
39578
39626
  if (existsSync10(join14(dir2, ".git"))) return dir2;
39579
39627
  const url = authedUrl(repo.url, repo.token);
39580
39628
  const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
@@ -39954,6 +40002,14 @@ async function writeAutonomous(root, req, by) {
39954
40002
  await writeFile2(join15(root, APPROVALS_REL), JSON.stringify(map, null, 2) + "\n", "utf8");
39955
40003
  break;
39956
40004
  }
40005
+ case "approvalsBatch": {
40006
+ const map = parseJson(await readText(join15(root, APPROVALS_REL), "{}")) ?? {};
40007
+ const at2 = (/* @__PURE__ */ new Date()).toISOString();
40008
+ for (const taskId of req.approve) map[taskId] = { approved: true, by, at: at2 };
40009
+ for (const taskId of req.unapprove) delete map[taskId];
40010
+ await writeFile2(join15(root, APPROVALS_REL), JSON.stringify(map, null, 2) + "\n", "utf8");
40011
+ break;
40012
+ }
39957
40013
  case "userTodo": {
39958
40014
  const cur = await readText(join15(root, BOOK_FILES.userTodo));
39959
40015
  const trace = `posted by ${tableCell(by)} at ${(/* @__PURE__ */ new Date()).toISOString()}`;
@@ -40828,8 +40884,10 @@ function handleCommandChannels(ctx, message, payload) {
40828
40884
  text: `$ ${dispatch.cmd} ${dispatch.args.join(" ")}`.trimEnd()
40829
40885
  });
40830
40886
  ctx.bus.startBusy(dispatch.cmd);
40887
+ const cwd2 = dispatch.path ?? ctx.physicRoot ?? readProfileConfig(ctx.profileDir).physicPath ?? process.cwd();
40888
+ const dispatchInRoot = { ...dispatch, path: cwd2 };
40831
40889
  const cmdStartMs = Date.now();
40832
- void runCommand(dispatch, (out) => {
40890
+ void runCommand(dispatchInRoot, (out) => {
40833
40891
  if (out.chunk) {
40834
40892
  ctx.bus.push({ source: "server", kind: "out", text: out.chunk });
40835
40893
  appendCommandOutput(out.commandId, out.chunk);
@@ -41294,7 +41352,7 @@ function rejectByReview(ctx, commandId, origin, prompt, reasons) {
41294
41352
  }
41295
41353
 
41296
41354
  // src/core/fs-browse.ts
41297
- import { readdir as readdir3 } from "fs/promises";
41355
+ import { readdir as readdir2 } from "fs/promises";
41298
41356
  import { resolve as resolve2, sep as sep2 } from "path";
41299
41357
  async function listDir(path, root) {
41300
41358
  if (!root) return { path: "", entries: [] };
@@ -41302,7 +41360,7 @@ async function listDir(path, root) {
41302
41360
  const requested = resolve2(base, path && path.trim() ? path : ".");
41303
41361
  const target = requested === base || requested.startsWith(base + sep2) ? requested : base;
41304
41362
  try {
41305
- const dirents = await readdir3(target, { withFileTypes: true });
41363
+ const dirents = await readdir2(target, { withFileTypes: true });
41306
41364
  const entries2 = dirents.map((d) => ({
41307
41365
  name: d.name,
41308
41366
  type: d.isDirectory() ? "dir" : "file"
@@ -41370,7 +41428,7 @@ async function writeWorkerFile(root, relPath, content) {
41370
41428
  }
41371
41429
 
41372
41430
  // src/core/fs-mutate.ts
41373
- import { mkdir as mkdir4, writeFile as writeFile4, rename as rename2, rm as rm2, stat } from "fs/promises";
41431
+ import { mkdir as mkdir4, writeFile as writeFile4, rename, rm as rm2, stat } from "fs/promises";
41374
41432
  import { basename as basename3, dirname as dirname5, resolve as resolve5, sep as sep5 } from "path";
41375
41433
  var MAX_BYTES3 = 2 * 1024 * 1024;
41376
41434
  function resolveInRoot3(root, rel) {
@@ -41425,7 +41483,7 @@ async function mutateFs(root, req) {
41425
41483
  if (to2 !== resolve5(root) && !to2.startsWith(resolve5(root) + sep5)) return escaped(req.to ?? "");
41426
41484
  if (await exists(to2)) return { ok: false, path: to2, error: "destination already exists" };
41427
41485
  await mkdir4(dirname5(to2), { recursive: true });
41428
- await rename2(from, to2);
41486
+ await rename(from, to2);
41429
41487
  return { ok: true, path: to2 };
41430
41488
  }
41431
41489
  case "delete": {
@@ -41724,7 +41782,6 @@ async function manageSshKey(op, profileDir2) {
41724
41782
  }
41725
41783
 
41726
41784
  // src/core/git-history.ts
41727
- import { readdir as readdir4 } from "fs/promises";
41728
41785
  import { execFile as execFile9 } from "child_process";
41729
41786
  import { resolve as resolve8, sep as sep8 } from "path";
41730
41787
  import { promisify as promisify9 } from "util";
@@ -41752,36 +41809,21 @@ async function git(cwd2, args) {
41752
41809
  async function gitRepos(root) {
41753
41810
  if (!root) return { repos: [] };
41754
41811
  const base = resolve8(root);
41755
- const found = /* @__PURE__ */ new Set();
41812
+ const top = (await git(base, ["rev-parse", "--show-toplevel"])).trim();
41813
+ if (!top || !(top === base || top.startsWith(base + sep8))) return { repos: [] };
41756
41814
  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 {
41815
+ const rootRemote = (await git(base, ["remote", "get-url", "origin"])).trim();
41816
+ repos.push({ subdir: "", name: "", remote: rootRemote || null });
41817
+ const subOut = await git(base, ["submodule", "status"]);
41818
+ for (const line of subOut.split("\n")) {
41819
+ const m2 = line.trim().match(/^[-+U ]?[0-9a-f]{7,40}\s+(\S+)/);
41820
+ const subPath = m2?.[1];
41821
+ if (!subPath) continue;
41822
+ const abs = resolve8(base, subPath);
41823
+ if (!(abs === base || abs.startsWith(base + sep8))) continue;
41824
+ const remote = (await git(abs, ["remote", "get-url", "origin"])).trim();
41825
+ repos.push({ subdir: subPath, name: subPath, remote: remote || null });
41783
41826
  }
41784
- repos.sort((a, b2) => a.subdir.localeCompare(b2.subdir));
41785
41827
  return { repos };
41786
41828
  }
41787
41829
  async function gitLog(root, repo, skip, limit) {
@@ -42304,7 +42346,7 @@ function handleToolsChannels(ctx, message, payload) {
42304
42346
  }
42305
42347
 
42306
42348
  // src/core/agents.ts
42307
- import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir7, readdir as readdir5, rm as rm4 } from "fs/promises";
42349
+ import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir7, readdir as readdir3, rm as rm4 } from "fs/promises";
42308
42350
  import { join as join21 } from "path";
42309
42351
  var AGENTS_REL = ".claude/agents";
42310
42352
  var SKILLS_REL = ".claude/skills";
@@ -42329,7 +42371,7 @@ async function listAgents(root) {
42329
42371
  const subagents = [];
42330
42372
  const skills = [];
42331
42373
  try {
42332
- for (const e of await readdir5(join21(root, AGENTS_REL), { withFileTypes: true })) {
42374
+ for (const e of await readdir3(join21(root, AGENTS_REL), { withFileTypes: true })) {
42333
42375
  if (!e.isFile() || !e.name.endsWith(".md") || MEMORY_DIRS.has(e.name)) continue;
42334
42376
  const fm = frontmatter(await readText2(join21(root, AGENTS_REL, e.name)));
42335
42377
  subagents.push({ name: e.name.replace(/\.md$/, ""), description: fm.description });
@@ -42337,7 +42379,7 @@ async function listAgents(root) {
42337
42379
  } catch {
42338
42380
  }
42339
42381
  try {
42340
- for (const e of await readdir5(join21(root, SKILLS_REL), { withFileTypes: true })) {
42382
+ for (const e of await readdir3(join21(root, SKILLS_REL), { withFileTypes: true })) {
42341
42383
  if (!e.isDirectory()) continue;
42342
42384
  const fm = frontmatter(await readText2(join21(root, SKILLS_REL, e.name, "SKILL.md")));
42343
42385
  skills.push({ name: e.name, description: fm.description });
@@ -42489,7 +42531,7 @@ async function writeAgentTools(root, scope, permissions) {
42489
42531
 
42490
42532
  // src/core/packages.ts
42491
42533
  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";
42534
+ import { readFile as readFile10, writeFile as writeFile9, mkdir as mkdir9, readdir as readdir4, rm as rm5, stat as stat3 } from "fs/promises";
42493
42535
  import { dirname as dirname9, join as join24, relative } from "path";
42494
42536
  var AGENTS_REL2 = ".claude/agents";
42495
42537
  var SKILLS_REL2 = ".claude/skills";
@@ -42509,7 +42551,7 @@ async function listFilesRel(dir2) {
42509
42551
  async function walk2(d) {
42510
42552
  let entries2;
42511
42553
  try {
42512
- entries2 = await readdir6(d, { withFileTypes: true });
42554
+ entries2 = await readdir4(d, { withFileTypes: true });
42513
42555
  } catch {
42514
42556
  return;
42515
42557
  }
@@ -42677,7 +42719,7 @@ async function removePackage(root, slug) {
42677
42719
  }
42678
42720
 
42679
42721
  // src/core/graph.ts
42680
- import { readFile as readFile11, readdir as readdir7 } from "fs/promises";
42722
+ import { readFile as readFile11, readdir as readdir5 } from "fs/promises";
42681
42723
  import { join as join25, relative as relative2, dirname as dirname10, resolve as resolve9, extname as extname2, basename as basename6 } from "path";
42682
42724
  var MAX_FILES = 2e3;
42683
42725
  var MAX_NODES = 3e3;
@@ -42690,7 +42732,7 @@ async function walk(root, accept) {
42690
42732
  const dir2 = stack.pop();
42691
42733
  let entries2;
42692
42734
  try {
42693
- entries2 = await readdir7(dir2, { withFileTypes: true });
42735
+ entries2 = await readdir5(dir2, { withFileTypes: true });
42694
42736
  } catch {
42695
42737
  continue;
42696
42738
  }
@@ -3,12 +3,14 @@
3
3
  > Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`
4
4
  > (group = one request/batch, task = a small sub-task doable in ~1 cycle).
5
5
  > **Priority** = High / Medium / Low.
6
- > **Approved**: the source of truth is `.claude/.autonomous.approvals.json` (the user ticks it in the
7
- > web VERIFY tab — ADR-0152); the Approved column here is display-only, not where the decision is made.
6
+ > **Tag** = optional catalog tag(s) (e.g. `UpdateSpecFromDB`) whose action runs from the server down to
7
+ > the project when the task is approved (approval is committed on Save — ADR-0311).
8
+ > **Approval** is NOT a table column — it lives in `.claude/.autonomous.approvals.json` (ADR-0152), set
9
+ > from the web AI Todo grid; `/auto-cycle` reads that file, never the table.
8
10
  > **Depends** = the `TSK-…` ids that must be DONE (present in `AI_DONE.md`) first.
9
11
  > `/auto-cycle` only takes tasks that are approved AND have their dependencies met → moves them to
10
12
  > `AI_PROGRESS.md`; runs group by group, within a group High → Medium → Low.
11
13
 
12
- | ID | Priority | Approved | Depends | Group | Task description | Notes |
13
- |----|----------|----------|---------|-------|------------------|-------|
14
+ | ID | Priority | Tag | Depends | Group | Task description | Notes |
15
+ |----|----------|-----|---------|-------|------------------|-------|
14
16
  | | | | | | | |
@@ -1,10 +1,13 @@
1
1
  # AI_TODO — Task queue (generated by the AI)
2
2
 
3
3
  > Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`.
4
- > **Priority** = High / Medium / Low. **Approved** source of truth = `.claude/.autonomous.approvals.json`
5
- > (web VERIFY tab). **Depends** = `TSK-…` ids that must be in `AI_DONE.md` first.
4
+ > **Priority** = High / Medium / Low. **Tag** = optional catalog tag(s) (e.g. `UpdateSpecFromDB`) whose
5
+ > action runs from the server down to the project on approve (committed on Save — ADR-0311).
6
+ > **Approval** lives in `.claude/.autonomous.approvals.json` (ADR-0152), not the table. **Depends** =
7
+ > `TSK-…` ids that must be in `AI_DONE.md` first.
6
8
 
7
- | ID | Priority | Approved | Depends | Group | Task description | Notes |
8
- |----|----------|----------|---------|-------|------------------|-------|
9
- | TSK-0001-0001 | High | ✓ | | Auth | Add login-form validation per docs/auth.md | |
10
- | TSK-0001-0002 | Medium | | TSK-0001-0001 | Auth | Wire the login API call + error handling | After 0001 |
9
+ | ID | Priority | Tag | Depends | Group | Task description | Notes |
10
+ |----|----------|-----|---------|-------|------------------|-------|
11
+ | TSK-0001-0001 | High | | | Auth | Add login-form validation per docs/auth.md | |
12
+ | TSK-0001-0002 | Medium | | TSK-0001-0001 | Auth | Wire the login API call + error handling | After 0001 |
13
+ | TSK-0001-0003 | Low | UpdateSpecFromDB | | Spec | Sync the project spec into project.spec.json on approve | Server action |
@@ -3,12 +3,14 @@
3
3
  > Tasks are generated from `USER_TODO.md`. ID format `TSK-{groupid:0000}-{taskid:0000}`
4
4
  > (group = one request/batch, task = a small sub-task doable in ~1 cycle).
5
5
  > **Priority** = High / Medium / Low.
6
- > **Approved**: the source of truth is `.claude/.autonomous.approvals.json` (the user ticks it in the
7
- > web VERIFY tab — ADR-0152); the Approved column here is display-only, not where the decision is made.
6
+ > **Tag** = optional catalog tag(s) (e.g. `UpdateSpecFromDB`) whose action runs from the server down to
7
+ > the project when the task is approved (approval is committed on Save — ADR-0311).
8
+ > **Approval** is NOT a table column — it lives in `.claude/.autonomous.approvals.json` (ADR-0152), set
9
+ > from the web AI Todo grid; `/auto-cycle` reads that file, never the table.
8
10
  > **Depends** = the `TSK-…` ids that must be DONE (present in `AI_DONE.md`) first.
9
11
  > `/auto-cycle` only takes tasks that are approved AND have their dependencies met → moves them to
10
12
  > `AI_PROGRESS.md`; runs group by group, within a group High → Medium → Low.
11
13
 
12
- | ID | Priority | Approved | Depends | Group | Task description | Notes |
13
- |----|----------|----------|---------|-------|------------------|-------|
14
+ | ID | Priority | Tag | Depends | Group | Task description | Notes |
15
+ |----|----------|-----|---------|-------|------------------|-------|
14
16
  | | | | | | | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4pm/cli",
3
- "version": "1.14.0",
3
+ "version": "1.17.0",
4
4
  "private": false,
5
5
  "description": "4PM CLI — drives Claude/Codex CLIs on AI worker machines (Node + TypeScript)",
6
6
  "license": "LicenseRef-4PM-Source-Available",