@helmsmith/workbrokr 0.2.0 → 0.3.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/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,27 @@ 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
+ /** Commits on origin/<base> that HEAD does not have (0 when the ref is unknown). */
169
+ behindCount(base) {
170
+ const r = this.tryRun(["rev-list", "--count", `HEAD..origin/${base}`]);
171
+ return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
172
+ }
167
173
  isDirty() {
168
174
  return this.run(["status", "--porcelain"]).trim().length > 0;
169
175
  }
170
176
  changedPathsStaged() {
171
177
  return this.lines(["diff", "--cached", "--name-only"]);
172
178
  }
179
+ /** The other parent of a merge in progress, or undefined when none is. */
180
+ mergeHead() {
181
+ const r = this.tryRun(["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
182
+ return r.code === 0 ? r.out.trim() : void 0;
183
+ }
184
+ /** Staged paths whose index content differs from `ref` (so, authored here rather than taken from `ref`). */
185
+ stagedDifferingFrom(ref, paths) {
186
+ if (paths.length === 0) return [];
187
+ return this.lines(["diff", "--cached", "--name-only", "--no-renames", ref, "--", ...paths]);
188
+ }
173
189
  changedPaths(from, to) {
174
190
  return this.lines(["diff", "--name-only", "--no-renames", from, to]);
175
191
  }
@@ -202,18 +218,45 @@ var Git = class _Git {
202
218
  const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
203
219
  return { kind: "conflict", before, after: before, conflicted };
204
220
  }
221
+ /** `origin/<branch>` after a fetch attempt, when the remote has it; the local copy may have been retired. */
222
+ remoteRef(branch) {
223
+ this.tryRun(["fetch", "-q", "origin", branch]);
224
+ const ref = `refs/remotes/origin/${branch}`;
225
+ return this.tryRun(["rev-parse", "--verify", "-q", ref]).code === 0 ? `origin/${branch}` : void 0;
226
+ }
227
+ /**
228
+ * Add a worktree on `branch`: the local branch if it exists, else a tracking
229
+ * branch from origin/<branch> if the remote has it (so retiring the local
230
+ * branch while its PR was open never recreates it from the base), else a
231
+ * new branch from `startPoint`.
232
+ */
205
233
  worktreeAdd(path, branch, startPoint) {
206
234
  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"]);
235
+ if (this.branchExists(branch)) {
236
+ this.run(["worktree", "add", "-q", path, branch]);
237
+ return;
238
+ }
239
+ const remote = this.remoteRef(branch);
240
+ if (remote) {
241
+ this.run(["worktree", "add", "-q", "--track", "-b", branch, path, remote]);
242
+ return;
243
+ }
244
+ this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
210
245
  }
211
246
  worktreeRemove(path) {
212
247
  this.run(["worktree", "remove", "--force", path]);
213
248
  }
214
249
  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"]);
250
+ if (this.branchExists(branch)) {
251
+ this.run(["checkout", "-q", branch]);
252
+ return;
253
+ }
254
+ const remote = this.remoteRef(branch);
255
+ if (remote) {
256
+ this.run(["checkout", "-q", "--track", "-b", branch, remote]);
257
+ return;
258
+ }
259
+ this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
217
260
  }
218
261
  push(branch) {
219
262
  this.run(["push", "-q", "-u", "origin", branch]);
@@ -327,13 +370,14 @@ function slotVars(m, o) {
327
370
  BRANCH: o.branch ?? "",
328
371
  WORKTREE: o.worktree ?? "",
329
372
  LOG_DIR: o.logDir ?? "",
330
- PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort),
331
373
  STORYBROKR_HOST: storybookHostFor(m, o.worktree)
332
374
  };
333
375
  for (const service of Object.keys(m.ports.services)) {
334
376
  const p = o.ports[service];
335
377
  vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
336
378
  }
379
+ if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
380
+ else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
337
381
  return vars;
338
382
  }
339
383
  function ensureExcluded(worktree, patterns) {
@@ -558,8 +602,15 @@ function checkCommit(c) {
558
602
  `branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
559
603
  );
560
604
  }
605
+ const mergeHead = c.git.mergeHead();
606
+ const authored = mergeHead ? new Set(c.git.stagedDifferingFrom(mergeHead, c.stagedPaths)) : new Set(c.stagedPaths);
607
+ const ownPaths = c.stagedPaths.filter((p) => authored.has(p));
561
608
  const allEfforts = c.registry.efforts({ project: c.project });
562
609
  const efforts = new Map(allEfforts.map((e) => [e.id, e]));
610
+ const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
611
+ const mine = openEfforts.find(
612
+ (e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
613
+ );
563
614
  for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
564
615
  const holder = efforts.get(lease.effort);
565
616
  const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
@@ -568,34 +619,31 @@ function checkCommit(c) {
568
619
  } else if (lease.owner.session === c.ownerSession) {
569
620
  continue;
570
621
  }
622
+ if (holder && mine && holder.area === mine.area) continue;
571
623
  const globs = c.m.commons[String(lease.value)] ?? [];
572
- const hit = pathsMatching(c.stagedPaths, globs);
624
+ const hit = pathsMatching(ownPaths, globs);
573
625
  if (hit.length > 0) {
574
626
  refusals.push(
575
627
  `commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
576
628
  );
577
629
  }
578
630
  }
579
- const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
580
631
  for (const other of openEfforts) {
581
632
  if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
582
633
  continue;
583
634
  }
584
635
  if (!c.checkoutRoot && other.branch === c.branch) continue;
