@helmsmith/workbrokr 0.2.0 → 0.3.1

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/cli.js CHANGED
@@ -61,7 +61,8 @@ var REFUSALS = /* @__PURE__ */ new Set([
61
61
  "PORT_RANGE_TAKEN",
62
62
  "SYNC_CONFLICT",
63
63
  "DIRTY_WORKTREE",
64
- "UNPUSHED"
64
+ "UNPUSHED",
65
+ "PR_CLOSED"
65
66
  ]);
66
67
  function exitCodeFor(code) {
67
68
  return REFUSALS.has(code) ? 2 : 1;
@@ -164,12 +165,43 @@ var Git = class _Git {
164
165
  else if (this.remoteBranchExists(this.currentBranch())) ref = `origin/${this.currentBranch()}`;
165
166
  return this.lines(["rev-list", `${ref}..HEAD`]).length;
166
167
  }
168
+ /** Non-merge commits on HEAD that origin/<base> does not have: the branch's own work (sync merges do not count). */
169
+ ownCommitCount(base) {
170
+ const r = this.tryRun(["rev-list", "--count", "--no-merges", `origin/${base}..HEAD`]);
171
+ return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
172
+ }
173
+ /** `branch` is already in origin/<base>: an ancestor of it, patch-equivalent (squash-merged), or without own commits. */
174
+ branchMergedInto(branch, base) {
175
+ const target = `origin/${base}`;
176
+ if (this.isAncestor(branch, target)) return true;
177
+ const own = this.tryRun(["rev-list", "--count", "--no-merges", `${target}..${branch}`]);
178
+ if (own.code === 0 && Number(own.out.trim()) === 0) return true;
179
+ const cherry = this.tryRun(["cherry", target, branch]);
180
+ if (cherry.code !== 0) return false;
181
+ const lines = cherry.out.split("\n").map((l) => l.trim()).filter(Boolean);
182
+ return lines.length > 0 && lines.every((l) => l.startsWith("- "));
183
+ }
184
+ /** Commits on origin/<base> that HEAD does not have (0 when the ref is unknown). */
185
+ behindCount(base) {
186
+ const r = this.tryRun(["rev-list", "--count", `HEAD..origin/${base}`]);
187
+ return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
188
+ }
167
189
  isDirty() {
168
190
  return this.run(["status", "--porcelain"]).trim().length > 0;
169
191
  }
170
192
  changedPathsStaged() {
171
193
  return this.lines(["diff", "--cached", "--name-only"]);
172
194
  }
195
+ /** The other parent of a merge in progress, or undefined when none is. */
196
+ mergeHead() {
197
+ const r = this.tryRun(["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
198
+ return r.code === 0 ? r.out.trim() : void 0;
199
+ }
200
+ /** Staged paths whose index content differs from `ref` (so, authored here rather than taken from `ref`). */
201
+ stagedDifferingFrom(ref, paths) {
202
+ if (paths.length === 0) return [];
203
+ return this.lines(["diff", "--cached", "--name-only", "--no-renames", ref, "--", ...paths]);
204
+ }
173
205
  changedPaths(from, to) {
174
206
  return this.lines(["diff", "--name-only", "--no-renames", from, to]);
175
207
  }
@@ -202,18 +234,45 @@ var Git = class _Git {
202
234
  const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
203
235
  return { kind: "conflict", before, after: before, conflicted };
204
236
  }
237
+ /** `origin/<branch>` after a fetch attempt, when the remote has it; the local copy may have been retired. */
238
+ remoteRef(branch) {
239
+ this.tryRun(["fetch", "-q", "origin", branch]);
240
+ const ref = `refs/remotes/origin/${branch}`;
241
+ return this.tryRun(["rev-parse", "--verify", "-q", ref]).code === 0 ? `origin/${branch}` : void 0;
242
+ }
243
+ /**
244
+ * Add a worktree on `branch`: the local branch if it exists, else a tracking
245
+ * branch from origin/<branch> if the remote has it (so retiring the local
246
+ * branch while its PR was open never recreates it from the base), else a
247
+ * new branch from `startPoint`.
248
+ */
205
249
  worktreeAdd(path, branch, startPoint) {
206
250
  mkdirSync(dirname(path), { recursive: true });
207
- if (this.branchExists(branch)) this.run(["worktree", "add", "-q", path, branch]);
208
- else
209
- this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
251
+ if (this.branchExists(branch)) {
252
+ this.run(["worktree", "add", "-q", path, branch]);
253
+ return;
254
+ }
255
+ const remote = this.remoteRef(branch);
256
+ if (remote) {
257
+ this.run(["worktree", "add", "-q", "--track", "-b", branch, path, remote]);
258
+ return;
259
+ }
260
+ this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
210
261
  }
211
262
  worktreeRemove(path) {
212
263
  this.run(["worktree", "remove", "--force", path]);
213
264
  }
214
265
  checkout(branch, createFrom) {
215
- if (this.branchExists(branch)) this.run(["checkout", "-q", branch]);
216
- else this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
266
+ if (this.branchExists(branch)) {
267
+ this.run(["checkout", "-q", branch]);
268
+ return;
269
+ }
270
+ const remote = this.remoteRef(branch);
271
+ if (remote) {
272
+ this.run(["checkout", "-q", "--track", "-b", branch, remote]);
273
+ return;
274
+ }
275
+ this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
217
276
  }
218
277
  push(branch) {
219
278
  this.run(["push", "-q", "-u", "origin", branch]);
@@ -327,13 +386,14 @@ function slotVars(m, o) {
327
386
  BRANCH: o.branch ?? "",
328
387
  WORKTREE: o.worktree ?? "",
329
388
  LOG_DIR: o.logDir ?? "",
330
- PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort),
331
389
  STORYBROKR_HOST: storybookHostFor(m, o.worktree)
332
390
  };
333
391
  for (const service of Object.keys(m.ports.services)) {
334
392
  const p = o.ports[service];
335
393
  vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
336
394
  }
395
+ if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
396
+ else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
337
397
  return vars;
338
398
  }
339
399
  function ensureExcluded(worktree, patterns) {
@@ -558,8 +618,15 @@ function checkCommit(c) {
558
618
  `branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
559
619
  );
560
620
  }
621
+ const mergeHead = c.git.mergeHead();
622
+ const authored = mergeHead ? new Set(c.git.stagedDifferingFrom(mergeHead, c.stagedPaths)) : new Set(c.stagedPaths);
623
+ const ownPaths = c.stagedPaths.filter((p) => authored.has(p));
561
624
  const allEfforts = c.registry.efforts({ project: c.project });
562
625
  const efforts = new Map(allEfforts.map((e) => [e.id, e]));
626
+ const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
627
+ const mine = openEfforts.find(
628
+ (e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
629
+ );
563
630
  for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
564
631
  const holder = efforts.get(lease.effort);
565
632
  const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
@@ -568,34 +635,31 @@ function checkCommit(c) {
568
635
  } else if (lease.owner.session === c.ownerSession) {
569
636
  continue;
570
637
  }
638
+ if (holder && mine && holder.area === mine.area) continue;
571
639
  const globs = c.m.commons[String(lease.value)] ?? [];
572
- const hit = pathsMatching(c.stagedPaths, globs);
640
+ const hit = pathsMatching(ownPaths, globs);
573
641
  if (hit.length > 0) {
574
642
  refusals.push(
575
643
  `commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
576
644
  );
577
645
  }
578
646
  }
579
- const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
580
647
  for (const other of openEfforts) {
581
648
  if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
582
649
  continue;
583
650
  }
584
651
  if (!c.checkoutRoot && other.branch === c.branch) continue;
585
- const hit = pathsMatching(c.stagedPaths, other.scopes);
652
+ const hit = pathsMatching(ownPaths, other.scopes);
586
653
  if (hit.length > 0) {
587
654
  refusals.push(
588
655
  `path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
589
656
  );
590
657
  }
591
658
  }
592
- const mine = openEfforts.find(
593
- (e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
594
- );
595
- if (mine && c.stagedPaths.length > 0) {
659
+ if (mine && ownPaths.length > 0) {
596
660
  const allowed = allowedScopesForEffort(c.m, mine);
597
- const inScope = new Set(pathsMatching(c.stagedPaths, allowed));
598
- const outside = c.stagedPaths.filter((p) => !inScope.has(p));
661
+ const inScope = new Set(pathsMatching(ownPaths, allowed));
662
+ const outside = ownPaths.filter((p) => !inScope.has(p));
599
663
  if (outside.length > 0) {
600
664
  warnings.push(
601
665
  `${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
@@ -922,8 +986,19 @@ function rangesFor(m) {
922
986
  function rangesOverlap(a, b) {
923
987
  return a.base < b.base + b.count && b.base < a.base + a.count;
924
988
  }
989
+ function ensureRanges(registry, m, repoRoot) {
990
+ const mine = rangesFor(m);
991
+ assertNoOverlap(registry, m, mine);
992
+ const current = registry.project(m.project);
993
+ if (current && JSON.stringify(current.ranges) === JSON.stringify(mine)) return;
994
+ registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
995
+ }
925
996
  function claimRanges(registry, m, repoRoot) {
926
997
  const mine = rangesFor(m);
998
+ assertNoOverlap(registry, m, mine);
999
+ registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
1000
+ }
1001
+ function assertNoOverlap(registry, m, mine) {
927
1002
  const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
928
1003
  for (const [service, range] of Object.entries(mine)) {
929
1004
  for (const [name, entry] of others) {
@@ -938,7 +1013,6 @@ function claimRanges(registry, m, repoRoot) {
938
1013
  }
939
1014
  }
940
1015
  }
941
- registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
942
1016
  }
943
1017
 
944
1018
  // src/core/refresh.ts
@@ -1070,8 +1144,8 @@ import {
1070
1144
  } from "fs";
1071
1145
  var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
1072
1146
  var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
1073
- var LOCK_STALE_MS = 3e4;
1074
- var LOCK_WAIT_MS = 5e3;
1147
+ var LOCK_STALE_MS = 1e4;
1148
+ var LOCK_WAIT_MS = 15e3;
1075
1149
  function sleep(ms) {
1076
1150
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1077
1151
  }
@@ -1127,7 +1201,9 @@ var Registry = class _Registry {
1127
1201
  } catch {
1128
1202
  try {
1129
1203
  if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
1130
- rmSync2(lock, { recursive: true, force: true });
1204
+ const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
1205
+ renameSync(lock, claimed);
1206
+ rmSync2(claimed, { recursive: true, force: true });
1131
1207
  continue;
1132
1208
  }
1133
1209
  } catch {
@@ -1237,7 +1313,7 @@ var Registry = class _Registry {
1237
1313
  if (slot < 0) {
1238
1314
  throw new WorkkitError(
1239
1315
  "NO_FREE_SLOT",
1240
- `no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")})`
1316
+ `no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")}): \`work status\` lists the efforts holding them; \`work finish\` or \`work reap\` frees one`
1241
1317
  );
1242
1318
  }
1243
1319
  const lease = this.lease(
@@ -1480,10 +1556,14 @@ function currentOwner(env = process.env) {
1480
1556
  return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
1481
1557
  }
1482
1558
 
1559
+ // src/lib/pritty.ts
1560
+ import { spawnSync as spawnSync5 } from "child_process";
1561
+
1483
1562
  // src/lib/storybrokr.ts
1484
1563
  import { spawnSync as spawnSync4 } from "child_process";
1485
1564
  var MIN_STORYBROKR_VERSION = "0.3.0";
1486
1565
  var versionChecked = false;
1566
+ var versionFound = "";
1487
1567
  function compareSemver(a, b) {
1488
1568
  const pa = a.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
1489
1569
  const pb = b.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
@@ -1514,12 +1594,14 @@ function assertStorybrokrVersion() {
1514
1594
  );
1515
1595
  }
1516
1596
  versionChecked = true;
1597
+ versionFound = found;
1517
1598
  }
1518
1599
  var storybrokrCli = {
1519
1600
  async up(story, hostRoot, opts) {
1520
1601
  assertStorybrokrVersion();
1521
1602
  const args = ["up", story, "--host", hostRoot, "--json"];
1522
1603
  if (opts?.ttlMinutes !== void 0) args.push("--ttl", String(opts.ttlMinutes));
1604
+ if (opts?.includeChildren) args.push("--include-children");
1523
1605
  const r = run(args);
1524
1606
  if (r.code !== 0) {
1525
1607
  throw new WorkkitError(
@@ -1536,8 +1618,26 @@ var storybrokrCli = {
1536
1618
  throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
1537
1619
  },
1538
1620
  async downHost(hostRoot) {
1539
- const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--json"], { encoding: "utf8" });
1540
- void r;
1621
+ const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--if-running", "--json"], {
1622
+ encoding: "utf8"
1623
+ });
1624
+ if (r.error) return [];
1625
+ if (r.status !== 0) {
1626
+ throw new WorkkitError(
1627
+ "STORYBROKR_FAILED",
1628
+ `storybrokr down --host ${hostRoot} failed: ${(r.stderr || r.stdout || "").trim()}`
1629
+ );
1630
+ }
1631
+ try {
1632
+ const parsed = JSON.parse(r.stdout);
1633
+ return parsed.stopped ?? [];
1634
+ } catch {
1635
+ return [];
1636
+ }
1637
+ },
1638
+ version() {
1639
+ assertStorybrokrVersion();
1640
+ return versionFound;
1541
1641
  },
1542
1642
  async touch(id) {
1543
1643
  const r = spawnSync4("storybrokr", ["touch", id], { encoding: "utf8" });
@@ -1545,6 +1645,81 @@ var storybrokrCli = {
1545
1645
  }
1546
1646
  };
1547
1647
 
1648
+ // src/lib/pritty.ts
1649
+ var MIN_PRITTY_VERSION = "0.1.2";
1650
+ function assertPrittyVersion(client) {
1651
+ const found = client.version();
1652
+ if (compareSemver(found, MIN_PRITTY_VERSION) < 0) {
1653
+ throw new WorkkitError(
1654
+ "PRITTY_FAILED",
1655
+ `pritty >= ${MIN_PRITTY_VERSION} required (found ${found || "unknown"}); npm i -g @helmsmith/pritty`
1656
+ );
1657
+ }
1658
+ }
1659
+ function run2(args, cwd) {
1660
+ const r = spawnSync5("pritty", args, { cwd, encoding: "utf8", env: process.env });
1661
+ if (r.error) {
1662
+ throw new WorkkitError(
1663
+ "PRITTY_FAILED",
1664
+ `pritty is not installed (npm i -g @helmsmith/pritty): ${r.error.message}`
1665
+ );
1666
+ }
1667
+ return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
1668
+ }
1669
+ function lastJsonLine(out, what) {
1670
+ const line = out.trim().split("\n").filter(Boolean).pop() ?? "";
1671
+ try {
1672
+ return JSON.parse(line);
1673
+ } catch {
1674
+ throw new WorkkitError("PRITTY_FAILED", `pritty ${what} printed no JSON: ${out.trim()}`);
1675
+ }
1676
+ }
1677
+ var versionFound2;
1678
+ var prittyCli = {
1679
+ version() {
1680
+ if (versionFound2 === void 0) {
1681
+ const r = run2(["--version"]);
1682
+ versionFound2 = r.code === 0 ? r.out.trim() : "";
1683
+ }
1684
+ return versionFound2;
1685
+ },
1686
+ async pr(o) {
1687
+ const args = ["pr", "--base", o.base, "--no-push", "--auto-approve", "--json"];
1688
+ if (o.draft) args.push("--draft");
1689
+ if (o.title !== void 0) args.push("--title", o.title);
1690
+ if (o.body !== void 0) args.push("--body", o.body);
1691
+ if (o.bodyFile !== void 0) args.push("--body-file", o.bodyFile);
1692
+ const r = run2(args, o.cwd);
1693
+ if (r.code !== 0) {
1694
+ throw new WorkkitError("PRITTY_FAILED", `pritty pr failed: ${(r.err || r.out).trim()}`, {
1695
+ stderr: r.err
1696
+ });
1697
+ }
1698
+ const j = lastJsonLine(
1699
+ r.out,
1700
+ "pr"
1701
+ );
1702
+ return { number: j.number, url: j.url, draft: Boolean(j.draft), created: Boolean(j.created) };
1703
+ },
1704
+ async check(o) {
1705
+ const r = run2(["pr", "--check", "--base", o.base], o.cwd);
1706
+ if (r.code !== 0) {
1707
+ throw new WorkkitError(
1708
+ "PRITTY_FAILED",
1709
+ `pritty pr --check failed: ${(r.err || r.out).trim()}`,
1710
+ {
1711
+ stderr: r.err
1712
+ }
1713
+ );
1714
+ }
1715
+ const j = lastJsonLine(r.out, "pr --check");
1716
+ return {
1717
+ open: j.open ? { number: j.open.number, url: j.open.url, draft: Boolean(j.open.draft) } : null,
1718
+ last: j.last ?? null
1719
+ };
1720
+ }
1721
+ };
1722
+
1548
1723
  // src/lib/context.ts
1549
1724
  function resolveRepoRoot(cwd, override) {
1550
1725
  if (override) return override;
@@ -1555,7 +1730,7 @@ async function buildCtx(opts = {}) {
1555
1730
  const manifest = await loadManifest(repoRoot);
1556
1731
  const home = homeDir();
1557
1732
  const registry = Registry.open(home);
1558
- if (!registry.project(manifest.project)) claimRanges(registry, manifest, repoRoot);
1733
+ ensureRanges(registry, manifest, repoRoot);
1559
1734
  return {
1560
1735
  home,
1561
1736
  repoRoot,
@@ -1564,6 +1739,7 @@ async function buildCtx(opts = {}) {
1564
1739
  owner: currentOwner(),
1565
1740
  runner: { start: startServices, stop: stopServices },
1566
1741
  storybrokr: storybrokrCli,
1742
+ pritty: prittyCli,
1567
1743
  log: opts.quiet ? () => {
1568
1744
  } : (line) => console.error(chalk.gray(line))
1569
1745
  };
@@ -1576,9 +1752,11 @@ import {
1576
1752
  existsSync as existsSync7,
1577
1753
  mkdirSync as mkdirSync6,
1578
1754
  readdirSync,
1755
+ readFileSync as readFileSync5,
1579
1756
  realpathSync as realpathSync4,
1580
1757
  rmSync as rmSync3,
1581
- statSync as statSync3
1758
+ statSync as statSync3,
1759
+ writeFileSync as writeFileSync5
1582
1760
  } from "fs";
1583
1761
  import { connect, createServer } from "net";
1584
1762
  import { dirname as dirname3, join as join8 } from "path";
@@ -1623,22 +1801,26 @@ function touchEffort(ctx, effort) {
1623
1801
  }
1624
1802
  ctx.registry.updateEffort(effort.id, { lastActivity: now() });
1625
1803
  }
1626
- function portBusy(port) {
1804
+ function bindFails(port, host) {
1627
1805
  return new Promise((resolve4) => {
1628
1806
  const srv = createServer();
1629
- srv.once("error", () => resolve4(true));
1630
- srv.listen(port, "127.0.0.1", () => srv.close(() => resolve4(false)));
1807
+ srv.once("error", (err) => resolve4(err.code === "EADDRINUSE"));
1808
+ srv.listen(port, host, () => srv.close(() => resolve4(false)));
1631
1809
  });
1632
1810
  }
1811
+ async function portBusy(port) {
1812
+ for (const host of ["127.0.0.1", "0.0.0.0", "::"]) if (await bindFails(port, host)) return true;
1813
+ return false;
1814
+ }
1633
1815
  async function busyPorts(ports) {
1634
1816
  const out = [];
1635
1817
  for (const [service, port] of Object.entries(ports))
1636
1818
  if (await portBusy(port)) out.push(`${service}=${port}`);
1637
1819
  return out;
1638
1820
  }
1639
- function portAnswers(port) {
1821
+ function portAnswersOn(port, host) {
1640
1822
  return new Promise((resolve4) => {
1641
- const sock = connect({ port, host: "127.0.0.1" });
1823
+ const sock = connect({ port, host });
1642
1824
  const done = (v) => {
1643
1825
  sock.destroy();
1644
1826
  resolve4(v);
@@ -1648,6 +1830,9 @@ function portAnswers(port) {
1648
1830
  sock.once("error", () => done(false));
1649
1831
  });
1650
1832
  }
1833
+ async function portAnswers(port) {
1834
+ return await portAnswersOn(port, "127.0.0.1") || await portAnswersOn(port, "::1");
1835
+ }
1651
1836
  async function anyPortAnswers(ports) {
1652
1837
  for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
1653
1838
  return false;
@@ -1764,6 +1949,15 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1764
1949
  });
1765
1950
  return effort;
1766
1951
  }
1952
+ function areaBranchFor(ctx, area, preferredType) {
1953
+ const main = new Git(ctx.repoRoot);
1954
+ const types = [preferredType, ...ctx.manifest.branch.types.filter((t) => t !== preferredType)];
1955
+ for (const type of types) {
1956
+ const b = branchFor(type, area);
1957
+ if (main.branchExists(b) || main.remoteRef(b)) return b;
1958
+ }
1959
+ return branchFor(preferredType, area);
1960
+ }
1767
1961
  function ensureWorktree(ctx, area, branch, task) {
1768
1962
  const m = ctx.manifest;
1769
1963
  const path = worktreePathFor(ctx, area, task);
@@ -1815,8 +2009,25 @@ function appendLog(file, line) {
1815
2009
  appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1816
2010
  `);
1817
2011
  }
2012
+ var PENDING_MARKS = ".pending-marks.json";
2013
+ function readPendingMarks(logDir) {
2014
+ try {
2015
+ const raw = JSON.parse(readFileSync5(join8(logDir, PENDING_MARKS), "utf8"));
2016
+ return Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : [];
2017
+ } catch {
2018
+ return [];
2019
+ }
2020
+ }
2021
+ function writePendingMarks(logDir, marks) {
2022
+ mkdirSync6(logDir, { recursive: true });
2023
+ writeFileSync5(join8(logDir, PENDING_MARKS), `${JSON.stringify(marks)}
2024
+ `);
2025
+ }
1818
2026
  async function startEffort(ctx, o) {
1819
2027
  const m = ctx.manifest;
2028
+ await reapLeases(ctx).catch(
2029
+ (err) => ctx.log(`pre-flight reap: ${err instanceof Error ? err.message : String(err)}`)
2030
+ );
1820
2031
  fetchBaseOnce(ctx);
1821
2032
  const { branch, scopes, commons, warnings } = guardStart(ctx, o);
1822
2033
  const notices = ctx.registry.notices(m.project, o.area);
@@ -1960,6 +2171,9 @@ async function startEffort(ctx, o) {
1960
2171
  }
1961
2172
  );
1962
2173
  const baseSha = wg.rev("HEAD");
2174
+ if (o.mode === "frontend" && sel.marks.length > 0) {
2175
+ writePendingMarks(logDir, [.../* @__PURE__ */ new Set([...readPendingMarks(logDir), ...sel.marks])]);
2176
+ }
1963
2177
  for (const name of commons)
1964
2178
  leases.push(
1965
2179
  ctx.registry.addLease(
@@ -1983,8 +2197,13 @@ async function startEffort(ctx, o) {
1983
2197
  branch
1984
2198
  });
1985
2199
  const env = { ...process.env, ...cfg.vars };
1986
- if (sel.marks.includes("db-reset") && m.services.postgres?.reset) {
2200
+ const owedMarks = [.../* @__PURE__ */ new Set([...sel.marks, ...readPendingMarks(logDir)])];
2201
+ if (owedMarks.includes("db-reset") && m.services.postgres?.reset) {
1987
2202
  runRefresh([m.services.postgres.reset], worktree, (l) => appendLog(refreshLog, l), env);
2203
+ writePendingMarks(
2204
+ logDir,
2205
+ owedMarks.filter((x) => x !== "db-reset")
2206
+ );
1988
2207
  }
1989
2208
  const pids = {};
1990
2209
  const signatures = {};
@@ -2073,7 +2292,7 @@ async function promoteEffort(ctx, o) {
2073
2292
  }
2074
2293
  const modeScopes = expandScopes(m, o.area, o.mode);
2075
2294
  const nextScopes = open.task ? [.../* @__PURE__ */ new Set([...open.scopes, ...modeScopes])] : modeScopes;
2076
- ctx.registry.update((r) => {
2295
+ const refuseOverlap = (r) => {
2077
2296
  const others = r.efforts.filter(
2078
2297
  (e) => e.project === m.project && e.id !== open.id && (e.state === "active" || e.state === "starting")
2079
2298
  );
@@ -2088,17 +2307,49 @@ async function promoteEffort(ctx, o) {
2088
2307
  );
2089
2308
  }
2090
2309
  }
2091
- const idx = r.efforts.findIndex((e) => e.id === open.id);
2092
- if (idx >= 0) {
2093
- r.efforts[idx] = { ...r.efforts[idx], mode: o.mode, scopes: nextScopes, lastActivity: now() };
2094
- }
2095
- });
2310
+ };
2311
+ ctx.registry.update(refuseOverlap);
2096
2312
  const warnings = [];
2097
2313
  let slot = ctx.registry.leases({ effort: open.id, kind: "slot" })[0];
2098
2314
  const sb = ctx.registry.leases({ effort: open.id, kind: "storybook" })[0];
2099
- if (!slot) {
2100
- slot = await claimFreeSlot(ctx, open.id, warnings);
2101
- ctx.registry.updateEffort(open.id, { leases: [...open.leases, slot.id] });
2315
+ const claimed = !slot;
2316
+ if (!slot) slot = await claimFreeSlot(ctx, open.id, warnings);
2317
+ try {
2318
+ ctx.registry.update((r) => {
2319
+ refuseOverlap(r);
2320
+ const idx = r.efforts.findIndex((e) => e.id === open.id);
2321
+ if (idx >= 0) {
2322
+ r.efforts[idx] = {
2323
+ ...r.efforts[idx],
2324
+ mode: o.mode,
2325
+ scopes: nextScopes,
2326
+ leases: claimed ? [...open.leases, slot.id] : r.efforts[idx].leases,
2327
+ lastActivity: now()
2328
+ };
2329
+ }
2330
+ });
2331
+ } catch (err) {
2332
+ if (claimed) ctx.registry.release(slot.id);
2333
+ throw err;
2334
+ }
2335
+ const owed = readPendingMarks(join8(open.worktree, m.logs.dir));
2336
+ if (owed.includes("db-reset") && m.services.postgres?.reset) {
2337
+ const cfgForReset = emitWorktreeConfig({
2338
+ m,
2339
+ worktree: open.worktree,
2340
+ slot: slot.value,
2341
+ ports: slot.ports ?? {},
2342
+ area: o.area,
2343
+ branch: open.branch
2344
+ });
2345
+ runRefresh([m.services.postgres.reset], open.worktree, ctx.log, {
2346
+ ...process.env,
2347
+ ...cfgForReset.vars
2348
+ });
2349
+ writePendingMarks(
2350
+ join8(open.worktree, m.logs.dir),
2351
+ owed.filter((x) => x !== "db-reset")
2352
+ );
2102
2353
  }
2103
2354
  const cfg = emitWorktreeConfig({
2104
2355
  m,
@@ -2149,9 +2400,7 @@ async function promoteEffort(ctx, o) {
2149
2400
  }
2150
2401
  function behindBase(ctx, worktree) {
2151
2402
  if (!existsSync7(worktree)) return 0;
2152
- const g = new Git(worktree);
2153
- const r = g.tryRun(["rev-list", "--count", `HEAD..origin/${ctx.manifest.baseBranch}`]);
2154
- return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
2403
+ return new Git(worktree).behindCount(ctx.manifest.baseBranch);
2155
2404
  }
2156
2405
  function statusFor(ctx, area, task) {
2157
2406
  const m = ctx.manifest;
@@ -2233,6 +2482,15 @@ async function releaseLease(ctx, effort, lease) {
2233
2482
  }
2234
2483
  ctx.registry.release(lease.id);
2235
2484
  }
2485
+ async function sweepStorybook(ctx, effort) {
2486
+ if (effort.mode === "backend" || !ctx.storybrokr.downHost) return;
2487
+ try {
2488
+ const stopped = await ctx.storybrokr.downHost(storybookHostFor(ctx.manifest, effort.worktree));
2489
+ if (stopped.length > 0) ctx.log(`storybrokr: stopped ${stopped.join(", ")}`);
2490
+ } catch (err) {
2491
+ ctx.log(`storybrokr sweep failed: ${err instanceof Error ? err.message : String(err)}`);
2492
+ }
2493
+ }
2236
2494
  function leaveNotices(ctx, effort, commons) {
2237
2495
  if (commons.length === 0) return 0;
2238
2496
  const notice = {
@@ -2301,7 +2559,7 @@ async function finishEffort(ctx, o) {
2301
2559
  );
2302
2560
  }
2303
2561
  fetchBaseOnce(ctx);
2304
- const parentBranch = branchFor(effort.type, effort.area);
2562
+ const parentBranch = areaBranchFor(ctx, effort.area, effort.type);
2305
2563
  const { wg: areaGit } = ensureWorktree(ctx, effort.area, parentBranch);
2306
2564
  if (areaGit.isDirty()) {
2307
2565
  throw new WorkkitError(
@@ -2321,14 +2579,24 @@ async function finishEffort(ctx, o) {
2321
2579
  );
2322
2580
  }
2323
2581
  }
2582
+ fetchBaseOnce(ctx);
2583
+ const shouldPush = !o.intoArea && (g.ownCommitCount(m.baseBranch) > 0 || g.remoteBranchExists(effort.branch));
2584
+ if (shouldPush) {
2585
+ try {
2586
+ g.push(effort.branch);
2587
+ } catch (err) {
2588
+ throw new WorkkitError(
2589
+ "PUSH_REJECTED",
2590
+ `push of ${effort.branch} was rejected (${err instanceof Error ? err.message : String(err)}); nothing was stopped or released. Merge origin/${effort.branch} into the worktree and run \`work finish\` again.`,
2591
+ { branch: effort.branch, worktree: effort.worktree }
2592
+ );
2593
+ }
2594
+ }
2324
2595
  for (const lease of ctx.registry.leases({ effort: effort.id }))
2325
2596
  await releaseLease(ctx, effort, lease);
2326
- await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
2327
- () => void 0
2328
- );
2597
+ await sweepStorybook(ctx, effort);
2329
2598
  let notices = 0;
2330
2599
  if (!o.intoArea) {
2331
- g.push(effort.branch);
2332
2600
  if (g.commitsNotOn(m.baseBranch).length > 0 && !g.isEquivalentToBase(m.baseBranch)) {
2333
2601
  const since = g.changedSince(m.baseBranch);
2334
2602
  const sinceSet = new Set(since);
@@ -2347,14 +2615,14 @@ async function finishEffort(ctx, o) {
2347
2615
  ctx.registry.removeEfforts((e) => e.id === effort.id);
2348
2616
  return {
2349
2617
  effort: finished2,
2350
- pushed: !o.intoArea,
2618
+ pushed: shouldPush,
2351
2619
  parked,
2352
2620
  mergedIntoArea: Boolean(o.intoArea),
2353
2621
  notices
2354
2622
  };
2355
2623
  }
2356
2624
  const finished = ctx.registry.updateEffort(effort.id, { state: "finished", lastActivity: now() });
2357
- return { effort: finished, pushed: true, parked, notices };
2625
+ return { effort: finished, pushed: shouldPush, parked, notices };
2358
2626
  }
2359
2627
  async function reapLeases(ctx, o = {}) {
2360
2628
  const m = ctx.manifest;
@@ -2371,19 +2639,24 @@ async function reapLeases(ctx, o = {}) {
2371
2639
  const garbage = orphaned && !await anyPortAnswers(l.ports);
2372
2640
  if (expired || garbage) doomed.add(l.effort);
2373
2641
  }
2642
+ for (const e of efforts.values()) {
2643
+ if (e.state !== "starting" || doomed.has(e.id)) continue;
2644
+ if (leases.some((l) => l.effort === e.id)) continue;
2645
+ if (Date.now() - Date.parse(e.started) < STARTING_GRACE_MS) continue;
2646
+ doomed.add(e.id);
2647
+ }
2374
2648
  const reaped = leases.filter((l) => doomed.has(l.effort));
2375
2649
  if (o.dryRun) return { reaped };
2376
2650
  for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
2377
2651
  for (const id of doomed) {
2378
2652
  const effort = efforts.get(id);
2379
2653
  if (effort) {
2380
- await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
2381
- () => void 0
2382
- );
2654
+ await sweepStorybook(ctx, effort);
2383
2655
  if (effort.state === "active" || effort.state === "starting") {
2656
+ const hadLease = leases.some((l) => l.effort === effort.id);
2384
2657
  ctx.registry.updateEffort(effort.id, {
2385
2658
  state: "parked",
2386
- parkedReason: `idle: a lease expired by ${at.toISOString()}`,
2659
+ parkedReason: hadLease ? `idle: a lease expired by ${at.toISOString()}` : `start died before its first lease (started ${effort.started})`,
2387
2660
  lastActivity: now()
2388
2661
  });
2389
2662
  }
@@ -2441,9 +2714,6 @@ async function retireArea(ctx, o) {
2441
2714
  const worktree = realpathOr2(worktreePathFor(ctx, o.area));
2442
2715
  const main = new Git(ctx.repoRoot);
2443
2716
  let archived = [];
2444
- await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, worktree))).catch(
2445
- () => void 0
2446
- );
2447
2717
  if (existsSync7(worktree)) {
2448
2718
  const g = new Git(worktree);
2449
2719
  if (g.isDirty())
@@ -2459,16 +2729,20 @@ async function retireArea(ctx, o) {
2459
2729
  `${g.currentBranch()} has ${unpushed} commit(s) not on origin; push before retiring`,
2460
2730
  { worktree, unpushed }
2461
2731
  );
2732
+ const hadStorybook = ctx.registry.efforts({ project: m.project, area: o.area }).some((e) => e.mode !== "backend");
2733
+ if (hadStorybook) await sweepStorybook(ctx, { worktree, mode: "frontend" });
2462
2734
  archived = archiveWorktree(ctx, o.area, worktree);
2463
2735
  main.worktreeRemove(worktree);
2464
2736
  }
2465
2737
  const branchesDeleted = [];
2466
2738
  const branchesKept = [];
2467
2739
  const allAreaBranches = m.branch.types.map((type) => `${type}/${o.area}`);
2740
+ fetchBaseOnce(ctx);
2468
2741
  for (const branch of allAreaBranches) {
2469
2742
  if (!main.branchExists(branch)) continue;
2470
2743
  const onOrigin = main.remoteBranchExists(branch) && main.tryRun(["rev-list", "--count", `origin/${branch}..${branch}`]).out.trim() === "0";
2471
- if (onOrigin) {
2744
+ const mergedIntoBase = !onOrigin && main.branchMergedInto(branch, m.baseBranch);
2745
+ if (onOrigin || mergedIntoBase) {
2472
2746
  main.deleteLocalBranch(branch);
2473
2747
  branchesDeleted.push(branch);
2474
2748
  } else branchesKept.push(branch);
@@ -2478,6 +2752,94 @@ async function retireArea(ctx, o) {
2478
2752
  ctx.registry.clearNoticesFrom(m.project, allAreaBranches);
2479
2753
  return { archived, removed: worktree, branchesDeleted, branchesKept };
2480
2754
  }
2755
+ function effortAt(ctx, cwd) {
2756
+ const top = new Git(cwd).tryRun(["rev-parse", "--show-toplevel"]);
2757
+ if (top.code !== 0) return void 0;
2758
+ const root = realpathOr2(top.out.trim());
2759
+ return ctx.registry.efforts({ project: ctx.manifest.project }).filter((e) => e.state === "active" || e.state === "starting" || e.state === "parked").sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)).find((e) => realpathOr2(e.worktree) === root);
2760
+ }
2761
+ async function prEffort(ctx, o) {
2762
+ const m = ctx.manifest;
2763
+ assertPrittyVersion(ctx.pritty);
2764
+ const effort = o.area ? ctx.registry.openEffortFor(m.project, o.area, o.task) ?? ctx.registry.efforts({ project: m.project, area: o.area, state: "parked" }).filter((e) => (e.task ?? void 0) === (o.task ?? void 0)).sort((a, b) => b.started.localeCompare(a.started))[0] : effortAt(ctx, o.cwd ?? process.cwd());
2765
+ if (!effort) {
2766
+ throw new WorkkitError(
2767
+ "EFFORT_NOT_FOUND",
2768
+ o.area ? `no open or parked effort on ${effortLabel2(o.area, o.task)}` : `${o.cwd ?? process.cwd()} is not inside an effort's worktree; pass the area`,
2769
+ { area: o.area, task: o.task }
2770
+ );
2771
+ }
2772
+ const label = effortLabel2(effort.area, effort.task);
2773
+ const g = new Git(effort.worktree);
2774
+ if (g.isDirty()) {
2775
+ throw new WorkkitError(
2776
+ "DIRTY_WORKTREE",
2777
+ `${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
2778
+ { worktree: effort.worktree }
2779
+ );
2780
+ }
2781
+ fetchBaseOnce(ctx);
2782
+ const warnings = [];
2783
+ if (!g.hasUpstream() || g.unpushedCount(m.baseBranch) > 0) {
2784
+ try {
2785
+ g.push(effort.branch);
2786
+ } catch (err) {
2787
+ throw new WorkkitError(
2788
+ "PUSH_REJECTED",
2789
+ `push of ${effort.branch} was rejected (${err instanceof Error ? err.message : String(err)}); merge origin/${effort.branch} into the worktree and run \`work pr\` again`,
2790
+ { branch: effort.branch, worktree: effort.worktree }
2791
+ );
2792
+ }
2793
+ }
2794
+ const behind = g.behindCount(m.baseBranch);
2795
+ if (behind > BEHIND_LIMIT) {
2796
+ warnings.push(
2797
+ `${effort.branch} is ${behind} commits behind ${m.baseBranch}; consider merging origin/${m.baseBranch} before review`
2798
+ );
2799
+ }
2800
+ const given = o.title !== void 0 || o.body !== void 0 || o.bodyFile !== void 0;
2801
+ const store = (pr2) => {
2802
+ const updated = ctx.registry.updateEffort(effort.id, { pr: pr2, lastActivity: now() });
2803
+ touchEffort(ctx, updated);
2804
+ return updated;
2805
+ };
2806
+ if (effort.pr) {
2807
+ const { open, last } = await ctx.pritty.check({ cwd: effort.worktree, base: m.baseBranch });
2808
+ if (open) {
2809
+ if (!given) {
2810
+ return { effort: store(open), pr: open, created: false, behindBase: behind, warnings };
2811
+ }
2812
+ const updated = await ctx.pritty.pr({
2813
+ cwd: effort.worktree,
2814
+ base: m.baseBranch,
2815
+ draft: o.draft,
2816
+ title: o.title,
2817
+ body: o.body,
2818
+ bodyFile: o.bodyFile
2819
+ });
2820
+ const pr2 = { number: updated.number, url: updated.url, draft: updated.draft };
2821
+ return { effort: store(pr2), pr: pr2, created: false, behindBase: behind, warnings };
2822
+ }
2823
+ if (!o.force) {
2824
+ const fate = last?.number === effort.pr.number ? last.state === "MERGED" ? "was merged" : "was closed without merging" : "is no longer open";
2825
+ throw new WorkkitError(
2826
+ "PR_CLOSED",
2827
+ `PR #${effort.pr.number} for ${label} ${fate} (${effort.pr.url}); pass --force to open a new one`,
2828
+ { pr: effort.pr, state: last?.state }
2829
+ );
2830
+ }
2831
+ }
2832
+ const res = await ctx.pritty.pr({
2833
+ cwd: effort.worktree,
2834
+ base: m.baseBranch,
2835
+ draft: o.draft,
2836
+ title: o.title,
2837
+ body: o.body,
2838
+ bodyFile: o.bodyFile
2839
+ });
2840
+ const pr = { number: res.number, url: res.url, draft: res.draft };
2841
+ return { effort: store(pr), pr, created: res.created, behindBase: behind, warnings };
2842
+ }
2481
2843
  var BEHIND_LIMIT = 20;
2482
2844
  var NOTICE_STALE_MS = 7 * 864e5;
2483
2845
  var ARCHIVE_LIMIT_BYTES = 5 * 1024 ** 3;
@@ -2665,7 +3027,7 @@ function registerFinish(program2) {
2665
3027
  }
2666
3028
 
2667
3029
  // src/commands/hook.ts
2668
- import { readFileSync as readFileSync5, realpathSync as realpathSync5 } from "fs";
3030
+ import { readFileSync as readFileSync6, realpathSync as realpathSync5 } from "fs";
2669
3031
  import { resolve as resolve2 } from "path";
2670
3032
  import chalk5 from "chalk";
2671
3033
  var ZEROS = /^0+$/;
@@ -2763,7 +3125,7 @@ function registerHook(program2) {
2763
3125
  "Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
2764
3126
  ).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
2765
3127
  try {
2766
- const stdin = process.stdin.isTTY ? "" : readFileSync5(0, "utf8");
3128
+ const stdin = process.stdin.isTTY ? "" : readFileSync6(0, "utf8");
2767
3129
  process.exit(await runPrePush(process.cwd(), stdin));
2768
3130
  } catch (err) {
2769
3131
  fail(err, false);
@@ -2786,7 +3148,7 @@ function registerHook(program2) {
2786
3148
  }
2787
3149
 
2788
3150
  // src/commands/init.ts
2789
- import { existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
3151
+ import { existsSync as existsSync8, writeFileSync as writeFileSync6 } from "fs";
2790
3152
  import { join as join9, resolve as resolve3 } from "path";
2791
3153
  var STORYBOOK_BASE = 6100;
2792
3154
  function defaultService(name) {
@@ -2862,7 +3224,7 @@ function runInit(o) {
2862
3224
  }
2863
3225
  const manifest = buildStarterManifest(o);
2864
3226
  const manifestPath = join9(repoRoot, "helmsmith.config.mjs");
2865
- writeFileSync5(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
3227
+ writeFileSync6(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
2866
3228
  `);
2867
3229
  claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
2868
3230
  const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
@@ -2930,7 +3292,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
2930
3292
  import { z as z2 } from "zod";
2931
3293
 
2932
3294
  // src/version.ts
2933
- import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
3295
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
2934
3296
  import { dirname as dirname4, join as join10 } from "path";
2935
3297
  import { fileURLToPath } from "url";
2936
3298
  function findPackageJson(startDir) {
@@ -2944,7 +3306,7 @@ function findPackageJson(startDir) {
2944
3306
  }
2945
3307
  }
2946
3308
  var pkgFile = findPackageJson(dirname4(fileURLToPath(import.meta.url)));
2947
- var VERSION = JSON.parse(readFileSync6(pkgFile, "utf8")).version;
3309
+ var VERSION = JSON.parse(readFileSync7(pkgFile, "utf8")).version;
2948
3310
 
2949
3311
  // src/mcp/server.ts
2950
3312
  var ok = (value) => ({
@@ -2969,7 +3331,9 @@ function buildMcpServer(ctxFactory) {
2969
3331
  description: "Start an effort on an area (or intra-area task): its worktree on <type>/<area> (or <type>/<area>--<task>), synced from the base branch, refreshed, leased (a slot for fullstack/backend, a storybrokr instance for frontend with a story) and running. Refuses with EFFORT_OPEN (another effort is open; join=true to share it), SCOPE_CONFLICT, SYNC_CONFLICT (resolve in the worktree, rerun with sync=false).",
2970
3332
  inputSchema: {
2971
3333
  area: z2.string().describe("a leaf area from helmsmith.config (e.g. org/applications)"),
2972
- task: z2.string().optional().describe("optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"),
3334
+ task: z2.string().optional().describe(
3335
+ "optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"
3336
+ ),
2973
3337
  type: z2.string().describe("feature | chore | fix"),
2974
3338
  mode: z2.enum(["frontend", "backend", "fullstack"]),
2975
3339
  scopes: z2.array(z2.string()).optional().describe("optional narrowed scope globs for a task sub-effort"),
@@ -3002,11 +3366,38 @@ function buildMcpServer(ctxFactory) {
3002
3366
  area: z2.string().describe("the area of the open effort"),
3003
3367
  task: z2.string().optional().describe("optional intra-area task slug"),
3004
3368
  mode: z2.enum(["backend", "fullstack"]).describe("target mode to promote into"),
3005
- story: z2.string().optional().describe("optional story path"),
3006
3369
  projectRoot: z2.string().optional().describe("optional repository root path override")
3007
3370
  }
3008
3371
  },
3009
- async ({ area, task, mode, story, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode, story }))
3372
+ async ({ area, task, mode, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode }))
3373
+ );
3374
+ server.registerTool(
3375
+ "work_pr",
3376
+ {
3377
+ description: "Open the effort's pull request through pritty: pushes the branch first, reuses a stored PR that is still open (updating its title/body when given), refuses with PR_CLOSED when it was merged or closed unless force, and stores { number, url, draft } on the effort. Pass title and body: the agent writes the words, pritty's AI path is only the fallback.",
3378
+ inputSchema: {
3379
+ area: z2.string().optional().describe("the area of the effort (default: the effort of the cwd worktree)"),
3380
+ task: z2.string().optional().describe("optional intra-area task slug"),
3381
+ draft: z2.boolean().optional().describe("open a draft PR"),
3382
+ title: z2.string().optional().describe("PR title"),
3383
+ // body text only: a file path here would let any MCP caller have pritty read an
3384
+ // arbitrary file and publish it as a PR body; read the file yourself and pass the text
3385
+ body: z2.string().optional().describe("PR body text (read your body file and pass its text)"),
3386
+ force: z2.boolean().optional().describe("open a new PR when the stored one was closed"),
3387
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3388
+ }
3389
+ },
3390
+ async ({ area, task, draft, title, body, force, projectRoot }) => guard(
3391
+ async () => prEffort(await ctxFactory(projectRoot), {
3392
+ area,
3393
+ task,
3394
+ cwd: process.cwd(),
3395
+ draft,
3396
+ title,
3397
+ body,
3398
+ force
3399
+ })
3400
+ )
3010
3401
  );
3011
3402
  server.registerTool(
3012
3403
  "work_status",
@@ -3084,24 +3475,56 @@ function registerMcp(program2) {
3084
3475
  });
3085
3476
  }
3086
3477
 
3087
- // src/commands/promote.ts
3478
+ // src/commands/pr.ts
3088
3479
  import chalk6 from "chalk";
3480
+ function registerPr(program2) {
3481
+ program2.command("pr [area]").description(
3482
+ "Open the effort's pull request through pritty (push first, reuse an open PR, store it on the effort). No area: the effort of the cwd's worktree."
3483
+ ).option("--task <slug>", "intra-area task slug").option("--draft", "open a draft PR").option("--title <title>", "PR title (with --body or --body-file: no AI involved)").option("--body <text>", "PR body text").option("--body-file <path>", "PR body from a file").option("--force", "open a new PR when the stored one was merged or closed").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
3484
+ async (area, o) => {
3485
+ try {
3486
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3487
+ const r = await prEffort(ctx, {
3488
+ area,
3489
+ task: o.task,
3490
+ cwd: process.cwd(),
3491
+ draft: o.draft,
3492
+ title: o.title,
3493
+ body: o.body,
3494
+ bodyFile: o.bodyFile,
3495
+ force: o.force
3496
+ });
3497
+ if (o.json) printJson(r);
3498
+ else {
3499
+ for (const w of r.warnings) console.error(chalk6.yellow(w));
3500
+ console.log(
3501
+ `${r.created ? "opened" : "open"} PR #${r.pr.number}${r.pr.draft ? " (draft)" : ""} for ${r.effort.branch}: ${r.pr.url}`
3502
+ );
3503
+ }
3504
+ } catch (err) {
3505
+ fail(err, Boolean(o.json));
3506
+ }
3507
+ }
3508
+ );
3509
+ }
3510
+
3511
+ // src/commands/promote.ts
3512
+ import chalk7 from "chalk";
3089
3513
  function registerPromote(program2) {
3090
- program2.command("promote").description("Promote an open effort in place to backend or fullstack mode").requiredOption("--area <area>", "the area of the open effort").option("--task <slug>", "intra-area task slug if promoting a task sub-effort").requiredOption("--mode <mode>", "target mode: backend | fullstack").option("--story <path>", "optional story path to attach via storybrokr").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
3514
+ program2.command("promote").description("Promote an open effort in place to backend or fullstack mode").requiredOption("--area <area>", "the area of the open effort").option("--task <slug>", "intra-area task slug if promoting a task sub-effort").requiredOption("--mode <mode>", "target mode: backend | fullstack").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
3091
3515
  async (o) => {
3092
3516
  try {
3093
3517
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3094
3518
  const r = await promoteEffort(ctx, {
3095
3519
  area: o.area,
3096
3520
  task: o.task,
3097
- mode: o.mode,
3098
- story: o.story
3521
+ mode: o.mode
3099
3522
  });
3100
3523
  if (o.json) printJson(r);
3101
3524
  else {
3102
3525
  const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
3103
3526
  console.log(
3104
- `${chalk6.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
3527
+ `${chalk7.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
3105
3528
  );
3106
3529
  for (const w of r.warnings) warn(w);
3107
3530
  }
@@ -3113,7 +3536,7 @@ function registerPromote(program2) {
3113
3536
  }
3114
3537
 
3115
3538
  // src/commands/reap.ts
3116
- import chalk7 from "chalk";
3539
+ import chalk8 from "chalk";
3117
3540
  function registerReap(program2) {
3118
3541
  program2.command("reap").description(
3119
3542
  "Expire idle leases: stop only what they started, release them, park their efforts"
@@ -3122,7 +3545,7 @@ function registerReap(program2) {
3122
3545
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3123
3546
  const r = await reapLeases(ctx, { dryRun: o.dryRun });
3124
3547
  if (o.json) printJson(r);
3125
- else if (r.reaped.length === 0) console.log(chalk7.gray("nothing to reap"));
3548
+ else if (r.reaped.length === 0) console.log(chalk8.gray("nothing to reap"));
3126
3549
  else
3127
3550
  for (const l of r.reaped)
3128
3551
  console.log(
@@ -3135,7 +3558,7 @@ function registerReap(program2) {
3135
3558
  }
3136
3559
 
3137
3560
  // src/commands/retire.ts
3138
- import chalk8 from "chalk";
3561
+ import chalk9 from "chalk";
3139
3562
  function registerRetire(program2) {
3140
3563
  program2.command("retire").description(
3141
3564
  "Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
@@ -3145,11 +3568,11 @@ function registerRetire(program2) {
3145
3568
  const r = await retireArea(ctx, { area: o.area });
3146
3569
  if (o.json) printJson(r);
3147
3570
  else {
3148
- console.log(`${chalk8.bold(o.area)} retired: removed ${r.removed}`);
3571
+ console.log(`${chalk9.bold(o.area)} retired: removed ${r.removed}`);
3149
3572
  for (const a of r.archived) console.log(` archived ${a}`);
3150
3573
  for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
3151
3574
  for (const b of r.branchesKept)
3152
- console.log(chalk8.yellow(` kept ${b}: not fully on origin`));
3575
+ console.log(chalk9.yellow(` kept ${b}: not fully on origin`));
3153
3576
  }
3154
3577
  } catch (err) {
3155
3578
  fail(err, Boolean(o.json));
@@ -3158,12 +3581,12 @@ function registerRetire(program2) {
3158
3581
  }
3159
3582
 
3160
3583
  // src/commands/start.ts
3161
- import chalk9 from "chalk";
3584
+ import chalk10 from "chalk";
3162
3585
  function printStart(r) {
3163
3586
  const e = r.effort;
3164
3587
  const label = e.task ? `${e.area}--${e.task}` : e.area;
3165
3588
  console.log(
3166
- `${chalk9.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk9.yellow(" (joined)") : ""}`
3589
+ `${chalk10.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk10.yellow(" (joined)") : ""}`
3167
3590
  );
3168
3591
  console.log(` worktree ${e.worktree}`);
3169
3592
  console.log(
@@ -3174,7 +3597,7 @@ function printStart(r) {
3174
3597
  console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
3175
3598
  }
3176
3599
  for (const w of r.warnings) warn(w);
3177
- for (const n of r.notices) console.log(chalk9.yellow(` notice ${n.text} (${n.at})`));
3600
+ for (const n of r.notices) console.log(chalk10.yellow(` notice ${n.text} (${n.at})`));
3178
3601
  }
3179
3602
  function registerStart(program2) {
3180
3603
  program2.command("start").description(
@@ -3206,36 +3629,47 @@ function registerStart(program2) {
3206
3629
  }
3207
3630
 
3208
3631
  // src/commands/status.ts
3209
- import chalk10 from "chalk";
3632
+ import chalk11 from "chalk";
3210
3633
  var age = (iso) => {
3211
3634
  const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
3212
3635
  return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
3213
3636
  };
3214
3637
  var expiresIn = (iso) => {
3215
3638
  const mins = Math.round((Date.parse(iso) - Date.now()) / 6e4);
3216
- return mins <= 0 ? chalk10.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
3639
+ return mins <= 0 ? chalk11.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
3217
3640
  };
3218
3641
  function printStatus(r) {
3219
- console.log(chalk10.bold(`${r.project}`));
3220
- const cols = ["AREA", "TYPE/MODE", "BRANCH", "OWNER", "STARTED", "LEASES", "BEHIND", "NOTICES"];
3642
+ console.log(chalk11.bold(`${r.project}`));
3643
+ const cols = [
3644
+ "AREA",
3645
+ "TYPE/MODE",
3646
+ "BRANCH",
3647
+ "OWNER",
3648
+ "STARTED",
3649
+ "LEASES",
3650
+ "PR",
3651
+ "BEHIND",
3652
+ "NOTICES"
3653
+ ];
3221
3654
  const rows = r.areas.map((a) => [
3222
3655
  a.area,
3223
- a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk10.gray("-"),
3224
- a.effort?.branch ?? chalk10.gray("-"),
3225
- a.effort?.owner.session ?? chalk10.gray("-"),
3226
- a.effort ? age(a.effort.started) : chalk10.gray("-"),
3227
- a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk10.gray("-"),
3228
- a.behindBase > 0 ? chalk10.yellow(String(a.behindBase)) : "0",
3229
- a.notices.length ? chalk10.yellow(String(a.notices.length)) : "0"
3656
+ a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk11.gray("-"),
3657
+ a.effort?.branch ?? chalk11.gray("-"),
3658
+ a.effort?.owner.session ?? chalk11.gray("-"),
3659
+ a.effort ? age(a.effort.started) : chalk11.gray("-"),
3660
+ a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk11.gray("-"),
3661
+ a.effort?.pr ? `#${a.effort.pr.number}${a.effort.pr.draft ? " draft" : ""}` : chalk11.gray("-"),
3662
+ a.behindBase > 0 ? chalk11.yellow(String(a.behindBase)) : "0",
3663
+ a.notices.length ? chalk11.yellow(String(a.notices.length)) : "0"
3230
3664
  ]);
3231
3665
  const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
3232
3666
  const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
3233
3667
  const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
3234
- console.log(chalk10.bold(line(cols)));
3668
+ console.log(chalk11.bold(line(cols)));
3235
3669
  for (const row of rows) console.log(line(row));
3236
3670
  if (r.leases.length) {
3237
3671
  console.log("");
3238
- console.log(chalk10.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
3672
+ console.log(chalk11.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
3239
3673
  for (const l of r.leases) {
3240
3674
  console.log(
3241
3675
  `${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
@@ -3260,13 +3694,14 @@ function registerStatus(program2) {
3260
3694
  var { program } = createCli({
3261
3695
  name: "work",
3262
3696
  version: VERSION,
3263
- description: "Area-based work: start, promote, status, finish, reap, retire, audit."
3697
+ description: "Area-based work: start, promote, status, pr, finish, reap, retire, audit."
3264
3698
  });
3265
3699
  registerInit(program);
3266
3700
  registerStart(program);
3267
3701
  registerPromote(program);
3268
3702
  registerStatus(program);
3269
3703
  registerEnv(program);
3704
+ registerPr(program);
3270
3705
  registerFinish(program);
3271
3706
  registerReap(program);
3272
3707
  registerRetire(program);