585
- const hit = pathsMatching(c.stagedPaths, other.scopes);
636
+ const hit = pathsMatching(ownPaths, other.scopes);
586
637
  if (hit.length > 0) {
587
638
  refusals.push(
588
639
  `path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
589
640
  );
590
641
  }
591
642
  }
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) {
643
+ if (mine && ownPaths.length > 0) {
596
644
  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));
645
+ const inScope = new Set(pathsMatching(ownPaths, allowed));
646
+ const outside = ownPaths.filter((p) => !inScope.has(p));
599
647
  if (outside.length > 0) {
600
648
  warnings.push(
601
649
  `${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
@@ -922,8 +970,19 @@ function rangesFor(m) {
922
970
  function rangesOverlap(a, b) {
923
971
  return a.base < b.base + b.count && b.base < a.base + a.count;
924
972
  }
973
+ function ensureRanges(registry, m, repoRoot) {
974
+ const mine = rangesFor(m);
975
+ assertNoOverlap(registry, m, mine);
976
+ const current = registry.project(m.project);
977
+ if (current && JSON.stringify(current.ranges) === JSON.stringify(mine)) return;
978
+ registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
979
+ }
925
980
  function claimRanges(registry, m, repoRoot) {
926
981
  const mine = rangesFor(m);
982
+ assertNoOverlap(registry, m, mine);
983
+ registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
984
+ }
985
+ function assertNoOverlap(registry, m, mine) {
927
986
  const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
928
987
  for (const [service, range] of Object.entries(mine)) {
929
988
  for (const [name, entry] of others) {
@@ -938,7 +997,6 @@ function claimRanges(registry, m, repoRoot) {
938
997
  }
939
998
  }
940
999
  }
941
- registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
942
1000
  }
943
1001
 
944
1002
  // src/core/refresh.ts
@@ -1070,8 +1128,8 @@ import {
1070
1128
  } from "fs";
1071
1129
  var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
1072
1130
  var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
1073
- var LOCK_STALE_MS = 3e4;
1074
- var LOCK_WAIT_MS = 5e3;
1131
+ var LOCK_STALE_MS = 1e4;
1132
+ var LOCK_WAIT_MS = 15e3;
1075
1133
  function sleep(ms) {
1076
1134
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1077
1135
  }
@@ -1127,7 +1185,9 @@ var Registry = class _Registry {
1127
1185
  } catch {
1128
1186
  try {
1129
1187
  if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
1130
- rmSync2(lock, { recursive: true, force: true });
1188
+ const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
1189
+ renameSync(lock, claimed);
1190
+ rmSync2(claimed, { recursive: true, force: true });
1131
1191
  continue;
1132
1192
  }
1133
1193
  } catch {
@@ -1237,7 +1297,7 @@ var Registry = class _Registry {
1237
1297
  if (slot < 0) {
1238
1298
  throw new WorkkitError(
1239
1299
  "NO_FREE_SLOT",
1240
- `no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")})`
1300
+ `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
1301
  );
1242
1302
  }
1243
1303
  const lease = this.lease(
@@ -1480,10 +1540,14 @@ function currentOwner(env = process.env) {
1480
1540
  return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
1481
1541
  }
1482
1542
 
1543
+ // src/lib/pritty.ts
1544
+ import { spawnSync as spawnSync5 } from "child_process";
1545
+
1483
1546
  // src/lib/storybrokr.ts
1484
1547
  import { spawnSync as spawnSync4 } from "child_process";
1485
1548
  var MIN_STORYBROKR_VERSION = "0.3.0";
1486
1549
  var versionChecked = false;
1550
+ var versionFound = "";
1487
1551
  function compareSemver(a, b) {
1488
1552
  const pa = a.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
1489
1553
  const pb = b.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
@@ -1514,12 +1578,14 @@ function assertStorybrokrVersion() {
1514
1578
  );
1515
1579
  }
1516
1580
  versionChecked = true;
1581
+ versionFound = found;
1517
1582
  }
1518
1583
  var storybrokrCli = {
1519
1584
  async up(story, hostRoot, opts) {
1520
1585
  assertStorybrokrVersion();
1521
1586
  const args = ["up", story, "--host", hostRoot, "--json"];
1522
1587
  if (opts?.ttlMinutes !== void 0) args.push("--ttl", String(opts.ttlMinutes));
1588
+ if (opts?.includeChildren) args.push("--include-children");
1523
1589
  const r = run(args);
1524
1590
  if (r.code !== 0) {
1525
1591
  throw new WorkkitError(
@@ -1536,8 +1602,26 @@ var storybrokrCli = {
1536
1602
  throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
1537
1603
  },
1538
1604
  async downHost(hostRoot) {
1539
- const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--json"], { encoding: "utf8" });
1540
- void r;
1605
+ const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--if-running", "--json"], {
1606
+ encoding: "utf8"
1607
+ });
1608
+ if (r.error) return [];
1609
+ if (r.status !== 0) {
1610
+ throw new WorkkitError(
1611
+ "STORYBROKR_FAILED",
1612
+ `storybrokr down --host ${hostRoot} failed: ${(r.stderr || r.stdout || "").trim()}`
1613
+ );
1614
+ }
1615
+ try {
1616
+ const parsed = JSON.parse(r.stdout);
1617
+ return parsed.stopped ?? [];
1618
+ } catch {
1619
+ return [];
1620
+ }
1621
+ },
1622
+ version() {
1623
+ assertStorybrokrVersion();
1624
+ return versionFound;
1541
1625
  },
1542
1626
  async touch(id) {
1543
1627
  const r = spawnSync4("storybrokr", ["touch", id], { encoding: "utf8" });
@@ -1545,6 +1629,81 @@ var storybrokrCli = {
1545
1629
  }
1546
1630
  };
1547
1631
 
1632
+ // src/lib/pritty.ts
1633
+ var MIN_PRITTY_VERSION = "0.1.1";
1634
+ function assertPrittyVersion(client) {
1635
+ const found = client.version();
1636
+ if (compareSemver(found, MIN_PRITTY_VERSION) < 0) {
1637
+ throw new WorkkitError(
1638
+ "PRITTY_FAILED",
1639
+ `pritty >= ${MIN_PRITTY_VERSION} required (found ${found || "unknown"}); npm i -g @helmsmith/pritty`
1640
+ );
1641
+ }
1642
+ }
1643
+ function run2(args, cwd) {
1644
+ const r = spawnSync5("pritty", args, { cwd, encoding: "utf8", env: process.env });
1645
+ if (r.error) {
1646
+ throw new WorkkitError(
1647
+ "PRITTY_FAILED",
1648
+ `pritty is not installed (npm i -g @helmsmith/pritty): ${r.error.message}`
1649
+ );
1650
+ }
1651
+ return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
1652
+ }
1653
+ function lastJsonLine(out, what) {
1654
+ const line = out.trim().split("\n").filter(Boolean).pop() ?? "";
1655
+ try {
1656
+ return JSON.parse(line);
1657
+ } catch {
1658
+ throw new WorkkitError("PRITTY_FAILED", `pritty ${what} printed no JSON: ${out.trim()}`);
1659
+ }
1660
+ }
1661
+ var versionFound2;
1662
+ var prittyCli = {
1663
+ version() {
1664
+ if (versionFound2 === void 0) {
1665
+ const r = run2(["--version"]);
1666
+ versionFound2 = r.code === 0 ? r.out.trim() : "";
1667
+ }
1668
+ return versionFound2;
1669
+ },
1670
+ async pr(o) {
1671
+ const args = ["pr", "--base", o.base, "--no-push", "--auto-approve", "--json"];
1672
+ if (o.draft) args.push("--draft");
1673
+ if (o.title !== void 0) args.push("--title", o.title);
1674
+ if (o.body !== void 0) args.push("--body", o.body);
1675
+ if (o.bodyFile !== void 0) args.push("--body-file", o.bodyFile);
1676
+ const r = run2(args, o.cwd);
1677
+ if (r.code !== 0) {
1678
+ throw new WorkkitError("PRITTY_FAILED", `pritty pr failed: ${(r.err || r.out).trim()}`, {
1679
+ stderr: r.err
1680
+ });
1681
+ }
1682
+ const j = lastJsonLine(
1683
+ r.out,
1684
+ "pr"
1685
+ );
1686
+ return { number: j.number, url: j.url, draft: Boolean(j.draft), created: Boolean(j.created) };
1687
+ },
1688
+ async check(o) {
1689
+ const r = run2(["pr", "--check", "--base", o.base], o.cwd);
1690
+ if (r.code !== 0) {
1691
+ throw new WorkkitError(
1692
+ "PRITTY_FAILED",
1693
+ `pritty pr --check failed: ${(r.err || r.out).trim()}`,
1694
+ {
1695
+ stderr: r.err
1696
+ }
1697
+ );
1698
+ }
1699
+ const j = lastJsonLine(
1700
+ r.out,
1701
+ "pr --check"
1702
+ );
1703
+ return j.open ? { number: j.open.number, url: j.open.url, draft: Boolean(j.open.draft) } : null;
1704
+ }
1705
+ };
1706
+
1548
1707
  // src/lib/context.ts
1549
1708
  function resolveRepoRoot(cwd, override) {
1550
1709
  if (override) return override;
@@ -1555,7 +1714,7 @@ async function buildCtx(opts = {}) {
1555
1714
  const manifest = await loadManifest(repoRoot);
1556
1715
  const home = homeDir();
1557
1716
  const registry = Registry.open(home);
1558
- if (!registry.project(manifest.project)) claimRanges(registry, manifest, repoRoot);
1717
+ ensureRanges(registry, manifest, repoRoot);
1559
1718
  return {
1560
1719
  home,
1561
1720
  repoRoot,
@@ -1564,6 +1723,7 @@ async function buildCtx(opts = {}) {
1564
1723
  owner: currentOwner(),
1565
1724
  runner: { start: startServices, stop: stopServices },
1566
1725
  storybrokr: storybrokrCli,
1726
+ pritty: prittyCli,
1567
1727
  log: opts.quiet ? () => {
1568
1728
  } : (line) => console.error(chalk.gray(line))
1569
1729
  };
@@ -1576,9 +1736,11 @@ import {
1576
1736
  existsSync as existsSync7,
1577
1737
  mkdirSync as mkdirSync6,
1578
1738
  readdirSync,
1739
+ readFileSync as readFileSync5,
1579
1740
  realpathSync as realpathSync4,
1580
1741
  rmSync as rmSync3,
1581
- statSync as statSync3
1742
+ statSync as statSync3,
1743
+ writeFileSync as writeFileSync5
1582
1744
  } from "fs";
1583
1745
  import { connect, createServer } from "net";
1584
1746
  import { dirname as dirname3, join as join8 } from "path";
@@ -1623,22 +1785,26 @@ function touchEffort(ctx, effort) {
1623
1785
  }
1624
1786
  ctx.registry.updateEffort(effort.id, { lastActivity: now() });
1625
1787
  }
1626
- function portBusy(port) {
1788
+ function bindFails(port, host) {
1627
1789
  return new Promise((resolve4) => {
1628
1790
  const srv = createServer();
1629
- srv.once("error", () => resolve4(true));
1630
- srv.listen(port, "127.0.0.1", () => srv.close(() => resolve4(false)));
1791
+ srv.once("error", (err) => resolve4(err.code === "EADDRINUSE"));
1792
+ srv.listen(port, host, () => srv.close(() => resolve4(false)));
1631
1793
  });
1632
1794
  }
1795
+ async function portBusy(port) {
1796
+ for (const host of ["127.0.0.1", "0.0.0.0", "::"]) if (await bindFails(port, host)) return true;
1797
+ return false;
1798
+ }
1633
1799
  async function busyPorts(ports) {
1634
1800
  const out = [];
1635
1801
  for (const [service, port] of Object.entries(ports))
1636
1802
  if (await portBusy(port)) out.push(`${service}=${port}`);
1637
1803
  return out;
1638
1804
  }
1639
- function portAnswers(port) {
1805
+ function portAnswersOn(port, host) {
1640
1806
  return new Promise((resolve4) => {
1641
- const sock = connect({ port, host: "127.0.0.1" });
1807
+ const sock = connect({ port, host });
1642
1808
  const done = (v) => {
1643
1809
  sock.destroy();
1644
1810
  resolve4(v);
@@ -1648,6 +1814,9 @@ function portAnswers(port) {
1648
1814
  sock.once("error", () => done(false));
1649
1815
  });
1650
1816
  }
1817
+ async function portAnswers(port) {
1818
+ return await portAnswersOn(port, "127.0.0.1") || await portAnswersOn(port, "::1");
1819
+ }
1651
1820
  async function anyPortAnswers(ports) {
1652
1821
  for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
1653
1822
  return false;
@@ -1764,6 +1933,15 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1764
1933
  });
1765
1934
  return effort;
1766
1935
  }
1936
+ function areaBranchFor(ctx, area, preferredType) {
1937
+ const main = new Git(ctx.repoRoot);
1938
+ const types = [preferredType, ...ctx.manifest.branch.types.filter((t) => t !== preferredType)];
1939
+ for (const type of types) {
1940
+ const b = branchFor(type, area);
1941
+ if (main.branchExists(b) || main.remoteRef(b)) return b;
1942
+ }
1943
+ return branchFor(preferredType, area);
1944
+ }
1767
1945
  function ensureWorktree(ctx, area, branch, task) {
1768
1946
  const m = ctx.manifest;
1769
1947
  const path = worktreePathFor(ctx, area, task);
@@ -1815,8 +1993,25 @@ function appendLog(file, line) {
1815
1993
  appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1816
1994
  `);
1817
1995
  }
1996
+ var PENDING_MARKS = ".pending-marks.json";
1997
+ function readPendingMarks(logDir) {
1998
+ try {
1999
+ const raw = JSON.parse(readFileSync5(join8(logDir, PENDING_MARKS), "utf8"));
2000
+ return Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : [];
2001
+ } catch {
2002
+ return [];
2003
+ }
2004
+ }
2005
+ function writePendingMarks(logDir, marks) {
2006
+ mkdirSync6(logDir, { recursive: true });
2007
+ writeFileSync5(join8(logDir, PENDING_MARKS), `${JSON.stringify(marks)}
2008
+ `);
2009
+ }
1818
2010
  async function startEffort(ctx, o) {
1819
2011
  const m = ctx.manifest;
2012
+ await reapLeases(ctx).catch(
2013
+ (err) => ctx.log(`pre-flight reap: ${err instanceof Error ? err.message : String(err)}`)
2014
+ );
1820
2015
  fetchBaseOnce(ctx);
1821
2016
  const { branch, scopes, commons, warnings } = guardStart(ctx, o);
1822
2017
  const notices = ctx.registry.notices(m.project, o.area);
@@ -1960,6 +2155,9 @@ async function startEffort(ctx, o) {
1960
2155
  }
1961
2156
  );
1962
2157
  const baseSha = wg.rev("HEAD");
2158
+ if (o.mode === "frontend" && sel.marks.length > 0) {
2159
+ writePendingMarks(logDir, [.../* @__PURE__ */ new Set([...readPendingMarks(logDir), ...sel.marks])]);
2160
+ }
1963
2161
  for (const name of commons)
1964
2162
  leases.push(
1965
2163
  ctx.registry.addLease(
@@ -1983,8 +2181,13 @@ async function startEffort(ctx, o) {
1983
2181
  branch
1984
2182
  });
1985
2183
  const env = { ...process.env, ...cfg.vars };
1986
- if (sel.marks.includes("db-reset") && m.services.postgres?.reset) {
2184
+ const owedMarks = [.../* @__PURE__ */ new Set([...sel.marks, ...readPendingMarks(logDir)])];
2185
+ if (owedMarks.includes("db-reset") && m.services.postgres?.reset) {
1987
2186
  runRefresh([m.services.postgres.reset], worktree, (l) => appendLog(refreshLog, l), env);
2187
+ writePendingMarks(
2188
+ logDir,
2189
+ owedMarks.filter((x) => x !== "db-reset")
2190
+ );
1988
2191
  }
1989
2192
  const pids = {};
1990
2193
  const signatures = {};
@@ -2073,7 +2276,7 @@ async function promoteEffort(ctx, o) {
2073
2276
  }
2074
2277
  const modeScopes = expandScopes(m, o.area, o.mode);
2075
2278
  const nextScopes = open.task ? [.../* @__PURE__ */ new Set([...open.scopes, ...modeScopes])] : modeScopes;
2076
- ctx.registry.update((r) => {
2279
+ const refuseOverlap = (r) => {
2077
2280
  const others = r.efforts.filter(
2078
2281
  (e) => e.project === m.project && e.id !== open.id && (e.state === "active" || e.state === "starting")
2079
2282
  );
@@ -2088,17 +2291,49 @@ async function promoteEffort(ctx, o) {
2088
2291
  );
2089
2292
  }
2090
2293
  }
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
- });
2294
+ };
2295
+ ctx.registry.update(refuseOverlap);
2096
2296
  const warnings = [];
2097
2297
  let slot = ctx.registry.leases({ effort: open.id, kind: "slot" })[0];
2098
2298
  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] });
2299
+ const claimed = !slot;
2300
+ if (!slot) slot = await claimFreeSlot(ctx, open.id, warnings);
2301
+ try {
2302
+ ctx.registry.update((r) => {
2303
+ refuseOverlap(r);
2304
+ const idx = r.efforts.findIndex((e) => e.id === open.id);
2305
+ if (idx >= 0) {
2306
+ r.efforts[idx] = {
2307
+ ...r.efforts[idx],
2308
+ mode: o.mode,
2309
+ scopes: nextScopes,
2310
+ leases: claimed ? [...open.leases, slot.id] : r.efforts[idx].leases,
2311
+ lastActivity: now()
2312
+ };
2313
+ }
2314
+ });
2315
+ } catch (err) {
2316
+ if (claimed) ctx.registry.release(slot.id);
2317
+ throw err;
2318
+ }
2319
+ const owed = readPendingMarks(join8(open.worktree, m.logs.dir));
2320
+ if (owed.includes("db-reset") && m.services.postgres?.reset) {
2321
+ const cfgForReset = emitWorktreeConfig({
2322
+ m,
2323
+ worktree: open.worktree,
2324
+ slot: slot.value,
2325
+ ports: slot.ports ?? {},
2326
+ area: o.area,
2327
+ branch: open.branch
2328
+ });
2329
+ runRefresh([m.services.postgres.reset], open.worktree, ctx.log, {
2330
+ ...process.env,
2331
+ ...cfgForReset.vars
2332
+ });
2333
+ writePendingMarks(
2334
+ join8(open.worktree, m.logs.dir),
2335
+ owed.filter((x) => x !== "db-reset")
2336
+ );
2102
2337
  }
2103
2338
  const cfg = emitWorktreeConfig({
2104
2339
  m,
@@ -2149,9 +2384,7 @@ async function promoteEffort(ctx, o) {
2149
2384
  }
2150
2385
  function behindBase(ctx, worktree) {
2151
2386
  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;
2387
+ return new Git(worktree).behindCount(ctx.manifest.baseBranch);
2155
2388
  }
2156
2389
  function statusFor(ctx, area, task) {
2157
2390
  const m = ctx.manifest;
@@ -2233,6 +2466,15 @@ async function releaseLease(ctx, effort, lease) {
2233
2466
  }
2234
2467
  ctx.registry.release(lease.id);
2235
2468
  }
2469
+ async function sweepStorybook(ctx, effort) {
2470
+ if (effort.mode === "backend" || !ctx.storybrokr.downHost) return;
2471
+ try {
2472
+ const stopped = await ctx.storybrokr.downHost(storybookHostFor(ctx.manifest, effort.worktree));
2473
+ if (stopped.length > 0) ctx.log(`storybrokr: stopped ${stopped.join(", ")}`);
2474
+ } catch (err) {
2475
+ ctx.log(`storybrokr sweep failed: ${err instanceof Error ? err.message : String(err)}`);
2476
+ }
2477
+ }
2236
2478
  function leaveNotices(ctx, effort, commons) {
2237
2479
  if (commons.length === 0) return 0;
2238
2480
  const notice = {
@@ -2301,7 +2543,7 @@ async function finishEffort(ctx, o) {
2301
2543
  );
2302
2544
  }
2303
2545
  fetchBaseOnce(ctx);
2304
- const parentBranch = branchFor(effort.type, effort.area);
2546
+ const parentBranch = areaBranchFor(ctx, effort.area, effort.type);
2305
2547
  const { wg: areaGit } = ensureWorktree(ctx, effort.area, parentBranch);
2306
2548
  if (areaGit.isDirty()) {
2307
2549
  throw new WorkkitError(
@@ -2321,14 +2563,22 @@ async function finishEffort(ctx, o) {
2321
2563
  );
2322
2564
  }
2323
2565
  }
2566
+ if (!o.intoArea) {
2567
+ try {
2568
+ g.push(effort.branch);
2569
+ } catch (err) {
2570
+ throw new WorkkitError(
2571
+ "PUSH_REJECTED",
2572
+ `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.`,
2573
+ { branch: effort.branch, worktree: effort.worktree }
2574
+ );
2575
+ }
2576
+ }
2324
2577
  for (const lease of ctx.registry.leases({ effort: effort.id }))
2325
2578
  await releaseLease(ctx, effort, lease);
2326
- await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
2327
- () => void 0
2328
- );
2579
+ await sweepStorybook(ctx, effort);
2329
2580
  let notices = 0;
2330
2581
  if (!o.intoArea) {
2331
- g.push(effort.branch);
2332
2582
  if (g.commitsNotOn(m.baseBranch).length > 0 && !g.isEquivalentToBase(m.baseBranch)) {
2333
2583
  const since = g.changedSince(m.baseBranch);
2334
2584
  const sinceSet = new Set(since);
@@ -2371,19 +2621,24 @@ async function reapLeases(ctx, o = {}) {
2371
2621
  const garbage = orphaned && !await anyPortAnswers(l.ports);
2372
2622
  if (expired || garbage) doomed.add(l.effort);
2373
2623
  }
2624
+ for (const e of efforts.values()) {
2625
+ if (e.state !== "starting" || doomed.has(e.id)) continue;
2626
+ if (leases.some((l) => l.effort === e.id)) continue;
2627
+ if (Date.now() - Date.parse(e.started) < STARTING_GRACE_MS) continue;
2628
+ doomed.add(e.id);
2629
+ }
2374
2630
  const reaped = leases.filter((l) => doomed.has(l.effort));
2375
2631
  if (o.dryRun) return { reaped };
2376
2632
  for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
2377
2633
  for (const id of doomed) {
2378
2634
  const effort = efforts.get(id);
2379
2635
  if (effort) {
2380
- await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
2381
- () => void 0
2382
- );
2636
+ await sweepStorybook(ctx, effort);
2383
2637
  if (effort.state === "active" || effort.state === "starting") {
2638
+ const hadLease = leases.some((l) => l.effort === effort.id);
2384
2639
  ctx.registry.updateEffort(effort.id, {
2385
2640
  state: "parked",
2386
- parkedReason: `idle: a lease expired by ${at.toISOString()}`,
2641
+ parkedReason: hadLease ? `idle: a lease expired by ${at.toISOString()}` : `start died before its first lease (started ${effort.started})`,
2387
2642
  lastActivity: now()
2388
2643
  });
2389
2644
  }
@@ -2441,9 +2696,6 @@ async function retireArea(ctx, o) {
2441
2696
  const worktree = realpathOr2(worktreePathFor(ctx, o.area));
2442
2697
  const main = new Git(ctx.repoRoot);
2443
2698
  let archived = [];
2444
- await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, worktree))).catch(
2445
- () => void 0
2446
- );
2447
2699
  if (existsSync7(worktree)) {
2448
2700
  const g = new Git(worktree);
2449
2701
  if (g.isDirty())
@@ -2459,6 +2711,8 @@ async function retireArea(ctx, o) {
2459
2711
  `${g.currentBranch()} has ${unpushed} commit(s) not on origin; push before retiring`,
2460
2712
  { worktree, unpushed }
2461
2713
  );
2714
+ const hadStorybook = ctx.registry.efforts({ project: m.project, area: o.area }).some((e) => e.mode !== "backend");
2715
+ if (hadStorybook) await sweepStorybook(ctx, { worktree, mode: "frontend" });
2462
2716
  archived = archiveWorktree(ctx, o.area, worktree);
2463
2717
  main.worktreeRemove(worktree);
2464
2718
  }
@@ -2478,6 +2732,93 @@ async function retireArea(ctx, o) {
2478
2732
  ctx.registry.clearNoticesFrom(m.project, allAreaBranches);
2479
2733
  return { archived, removed: worktree, branchesDeleted, branchesKept };
2480
2734
  }
2735
+ function effortAt(ctx, cwd) {
2736
+ const top = new Git(cwd).tryRun(["rev-parse", "--show-toplevel"]);
2737
+ if (top.code !== 0) return void 0;
2738
+ const root = realpathOr2(top.out.trim());
2739
+ 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);
2740
+ }
2741
+ async function prEffort(ctx, o) {
2742
+ const m = ctx.manifest;
2743
+ assertPrittyVersion(ctx.pritty);
2744
+ 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());
2745
+ if (!effort) {
2746
+ throw new WorkkitError(
2747
+ "EFFORT_NOT_FOUND",
2748
+ 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`,
2749
+ { area: o.area, task: o.task }
2750
+ );
2751
+ }
2752
+ const label = effortLabel2(effort.area, effort.task);
2753
+ const g = new Git(effort.worktree);
2754
+ if (g.isDirty()) {
2755
+ throw new WorkkitError(
2756
+ "DIRTY_WORKTREE",
2757
+ `${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
2758
+ { worktree: effort.worktree }
2759
+ );
2760
+ }
2761
+ fetchBaseOnce(ctx);
2762
+ const warnings = [];
2763
+ if (!g.hasUpstream() || g.unpushedCount(m.baseBranch) > 0) {
2764
+ try {
2765
+ g.push(effort.branch);
2766
+ } catch (err) {
2767
+ throw new WorkkitError(
2768
+ "PUSH_REJECTED",
2769
+ `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`,
2770
+ { branch: effort.branch, worktree: effort.worktree }
2771
+ );
2772
+ }
2773
+ }
2774
+ const behind = g.behindCount(m.baseBranch);
2775
+ if (behind > BEHIND_LIMIT) {
2776
+ warnings.push(
2777
+ `${effort.branch} is ${behind} commits behind ${m.baseBranch}; consider merging origin/${m.baseBranch} before review`
2778
+ );
2779
+ }
2780
+ const given = o.title !== void 0 || o.body !== void 0 || o.bodyFile !== void 0;
2781
+ const store = (pr2) => {
2782
+ const updated = ctx.registry.updateEffort(effort.id, { pr: pr2, lastActivity: now() });
2783
+ touchEffort(ctx, updated);
2784
+ return updated;
2785
+ };
2786
+ if (effort.pr) {
2787
+ const open = await ctx.pritty.check({ cwd: effort.worktree, base: m.baseBranch });
2788
+ if (open) {
2789
+ if (!given) {
2790
+ return { effort: store(open), pr: open, created: false, behindBase: behind, warnings };
2791
+ }
2792
+ const updated = await ctx.pritty.pr({
2793
+ cwd: effort.worktree,
2794
+ base: m.baseBranch,
2795
+ draft: o.draft,
2796
+ title: o.title,
2797
+ body: o.body,
2798
+ bodyFile: o.bodyFile
2799
+ });
2800
+ const pr2 = { number: updated.number, url: updated.url, draft: updated.draft };
2801
+ return { effort: store(pr2), pr: pr2, created: false, behindBase: behind, warnings };
2802
+ }
2803
+ if (!o.force) {
2804
+ throw new WorkkitError(
2805
+ "PR_CLOSED",
2806
+ `PR #${effort.pr.number} for ${label} is no longer open (${effort.pr.url}); pass --force to open a new one`,
2807
+ { pr: effort.pr }
2808
+ );
2809
+ }
2810
+ }
2811
+ const res = await ctx.pritty.pr({
2812
+ cwd: effort.worktree,
2813
+ base: m.baseBranch,
2814
+ draft: o.draft,
2815
+ title: o.title,
2816
+ body: o.body,
2817
+ bodyFile: o.bodyFile
2818
+ });
2819
+ const pr = { number: res.number, url: res.url, draft: res.draft };
2820
+ return { effort: store(pr), pr, created: res.created, behindBase: behind, warnings };
2821
+ }
2481
2822
  var BEHIND_LIMIT = 20;
2482
2823
  var NOTICE_STALE_MS = 7 * 864e5;
2483
2824
  var ARCHIVE_LIMIT_BYTES = 5 * 1024 ** 3;
@@ -2665,7 +3006,7 @@ function registerFinish(program2) {
2665
3006
  }
2666
3007
 
2667
3008
  // src/commands/hook.ts
2668
- import { readFileSync as readFileSync5, realpathSync as realpathSync5 } from "fs";
3009
+ import { readFileSync as readFileSync6, realpathSync as realpathSync5 } from "fs";
2669
3010
  import { resolve as resolve2 } from "path";
2670
3011
  import chalk5 from "chalk";
2671
3012
  var ZEROS = /^0+$/;
@@ -2763,7 +3104,7 @@ function registerHook(program2) {
2763
3104
  "Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
2764
3105
  ).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
2765
3106
  try {
2766
- const stdin = process.stdin.isTTY ? "" : readFileSync5(0, "utf8");
3107
+ const stdin = process.stdin.isTTY ? "" : readFileSync6(0, "utf8");
2767
3108
  process.exit(await runPrePush(process.cwd(), stdin));
2768
3109
  } catch (err) {
2769
3110
  fail(err, false);
@@ -2786,7 +3127,7 @@ function registerHook(program2) {
2786
3127
  }
2787
3128
 
2788
3129
  // src/commands/init.ts
2789
- import { existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
3130
+ import { existsSync as existsSync8, writeFileSync as writeFileSync6 } from "fs";
2790
3131
  import { join as join9, resolve as resolve3 } from "path";
2791
3132
  var STORYBOOK_BASE = 6100;
2792
3133
  function defaultService(name) {
@@ -2862,7 +3203,7 @@ function runInit(o) {
2862
3203
  }
2863
3204
  const manifest = buildStarterManifest(o);
2864
3205
  const manifestPath = join9(repoRoot, "helmsmith.config.mjs");
2865
- writeFileSync5(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
3206
+ writeFileSync6(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
2866
3207
  `);
2867
3208
  claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
2868
3209
  const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
@@ -2930,7 +3271,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
2930
3271
  import { z as z2 } from "zod";
2931
3272
 
2932
3273
  // src/version.ts
2933
- import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
3274
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
2934
3275
  import { dirname as dirname4, join as join10 } from "path";
2935
3276
  import { fileURLToPath } from "url";
2936
3277
  function findPackageJson(startDir) {
@@ -2944,7 +3285,7 @@ function findPackageJson(startDir) {
2944
3285
  }
2945
3286
  }
2946
3287
  var pkgFile = findPackageJson(dirname4(fileURLToPath(import.meta.url)));
2947
- var VERSION = JSON.parse(readFileSync6(pkgFile, "utf8")).version;
3288
+ var VERSION = JSON.parse(readFileSync7(pkgFile, "utf8")).version;
2948
3289
 
2949
3290
  // src/mcp/server.ts
2950
3291
  var ok = (value) => ({
@@ -2969,7 +3310,9 @@ function buildMcpServer(ctxFactory) {
2969
3310
  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
3311
  inputSchema: {
2971
3312
  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>)"),
3313
+ task: z2.string().optional().describe(
3314
+ "optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"
3315
+ ),
2973
3316
  type: z2.string().describe("feature | chore | fix"),
2974
3317
  mode: z2.enum(["frontend", "backend", "fullstack"]),
2975
3318
  scopes: z2.array(z2.string()).optional().describe("optional narrowed scope globs for a task sub-effort"),
@@ -3002,11 +3345,38 @@ function buildMcpServer(ctxFactory) {
3002
3345
  area: z2.string().describe("the area of the open effort"),
3003
3346
  task: z2.string().optional().describe("optional intra-area task slug"),
3004
3347
  mode: z2.enum(["backend", "fullstack"]).describe("target mode to promote into"),
3005
- story: z2.string().optional().describe("optional story path"),
3006
3348
  projectRoot: z2.string().optional().describe("optional repository root path override")
3007
3349
  }
3008
3350
  },
3009
- async ({ area, task, mode, story, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode, story }))
3351
+ async ({ area, task, mode, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode }))
3352
+ );
3353
+ server.registerTool(
3354
+ "work_pr",
3355
+ {
3356
+ 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.",
3357
+ inputSchema: {
3358
+ area: z2.string().optional().describe("the area of the effort (default: the effort of the cwd worktree)"),
3359
+ task: z2.string().optional().describe("optional intra-area task slug"),
3360
+ draft: z2.boolean().optional().describe("open a draft PR"),
3361
+ title: z2.string().optional().describe("PR title"),
3362
+ // body text only: a file path here would let any MCP caller have pritty read an
3363
+ // arbitrary file and publish it as a PR body; read the file yourself and pass the text
3364
+ body: z2.string().optional().describe("PR body text (read your body file and pass its text)"),
3365
+ force: z2.boolean().optional().describe("open a new PR when the stored one was closed"),
3366
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3367
+ }
3368
+ },
3369
+ async ({ area, task, draft, title, body, force, projectRoot }) => guard(
3370
+ async () => prEffort(await ctxFactory(projectRoot), {
3371
+ area,
3372
+ task,
3373
+ cwd: process.cwd(),
3374
+ draft,
3375
+ title,
3376
+ body,
3377
+ force
3378
+ })
3379
+ )
3010
3380
  );
3011
3381
  server.registerTool(
3012
3382
  "work_status",
@@ -3084,24 +3454,56 @@ function registerMcp(program2) {
3084
3454
  });
3085
3455
  }
3086
3456
 
3087
- // src/commands/promote.ts
3457
+ // src/commands/pr.ts
3088
3458
  import chalk6 from "chalk";
3459
+ function registerPr(program2) {
3460
+ program2.command("pr [area]").description(
3461
+ "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."
3462
+ ).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(
3463
+ async (area, o) => {
3464
+ try {
3465
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3466
+ const r = await prEffort(ctx, {
3467
+ area,
3468
+ task: o.task,
3469
+ cwd: process.cwd(),
3470
+ draft: o.draft,
3471
+ title: o.title,
3472
+ body: o.body,
3473
+ bodyFile: o.bodyFile,
3474
+ force: o.force
3475
+ });
3476
+ if (o.json) printJson(r);
3477
+ else {
3478
+ for (const w of r.warnings) console.error(chalk6.yellow(w));
3479
+ console.log(
3480
+ `${r.created ? "opened" : "open"} PR #${r.pr.number}${r.pr.draft ? " (draft)" : ""} for ${r.effort.branch}: ${r.pr.url}`
3481
+ );
3482
+ }
3483
+ } catch (err) {
3484
+ fail(err, Boolean(o.json));
3485
+ }
3486
+ }
3487
+ );
3488
+ }
3489
+
3490
+ // src/commands/promote.ts
3491
+ import chalk7 from "chalk";
3089
3492
  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(
3493
+ 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
3494
  async (o) => {
3092
3495
  try {
3093
3496
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3094
3497
  const r = await promoteEffort(ctx, {
3095
3498
  area: o.area,
3096
3499
  task: o.task,
3097
- mode: o.mode,
3098
- story: o.story
3500
+ mode: o.mode
3099
3501
  });
3100
3502
  if (o.json) printJson(r);
3101
3503
  else {
3102
3504
  const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
3103
3505
  console.log(
3104
- `${chalk6.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
3506
+ `${chalk7.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
3105
3507
  );
3106
3508
  for (const w of r.warnings) warn(w);
3107
3509
  }
@@ -3113,7 +3515,7 @@ function registerPromote(program2) {
3113
3515
  }
3114
3516
 
3115
3517
  // src/commands/reap.ts
3116
- import chalk7 from "chalk";
3518
+ import chalk8 from "chalk";
3117
3519
  function registerReap(program2) {
3118
3520
  program2.command("reap").description(
3119
3521
  "Expire idle leases: stop only what they started, release them, park their efforts"
@@ -3122,7 +3524,7 @@ function registerReap(program2) {
3122
3524
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3123
3525
  const r = await reapLeases(ctx, { dryRun: o.dryRun });
3124
3526
  if (o.json) printJson(r);
3125
- else if (r.reaped.length === 0) console.log(chalk7.gray("nothing to reap"));
3527
+ else if (r.reaped.length === 0) console.log(chalk8.gray("nothing to reap"));
3126
3528
  else
3127
3529
  for (const l of r.reaped)
3128
3530
  console.log(
@@ -3135,7 +3537,7 @@ function registerReap(program2) {
3135
3537
  }
3136
3538
 
3137
3539
  // src/commands/retire.ts
3138
- import chalk8 from "chalk";
3540
+ import chalk9 from "chalk";
3139
3541
  function registerRetire(program2) {
3140
3542
  program2.command("retire").description(
3141
3543
  "Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
@@ -3145,11 +3547,11 @@ function registerRetire(program2) {
3145
3547
  const r = await retireArea(ctx, { area: o.area });
3146
3548
  if (o.json) printJson(r);
3147
3549
  else {
3148
- console.log(`${chalk8.bold(o.area)} retired: removed ${r.removed}`);
3550
+ console.log(`${chalk9.bold(o.area)} retired: removed ${r.removed}`);
3149
3551
  for (const a of r.archived) console.log(` archived ${a}`);
3150
3552
  for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
3151
3553
  for (const b of r.branchesKept)
3152
- console.log(chalk8.yellow(` kept ${b}: not fully on origin`));
3554
+ console.log(chalk9.yellow(` kept ${b}: not fully on origin`));
3153
3555
  }
3154
3556
  } catch (err) {
3155
3557
  fail(err, Boolean(o.json));
@@ -3158,12 +3560,12 @@ function registerRetire(program2) {
3158
3560
  }
3159
3561
 
3160
3562
  // src/commands/start.ts
3161
- import chalk9 from "chalk";
3563
+ import chalk10 from "chalk";
3162
3564
  function printStart(r) {
3163
3565
  const e = r.effort;
3164
3566
  const label = e.task ? `${e.area}--${e.task}` : e.area;
3165
3567
  console.log(
3166
- `${chalk9.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk9.yellow(" (joined)") : ""}`
3568
+ `${chalk10.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk10.yellow(" (joined)") : ""}`
3167
3569
  );
3168
3570
  console.log(` worktree ${e.worktree}`);
3169
3571
  console.log(
@@ -3174,7 +3576,7 @@ function printStart(r) {
3174
3576
  console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
3175
3577
  }
3176
3578
  for (const w of r.warnings) warn(w);
3177
- for (const n of r.notices) console.log(chalk9.yellow(` notice ${n.text} (${n.at})`));
3579
+ for (const n of r.notices) console.log(chalk10.yellow(` notice ${n.text} (${n.at})`));
3178
3580
  }
3179
3581
  function registerStart(program2) {
3180
3582
  program2.command("start").description(
@@ -3206,36 +3608,47 @@ function registerStart(program2) {
3206
3608
  }
3207
3609
 
3208
3610
  // src/commands/status.ts
3209
- import chalk10 from "chalk";
3611
+ import chalk11 from "chalk";
3210
3612
  var age = (iso) => {
3211
3613
  const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
3212
3614
  return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
3213
3615
  };
3214
3616
  var expiresIn = (iso) => {
3215
3617
  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`;
3618
+ return mins <= 0 ? chalk11.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
3217
3619
  };
3218
3620
  function printStatus(r) {
3219
- console.log(chalk10.bold(`${r.project}`));
3220
- const cols = ["AREA", "TYPE/MODE", "BRANCH", "OWNER", "STARTED", "LEASES", "BEHIND", "NOTICES"];
3621
+ console.log(chalk11.bold(`${r.project}`));
3622
+ const cols = [
3623
+ "AREA",
3624
+ "TYPE/MODE",
3625
+ "BRANCH",
3626
+ "OWNER",
3627
+ "STARTED",
3628
+ "LEASES",
3629
+ "PR",
3630
+ "BEHIND",
3631
+ "NOTICES"
3632
+ ];
3221
3633
  const rows = r.areas.map((a) => [
3222
3634
  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"
3635
+ a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk11.gray("-"),
3636
+ a.effort?.branch ?? chalk11.gray("-"),
3637
+ a.effort?.owner.session ?? chalk11.gray("-"),
3638
+ a.effort ? age(a.effort.started) : chalk11.gray("-"),
3639
+ a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk11.gray("-"),
3640
+ a.effort?.pr ? `#${a.effort.pr.number}${a.effort.pr.draft ? " draft" : ""}` : chalk11.gray("-"),
3641
+ a.behindBase > 0 ? chalk11.yellow(String(a.behindBase)) : "0",
3642
+ a.notices.length ? chalk11.yellow(String(a.notices.length)) : "0"
3230
3643
  ]);
3231
3644
  const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
3232
3645
  const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
3233
3646
  const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
3234
- console.log(chalk10.bold(line(cols)));
3647
+ console.log(chalk11.bold(line(cols)));
3235
3648
  for (const row of rows) console.log(line(row));
3236
3649
  if (r.leases.length) {
3237
3650
  console.log("");
3238
- console.log(chalk10.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
3651
+ console.log(chalk11.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
3239
3652
  for (const l of r.leases) {
3240
3653
  console.log(
3241
3654
  `${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
@@ -3260,13 +3673,14 @@ function registerStatus(program2) {
3260
3673
  var { program } = createCli({
3261
3674
  name: "work",
3262
3675
  version: VERSION,
3263
- description: "Area-based work: start, promote, status, finish, reap, retire, audit."
3676
+ description: "Area-based work: start, promote, status, pr, finish, reap, retire, audit."
3264
3677
  });
3265
3678
  registerInit(program);
3266
3679
  registerStart(program);
3267
3680
  registerPromote(program);
3268
3681
  registerStatus(program);
3269
3682
  registerEnv(program);
3683
+ registerPr(program);
3270
3684
  registerFinish(program);
3271
3685
  registerReap(program);
3272
3686
  registerRetire(program);