@helmsmith/workbrokr 0.1.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
@@ -25,23 +25,30 @@ function createCli(opts) {
25
25
  // src/commands/audit.ts
26
26
  import chalk3 from "chalk";
27
27
 
28
- // ../../platform/core/workkit-lib/src/config.ts
28
+ // src/lib/context.ts
29
+ import chalk from "chalk";
30
+
31
+ // src/core/config.ts
29
32
  import {
30
33
  appendFileSync,
31
- existsSync,
34
+ existsSync as existsSync2,
32
35
  mkdirSync as mkdirSync2,
33
36
  readFileSync,
34
37
  realpathSync as realpathSync2,
35
38
  writeFileSync
36
39
  } from "fs";
37
- import { join } from "path";
40
+ import { join as join2 } from "path";
38
41
 
39
- // ../../platform/core/workkit-lib/src/errors.ts
42
+ // src/core/errors.ts
40
43
  var REFUSALS = /* @__PURE__ */ new Set([
41
44
  "MANIFEST_INVALID",
42
45
  "AREA_UNKNOWN",
46
+ "TASK_INVALID",
43
47
  "TYPE_UNKNOWN",
44
48
  "MODE_UNKNOWN",
49
+ "MODE_MISMATCH",
50
+ "SCOPE_REQUIRES_TASK",
51
+ "SCOPE_OUTSIDE_AREA",
45
52
  "MAIN_CHECKOUT",
46
53
  "WORKTREE_INVALID",
47
54
  "PROTECTED_BRANCH",
@@ -54,7 +61,8 @@ var REFUSALS = /* @__PURE__ */ new Set([
54
61
  "PORT_RANGE_TAKEN",
55
62
  "SYNC_CONFLICT",
56
63
  "DIRTY_WORKTREE",
57
- "UNPUSHED"
64
+ "UNPUSHED",
65
+ "PR_CLOSED"
58
66
  ]);
59
67
  function exitCodeFor(code) {
60
68
  return REFUSALS.has(code) ? 2 : 1;
@@ -76,11 +84,19 @@ function toErrorBody(err) {
76
84
  return { code: "INTERNAL", message: err instanceof Error ? err.message : String(err) };
77
85
  }
78
86
 
79
- // ../../platform/core/workkit-lib/src/git.ts
87
+ // src/core/git.ts
80
88
  import { spawnSync } from "child_process";
81
- import { mkdirSync, realpathSync } from "fs";
82
- import { dirname } from "path";
83
- var Git = class {
89
+ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
90
+ import { dirname, join, resolve, sep } from "path";
91
+ var realpathOr = (p) => {
92
+ try {
93
+ return realpathSync(p);
94
+ } catch {
95
+ return p;
96
+ }
97
+ };
98
+ var SAFE_SUBMODULE_NAME = /^[^\p{Cc}\s=]+$/u;
99
+ var Git = class _Git {
84
100
  constructor(cwd) {
85
101
  this.cwd = cwd;
86
102
  }
@@ -149,12 +165,27 @@ var Git = class {
149
165
  else if (this.remoteBranchExists(this.currentBranch())) ref = `origin/${this.currentBranch()}`;
150
166
  return this.lines(["rev-list", `${ref}..HEAD`]).length;
151
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
+ }
152
173
  isDirty() {
153
174
  return this.run(["status", "--porcelain"]).trim().length > 0;
154
175
  }
155
176
  changedPathsStaged() {
156
177
  return this.lines(["diff", "--cached", "--name-only"]);
157
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
+ }
158
189
  changedPaths(from, to) {
159
190
  return this.lines(["diff", "--name-only", "--no-renames", from, to]);
160
191
  }
@@ -187,18 +218,45 @@ var Git = class {
187
218
  const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
188
219
  return { kind: "conflict", before, after: before, conflicted };
189
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
+ */
190
233
  worktreeAdd(path, branch, startPoint) {
191
234
  mkdirSync(dirname(path), { recursive: true });
192
- if (this.branchExists(branch)) this.run(["worktree", "add", "-q", path, branch]);
193
- else
194
- 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"]);
195
245
  }
196
246
  worktreeRemove(path) {
197
247
  this.run(["worktree", "remove", "--force", path]);
198
248
  }
199
249
  checkout(branch, createFrom) {
200
- if (this.branchExists(branch)) this.run(["checkout", "-q", branch]);
201
- 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"]);
202
260
  }
203
261
  push(branch) {
204
262
  this.run(["push", "-q", "-u", "origin", branch]);
@@ -206,9 +264,89 @@ var Git = class {
206
264
  deleteLocalBranch(branch) {
207
265
  this.run(["branch", "-q", "-D", branch]);
208
266
  }
267
+ isAncestor(ancestor, descendant) {
268
+ return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).code === 0;
269
+ }
270
+ /** True when HEAD's tree matches origin/<base>, or every commit on HEAD is patch-equivalent to origin/<base> (squash-merged). */
271
+ isEquivalentToBase(base) {
272
+ const target = `origin/${base}`;
273
+ if (this.tryRun(["diff", "--quiet", target, "HEAD"]).code === 0) return true;
274
+ const cherry = this.tryRun(["cherry", target, "HEAD"]);
275
+ if (cherry.code !== 0) return false;
276
+ const lines = cherry.out.split("\n").map((l) => l.trim()).filter(Boolean);
277
+ return lines.length > 0 && lines.every((l) => l.startsWith("- "));
278
+ }
279
+ /**
280
+ * Give a linked worktree its own clone of every submodule the main checkout has initialized,
281
+ * cloned locally from the main checkout's module directory (no network) and then re-pointed at
282
+ * the real URL. `git worktree add` under submodule.recurse leaves a gitfile aimed at a module
283
+ * directory that was never cloned; a gitfile aimed at the MAIN checkout's module directory is
284
+ * worse, because that module's core.worktree is the main checkout's submodule, so any git
285
+ * command in the worktree's submodule (checkout -f, reset, merge) rewrites the human's files.
286
+ * Both shapes are replaced; a submodule that is already its own working tree is left alone.
287
+ */
288
+ repairSubmodules(mainRoot, log = () => {
289
+ }) {
290
+ const gitmodules = join(this.cwd, ".gitmodules");
291
+ if (!existsSync(gitmodules)) return;
292
+ const cfg = this.tryRun([
293
+ "config",
294
+ "-f",
295
+ ".gitmodules",
296
+ "--get-regexp",
297
+ "^submodule\\..*\\.path$"
298
+ ]);
299
+ if (cfg.code !== 0) return;
300
+ const wtGitDir = this.run(["rev-parse", "--path-format=absolute", "--git-dir"]).trim();
301
+ for (const line of cfg.out.split("\n").filter(Boolean)) {
302
+ const m = /^submodule\.(.+)\.path\s+(.+)$/.exec(line.trim());
303
+ if (!m) continue;
304
+ const [, name, subPath] = m;
305
+ const modulesDir = resolve(wtGitDir, "modules");
306
+ const moduleDir = resolve(modulesDir, name);
307
+ if (!SAFE_SUBMODULE_NAME.test(name) || name.split(/[\\/]/).some((seg) => seg === "" || seg === "." || seg === "..") || !moduleDir.startsWith(modulesDir + sep)) {
308
+ log(`submodule ${subPath}: refusing name ${JSON.stringify(name)}; not repaired`);
309
+ continue;
310
+ }
311
+ const wtSub = join(this.cwd, subPath);
312
+ const wtGit = join(wtSub, ".git");
313
+ if (this.isOwnSubmodule(wtSub, wtGit)) continue;
314
+ const mainSub = join(mainRoot, subPath);
315
+ if (!existsSync(join(mainSub, ".git"))) continue;
316
+ const src = new _Git(mainSub).tryRun(["rev-parse", "--path-format=absolute", "--git-dir"]);
317
+ if (src.code !== 0 || !src.out.trim()) continue;
318
+ if (existsSync(wtGit) && statSync(wtGit).isFile()) rmSync(wtGit);
319
+ rmSync(moduleDir, { recursive: true, force: true });
320
+ const r = this.tryRun([
321
+ "-c",
322
+ "protocol.file.allow=always",
323
+ "-c",
324
+ `submodule.${name}.url=${src.out.trim()}`,
325
+ "submodule",
326
+ "update",
327
+ "--init",
328
+ "--",
329
+ subPath
330
+ ]);
331
+ if (r.code !== 0) {
332
+ log(
333
+ `submodule ${subPath}: not repaired (${(r.err || r.out).trim()}); the project's refresh steps may still set it up`
334
+ );
335
+ continue;
336
+ }
337
+ this.tryRun(["submodule", "sync", "-q", "--", subPath]);
338
+ }
339
+ }
340
+ /** A submodule is the worktree's own when git, run inside it, says its top level IS that directory. */
341
+ isOwnSubmodule(wtSub, wtGit) {
342
+ if (!existsSync(wtGit)) return false;
343
+ const top = new _Git(wtSub).tryRun(["rev-parse", "--show-toplevel"]);
344
+ if (top.code !== 0) return false;
345
+ return realpathOr(top.out.trim()) === realpathOr(wtSub);
346
+ }
209
347
  };
210
348
 
211
- // ../../platform/core/workkit-lib/src/config.ts
349
+ // src/core/config.ts
212
350
  function renderTemplate(s, vars) {
213
351
  return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => {
214
352
  if (!(name in vars)) {
@@ -220,6 +358,10 @@ function renderTemplate(s, vars) {
220
358
  return vars[name];
221
359
  });
222
360
  }
361
+ function storybookHostFor(m, worktree) {
362
+ if (!worktree) return "";
363
+ return m.storybook?.hostRoot ? join2(worktree, m.storybook.hostRoot) : worktree;
364
+ }
223
365
  function slotVars(m, o) {
224
366
  const vars = {
225
367
  SLOT: o.slot === void 0 ? "" : String(o.slot),
@@ -228,28 +370,30 @@ function slotVars(m, o) {
228
370
  BRANCH: o.branch ?? "",
229
371
  WORKTREE: o.worktree ?? "",
230
372
  LOG_DIR: o.logDir ?? "",
231
- PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort)
373
+ STORYBROKR_HOST: storybookHostFor(m, o.worktree)
232
374
  };
233
375
  for (const service of Object.keys(m.ports.services)) {
234
376
  const p = o.ports[service];
235
377
  vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
236
378
  }
379
+ if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
380
+ else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
237
381
  return vars;
238
382
  }
239
383
  function ensureExcluded(worktree, patterns) {
240
384
  const r = new Git(worktree).tryRun(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
241
385
  if (r.code !== 0) return;
242
386
  const common = realpathSync2(r.out.trim());
243
- const file = join(common, "info", "exclude");
244
- mkdirSync2(join(common, "info"), { recursive: true });
387
+ const file = join2(common, "info", "exclude");
388
+ mkdirSync2(join2(common, "info"), { recursive: true });
245
389
  const present = new Set(
246
- existsSync(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
390
+ existsSync2(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
247
391
  );
248
392
  const missing = patterns.filter((p) => !present.has(p));
249
393
  if (missing.length === 0) return;
250
394
  appendFileSync(
251
395
  file,
252
- `${present.size ? "\n" : ""}# written by @helmsmith/workkit: per-worktree config and logs
396
+ `${present.size ? "\n" : ""}# written by @helmsmith/workbrokr: per-worktree config and logs
253
397
  ${missing.join("\n")}
254
398
  `
255
399
  );
@@ -261,26 +405,26 @@ function emitWorktreeConfig(a) {
261
405
  `${a.m.logs.dir.replace(/\/+$/, "")}/`,
262
406
  `${a.m.worktreeRoot.replace(/\/+$/, "")}/`
263
407
  ]);
264
- const logDir = join(a.worktree, a.m.logs.dir);
408
+ const logDir = join2(a.worktree, a.m.logs.dir);
265
409
  mkdirSync2(logDir, { recursive: true });
266
410
  const vars = slotVars(a.m, { ...a, logDir });
267
- const envFile = join(a.worktree, a.m.env.file);
411
+ const envFile = join2(a.worktree, a.m.env.file);
268
412
  const lines = Object.entries(a.m.env.template).map(([k, v]) => `${k}=${renderTemplate(v, vars)}`);
269
413
  writeFileSync(envFile, `${lines.join("\n")}
270
414
  `);
271
415
  let slotFile;
272
416
  if (a.slot !== void 0) {
273
- slotFile = join(a.worktree, ".slot");
417
+ slotFile = join2(a.worktree, ".slot");
274
418
  writeFileSync(slotFile, `${a.slot}
275
419
  `);
276
420
  }
277
421
  return slotFile ? { envFile, slotFile, logDir, vars } : { envFile, logDir, vars };
278
422
  }
279
423
 
280
- // ../../platform/core/workkit-lib/src/guards.ts
281
- import { existsSync as existsSync2, realpathSync as realpathSync3 } from "fs";
424
+ // src/core/guards.ts
425
+ import { existsSync as existsSync3, realpathSync as realpathSync3 } from "fs";
282
426
 
283
- // ../../platform/core/workkit-lib/src/scopes.ts
427
+ // src/core/scopes.ts
284
428
  import picomatch from "picomatch";
285
429
  function areaVars(area) {
286
430
  const parts = area.split("/");
@@ -298,7 +442,42 @@ function fill(template, vars) {
298
442
  });
299
443
  return missing ? null : out;
300
444
  }
301
- function expandScopes(m, area, mode) {
445
+ function extraScopesForMode(spec, mode) {
446
+ if (!spec.extraScopes) return [];
447
+ if (Array.isArray(spec.extraScopes)) {
448
+ return mode === "frontend" || mode === "fullstack" ? spec.extraScopes : [];
449
+ }
450
+ const out = [];
451
+ if (mode === "frontend" || mode === "fullstack") out.push(...spec.extraScopes.frontend ?? []);
452
+ if (mode === "backend" || mode === "fullstack") out.push(...spec.extraScopes.backend ?? []);
453
+ return out;
454
+ }
455
+ var normalizeGlob = (g) => g.replace(/^\.\//, "").replace(/\/+$/, "");
456
+ var hasWildcard = (s) => /[*?[{]/.test(s);
457
+ function isSubGlob(child, parent) {
458
+ const c = normalizeGlob(child);
459
+ const p = normalizeGlob(parent);
460
+ if (c === p) return true;
461
+ const cSegs = c.split("/");
462
+ const pSegs = p.split("/");
463
+ for (let i = 0; i < pSegs.length; i++) {
464
+ const ps = pSegs[i];
465
+ if (ps === "**") return true;
466
+ const cs = cSegs[i];
467
+ if (cs === void 0 || cs === "**") return false;
468
+ if (!hasWildcard(ps) && !hasWildcard(cs)) {
469
+ if (ps !== cs) return false;
470
+ continue;
471
+ }
472
+ if (!hasWildcard(cs) && hasWildcard(ps)) {
473
+ if (!picomatch.isMatch(cs, ps, { dot: true })) return false;
474
+ continue;
475
+ }
476
+ if (cs !== ps) return false;
477
+ }
478
+ return cSegs.length === pSegs.length;
479
+ }
480
+ function expandScopes(m, area, mode, narrowedScopes, hasTask = false) {
302
481
  const spec = m.areas[area];
303
482
  if (!spec)
304
483
  throw new WorkkitError(
@@ -307,15 +486,17 @@ function expandScopes(m, area, mode) {
307
486
  );
308
487
  const vars = { ...areaVars(area), module: spec.module };
309
488
  const out = [];
310
- for (const t of m.scopes.frontend) {
311
- const g = fill(t, vars);
312
- if (g) out.push(g);
489
+ if (mode === "frontend" || mode === "fullstack") {
490
+ for (const t of m.scopes.frontend) {
491
+ const g = fill(t, vars);
492
+ if (g) out.push(g);
493
+ }
313
494
  }
314
- if (mode === "fullstack") {
495
+ if (mode === "backend" || mode === "fullstack") {
315
496
  if (!spec.module) {
316
497
  throw new WorkkitError(
317
498
  "MANIFEST_INVALID",
318
- `area "${area}" has no module, so a fullstack effort cannot hold a backend scope; add areas["${area}"].module or use --mode frontend`
499
+ `area "${area}" has no module, so a ${mode} effort cannot hold a backend scope; add areas["${area}"].module or use --mode frontend`
319
500
  );
320
501
  }
321
502
  for (const t of m.scopes.backend) {
@@ -323,20 +504,65 @@ function expandScopes(m, area, mode) {
323
504
  if (g) out.push(g);
324
505
  }
325
506
  }
326
- out.push(...spec.extraScopes ?? []);
327
- return out;
328
- }
329
- function literalPrefix(glob) {
330
- const i = glob.search(/[*?[{]/);
331
- return i < 0 ? glob : glob.slice(0, i);
507
+ out.push(...extraScopesForMode(spec, mode));
508
+ if (!narrowedScopes || narrowedScopes.length === 0) return out;
509
+ if (!hasTask) {
510
+ throw new WorkkitError(
511
+ "SCOPE_REQUIRES_TASK",
512
+ "--scope narrowing requires --task <slug> so primary area efforts always protect the full area"
513
+ );
514
+ }
515
+ const allowed = [...out, ...spec.sharedScopes ?? []];
516
+ for (const g of narrowedScopes) {
517
+ if (!allowed.some((base) => isSubGlob(g, base))) {
518
+ throw new WorkkitError(
519
+ "SCOPE_OUTSIDE_AREA",
520
+ `narrowed scope "${g}" is outside ${area}'s ${mode} scopes (${allowed.join(", ")})`,
521
+ { area, mode, scope: g, allowed }
522
+ );
523
+ }
524
+ }
525
+ return narrowedScopes;
526
+ }
527
+ function allowedScopesForEffort(m, effort) {
528
+ const shared = m.areas[effort.area]?.sharedScopes ?? [];
529
+ const allCommons = Object.values(m.commons).flat();
530
+ return [...effort.scopes, ...shared, ...allCommons];
531
+ }
532
+ function segmentsOverlap(sx, sy) {
533
+ if (sx === sy) return true;
534
+ const wx = hasWildcard(sx);
535
+ const wy = hasWildcard(sy);
536
+ if (!wx && !wy) return false;
537
+ if (!wx && wy) return picomatch.isMatch(sx, sy, { dot: true });
538
+ if (wx && !wy) return picomatch.isMatch(sy, sx, { dot: true });
539
+ const starToken = /^\*([^*?[{]+)\*$/;
540
+ const mx = starToken.exec(sx);
541
+ const my = starToken.exec(sy);
542
+ if (mx && my) {
543
+ const tx = mx[1];
544
+ const ty = my[1];
545
+ return tx.includes(ty) || ty.includes(tx);
546
+ }
547
+ return true;
548
+ }
549
+ function twoGlobsOverlap(x, y) {
550
+ const xs = normalizeGlob(x).split("/");
551
+ const ys = normalizeGlob(y).split("/");
552
+ const len = Math.min(xs.length, ys.length);
553
+ for (let i = 0; i < len; i++) {
554
+ if (xs[i] === "**" || ys[i] === "**") return true;
555
+ if (!segmentsOverlap(xs[i], ys[i])) return false;
556
+ }
557
+ if (xs.length === ys.length) return true;
558
+ const longer = xs.length > ys.length ? xs : ys;
559
+ return longer[len] === "**";
332
560
  }
333
561
  function globsOverlap(a, b) {
334
562
  const pairs = [];
335
563
  for (const x of a) {
336
- const px = literalPrefix(x);
337
564
  for (const y of b) {
338
- const py = literalPrefix(y);
339
- if (px.startsWith(py) || py.startsWith(px)) pairs.push(`${x} ~ ${y}`);
565
+ if (twoGlobsOverlap(x, y)) pairs.push(`${x} ~ ${y}`);
340
566
  }
341
567
  }
342
568
  return pairs;
@@ -350,7 +576,7 @@ function commonsTouched(m, paths) {
350
576
  return Object.entries(m.commons).filter(([, globs]) => pathsMatching(paths, globs).length > 0).map(([name]) => name);
351
577
  }
352
578
 
353
- // ../../platform/core/workkit-lib/src/guards.ts
579
+ // src/core/guards.ts
354
580
  function assertNotProtected(m, branch) {
355
581
  if (m.protectedBranches.includes(branch)) {
356
582
  throw new WorkkitError(
@@ -367,28 +593,64 @@ var same = (a, b) => {
367
593
  return a === b;
368
594
  }
369
595
  };
596
+ var effortLabel = (e) => e.task ? `${e.area}--${e.task}` : e.area;
370
597
  function checkCommit(c) {
371
598
  const refusals = [];
599
+ const warnings = [];
372
600
  if (c.m.protectedBranches.includes(c.branch)) {
373
601
  refusals.push(
374
602
  `branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
375
603
  );
376
604
  }
377
- const efforts = new Map(c.registry.efforts({ project: c.project }).map((e) => [e.id, e]));
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));
608
+ const allEfforts = c.registry.efforts({ project: c.project });
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
+ );
378
614
  for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
379
- if (lease.owner.session === c.ownerSession) continue;
380
615
  const holder = efforts.get(lease.effort);
381
- if (holder && c.checkoutRoot && existsSync2(holder.worktree) && same(holder.worktree, c.checkoutRoot))
616
+ const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
617
+ if (holder && holderHasWorktree && c.checkoutRoot) {
618
+ if (same(holder.worktree, c.checkoutRoot)) continue;
619
+ } else if (lease.owner.session === c.ownerSession) {
382
620
  continue;
621
+ }
622
+ if (holder && mine && holder.area === mine.area) continue;
383
623
  const globs = c.m.commons[String(lease.value)] ?? [];
384
- const hit = pathsMatching(c.stagedPaths, globs);
624
+ const hit = pathsMatching(ownPaths, globs);
385
625
  if (hit.length > 0) {
386
626
  refusals.push(
387
627
  `commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
388
628
  );
389
629
  }
390
630
  }
391
- return { refusals };
631
+ for (const other of openEfforts) {
632
+ if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
633
+ continue;
634
+ }
635
+ if (!c.checkoutRoot && other.branch === c.branch) continue;
636
+ const hit = pathsMatching(ownPaths, other.scopes);
637
+ if (hit.length > 0) {
638
+ refusals.push(
639
+ `path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
640
+ );
641
+ }
642
+ }
643
+ if (mine && ownPaths.length > 0) {
644
+ const allowed = allowedScopesForEffort(c.m, mine);
645
+ const inScope = new Set(pathsMatching(ownPaths, allowed));
646
+ const outside = ownPaths.filter((p) => !inScope.has(p));
647
+ if (outside.length > 0) {
648
+ warnings.push(
649
+ `${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
650
+ );
651
+ }
652
+ }
653
+ return { refusals, warnings };
392
654
  }
393
655
  function checkPush(c) {
394
656
  const refusals = [];
@@ -401,15 +663,15 @@ function checkPush(c) {
401
663
  return { refusals, warnings };
402
664
  }
403
665
  if (c.isDelete) return { refusals, warnings };
404
- const effort = c.registry.efforts({ project: c.project, state: "active" }).find((e) => e.branch === c.branch);
666
+ const effort = c.registry.efforts({ project: c.project }).find((e) => (e.state === "active" || e.state === "parked") && e.branch === c.branch);
405
667
  const commons = commonsTouched(c.m, c.changedPaths);
406
668
  if (effort) {
407
- const allCommonsGlobs = Object.values(c.m.commons).flat();
408
- const inScope = new Set(pathsMatching(c.changedPaths, [...effort.scopes, ...allCommonsGlobs]));
669
+ const allowed = allowedScopesForEffort(c.m, effort);
670
+ const inScope = new Set(pathsMatching(c.changedPaths, allowed));
409
671
  const outside = c.changedPaths.filter((p) => !inScope.has(p));
410
672
  if (outside.length > 0) {
411
673
  warnings.push(
412
- `${outside.length} changed path(s) are outside the effort's scope (${effort.area}): ${outside.join(", ")} \u2014 add a scope template or expect a merge collision`
674
+ `${outside.length} changed path(s) are outside the effort's scope (${effortLabel(effort)}): ${outside.join(", ")} \u2014 add a scope template or expect a merge collision`
413
675
  );
414
676
  }
415
677
  }
@@ -418,13 +680,13 @@ function checkPush(c) {
418
680
  return { refusals, warnings };
419
681
  }
420
682
 
421
- // ../../platform/core/workkit-lib/src/hooks.ts
683
+ // src/core/hooks.ts
422
684
  import { chmodSync, lstatSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
423
- import { join as join3 } from "path";
685
+ import { join as join4 } from "path";
424
686
 
425
- // ../../platform/core/workkit-lib/src/manifest.ts
426
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
427
- import { join as join2 } from "path";
687
+ // src/core/manifest.ts
688
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
689
+ import { join as join3 } from "path";
428
690
  import { pathToFileURL } from "url";
429
691
  import { z } from "zod";
430
692
  var MANIFEST_FILES = [
@@ -434,14 +696,23 @@ var MANIFEST_FILES = [
434
696
  ];
435
697
  var BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
436
698
  var BRANCH_NAME_HINT = 'must be a plain ref name: letters, digits, ".", "_", "/", "-"';
699
+ var TASK_SLUG = /^(?!.*--)[a-z0-9][a-z0-9_-]{0,47}$/;
437
700
  var Service = z.object({
438
701
  start: z.string().min(1),
439
702
  stop: z.string().min(1),
440
703
  health: z.string().min(1),
441
704
  healthyCodes: z.array(z.number().int()).optional(),
442
705
  reset: z.string().optional(),
443
- healthTimeoutMs: z.number().int().positive().optional()
706
+ healthTimeoutMs: z.number().int().positive().optional(),
707
+ modes: z.array(z.enum(["backend", "fullstack"])).optional()
444
708
  });
709
+ var ExtraScopes = z.union([
710
+ z.array(z.string()),
711
+ z.object({
712
+ frontend: z.array(z.string()).optional(),
713
+ backend: z.array(z.string()).optional()
714
+ })
715
+ ]);
445
716
  var Schema = z.object({
446
717
  project: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
447
718
  baseBranch: z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT),
@@ -459,21 +730,24 @@ var Schema = z.object({
459
730
  storybook: z.object({ hostRoot: z.string() }).optional(),
460
731
  logs: z.object({ dir: z.string() }).default({ dir: ".logs" }),
461
732
  env: z.object({ file: z.string(), template: z.record(z.string(), z.string()) }).optional(),
733
+ seed: z.array(z.string().min(1)).optional(),
462
734
  refresh: z.array(
463
735
  z.object({
464
736
  when: z.string().optional(),
465
737
  always: z.boolean().optional(),
466
738
  run: z.string().optional(),
467
- mark: z.string().optional()
739
+ mark: z.string().optional(),
740
+ restoreWhenIfClean: z.boolean().optional()
468
741
  })
469
742
  ).default([]),
470
743
  scopes: z.object({ frontend: z.array(z.string()), backend: z.array(z.string()) }),
471
744
  areas: z.record(
472
- z.string().regex(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/),
745
+ z.string().regex(/^(?!.*--)[a-z0-9-]+(\/[a-z0-9-]+)*$/),
473
746
  z.object({
474
747
  module: z.string().optional(),
475
748
  commons: z.array(z.string()).optional(),
476
- extraScopes: z.array(z.string()).optional()
749
+ extraScopes: ExtraScopes.optional(),
750
+ sharedScopes: z.array(z.string()).optional()
477
751
  })
478
752
  ),
479
753
  commons: z.record(z.string(), z.array(z.string())).default({}),
@@ -496,13 +770,21 @@ function templateVariables(m) {
496
770
  "AREA",
497
771
  "BRANCH",
498
772
  "WORKTREE",
499
- "PORT_STORYBOOK"
773
+ "PORT_STORYBOOK",
774
+ "STORYBROKR_HOST"
500
775
  ]);
501
776
  for (const s of Object.keys(m.ports.services)) vars.add(`PORT_${s.toUpperCase()}`);
502
777
  return vars;
503
778
  }
504
779
  function defaultEnv(services) {
505
- const template = { SLOT: "${SLOT}", LOG_DIR: "${LOG_DIR}" };
780
+ const template = {
781
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
782
+ SLOT: "${SLOT}",
783
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
784
+ LOG_DIR: "${LOG_DIR}",
785
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
786
+ STORYBROKR_HOST: "${STORYBROKR_HOST}"
787
+ };
506
788
  for (const s of services) template[`PORT_${s.toUpperCase()}`] = `\${PORT_${s.toUpperCase()}}`;
507
789
  return { file: ".env.slot", template };
508
790
  }
@@ -555,8 +837,8 @@ function validateManifest(raw) {
555
837
  }
556
838
  async function loadManifest(repoRoot) {
557
839
  for (const name of MANIFEST_FILES) {
558
- const file = join2(repoRoot, name);
559
- if (!existsSync3(file)) continue;
840
+ const file = join3(repoRoot, name);
841
+ if (!existsSync4(file)) continue;
560
842
  if (name.endsWith(".json")) return validateManifest(JSON.parse(readFileSync2(file, "utf8")));
561
843
  const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
562
844
  return validateManifest(mod.default);
@@ -567,7 +849,7 @@ async function loadManifest(repoRoot) {
567
849
  );
568
850
  }
569
851
 
570
- // ../../platform/core/workkit-lib/src/hooks.ts
852
+ // src/core/hooks.ts
571
853
  var HOOKS_DIR = ".helmsmith/hooks";
572
854
  function preCommitScript(protectedBranches) {
573
855
  return `#!/bin/sh
@@ -580,11 +862,21 @@ for p in $PROTECTED; do
580
862
  exit 2
581
863
  fi
582
864
  done
583
- if ! command -v workbrokr >/dev/null 2>&1; then
865
+ if command -v workbrokr >/dev/null 2>&1; then
866
+ workbrokr hook pre-commit "$@" || exit $?
867
+ else
584
868
  echo "work: workbrokr is not on PATH; commons and scope checks skipped (npm i -g @helmsmith/workbrokr)" >&2
585
- exit 0
586
869
  fi
587
- exec workbrokr hook pre-commit "$@"
870
+ COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
871
+ LEGACY="$COMMON_DIR/hooks/pre-commit"
872
+ if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
873
+ SELF_DIR=$(cd "$(dirname "$0")" && pwd)
874
+ LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
875
+ if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
876
+ exec "$LEGACY" "$@"
877
+ fi
878
+ fi
879
+ exit 0
588
880
  `;
589
881
  }
590
882
  function prePushScript(protectedBranches) {
@@ -602,11 +894,21 @@ printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
602
894
  fi
603
895
  done
604
896
  done || exit 2
605
- if ! command -v workbrokr >/dev/null 2>&1; then
897
+ if command -v workbrokr >/dev/null 2>&1; then
898
+ printf '%s\\n' "$input" | workbrokr hook pre-push "$@" || exit $?
899
+ else
606
900
  echo "work: workbrokr is not on PATH; scope and commons warnings skipped" >&2
607
- exit 0
608
901
  fi
609
- printf '%s\\n' "$input" | exec workbrokr hook pre-push "$@"
902
+ COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
903
+ LEGACY="$COMMON_DIR/hooks/pre-push"
904
+ if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
905
+ SELF_DIR=$(cd "$(dirname "$0")" && pwd)
906
+ LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
907
+ if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
908
+ printf '%s\\n' "$input" | exec "$LEGACY" "$@"
909
+ fi
910
+ fi
911
+ exit 0
610
912
  `;
611
913
  }
612
914
  function installHooks(repoRoot, opts) {
@@ -614,7 +916,7 @@ function installHooks(repoRoot, opts) {
614
916
  if (!BRANCH_NAME.test(b))
615
917
  throw new WorkkitError("MANIFEST_INVALID", `protected branch "${b}" is not a plain ref name`);
616
918
  }
617
- const hooksDir = join3(repoRoot, HOOKS_DIR);
919
+ const hooksDir = join4(repoRoot, HOOKS_DIR);
618
920
  mkdirSync3(hooksDir, { recursive: true });
619
921
  const files = [];
620
922
  const scripts = [
@@ -622,7 +924,7 @@ function installHooks(repoRoot, opts) {
622
924
  ["pre-push", prePushScript(opts.protectedBranches)]
623
925
  ];
624
926
  for (const [name, body] of scripts) {
625
- const file = join3(hooksDir, name);
927
+ const file = join4(hooksDir, name);
626
928
  let existing;
627
929
  try {
628
930
  existing = lstatSync(file);
@@ -642,16 +944,16 @@ function installHooks(repoRoot, opts) {
642
944
  return { hooksDir, files };
643
945
  }
644
946
 
645
- // ../../platform/core/workkit-lib/src/paths.ts
947
+ // src/core/paths.ts
646
948
  import { homedir } from "os";
647
- import { join as join4 } from "path";
949
+ import { join as join5 } from "path";
648
950
  function homeDir(env = process.env) {
649
- return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join4(homedir(), ".cache", "helmsmith");
951
+ return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join5(homedir(), ".cache", "helmsmith");
650
952
  }
651
- var registryFile = (home) => join4(home, "registry.json");
652
- var lockDir = (home) => join4(home, "registry.lock");
953
+ var registryFile = (home) => join5(home, "registry.json");
954
+ var lockDir = (home) => join5(home, "registry.lock");
653
955
 
654
- // ../../platform/core/workkit-lib/src/ports.ts
956
+ // src/core/ports.ts
655
957
  function portsForSlot(m, slot) {
656
958
  return Object.fromEntries(
657
959
  Object.entries(m.ports.services).map(([service, { base }]) => [service, base + slot])
@@ -668,8 +970,19 @@ function rangesFor(m) {
668
970
  function rangesOverlap(a, b) {
669
971
  return a.base < b.base + b.count && b.base < a.base + a.count;
670
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
+ }
671
980
  function claimRanges(registry, m, repoRoot) {
672
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) {
673
986
  const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
674
987
  for (const [service, range] of Object.entries(mine)) {
675
988
  for (const [name, entry] of others) {
@@ -684,28 +997,100 @@ function claimRanges(registry, m, repoRoot) {
684
997
  }
685
998
  }
686
999
  }
687
- registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
688
1000
  }
689
1001
 
690
- // ../../platform/core/workkit-lib/src/refresh.ts
1002
+ // src/core/refresh.ts
691
1003
  import { spawnSync as spawnSync2 } from "child_process";
1004
+ import { createHash } from "crypto";
1005
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1006
+ import { dirname as dirname2, join as join6 } from "path";
692
1007
  import picomatch2 from "picomatch";
693
1008
  function matchesWhen(glob) {
694
1009
  const isMatch = picomatch2(glob, { dot: true });
695
1010
  return (p) => isMatch(p);
696
1011
  }
697
- function selectRefresh(entries, changedPaths) {
1012
+ function readStamps(stampFile) {
1013
+ if (!stampFile || !existsSync5(stampFile)) return {};
1014
+ try {
1015
+ return JSON.parse(readFileSync3(stampFile, "utf8"));
1016
+ } catch {
1017
+ return {};
1018
+ }
1019
+ }
1020
+ function writeStamps(stampFile, stamps) {
1021
+ mkdirSync4(dirname2(stampFile), { recursive: true });
1022
+ writeFileSync3(stampFile, `${JSON.stringify(stamps, null, 2)}
1023
+ `);
1024
+ }
1025
+ function matchedFilesInWorktree(worktree, whenGlob) {
1026
+ if (!/[*?[{]/.test(whenGlob)) {
1027
+ return existsSync5(join6(worktree, whenGlob)) ? [whenGlob] : [];
1028
+ }
1029
+ const r = spawnSync2("git", ["ls-files"], { cwd: worktree, encoding: "utf8" });
1030
+ if (r.status !== 0) return [];
1031
+ const isMatch = matchesWhen(whenGlob);
1032
+ return (r.stdout ?? "").split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && isMatch(l) && existsSync5(join6(worktree, l)));
1033
+ }
1034
+ function hashFiles(worktree, relPaths) {
1035
+ if (relPaths.length === 0) return null;
1036
+ const h = createHash("sha256");
1037
+ for (const rel of [...relPaths].sort()) {
1038
+ const abs = join6(worktree, rel);
1039
+ if (!existsSync5(abs)) continue;
1040
+ h.update(`${rel}\0`);
1041
+ h.update(readFileSync3(abs));
1042
+ h.update("\0");
1043
+ }
1044
+ return h.digest("hex");
1045
+ }
1046
+ function isFileCleanInGit(worktree, relPath) {
1047
+ const r = spawnSync2("git", ["status", "--porcelain", "--", relPath], {
1048
+ cwd: worktree,
1049
+ encoding: "utf8"
1050
+ });
1051
+ return r.status === 0 && (r.stdout ?? "").trim().length === 0;
1052
+ }
1053
+ function selectRefresh(entries, changedPaths, opts) {
698
1054
  const runs = [];
699
1055
  const marks = [];
1056
+ const stored = readStamps(opts?.stampFile);
1057
+ const pendingStamps = { ...stored };
1058
+ const cleanWhenFiles = /* @__PURE__ */ new Set();
700
1059
  for (const e of entries) {
701
- const hit = e.always === true || e.when !== void 0 && changedPaths.some(matchesWhen(e.when));
1060
+ let hit = e.always === true;
1061
+ let matchedFiles = [];
1062
+ if (e.when !== void 0) {
1063
+ const byChanged = changedPaths.some(matchesWhen(e.when));
1064
+ if (opts?.worktree) {
1065
+ matchedFiles = matchedFilesInWorktree(opts.worktree, e.when);
1066
+ const digest = hashFiles(opts.worktree, matchedFiles);
1067
+ if (digest !== null) {
1068
+ if (stored[e.when] !== digest) hit = true;
1069
+ pendingStamps[e.when] = digest;
1070
+ }
1071
+ }
1072
+ if (byChanged) hit = true;
1073
+ }
702
1074
  if (!hit) continue;
703
- if (e.run) runs.push(e.run);
1075
+ if (e.run) {
1076
+ runs.push(e.run);
1077
+ if (opts?.worktree && e.when && e.restoreWhenIfClean !== false) {
1078
+ for (const f of matchedFiles) {
1079
+ if (isFileCleanInGit(opts.worktree, f)) cleanWhenFiles.add(f);
1080
+ }
1081
+ }
1082
+ }
704
1083
  if (e.mark) marks.push(e.mark);
705
1084
  }
706
- return { runs, marks };
1085
+ if (!opts?.worktree) return { runs, marks };
1086
+ return {
1087
+ runs,
1088
+ marks,
1089
+ pendingStamps,
1090
+ cleanWhenFiles: [...cleanWhenFiles]
1091
+ };
707
1092
  }
708
- function runRefresh(runs, cwd, log, env = process.env) {
1093
+ function runRefresh(runs, cwd, log, env = process.env, opts) {
709
1094
  for (const cmd of runs) {
710
1095
  log(`$ ${cmd}`);
711
1096
  const r = spawnSync2("sh", ["-c", cmd], { cwd, env, encoding: "utf8" });
@@ -718,23 +1103,33 @@ function runRefresh(runs, cwd, log, env = process.env) {
718
1103
  });
719
1104
  }
720
1105
  }
1106
+ if (opts?.restoreFiles && opts.restoreFiles.length > 0) {
1107
+ for (const rel of opts.restoreFiles) {
1108
+ if (!isFileCleanInGit(cwd, rel)) {
1109
+ spawnSync2("git", ["checkout", "--", rel], { cwd, encoding: "utf8" });
1110
+ }
1111
+ }
1112
+ }
1113
+ if (opts?.stampFile && opts.pendingStamps) {
1114
+ writeStamps(opts.stampFile, opts.pendingStamps);
1115
+ }
721
1116
  }
722
1117
 
723
- // ../../platform/core/workkit-lib/src/registry.ts
1118
+ // src/core/registry.ts
724
1119
  import { randomBytes } from "crypto";
725
1120
  import {
726
- existsSync as existsSync4,
727
- mkdirSync as mkdirSync4,
728
- readFileSync as readFileSync3,
1121
+ existsSync as existsSync6,
1122
+ mkdirSync as mkdirSync5,
1123
+ readFileSync as readFileSync4,
729
1124
  renameSync,
730
- rmSync,
731
- statSync,
732
- writeFileSync as writeFileSync3
1125
+ rmSync as rmSync2,
1126
+ statSync as statSync2,
1127
+ writeFileSync as writeFileSync4
733
1128
  } from "fs";
734
1129
  var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
735
1130
  var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
736
- var LOCK_STALE_MS = 3e4;
737
- var LOCK_WAIT_MS = 5e3;
1131
+ var LOCK_STALE_MS = 1e4;
1132
+ var LOCK_WAIT_MS = 15e3;
738
1133
  function sleep(ms) {
739
1134
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
740
1135
  }
@@ -746,16 +1141,16 @@ var Registry = class _Registry {
746
1141
  home;
747
1142
  now;
748
1143
  static open(home, now2 = () => /* @__PURE__ */ new Date()) {
749
- mkdirSync4(home, { recursive: true });
1144
+ mkdirSync5(home, { recursive: true });
750
1145
  return new _Registry(home, now2);
751
1146
  }
752
1147
  /** The registry as it is on disk. A file that does not parse is refused, never replaced. */
753
1148
  read() {
754
1149
  const file = registryFile(this.home);
755
- if (!existsSync4(file)) return structuredClone(EMPTY);
1150
+ if (!existsSync6(file)) return structuredClone(EMPTY);
756
1151
  let parsed;
757
1152
  try {
758
- parsed = JSON.parse(readFileSync3(file, "utf8"));
1153
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
759
1154
  } catch (err) {
760
1155
  throw new WorkkitError(
761
1156
  "INTERNAL",
@@ -785,12 +1180,14 @@ var Registry = class _Registry {
785
1180
  const started = Date.now();
786
1181
  for (; ; ) {
787
1182
  try {
788
- mkdirSync4(lock);
1183
+ mkdirSync5(lock);
789
1184
  break;
790
1185
  } catch {
791
1186
  try {
792
- if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
793
- rmSync(lock, { recursive: true, force: true });
1187
+ if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
1188
+ const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
1189
+ renameSync(lock, claimed);
1190
+ rmSync2(claimed, { recursive: true, force: true });
794
1191
  continue;
795
1192
  }
796
1193
  } catch {
@@ -810,12 +1207,12 @@ var Registry = class _Registry {
810
1207
  const out = fn(r);
811
1208
  const file = registryFile(this.home);
812
1209
  const tmp = `${file}.${process.pid}.tmp`;
813
- writeFileSync3(tmp, `${JSON.stringify(r, null, 2)}
1210
+ writeFileSync4(tmp, `${JSON.stringify(r, null, 2)}
814
1211
  `);
815
1212
  renameSync(tmp, file);
816
1213
  return out;
817
1214
  } finally {
818
- rmSync(lock, { recursive: true, force: true });
1215
+ rmSync2(lock, { recursive: true, force: true });
819
1216
  }
820
1217
  }
821
1218
  project(name) {
@@ -836,18 +1233,29 @@ var Registry = class _Registry {
836
1233
  (e) => Object.entries(filter).every(([k, v]) => e[k] === v)
837
1234
  );
838
1235
  }
839
- activeEffortFor(project, area) {
840
- return this.efforts({ project, area, state: "active" })[0];
1236
+ activeEffortFor(project, area, task) {
1237
+ return this.efforts({ project, area, state: "active" }).find(
1238
+ (e) => (e.task ?? void 0) === (task ?? void 0)
1239
+ );
841
1240
  }
842
- /** An effort that is being started counts as open: nobody else may start on the area meanwhile. */
843
- openEffortFor(project, area) {
1241
+ /** An effort that is being started counts as open: nobody else may start on the same (area, task) meanwhile. */
1242
+ openEffortFor(project, area, task) {
844
1243
  return this.efforts({ project, area }).find(
845
- (e) => e.state === "starting" || e.state === "active"
1244
+ (e) => (e.state === "starting" || e.state === "active") && (e.task ?? void 0) === (task ?? void 0)
846
1245
  );
847
1246
  }
848
1247
  addNotice(project, area, notice) {
849
1248
  this.update((r) => {
850
- r.notices = [...r.notices ?? [], { project, area, notice }];
1249
+ const existing = r.notices ?? [];
1250
+ const dup = existing.findIndex(
1251
+ (n) => n.project === project && n.area === area && n.notice.from === notice.from && n.notice.text === notice.text
1252
+ );
1253
+ if (dup >= 0) {
1254
+ existing[dup] = { project, area, notice };
1255
+ r.notices = existing;
1256
+ } else {
1257
+ r.notices = [...existing, { project, area, notice }];
1258
+ }
851
1259
  });
852
1260
  }
853
1261
  notices(project, area) {
@@ -858,6 +1266,21 @@ var Registry = class _Registry {
858
1266
  r.notices = (r.notices ?? []).filter((n) => !(n.project === project && n.area === area));
859
1267
  });
860
1268
  }
1269
+ clearNoticesFrom(project, branches) {
1270
+ const set = new Set(branches);
1271
+ this.update((r) => {
1272
+ r.notices = (r.notices ?? []).filter(
1273
+ (n) => !(n.project === project && set.has(n.notice.from))
1274
+ );
1275
+ });
1276
+ }
1277
+ pruneNoticesWhere(project, area, remove) {
1278
+ this.update((r) => {
1279
+ r.notices = (r.notices ?? []).filter(
1280
+ (n) => !(n.project === project && n.area === area && remove(n.notice))
1281
+ );
1282
+ });
1283
+ }
861
1284
  allocateSlot(project, m, owner, effort, exclude = /* @__PURE__ */ new Set()) {
862
1285
  return this.update((r) => {
863
1286
  const taken = new Set(
@@ -874,7 +1297,7 @@ var Registry = class _Registry {
874
1297
  if (slot < 0) {
875
1298
  throw new WorkkitError(
876
1299
  "NO_FREE_SLOT",
877
- `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`
878
1301
  );
879
1302
  }
880
1303
  const lease = this.lease(
@@ -958,10 +1381,10 @@ var Registry = class _Registry {
958
1381
  }
959
1382
  };
960
1383
 
961
- // ../../platform/core/workkit-lib/src/runner.ts
1384
+ // src/core/runner.ts
962
1385
  import { spawn, spawnSync as spawnSync3 } from "child_process";
963
1386
  import { closeSync, openSync } from "fs";
964
- import { join as join5 } from "path";
1387
+ import { join as join7 } from "path";
965
1388
  var DEFAULT_HEALTH_TIMEOUT_MS = 18e4;
966
1389
  var HEALTH_POLL_MS = 1e3;
967
1390
  var PROBE_CAP_MS = 1e4;
@@ -980,6 +1403,10 @@ function pidSignature(pid) {
980
1403
  const r = spawnSync3("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
981
1404
  return r.status === 0 ? (r.stdout ?? "").trim() : "";
982
1405
  }
1406
+ function serviceModes(name, spec) {
1407
+ if (spec.modes && spec.modes.length > 0) return spec.modes;
1408
+ return name === "frontend" ? ["fullstack"] : ["backend", "fullstack"];
1409
+ }
983
1410
  function probe(spec, cwd, env, timeoutMs = PROBE_CAP_MS) {
984
1411
  const r = spawnSync3("sh", ["-c", spec.health], {
985
1412
  cwd,
@@ -1013,7 +1440,7 @@ async function waitHealthy(o) {
1013
1440
  );
1014
1441
  }
1015
1442
  function spawnService(name, spec, a) {
1016
- const fd = openSync(join5(a.logDir, `${name}.log`), "a");
1443
+ const fd = openSync(join7(a.logDir, `${name}.log`), "a");
1017
1444
  try {
1018
1445
  const child = spawn("sh", ["-c", spec.start], {
1019
1446
  cwd: a.cwd,
@@ -1035,7 +1462,12 @@ function spawnService(name, spec, a) {
1035
1462
  async function startServices(a) {
1036
1463
  const pids = {};
1037
1464
  const signatures = {};
1038
- for (const [name, spec] of Object.entries(a.m.services)) {
1465
+ const targetMode = a.mode ?? "fullstack";
1466
+ const entries = Object.entries(a.m.services).filter(([name, spec]) => {
1467
+ if (a.onlyServices) return a.onlyServices.includes(name);
1468
+ return serviceModes(name, spec).includes(targetMode);
1469
+ });
1470
+ for (const [name, spec] of entries) {
1039
1471
  a.onLog?.(`starting ${name}: ${spec.start}`);
1040
1472
  pids[name] = spawnService(name, spec, a);
1041
1473
  signatures[name] = pidSignature(pids[name]);
@@ -1099,7 +1531,7 @@ async function stopServices(o) {
1099
1531
  while (alive().length > 0 && Date.now() < hardDeadline) await sleep2(100);
1100
1532
  }
1101
1533
 
1102
- // ../../platform/core/workkit-lib/src/session.ts
1534
+ // src/core/session.ts
1103
1535
  import { hostname, userInfo } from "os";
1104
1536
  function currentOwner(env = process.env) {
1105
1537
  const named = [env.WORK_SESSION, env.CLAUDE_SESSION_NAME, env.CLAUDE_CODE_SESSION_ID].find(
@@ -1108,11 +1540,23 @@ function currentOwner(env = process.env) {
1108
1540
  return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
1109
1541
  }
1110
1542
 
1111
- // src/lib/context.ts
1112
- import chalk from "chalk";
1543
+ // src/lib/pritty.ts
1544
+ import { spawnSync as spawnSync5 } from "child_process";
1113
1545
 
1114
1546
  // src/lib/storybrokr.ts
1115
1547
  import { spawnSync as spawnSync4 } from "child_process";
1548
+ var MIN_STORYBROKR_VERSION = "0.3.0";
1549
+ var versionChecked = false;
1550
+ var versionFound = "";
1551
+ function compareSemver(a, b) {
1552
+ const pa = a.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
1553
+ const pb = b.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
1554
+ for (let i = 0; i < 3; i++) {
1555
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
1556
+ if (diff !== 0) return diff;
1557
+ }
1558
+ return 0;
1559
+ }
1116
1560
  function run(args) {
1117
1561
  const r = spawnSync4("storybrokr", args, { encoding: "utf8" });
1118
1562
  if (r.error) {
@@ -1123,9 +1567,26 @@ function run(args) {
1123
1567
  }
1124
1568
  return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
1125
1569
  }
1570
+ function assertStorybrokrVersion() {
1571
+ if (versionChecked) return;
1572
+ const r = run(["--version"]);
1573
+ const found = r.out.trim();
1574
+ if (r.code !== 0 || compareSemver(found, MIN_STORYBROKR_VERSION) < 0) {
1575
+ throw new WorkkitError(
1576
+ "STORYBROKR_FAILED",
1577
+ `storybrokr >= ${MIN_STORYBROKR_VERSION} required (found ${found || "unknown"}); upgrade @helmsmith/storybrokr`
1578
+ );
1579
+ }
1580
+ versionChecked = true;
1581
+ versionFound = found;
1582
+ }
1126
1583
  var storybrokrCli = {
1127
- async up(story, hostRoot) {
1128
- const r = run(["up", story, "--host", hostRoot, "--json"]);
1584
+ async up(story, hostRoot, opts) {
1585
+ assertStorybrokrVersion();
1586
+ const args = ["up", story, "--host", hostRoot, "--json"];
1587
+ if (opts?.ttlMinutes !== void 0) args.push("--ttl", String(opts.ttlMinutes));
1588
+ if (opts?.includeChildren) args.push("--include-children");
1589
+ const r = run(args);
1129
1590
  if (r.code !== 0) {
1130
1591
  throw new WorkkitError(
1131
1592
  "STORYBROKR_FAILED",
@@ -1139,6 +1600,107 @@ var storybrokrCli = {
1139
1600
  const r = run(["down", id]);
1140
1601
  if (r.code !== 0)
1141
1602
  throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
1603
+ },
1604
+ async downHost(hostRoot) {
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;
1625
+ },
1626
+ async touch(id) {
1627
+ const r = spawnSync4("storybrokr", ["touch", id], { encoding: "utf8" });
1628
+ void r;
1629
+ }
1630
+ };
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;
1142
1704
  }
1143
1705
  };
1144
1706
 
@@ -1152,7 +1714,7 @@ async function buildCtx(opts = {}) {
1152
1714
  const manifest = await loadManifest(repoRoot);
1153
1715
  const home = homeDir();
1154
1716
  const registry = Registry.open(home);
1155
- if (!registry.project(manifest.project)) claimRanges(registry, manifest, repoRoot);
1717
+ ensureRanges(registry, manifest, repoRoot);
1156
1718
  return {
1157
1719
  home,
1158
1720
  repoRoot,
@@ -1161,6 +1723,7 @@ async function buildCtx(opts = {}) {
1161
1723
  owner: currentOwner(),
1162
1724
  runner: { start: startServices, stop: stopServices },
1163
1725
  storybrokr: storybrokrCli,
1726
+ pritty: prittyCli,
1164
1727
  log: opts.quiet ? () => {
1165
1728
  } : (line) => console.error(chalk.gray(line))
1166
1729
  };
@@ -1170,26 +1733,34 @@ async function buildCtx(opts = {}) {
1170
1733
  import {
1171
1734
  appendFileSync as appendFileSync2,
1172
1735
  cpSync,
1173
- existsSync as existsSync5,
1174
- mkdirSync as mkdirSync5,
1736
+ existsSync as existsSync7,
1737
+ mkdirSync as mkdirSync6,
1175
1738
  readdirSync,
1739
+ readFileSync as readFileSync5,
1176
1740
  realpathSync as realpathSync4,
1177
- rmSync as rmSync2,
1178
- statSync as statSync2
1741
+ rmSync as rmSync3,
1742
+ statSync as statSync3,
1743
+ writeFileSync as writeFileSync5
1179
1744
  } from "fs";
1180
1745
  import { connect, createServer } from "net";
1181
- import { dirname as dirname2, join as join6 } from "path";
1182
- var worktreePathFor = (ctx, area) => join6(ctx.repoRoot, ctx.manifest.worktreeRoot, area);
1183
- var branchFor = (type, area) => `${type}/${area}`;
1746
+ import { dirname as dirname3, join as join8 } from "path";
1747
+ var worktreePathFor = (ctx, area, task) => task ? join8(ctx.repoRoot, ctx.manifest.worktreeRoot, "_tasks", `${area}--${task}`) : join8(ctx.repoRoot, ctx.manifest.worktreeRoot, area);
1748
+ var branchFor = (type, area, task) => task ? `${type}/${area}--${task}` : `${type}/${area}`;
1749
+ var effortLabel2 = (area, task) => task ? `${area}--${task}` : area;
1184
1750
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
1185
1751
  var STARTING_GRACE_MS = 30 * 6e4;
1186
- var realpathOr = (p) => {
1752
+ var JOIN_WAIT_MS = 6e4;
1753
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1754
+ var realpathOr2 = (p) => {
1187
1755
  try {
1188
1756
  return realpathSync4(p);
1189
1757
  } catch {
1190
1758
  return p;
1191
1759
  }
1192
1760
  };
1761
+ function fetchBaseOnce(ctx) {
1762
+ new Git(ctx.repoRoot).tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
1763
+ }
1193
1764
  function hoursFor(m, kind) {
1194
1765
  return {
1195
1766
  slot: m.expiry.slotHours,
@@ -1197,40 +1768,76 @@ function hoursFor(m, kind) {
1197
1768
  commons: m.expiry.commonsHours
1198
1769
  }[kind];
1199
1770
  }
1771
+ function modeCovers(existing, requested) {
1772
+ if (existing === "fullstack") return true;
1773
+ return existing === requested;
1774
+ }
1775
+ var STORYBROKR_TOUCH_THROTTLE_MS = 6e4;
1200
1776
  function touchEffort(ctx, effort) {
1201
- for (const l of ctx.registry.leases({ effort: effort.id }))
1777
+ const prevMs = Date.parse(effort.lastActivity);
1778
+ const shouldTouchStorybrokr = Number.isNaN(prevMs) || Date.now() - prevMs >= STORYBROKR_TOUCH_THROTTLE_MS;
1779
+ for (const l of ctx.registry.leases({ effort: effort.id })) {
1202
1780
  ctx.registry.touch(l.id, hoursFor(ctx.manifest, l.kind));
1781
+ if (l.kind === "storybook" && shouldTouchStorybrokr) {
1782
+ void Promise.resolve(ctx.storybrokr.touch?.(String(l.value))).catch(() => {
1783
+ });
1784
+ }
1785
+ }
1203
1786
  ctx.registry.updateEffort(effort.id, { lastActivity: now() });
1204
1787
  }
1205
- function portBusy(port) {
1206
- return new Promise((resolve3) => {
1788
+ function bindFails(port, host) {
1789
+ return new Promise((resolve4) => {
1207
1790
  const srv = createServer();
1208
- srv.once("error", () => resolve3(true));
1209
- srv.listen(port, "127.0.0.1", () => srv.close(() => resolve3(false)));
1791
+ srv.once("error", (err) => resolve4(err.code === "EADDRINUSE"));
1792
+ srv.listen(port, host, () => srv.close(() => resolve4(false)));
1210
1793
  });
1211
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
+ }
1212
1799
  async function busyPorts(ports) {
1213
1800
  const out = [];
1214
1801
  for (const [service, port] of Object.entries(ports))
1215
1802
  if (await portBusy(port)) out.push(`${service}=${port}`);
1216
1803
  return out;
1217
1804
  }
1218
- function portAnswers(port) {
1219
- return new Promise((resolve3) => {
1220
- const sock = connect({ port, host: "127.0.0.1" });
1805
+ function portAnswersOn(port, host) {
1806
+ return new Promise((resolve4) => {
1807
+ const sock = connect({ port, host });
1221
1808
  const done = (v) => {
1222
1809
  sock.destroy();
1223
- resolve3(v);
1810
+ resolve4(v);
1224
1811
  };
1225
1812
  sock.setTimeout(300, () => done(false));
1226
1813
  sock.once("connect", () => done(true));
1227
1814
  sock.once("error", () => done(false));
1228
1815
  });
1229
1816
  }
1817
+ async function portAnswers(port) {
1818
+ return await portAnswersOn(port, "127.0.0.1") || await portAnswersOn(port, "::1");
1819
+ }
1230
1820
  async function anyPortAnswers(ports) {
1231
1821
  for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
1232
1822
  return false;
1233
1823
  }
1824
+ async function claimFreeSlot(ctx, effortId, warnings) {
1825
+ const m = ctx.manifest;
1826
+ const exclude = /* @__PURE__ */ new Set();
1827
+ const busyNotes = [];
1828
+ for (; ; ) {
1829
+ const candidate = ctx.registry.allocateSlot(m.project, m, ctx.owner, effortId, exclude);
1830
+ const busy = await busyPorts(candidate.ports ?? {});
1831
+ if (busy.length === 0) {
1832
+ if (busyNotes.length) warnings.push(...busyNotes);
1833
+ return candidate;
1834
+ }
1835
+ ctx.registry.release(candidate.id);
1836
+ exclude.add(candidate.value);
1837
+ busyNotes.push(`slot ${candidate.value}: ${busy.join(", ")} already in use`);
1838
+ ctx.log(busyNotes[busyNotes.length - 1]);
1839
+ }
1840
+ }
1234
1841
  function guardStart(ctx, o) {
1235
1842
  const m = ctx.manifest;
1236
1843
  const spec = m.areas[o.area];
@@ -1240,28 +1847,37 @@ function guardStart(ctx, o) {
1240
1847
  `unknown area "${o.area}" (known: ${Object.keys(m.areas).join(", ")})`,
1241
1848
  { area: o.area }
1242
1849
  );
1850
+ if (o.task !== void 0 && !TASK_SLUG.test(o.task))
1851
+ throw new WorkkitError(
1852
+ "TASK_INVALID",
1853
+ `invalid task slug "${o.task}" (must match /^[a-z0-9][a-z0-9_-]{0,47}$/ with no "--")`,
1854
+ { task: o.task }
1855
+ );
1243
1856
  if (!m.branch.types.includes(o.type))
1244
1857
  throw new WorkkitError(
1245
1858
  "TYPE_UNKNOWN",
1246
1859
  `unknown type "${o.type}" (allowed: ${m.branch.types.join(", ")})`,
1247
1860
  { type: o.type }
1248
1861
  );
1249
- if (o.mode !== "frontend" && o.mode !== "fullstack")
1250
- throw new WorkkitError("MODE_UNKNOWN", `unknown mode "${o.mode}" (frontend | fullstack)`);
1251
- const branch = branchFor(o.type, o.area);
1862
+ if (o.mode !== "frontend" && o.mode !== "backend" && o.mode !== "fullstack")
1863
+ throw new WorkkitError(
1864
+ "MODE_UNKNOWN",
1865
+ `unknown mode "${o.mode}" (frontend | backend | fullstack)`
1866
+ );
1867
+ const branch = branchFor(o.type, o.area, o.task);
1252
1868
  assertNotProtected(m, branch);
1253
- const scopes = expandScopes(m, o.area, o.mode);
1869
+ const scopes = expandScopes(m, o.area, o.mode, o.scopes, Boolean(o.task));
1254
1870
  const commons = spec.commons ?? [];
1255
1871
  const warnings = [];
1256
1872
  if (commons.length > 0) {
1257
1873
  for (const e of ctx.registry.efforts({ project: m.project })) {
1258
- if (e.area === o.area || e.state !== "active" && e.state !== "starting" || !existsSync5(e.worktree))
1874
+ if (e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0) || e.state !== "active" && e.state !== "starting" || !existsSync7(e.worktree))
1259
1875
  continue;
1260
1876
  const changed = new Git(e.worktree).tryRun(["diff", "--name-only", "--no-renames", `origin/${m.baseBranch}...HEAD`]).out.split("\n").filter(Boolean);
1261
1877
  const hit = commonsTouched(m, changed).filter((c) => commons.includes(c));
1262
1878
  if (hit.length > 0)
1263
1879
  warnings.push(
1264
- `${e.area} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
1880
+ `${effortLabel2(e.area, e.task)} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
1265
1881
  );
1266
1882
  }
1267
1883
  }
@@ -1273,10 +1889,11 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1273
1889
  id: newId("eff"),
1274
1890
  project: m.project,
1275
1891
  area: o.area,
1892
+ ...o.task ? { task: o.task } : {},
1276
1893
  type: o.type,
1277
1894
  mode: o.mode,
1278
1895
  branch,
1279
- worktree: worktreePathFor(ctx, o.area),
1896
+ worktree: worktreePathFor(ctx, o.area, o.task),
1280
1897
  owner: ctx.owner,
1281
1898
  state: "starting",
1282
1899
  scopes,
@@ -1290,21 +1907,25 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1290
1907
  const open = r.efforts.filter(
1291
1908
  (e) => e.project === m.project && (e.state === "active" || e.state === "starting")
1292
1909
  );
1293
- const onArea = open.find((e) => e.area === o.area);
1294
- if (onArea) {
1910
+ const onTarget = open.find(
1911
+ (e) => e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0)
1912
+ );
1913
+ if (onTarget) {
1914
+ const label = effortLabel2(o.area, o.task);
1295
1915
  throw new WorkkitError(
1296
1916
  "EFFORT_OPEN",
1297
- `an effort is open on ${o.area} (${onArea.branch}, ${onArea.mode}) owned by ${onArea.owner.session} since ${onArea.started}; finish it, or pass --join to work in the same checkout`,
1298
- { owner: onArea.owner.session, effort: onArea.id, branch: onArea.branch }
1917
+ `an effort is open on ${label} (${onTarget.branch}, ${onTarget.mode}) owned by ${onTarget.owner.session} since ${onTarget.started}; finish it, or pass --join to work in the same checkout`,
1918
+ { owner: onTarget.owner.session, effort: onTarget.id, branch: onTarget.branch }
1299
1919
  );
1300
1920
  }
1301
1921
  for (const e of open) {
1302
1922
  const pairs = globsOverlap(scopes, e.scopes);
1303
1923
  if (pairs.length > 0) {
1924
+ const otherLabel = effortLabel2(e.area, e.task);
1304
1925
  throw new WorkkitError(
1305
1926
  "SCOPE_CONFLICT",
1306
- `scope overlaps the open effort on ${e.area} (${e.branch}, owner ${e.owner.session}): ${pairs.join("; ")}`,
1307
- { with: e.area, owner: e.owner.session, effort: e.id, pairs }
1927
+ `scope overlaps the open effort on ${otherLabel} (${e.branch}, owner ${e.owner.session}): ${pairs.join("; ")}`,
1928
+ { with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
1308
1929
  );
1309
1930
  }
1310
1931
  }
@@ -1312,55 +1933,136 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1312
1933
  });
1313
1934
  return effort;
1314
1935
  }
1315
- function ensureWorktree(ctx, area, branch) {
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
+ }
1945
+ function ensureWorktree(ctx, area, branch, task) {
1316
1946
  const m = ctx.manifest;
1317
- const path = worktreePathFor(ctx, area);
1947
+ const path = worktreePathFor(ctx, area, task);
1318
1948
  const main = new Git(ctx.repoRoot);
1319
- main.fetch(m.baseBranch);
1320
- if (!existsSync5(path)) {
1949
+ if (!existsSync7(path)) {
1321
1950
  main.worktreeAdd(path, branch, `origin/${m.baseBranch}`);
1322
- return new Git(path);
1951
+ const wg2 = new Git(path);
1952
+ wg2.repairSubmodules(ctx.repoRoot, ctx.log);
1953
+ return { wg: wg2, created: true };
1323
1954
  }
1324
- const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) => realpathOr(l.slice(9)));
1325
- const real = realpathOr(path);
1955
+ const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) => realpathOr2(l.slice(9)));
1956
+ const real = realpathOr2(path);
1326
1957
  const wg = new Git(path);
1327
1958
  const topLevel = wg.tryRun(["rev-parse", "--show-toplevel"]);
1328
- if (!registered.includes(real) || topLevel.code !== 0 || realpathOr(topLevel.out.trim()) !== real || wg.isMainCheckout()) {
1959
+ if (!registered.includes(real) || topLevel.code !== 0 || realpathOr2(topLevel.out.trim()) !== real || wg.isMainCheckout()) {
1329
1960
  throw new WorkkitError(
1330
1961
  "WORKTREE_INVALID",
1331
- `${path} exists but is not this repository's worktree for ${area} (a leftover directory?); move it away, or \`git worktree prune\` and retry`,
1962
+ `${path} exists but is not this repository's worktree for ${effortLabel2(area, task)} (a leftover directory?); move it away, or \`git worktree prune\` and retry`,
1332
1963
  { worktree: path }
1333
1964
  );
1334
1965
  }
1966
+ wg.repairSubmodules(ctx.repoRoot, ctx.log);
1335
1967
  const current = wg.currentBranch();
1336
1968
  if (current !== branch) {
1337
- if (current.endsWith(`/${area}`) && wg.commitsNotOn(m.baseBranch).length > 0) {
1338
- throw new WorkkitError(
1339
- "EFFORT_OPEN",
1340
- `${current} holds ${wg.commitsNotOn(m.baseBranch).length} commit(s) not yet on ${m.baseBranch}: ship or finish it first, or --join it`,
1341
- { branch: current, area }
1342
- );
1343
- }
1344
1969
  if (wg.isDirty())
1345
1970
  throw new WorkkitError(
1346
1971
  "DIRTY_WORKTREE",
1347
1972
  `${path} has uncommitted changes on ${current}; commit or park them before switching to ${branch}`,
1348
1973
  { worktree: path }
1349
1974
  );
1975
+ const unshipped = wg.commitsNotOn(m.baseBranch);
1976
+ if (unshipped.length > 0 && !wg.isEquivalentToBase(m.baseBranch)) {
1977
+ throw new WorkkitError(
1978
+ "EFFORT_OPEN",
1979
+ `${current} holds ${unshipped.length} commit(s) not yet on ${m.baseBranch}: ship or finish it first, or --join it`,
1980
+ { branch: current, area }
1981
+ );
1982
+ }
1350
1983
  wg.checkout(branch, `origin/${m.baseBranch}`);
1984
+ if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
1985
+ wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
1986
+ }
1987
+ } else if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
1988
+ wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
1351
1989
  }
1352
- return wg;
1990
+ return { wg, created: false };
1353
1991
  }
1354
1992
  function appendLog(file, line) {
1355
1993
  appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1356
1994
  `);
1357
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
+ }
1358
2010
  async function startEffort(ctx, o) {
1359
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
+ );
2015
+ fetchBaseOnce(ctx);
1360
2016
  const { branch, scopes, commons, warnings } = guardStart(ctx, o);
1361
2017
  const notices = ctx.registry.notices(m.project, o.area);
1362
- const open = ctx.registry.openEffortFor(m.project, o.area);
2018
+ let open = ctx.registry.openEffortFor(m.project, o.area, o.task);
1363
2019
  if (open && o.join) {
2020
+ const deadline = Date.now() + JOIN_WAIT_MS;
2021
+ while (open && open.state === "starting" && Date.now() < deadline) {
2022
+ await sleep3(200);
2023
+ open = ctx.registry.openEffortFor(m.project, o.area, o.task);
2024
+ }
2025
+ if (!open || open.state !== "active") {
2026
+ throw new WorkkitError(
2027
+ "EFFORT_NOT_FOUND",
2028
+ `effort on ${effortLabel2(o.area, o.task)} did not become active in time to join`,
2029
+ { area: o.area, task: o.task }
2030
+ );
2031
+ }
2032
+ if (!modeCovers(open.mode, o.mode)) {
2033
+ const label = effortLabel2(o.area, o.task);
2034
+ throw new WorkkitError(
2035
+ "MODE_MISMATCH",
2036
+ `open effort on ${label} is in ${open.mode} mode, which does not cover ${o.mode}; run \`work promote --area ${o.area}${o.task ? ` --task ${o.task}` : ""} --mode fullstack\` first`,
2037
+ { area: o.area, task: o.task, openMode: open.mode, requestedMode: o.mode }
2038
+ );
2039
+ }
2040
+ const currentLeases = ctx.registry.leases({ effort: open.id });
2041
+ if (o.story && !currentLeases.some((l) => l.kind === "storybook")) {
2042
+ const hostRoot = storybookHostFor(m, open.worktree);
2043
+ const inst = await ctx.storybrokr.up(o.story, hostRoot, {
2044
+ ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
2045
+ });
2046
+ const sbLease = ctx.registry.addLease(
2047
+ "storybook",
2048
+ m.project,
2049
+ inst.id,
2050
+ ctx.owner,
2051
+ open.id,
2052
+ m.expiry.storybookHours,
2053
+ { ports: { storybook: inst.port } }
2054
+ );
2055
+ open = ctx.registry.updateEffort(open.id, {
2056
+ leases: [...open.leases, sbLease.id]
2057
+ });
2058
+ }
2059
+ const existingParticipants = open.participants ?? [];
2060
+ const isAlreadyMember = open.owner.session === ctx.owner.session || existingParticipants.some((p) => p.session === ctx.owner.session);
2061
+ if (!isAlreadyMember) {
2062
+ open = ctx.registry.updateEffort(open.id, {
2063
+ participants: [...existingParticipants, ctx.owner]
2064
+ });
2065
+ }
1364
2066
  touchEffort(ctx, open);
1365
2067
  return {
1366
2068
  effort: open,
@@ -1386,6 +2088,8 @@ async function startEffort(ctx, o) {
1386
2088
  signatures: l.signatures,
1387
2089
  onLog: ctx.log
1388
2090
  }).catch(() => void 0);
2091
+ } else if (stopPids && l.kind === "storybook") {
2092
+ await ctx.storybrokr.down(String(l.value)).catch(() => void 0);
1389
2093
  }
1390
2094
  ctx.registry.release(l.id);
1391
2095
  }
@@ -1397,17 +2101,28 @@ async function startEffort(ctx, o) {
1397
2101
  process.once("SIGINT", onSignal);
1398
2102
  process.once("SIGTERM", onSignal);
1399
2103
  try {
1400
- const wg = ensureWorktree(ctx, o.area, branch);
2104
+ const { wg, created } = ensureWorktree(ctx, o.area, branch, o.task);
1401
2105
  const worktree = wg.root();
1402
2106
  ctx.registry.updateEffort(effort.id, { worktree });
1403
2107
  effort.worktree = worktree;
1404
- const logDir = join6(worktree, m.logs.dir);
1405
- mkdirSync5(logDir, { recursive: true });
1406
- const refreshLog = join6(logDir, "refresh.log");
2108
+ const logDir = join8(worktree, m.logs.dir);
2109
+ mkdirSync6(logDir, { recursive: true });
2110
+ const refreshLog = join8(logDir, "refresh.log");
2111
+ const stampFile = join8(logDir, ".refresh-stamps.json");
2112
+ if (created && m.seed && m.seed.length > 0) {
2113
+ runRefresh(
2114
+ m.seed,
2115
+ worktree,
2116
+ (l) => {
2117
+ appendLog(refreshLog, l);
2118
+ ctx.log(l);
2119
+ },
2120
+ { ...process.env, WORKTREE: worktree, MAIN_ROOT: ctx.repoRoot }
2121
+ );
2122
+ }
1407
2123
  let sync = "skipped";
1408
2124
  let changed = [];
1409
2125
  if (o.sync !== false) {
1410
- wg.fetch(m.baseBranch);
1411
2126
  const r = wg.syncFrom(m.baseBranch);
1412
2127
  if (r.kind === "conflict") {
1413
2128
  throw new WorkkitError(
@@ -1418,12 +2133,31 @@ async function startEffort(ctx, o) {
1418
2133
  }
1419
2134
  sync = r.kind;
1420
2135
  if (r.kind === "ff" || r.kind === "merge") changed = wg.changedPaths(r.before, r.after);
2136
+ ctx.registry.pruneNoticesWhere(
2137
+ m.project,
2138
+ o.area,
2139
+ (n) => wg.isAncestor(`origin/${n.from}`, "HEAD") || wg.isAncestor(n.from, "HEAD")
2140
+ );
2141
+ }
2142
+ const sel = selectRefresh(m.refresh, changed, { worktree, stampFile });
2143
+ runRefresh(
2144
+ sel.runs,
2145
+ worktree,
2146
+ (l) => {
2147
+ appendLog(refreshLog, l);
2148
+ ctx.log(l);
2149
+ },
2150
+ process.env,
2151
+ {
2152
+ stampFile,
2153
+ pendingStamps: sel.pendingStamps,
2154
+ restoreFiles: sel.cleanWhenFiles
2155
+ }
2156
+ );
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])]);
1421
2160
  }
1422
- const sel = selectRefresh(m.refresh, changed);
1423
- runRefresh(sel.runs, worktree, (l) => {
1424
- appendLog(refreshLog, l);
1425
- ctx.log(l);
1426
- });
1427
2161
  for (const name of commons)
1428
2162
  leases.push(
1429
2163
  ctx.registry.addLease(
@@ -1435,25 +2169,9 @@ async function startEffort(ctx, o) {
1435
2169
  m.expiry.commonsHours
1436
2170
  )
1437
2171
  );
1438
- if (o.mode === "fullstack") {
1439
- const exclude = /* @__PURE__ */ new Set();
1440
- let slot;
1441
- const busyNotes = [];
1442
- for (; ; ) {
1443
- const candidate = ctx.registry.allocateSlot(m.project, m, ctx.owner, effort.id, exclude);
1444
- const busy = await busyPorts(candidate.ports ?? {});
1445
- if (busy.length === 0) {
1446
- slot = candidate;
1447
- break;
1448
- }
1449
- ctx.registry.release(candidate.id);
1450
- exclude.add(candidate.value);
1451
- busyNotes.push(`slot ${candidate.value}: ${busy.join(", ")} already in use`);
1452
- ctx.log(busyNotes[busyNotes.length - 1]);
1453
- }
1454
- if (!slot) throw new WorkkitError("NO_FREE_SLOT", busyNotes.join("; "));
2172
+ if (o.mode === "fullstack" || o.mode === "backend") {
2173
+ const slot = await claimFreeSlot(ctx, effort.id, warnings);
1455
2174
  leases.push(slot);
1456
- if (busyNotes.length) warnings.push(...busyNotes);
1457
2175
  const cfg = emitWorktreeConfig({
1458
2176
  m,
1459
2177
  worktree,
@@ -1463,8 +2181,13 @@ async function startEffort(ctx, o) {
1463
2181
  branch
1464
2182
  });
1465
2183
  const env = { ...process.env, ...cfg.vars };
1466
- 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) {
1467
2186
  runRefresh([m.services.postgres.reset], worktree, (l) => appendLog(refreshLog, l), env);
2187
+ writePendingMarks(
2188
+ logDir,
2189
+ owedMarks.filter((x) => x !== "db-reset")
2190
+ );
1468
2191
  }
1469
2192
  const pids = {};
1470
2193
  const signatures = {};
@@ -1473,6 +2196,7 @@ async function startEffort(ctx, o) {
1473
2196
  cwd: worktree,
1474
2197
  env,
1475
2198
  logDir,
2199
+ mode: o.mode,
1476
2200
  onLog: ctx.log,
1477
2201
  onSpawn: (service, pid, signature) => {
1478
2202
  pids[service] = pid;
@@ -1487,8 +2211,10 @@ async function startEffort(ctx, o) {
1487
2211
  } else {
1488
2212
  let storybookPort;
1489
2213
  if (o.story) {
1490
- const hostRoot = m.storybook ? join6(worktree, m.storybook.hostRoot) : worktree;
1491
- const inst = await ctx.storybrokr.up(o.story, hostRoot);
2214
+ const hostRoot = storybookHostFor(m, worktree);
2215
+ const inst = await ctx.storybrokr.up(o.story, hostRoot, {
2216
+ ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
2217
+ });
1492
2218
  storybookPort = inst.port;
1493
2219
  leases.push(
1494
2220
  ctx.registry.addLease(
@@ -1506,6 +2232,7 @@ async function startEffort(ctx, o) {
1506
2232
  }
1507
2233
  const active = ctx.registry.updateEffort(effort.id, {
1508
2234
  state: "active",
2235
+ baseSha,
1509
2236
  leases: leases.map((l) => l.id),
1510
2237
  lastActivity: now()
1511
2238
  });
@@ -1527,28 +2254,154 @@ async function startEffort(ctx, o) {
1527
2254
  process.off("SIGTERM", onSignal);
1528
2255
  }
1529
2256
  }
2257
+ async function promoteEffort(ctx, o) {
2258
+ const m = ctx.manifest;
2259
+ if (o.mode !== "backend" && o.mode !== "fullstack") {
2260
+ throw new WorkkitError("MODE_UNKNOWN", `promote target mode must be backend or fullstack`);
2261
+ }
2262
+ const open = ctx.registry.openEffortFor(m.project, o.area, o.task);
2263
+ if (!open) {
2264
+ throw new WorkkitError(
2265
+ "EFFORT_NOT_FOUND",
2266
+ `no open effort on ${effortLabel2(o.area, o.task)} to promote`,
2267
+ { area: o.area, task: o.task }
2268
+ );
2269
+ }
2270
+ if (open.mode === "fullstack" && o.mode === "backend" || open.mode === "frontend" && o.mode === "backend") {
2271
+ throw new WorkkitError(
2272
+ "MODE_MISMATCH",
2273
+ `cannot transition effort on ${effortLabel2(o.area, o.task)} from ${open.mode} to ${o.mode}`,
2274
+ { from: open.mode, to: o.mode }
2275
+ );
2276
+ }
2277
+ const modeScopes = expandScopes(m, o.area, o.mode);
2278
+ const nextScopes = open.task ? [.../* @__PURE__ */ new Set([...open.scopes, ...modeScopes])] : modeScopes;
2279
+ const refuseOverlap = (r) => {
2280
+ const others = r.efforts.filter(
2281
+ (e) => e.project === m.project && e.id !== open.id && (e.state === "active" || e.state === "starting")
2282
+ );
2283
+ for (const e of others) {
2284
+ const pairs = globsOverlap(nextScopes, e.scopes);
2285
+ if (pairs.length > 0) {
2286
+ const otherLabel = effortLabel2(e.area, e.task);
2287
+ throw new WorkkitError(
2288
+ "SCOPE_CONFLICT",
2289
+ `promoting ${effortLabel2(o.area, o.task)} to ${o.mode} overlaps ${otherLabel} (${e.branch}): ${pairs.join("; ")}`,
2290
+ { with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
2291
+ );
2292
+ }
2293
+ }
2294
+ };
2295
+ ctx.registry.update(refuseOverlap);
2296
+ const warnings = [];
2297
+ let slot = ctx.registry.leases({ effort: open.id, kind: "slot" })[0];
2298
+ const sb = ctx.registry.leases({ effort: open.id, kind: "storybook" })[0];
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
+ );
2337
+ }
2338
+ const cfg = emitWorktreeConfig({
2339
+ m,
2340
+ worktree: open.worktree,
2341
+ slot: slot.value,
2342
+ ports: slot.ports ?? {},
2343
+ storybookPort: sb?.ports?.storybook,
2344
+ area: o.area,
2345
+ branch: open.branch
2346
+ });
2347
+ const env = { ...process.env, ...cfg.vars };
2348
+ const logDir = join8(open.worktree, m.logs.dir);
2349
+ mkdirSync6(logDir, { recursive: true });
2350
+ const existingPids = { ...slot.pids ?? {} };
2351
+ const existingSigs = { ...slot.signatures ?? {} };
2352
+ const missingServices = Object.entries(m.services).filter(([name, spec]) => serviceModes(name, spec).includes(o.mode) && !existingPids[name]).map(([name]) => name);
2353
+ if (missingServices.length > 0) {
2354
+ const result = await ctx.runner.start({
2355
+ m,
2356
+ cwd: open.worktree,
2357
+ env,
2358
+ logDir,
2359
+ mode: o.mode,
2360
+ onlyServices: missingServices,
2361
+ onLog: ctx.log,
2362
+ onSpawn: (service, pid, signature) => {
2363
+ existingPids[service] = pid;
2364
+ existingSigs[service] = signature;
2365
+ ctx.registry.patchLease(slot.id, {
2366
+ pids: { ...existingPids },
2367
+ signatures: { ...existingSigs }
2368
+ });
2369
+ }
2370
+ });
2371
+ ctx.registry.patchLease(slot.id, {
2372
+ pids: { ...existingPids, ...result.pids },
2373
+ signatures: { ...existingSigs, ...result.signatures ?? {} }
2374
+ });
2375
+ }
2376
+ const updated = ctx.registry.updateEffort(open.id, { lastActivity: now() });
2377
+ touchEffort(ctx, updated);
2378
+ return {
2379
+ effort: updated,
2380
+ leases: ctx.registry.leases({ effort: open.id }),
2381
+ startedServices: missingServices,
2382
+ warnings
2383
+ };
2384
+ }
1530
2385
  function behindBase(ctx, worktree) {
1531
- if (!existsSync5(worktree)) return 0;
1532
- const g = new Git(worktree);
1533
- g.tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
1534
- const r = g.tryRun(["rev-list", "--count", `HEAD..origin/${ctx.manifest.baseBranch}`]);
1535
- return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
2386
+ if (!existsSync7(worktree)) return 0;
2387
+ return new Git(worktree).behindCount(ctx.manifest.baseBranch);
1536
2388
  }
1537
- function statusFor(ctx, area) {
2389
+ function statusFor(ctx, area, task) {
1538
2390
  const m = ctx.manifest;
2391
+ fetchBaseOnce(ctx);
1539
2392
  const names = (area ? [area] : Object.keys(m.areas)).sort();
1540
2393
  const areas = names.map((name) => {
1541
- const effort = ctx.registry.openEffortFor(m.project, name);
2394
+ const effort = ctx.registry.openEffortFor(m.project, name, area ? task : void 0);
1542
2395
  if (effort && area) touchEffort(ctx, effort);
1543
- const worktree = worktreePathFor(ctx, name);
2396
+ const worktree = worktreePathFor(ctx, name, area ? task : void 0);
1544
2397
  const out = {
1545
- area: name,
2398
+ area: area && task ? `${name}--${task}` : name,
1546
2399
  leases: effort ? ctx.registry.leases({ effort: effort.id }) : [],
1547
2400
  behindBase: behindBase(ctx, worktree),
1548
2401
  notices: ctx.registry.notices(m.project, name)
1549
2402
  };
1550
2403
  if (effort) out.effort = effort;
1551
- if (existsSync5(worktree)) out.worktree = worktree;
2404
+ if (existsSync7(worktree)) out.worktree = worktree;
1552
2405
  return out;
1553
2406
  });
1554
2407
  return { project: m.project, areas, leases: ctx.registry.leases({ project: m.project }) };
@@ -1556,14 +2409,14 @@ function statusFor(ctx, area) {
1556
2409
  function slotEnv(m, slot) {
1557
2410
  return slotVars(m, { slot, ports: portsForSlot(m, slot) });
1558
2411
  }
1559
- function effortEnv(ctx, area) {
2412
+ function effortEnv(ctx, area, task) {
1560
2413
  const m = ctx.manifest;
1561
- const effort = ctx.registry.openEffortFor(m.project, area);
2414
+ const effort = ctx.registry.openEffortFor(m.project, area, task);
1562
2415
  if (!effort)
1563
2416
  throw new WorkkitError(
1564
2417
  "EFFORT_NOT_FOUND",
1565
- `no open effort on ${area}; \`work start --area ${area} \u2026\` first`,
1566
- { area }
2418
+ `no open effort on ${effortLabel2(area, task)}; \`work start --area ${area} \u2026\` first`,
2419
+ { area, task }
1567
2420
  );
1568
2421
  touchEffort(ctx, effort);
1569
2422
  const slot = ctx.registry.leases({ effort: effort.id, kind: "slot" })[0];
@@ -1575,7 +2428,7 @@ function effortEnv(ctx, area) {
1575
2428
  area,
1576
2429
  branch: effort.branch,
1577
2430
  worktree: effort.worktree,
1578
- logDir: join6(effort.worktree, m.logs.dir)
2431
+ logDir: join8(effort.worktree, m.logs.dir)
1579
2432
  });
1580
2433
  return { ...vars, MODE: effort.mode };
1581
2434
  }
@@ -1586,14 +2439,14 @@ function leaseEnv(ctx, effort, lease) {
1586
2439
  area: effort.area,
1587
2440
  branch: effort.branch,
1588
2441
  worktree: effort.worktree,
1589
- logDir: join6(effort.worktree, ctx.manifest.logs.dir)
2442
+ logDir: join8(effort.worktree, ctx.manifest.logs.dir)
1590
2443
  });
1591
2444
  return { ...process.env, ...vars };
1592
2445
  }
1593
2446
  async function releaseLease(ctx, effort, lease) {
1594
2447
  try {
1595
2448
  if (lease.kind === "slot" && lease.pids && Object.keys(lease.pids).length > 0) {
1596
- const hasWorktree = effort !== void 0 && existsSync5(effort.worktree);
2449
+ const hasWorktree = effort !== void 0 && existsSync7(effort.worktree);
1597
2450
  await ctx.runner.stop({
1598
2451
  m: ctx.manifest,
1599
2452
  cwd: hasWorktree && effort ? effort.worktree : ctx.repoRoot,
@@ -1613,6 +2466,15 @@ async function releaseLease(ctx, effort, lease) {
1613
2466
  }
1614
2467
  ctx.registry.release(lease.id);
1615
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
+ }
1616
2478
  function leaveNotices(ctx, effort, commons) {
1617
2479
  if (commons.length === 0) return 0;
1618
2480
  const notice = {
@@ -1630,29 +2492,117 @@ function leaveNotices(ctx, effort, commons) {
1630
2492
  }
1631
2493
  async function finishEffort(ctx, o) {
1632
2494
  const m = ctx.manifest;
1633
- const effort = ctx.registry.openEffortFor(m.project, o.area) ?? ctx.registry.efforts({ project: m.project, area: o.area, state: "parked" }).sort((a, b) => b.started.localeCompare(a.started))[0];
2495
+ const effort = 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];
1634
2496
  if (!effort)
1635
- throw new WorkkitError("EFFORT_NOT_FOUND", `no open or parked effort on ${o.area}`, {
1636
- area: o.area
1637
- });
2497
+ throw new WorkkitError(
2498
+ "EFFORT_NOT_FOUND",
2499
+ `no open or parked effort on ${effortLabel2(o.area, o.task)}`,
2500
+ { area: o.area, task: o.task }
2501
+ );
2502
+ if (!o.force && effort.participants && effort.participants.length > 0) {
2503
+ if (ctx.owner.session === effort.owner.session) {
2504
+ const [nextOwner, ...rest] = effort.participants;
2505
+ const updated = ctx.registry.updateEffort(effort.id, {
2506
+ owner: nextOwner,
2507
+ participants: rest,
2508
+ lastActivity: now()
2509
+ });
2510
+ touchEffort(ctx, updated);
2511
+ return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
2512
+ }
2513
+ if (effort.participants.some((p) => p.session === ctx.owner.session)) {
2514
+ const updated = ctx.registry.updateEffort(effort.id, {
2515
+ participants: effort.participants.filter((p) => p.session !== ctx.owner.session),
2516
+ lastActivity: now()
2517
+ });
2518
+ touchEffort(ctx, updated);
2519
+ return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
2520
+ }
2521
+ }
2522
+ const label = effortLabel2(effort.area, effort.task);
1638
2523
  const g = new Git(effort.worktree);
1639
2524
  let parked = false;
1640
2525
  if (g.isDirty()) {
1641
2526
  if (!o.park) {
1642
2527
  throw new WorkkitError(
1643
2528
  "DIRTY_WORKTREE",
1644
- `${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${effort.area}) and push`,
2529
+ `${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
1645
2530
  { worktree: effort.worktree }
1646
2531
  );
1647
2532
  }
1648
2533
  g.run(["add", "-A"]);
1649
- g.run(["commit", "-q", "-m", `wip(${effort.area}): parked ${now()}`]);
2534
+ g.run(["commit", "-q", "-m", `wip(${label}): parked ${now()}`]);
1650
2535
  parked = true;
1651
2536
  }
2537
+ if (o.intoArea) {
2538
+ if (!effort.task) {
2539
+ throw new WorkkitError(
2540
+ "MANIFEST_INVALID",
2541
+ "--into-area requires a task sub-effort (--task <slug>)",
2542
+ { area: effort.area }
2543
+ );
2544
+ }
2545
+ fetchBaseOnce(ctx);
2546
+ const parentBranch = areaBranchFor(ctx, effort.area, effort.type);
2547
+ const { wg: areaGit } = ensureWorktree(ctx, effort.area, parentBranch);
2548
+ if (areaGit.isDirty()) {
2549
+ throw new WorkkitError(
2550
+ "DIRTY_WORKTREE",
2551
+ `parent area worktree ${areaGit.cwd} has uncommitted changes; commit or clean it before merging task ${effort.branch} --into-area`,
2552
+ { worktree: areaGit.cwd }
2553
+ );
2554
+ }
2555
+ const merged = areaGit.tryRun(["merge", "--no-edit", "-q", effort.branch]);
2556
+ if (merged.code !== 0) {
2557
+ const conflicted = areaGit.tryRun(["diff", "--name-only", "--diff-filter=U"]).out.split("\n").map((l) => l.trim()).filter(Boolean);
2558
+ areaGit.tryRun(["merge", "--abort"]);
2559
+ throw new WorkkitError(
2560
+ "SYNC_CONFLICT",
2561
+ `merging ${effort.branch} into ${parentBranch} conflicts in ${conflicted.join(", ")}; resolve in ${effort.worktree} before retrying \`work finish --into-area\``,
2562
+ { conflicted, worktree: areaGit.cwd }
2563
+ );
2564
+ }
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
+ }
1652
2577
  for (const lease of ctx.registry.leases({ effort: effort.id }))
1653
2578
  await releaseLease(ctx, effort, lease);
1654
- g.push(effort.branch);
1655
- const notices = leaveNotices(ctx, effort, commonsTouched(m, g.changedSince(m.baseBranch)));
2579
+ await sweepStorybook(ctx, effort);
2580
+ let notices = 0;
2581
+ if (!o.intoArea) {
2582
+ if (g.commitsNotOn(m.baseBranch).length > 0 && !g.isEquivalentToBase(m.baseBranch)) {
2583
+ const since = g.changedSince(m.baseBranch);
2584
+ const sinceSet = new Set(since);
2585
+ const changed = effort.baseSha ? g.changedPaths(effort.baseSha, "HEAD").filter((p) => sinceSet.has(p)) : since;
2586
+ notices = leaveNotices(ctx, effort, commonsTouched(m, changed));
2587
+ }
2588
+ }
2589
+ if (effort.task) {
2590
+ archiveWorktree(ctx, label, effort.worktree);
2591
+ const main = new Git(ctx.repoRoot);
2592
+ main.worktreeRemove(effort.worktree);
2593
+ if (o.intoArea) {
2594
+ main.deleteLocalBranch(effort.branch);
2595
+ }
2596
+ const finished2 = { ...effort, state: "finished", lastActivity: now() };
2597
+ ctx.registry.removeEfforts((e) => e.id === effort.id);
2598
+ return {
2599
+ effort: finished2,
2600
+ pushed: !o.intoArea,
2601
+ parked,
2602
+ mergedIntoArea: Boolean(o.intoArea),
2603
+ notices
2604
+ };
2605
+ }
1656
2606
  const finished = ctx.registry.updateEffort(effort.id, { state: "finished", lastActivity: now() });
1657
2607
  return { effort: finished, pushed: true, parked, notices };
1658
2608
  }
@@ -1667,57 +2617,67 @@ async function reapLeases(ctx, o = {}) {
1667
2617
  if (effort?.state === "starting" && Date.now() - Date.parse(effort.started) < STARTING_GRACE_MS)
1668
2618
  continue;
1669
2619
  const expired = Date.parse(l.expires) < at.getTime();
1670
- const orphaned = !effort || !existsSync5(effort.worktree);
2620
+ const orphaned = !effort || !existsSync7(effort.worktree);
1671
2621
  const garbage = orphaned && !await anyPortAnswers(l.ports);
1672
2622
  if (expired || garbage) doomed.add(l.effort);
1673
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
+ }
1674
2630
  const reaped = leases.filter((l) => doomed.has(l.effort));
1675
2631
  if (o.dryRun) return { reaped };
1676
2632
  for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
1677
2633
  for (const id of doomed) {
1678
2634
  const effort = efforts.get(id);
1679
- if (effort && (effort.state === "active" || effort.state === "starting")) {
1680
- ctx.registry.updateEffort(effort.id, {
1681
- state: "parked",
1682
- parkedReason: `idle: a lease expired by ${at.toISOString()}`,
1683
- lastActivity: now()
1684
- });
2635
+ if (effort) {
2636
+ await sweepStorybook(ctx, effort);
2637
+ if (effort.state === "active" || effort.state === "starting") {
2638
+ const hadLease = leases.some((l) => l.effort === effort.id);
2639
+ ctx.registry.updateEffort(effort.id, {
2640
+ state: "parked",
2641
+ parkedReason: hadLease ? `idle: a lease expired by ${at.toISOString()}` : `start died before its first lease (started ${effort.started})`,
2642
+ lastActivity: now()
2643
+ });
2644
+ }
1685
2645
  }
1686
2646
  }
1687
2647
  return { reaped };
1688
2648
  }
1689
2649
  var ARCHIVE_DIR = ".slot-archive";
1690
2650
  function dirSize(path) {
1691
- if (!existsSync5(path)) return 0;
2651
+ if (!existsSync7(path)) return 0;
1692
2652
  let total = 0;
1693
2653
  for (const entry of readdirSync(path, { withFileTypes: true })) {
1694
- const p = join6(path, entry.name);
1695
- total += entry.isDirectory() ? dirSize(p) : statSync2(p).size;
2654
+ const p = join8(path, entry.name);
2655
+ total += entry.isDirectory() ? dirSize(p) : statSync3(p).size;
1696
2656
  }
1697
2657
  return total;
1698
2658
  }
1699
2659
  function archiveWorktree(ctx, area, worktree) {
1700
2660
  const m = ctx.manifest;
1701
- const areaRoot = join6(ctx.repoRoot, ARCHIVE_DIR, area);
2661
+ const areaRoot = join8(ctx.repoRoot, ARCHIVE_DIR, area);
1702
2662
  const stamp = now().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
1703
- const target = join6(areaRoot, stamp);
2663
+ const target = join8(areaRoot, stamp);
1704
2664
  const archived = [];
1705
2665
  for (const rel of m.archive.paths) {
1706
- const src = join6(worktree, rel);
1707
- if (!existsSync5(src)) continue;
1708
- const dst = join6(target, rel);
1709
- mkdirSync5(dirname2(dst), { recursive: true });
2666
+ const src = join8(worktree, rel);
2667
+ if (!existsSync7(src)) continue;
2668
+ const dst = join8(target, rel);
2669
+ mkdirSync6(dirname3(dst), { recursive: true });
1710
2670
  cpSync(src, dst, { recursive: true });
1711
2671
  archived.push(dst);
1712
2672
  }
1713
2673
  if (archived.length > 0) ensureExcluded(ctx.repoRoot, [`${ARCHIVE_DIR}/`]);
1714
- if (existsSync5(areaRoot)) {
2674
+ if (existsSync7(areaRoot)) {
1715
2675
  const stamps = readdirSync(areaRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
1716
2676
  const cutoff = Date.now() - m.archive.keepDays * 864e5;
1717
2677
  stamps.forEach((d, i) => {
1718
- const t = statSync2(join6(areaRoot, d)).mtimeMs;
2678
+ const t = statSync3(join8(areaRoot, d)).mtimeMs;
1719
2679
  if (i >= m.archive.keepPerArea || t < cutoff)
1720
- rmSync2(join6(areaRoot, d), { recursive: true, force: true });
2680
+ rmSync3(join8(areaRoot, d), { recursive: true, force: true });
1721
2681
  });
1722
2682
  }
1723
2683
  return archived;
@@ -1726,17 +2686,17 @@ async function retireArea(ctx, o) {
1726
2686
  const m = ctx.manifest;
1727
2687
  if (!m.areas[o.area])
1728
2688
  throw new WorkkitError("AREA_UNKNOWN", `unknown area "${o.area}"`, { area: o.area });
1729
- const open = ctx.registry.openEffortFor(m.project, o.area);
2689
+ const open = ctx.registry.efforts({ project: m.project, area: o.area }).find((e) => e.state === "starting" || e.state === "active");
1730
2690
  if (open)
1731
2691
  throw new WorkkitError(
1732
2692
  "EFFORT_OPEN",
1733
- `an effort is open on ${o.area} (${open.owner.session}); \`work finish\` it first`,
2693
+ `an effort is open on ${effortLabel2(open.area, open.task)} (${open.owner.session}); \`work finish\` it first`,
1734
2694
  { owner: open.owner.session }
1735
2695
  );
1736
- const worktree = worktreePathFor(ctx, o.area);
2696
+ const worktree = realpathOr2(worktreePathFor(ctx, o.area));
1737
2697
  const main = new Git(ctx.repoRoot);
1738
2698
  let archived = [];
1739
- if (existsSync5(worktree)) {
2699
+ if (existsSync7(worktree)) {
1740
2700
  const g = new Git(worktree);
1741
2701
  if (g.isDirty())
1742
2702
  throw new WorkkitError(
@@ -1751,13 +2711,15 @@ async function retireArea(ctx, o) {
1751
2711
  `${g.currentBranch()} has ${unpushed} commit(s) not on origin; push before retiring`,
1752
2712
  { worktree, unpushed }
1753
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" });
1754
2716
  archived = archiveWorktree(ctx, o.area, worktree);
1755
2717
  main.worktreeRemove(worktree);
1756
2718
  }
1757
2719
  const branchesDeleted = [];
1758
2720
  const branchesKept = [];
1759
- for (const type of m.branch.types) {
1760
- const branch = `${type}/${o.area}`;
2721
+ const allAreaBranches = m.branch.types.map((type) => `${type}/${o.area}`);
2722
+ for (const branch of allAreaBranches) {
1761
2723
  if (!main.branchExists(branch)) continue;
1762
2724
  const onOrigin = main.remoteBranchExists(branch) && main.tryRun(["rev-list", "--count", `origin/${branch}..${branch}`]).out.trim() === "0";
1763
2725
  if (onOrigin) {
@@ -1767,8 +2729,96 @@ async function retireArea(ctx, o) {
1767
2729
  }
1768
2730
  ctx.registry.removeEfforts((e) => e.project === m.project && e.area === o.area);
1769
2731
  ctx.registry.clearNotices(m.project, o.area);
2732
+ ctx.registry.clearNoticesFrom(m.project, allAreaBranches);
1770
2733
  return { archived, removed: worktree, branchesDeleted, branchesKept };
1771
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
+ }
1772
2822
  var BEHIND_LIMIT = 20;
1773
2823
  var NOTICE_STALE_MS = 7 * 864e5;
1774
2824
  var ARCHIVE_LIMIT_BYTES = 5 * 1024 ** 3;
@@ -1786,15 +2836,16 @@ function registeredWorktrees(ctx) {
1786
2836
  }
1787
2837
  }
1788
2838
  if (current) out.push(current);
1789
- const wtRoot = join6(realpathOr(ctx.repoRoot), ctx.manifest.worktreeRoot);
1790
- return out.filter((w) => existsSync5(w.path) && realpathOr(w.path).startsWith(wtRoot));
2839
+ const wtRoot = join8(realpathOr2(ctx.repoRoot), ctx.manifest.worktreeRoot);
2840
+ return out.filter((w) => existsSync7(w.path) && realpathOr2(w.path).startsWith(wtRoot));
1791
2841
  }
1792
2842
  async function auditProject(ctx) {
1793
2843
  const m = ctx.manifest;
2844
+ fetchBaseOnce(ctx);
1794
2845
  const findings = [];
1795
2846
  const types = m.branch.types.join("|");
1796
2847
  const areaOf = (branch) => {
1797
- const match = new RegExp(`^(${types})/(.+)$`).exec(branch);
2848
+ const match = new RegExp(`^(${types})/(.+?)(?:--([a-z0-9][a-z0-9_-]*))?$`).exec(branch);
1798
2849
  return match && m.areas[match[2]] ? match[2] : null;
1799
2850
  };
1800
2851
  for (const w of registeredWorktrees(ctx)) {
@@ -1843,7 +2894,7 @@ async function auditProject(ctx) {
1843
2894
  findings.push({ kind: "notice-stale", area, detail: `${n.text} (since ${n.at})` });
1844
2895
  }
1845
2896
  }
1846
- const bytes = dirSize(join6(ctx.repoRoot, ARCHIVE_DIR));
2897
+ const bytes = dirSize(join8(ctx.repoRoot, ARCHIVE_DIR));
1847
2898
  if (bytes > ARCHIVE_LIMIT_BYTES)
1848
2899
  findings.push({
1849
2900
  kind: "archive-size",
@@ -1893,51 +2944,70 @@ function registerAudit(program2) {
1893
2944
 
1894
2945
  // src/commands/env.ts
1895
2946
  function registerEnv(program2) {
1896
- program2.command("env").description("The slot-derived variables of an area's open effort, or of a slot number alone").option("--area <area>", "the area whose open effort to read").option(
2947
+ program2.command("env").description("The slot-derived variables of an area's open effort, or of a slot number alone").option("--area <area>", "the area whose open effort to read").option("--task <slug>", "intra-area task slug").option(
1897
2948
  "--slot <n>",
1898
2949
  "compute the variables for this slot from the manifest alone",
1899
2950
  (v) => Number(v)
1900
- ).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print as JSON (default: KEY=value lines)").option("--sh", "print KEY=value lines (the default)").action(async (o) => {
1901
- try {
1902
- const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
1903
- let vars;
1904
- if (o.area) vars = effortEnv(ctx, o.area);
1905
- else if (o.slot !== void 0 && Number.isInteger(o.slot))
1906
- vars = slotEnv(ctx.manifest, o.slot);
1907
- else throw new WorkkitError("EFFORT_NOT_FOUND", "pass --area <area> or --slot <n>");
1908
- if (o.json) printJson(vars);
1909
- else for (const [k, v] of Object.entries(vars)) console.log(`${k}=${v}`);
1910
- } catch (err) {
1911
- fail(err, Boolean(o.json));
2951
+ ).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print as JSON (default: KEY=value lines)").option("--sh", "print KEY=value lines (the default)").action(
2952
+ async (o) => {
2953
+ try {
2954
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
2955
+ let vars;
2956
+ if (o.area) vars = effortEnv(ctx, o.area, o.task);
2957
+ else if (o.slot !== void 0 && Number.isInteger(o.slot))
2958
+ vars = slotEnv(ctx.manifest, o.slot);
2959
+ else throw new WorkkitError("EFFORT_NOT_FOUND", "pass --area <area> or --slot <n>");
2960
+ if (o.json) printJson(vars);
2961
+ else for (const [k, v] of Object.entries(vars)) console.log(`${k}=${v}`);
2962
+ } catch (err) {
2963
+ fail(err, Boolean(o.json));
2964
+ }
1912
2965
  }
1913
- });
2966
+ );
1914
2967
  }
1915
2968
 
1916
2969
  // src/commands/finish.ts
1917
2970
  import chalk4 from "chalk";
1918
2971
  function registerFinish(program2) {
1919
2972
  program2.command("finish").description(
1920
- "Stop the effort's runner, release its leases, push its branch; the worktree and branch stay"
1921
- ).requiredOption("--area <area>", "the area whose open effort to finish").option("--park", "commit uncommitted changes as wip(<area>) instead of refusing").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
1922
- try {
1923
- const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
1924
- const r = await finishEffort(ctx, { area: o.area, park: o.park });
1925
- if (o.json) printJson(r);
1926
- else {
1927
- console.log(
1928
- `${chalk4.bold(r.effort.area)} finished: ${r.effort.branch} pushed${r.parked ? " (uncommitted changes parked as wip)" : ""}`
1929
- );
1930
- if (r.notices) console.log(` ${r.notices} area(s) told about commons changes`);
2973
+ "Stop the effort's runner, release its leases, push its branch (or merge --into-area); area worktrees stay, task worktrees are cleaned up"
2974
+ ).requiredOption("--area <area>", "the area whose open effort to finish").option("--task <slug>", "finish an intra-area task sub-effort").option("--park", "commit uncommitted changes as wip(<area>) instead of refusing").option("--into-area", "merge task branch into <type>/<area> instead of pushing to origin").option("--force", "stop and finish even if other joined participants remain").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
2975
+ async (o) => {
2976
+ try {
2977
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
2978
+ const r = await finishEffort(ctx, {
2979
+ area: o.area,
2980
+ task: o.task,
2981
+ park: o.park,
2982
+ intoArea: o.intoArea,
2983
+ force: o.force
2984
+ });
2985
+ if (o.json) printJson(r);
2986
+ else {
2987
+ const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
2988
+ if (r.left) {
2989
+ console.log(`${chalk4.bold(label)}: left shared effort (other participants remain)`);
2990
+ } else if (r.mergedIntoArea) {
2991
+ console.log(
2992
+ `${chalk4.bold(label)} finished: ${r.effort.branch} merged into ${r.effort.type}/${r.effort.area}`
2993
+ );
2994
+ } else {
2995
+ console.log(
2996
+ `${chalk4.bold(label)} finished: ${r.effort.branch} pushed${r.parked ? " (uncommitted changes parked as wip)" : ""}`
2997
+ );
2998
+ }
2999
+ if (r.notices) console.log(` ${r.notices} area(s) told about commons changes`);
3000
+ }
3001
+ } catch (err) {
3002
+ fail(err, Boolean(o.json));
1931
3003
  }
1932
- } catch (err) {
1933
- fail(err, Boolean(o.json));
1934
3004
  }
1935
- });
3005
+ );
1936
3006
  }
1937
3007
 
1938
3008
  // src/commands/hook.ts
1939
- import { readFileSync as readFileSync4, realpathSync as realpathSync5 } from "fs";
1940
- import { resolve } from "path";
3009
+ import { readFileSync as readFileSync6, realpathSync as realpathSync5 } from "fs";
3010
+ import { resolve as resolve2 } from "path";
1941
3011
  import chalk5 from "chalk";
1942
3012
  var ZEROS = /^0+$/;
1943
3013
  async function hookContext(cwd) {
@@ -1956,7 +3026,7 @@ async function runPreCommit(cwd) {
1956
3026
  if (!c) return 0;
1957
3027
  const root = c.git.root();
1958
3028
  touchByWorktree(c.registry, c.manifest, root);
1959
- const { refusals } = checkCommit({
3029
+ const { refusals, warnings } = checkCommit({
1960
3030
  m: c.manifest,
1961
3031
  registry: c.registry,
1962
3032
  git: c.git,
@@ -1966,6 +3036,7 @@ async function runPreCommit(cwd) {
1966
3036
  ownerSession: currentOwner().session,
1967
3037
  checkoutRoot: root
1968
3038
  });
3039
+ for (const w of warnings) console.error(chalk5.yellow(`work: ${w}`));
1969
3040
  for (const r of refusals) console.error(chalk5.red(`work: ${r}`));
1970
3041
  return refusals.length ? 2 : 0;
1971
3042
  }
@@ -2033,7 +3104,7 @@ function registerHook(program2) {
2033
3104
  "Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
2034
3105
  ).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
2035
3106
  try {
2036
- const stdin = process.stdin.isTTY ? "" : readFileSync4(0, "utf8");
3107
+ const stdin = process.stdin.isTTY ? "" : readFileSync6(0, "utf8");
2037
3108
  process.exit(await runPrePush(process.cwd(), stdin));
2038
3109
  } catch (err) {
2039
3110
  fail(err, false);
@@ -2044,7 +3115,7 @@ function registerHook(program2) {
2044
3115
  "Write .helmsmith/hooks and point this clone's core.hooksPath at them (for a repo whose manifest already exists)"
2045
3116
  ).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
2046
3117
  try {
2047
- const repoRoot = o.projectRoot ? resolve(o.projectRoot) : new Git(process.cwd()).mainRoot();
3118
+ const repoRoot = o.projectRoot ? resolve2(o.projectRoot) : new Git(process.cwd()).mainRoot();
2048
3119
  const manifest = await loadManifest(repoRoot);
2049
3120
  const out = installHooks(repoRoot, { protectedBranches: manifest.protectedBranches });
2050
3121
  if (o.json) printJson(out);
@@ -2056,8 +3127,8 @@ function registerHook(program2) {
2056
3127
  }
2057
3128
 
2058
3129
  // src/commands/init.ts
2059
- import { existsSync as existsSync6, writeFileSync as writeFileSync4 } from "fs";
2060
- import { join as join7, resolve as resolve2 } from "path";
3130
+ import { existsSync as existsSync8, writeFileSync as writeFileSync6 } from "fs";
3131
+ import { join as join9, resolve as resolve3 } from "path";
2061
3132
  var STORYBOOK_BASE = 6100;
2062
3133
  function defaultService(name) {
2063
3134
  const PORT = `\${PORT_${name.toUpperCase()}}`;
@@ -2121,9 +3192,9 @@ var HEADER = `// biome-ignore-all lint/suspicious/noTemplateCurlyInString: \${PO
2121
3192
  // each with its backend module) and the commons. Nothing here is read by the application.
2122
3193
  `;
2123
3194
  function runInit(o) {
2124
- const repoRoot = resolve2(o.repoRoot);
3195
+ const repoRoot = resolve3(o.repoRoot);
2125
3196
  for (const name of MANIFEST_FILES) {
2126
- if (existsSync6(join7(repoRoot, name)) && !o.force) {
3197
+ if (existsSync8(join9(repoRoot, name)) && !o.force) {
2127
3198
  throw new WorkkitError(
2128
3199
  "MANIFEST_INVALID",
2129
3200
  `${name} already exists in ${repoRoot}; pass --force to overwrite`
@@ -2131,8 +3202,8 @@ function runInit(o) {
2131
3202
  }
2132
3203
  }
2133
3204
  const manifest = buildStarterManifest(o);
2134
- const manifestPath = join7(repoRoot, "helmsmith.config.mjs");
2135
- writeFileSync4(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
3205
+ const manifestPath = join9(repoRoot, "helmsmith.config.mjs");
3206
+ writeFileSync6(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
2136
3207
  `);
2137
3208
  claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
2138
3209
  const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
@@ -2162,7 +3233,7 @@ function registerInit(program2) {
2162
3233
  ).option("--storybook-host <dir>", "the package whose .storybook storybrokr boots from").option("--worktree-root <dir>", "where area worktrees live (default .claude/worktrees)").option("--repo-root <dir>", "repository root (default: the main checkout of the cwd)").option("--no-hooks", "do not install the git hooks").option("--force", "overwrite an existing manifest").option("--json", "print the result as JSON").action(
2163
3234
  (o) => {
2164
3235
  try {
2165
- const repoRoot = o.repoRoot ? resolve2(o.repoRoot) : defaultRepoRoot();
3236
+ const repoRoot = o.repoRoot ? resolve3(o.repoRoot) : defaultRepoRoot();
2166
3237
  if (o.service.length === 0)
2167
3238
  throw new WorkkitError(
2168
3239
  "MANIFEST_INVALID",
@@ -2200,21 +3271,21 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
2200
3271
  import { z as z2 } from "zod";
2201
3272
 
2202
3273
  // src/version.ts
2203
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
2204
- import { dirname as dirname3, join as join8 } from "path";
3274
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
3275
+ import { dirname as dirname4, join as join10 } from "path";
2205
3276
  import { fileURLToPath } from "url";
2206
3277
  function findPackageJson(startDir) {
2207
3278
  let dir = startDir;
2208
3279
  for (; ; ) {
2209
- const candidate = join8(dir, "package.json");
2210
- if (existsSync7(candidate)) return candidate;
2211
- const parent = dirname3(dir);
3280
+ const candidate = join10(dir, "package.json");
3281
+ if (existsSync9(candidate)) return candidate;
3282
+ const parent = dirname4(dir);
2212
3283
  if (parent === dir) throw new Error(`package.json not found above ${startDir}`);
2213
3284
  dir = parent;
2214
3285
  }
2215
3286
  }
2216
- var pkgFile = findPackageJson(dirname3(fileURLToPath(import.meta.url)));
2217
- var VERSION = JSON.parse(readFileSync5(pkgFile, "utf8")).version;
3287
+ var pkgFile = findPackageJson(dirname4(fileURLToPath(import.meta.url)));
3288
+ var VERSION = JSON.parse(readFileSync7(pkgFile, "utf8")).version;
2218
3289
 
2219
3290
  // src/mcp/server.ts
2220
3291
  var ok = (value) => ({
@@ -2236,64 +3307,143 @@ function buildMcpServer(ctxFactory) {
2236
3307
  server.registerTool(
2237
3308
  "work_start",
2238
3309
  {
2239
- description: "Start an effort on an area: its worktree on <type>/<area>, synced from the base branch, refreshed, leased (a slot for fullstack, 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).",
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).",
2240
3311
  inputSchema: {
2241
3312
  area: z2.string().describe("a leaf area from helmsmith.config (e.g. org/applications)"),
3313
+ task: z2.string().optional().describe(
3314
+ "optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"
3315
+ ),
2242
3316
  type: z2.string().describe("feature | chore | fix"),
2243
- mode: z2.enum(["frontend", "fullstack"]),
3317
+ mode: z2.enum(["frontend", "backend", "fullstack"]),
3318
+ scopes: z2.array(z2.string()).optional().describe("optional narrowed scope globs for a task sub-effort"),
2244
3319
  story: z2.string().optional().describe(
2245
3320
  "frontend: story file or component folder for storybrokr, relative to its host"
2246
3321
  ),
2247
3322
  join: z2.boolean().optional().describe("work in the already-open effort instead of refusing"),
2248
- sync: z2.boolean().optional().describe("fetch and merge the base branch first (default true)")
3323
+ sync: z2.boolean().optional().describe("fetch and merge the base branch first (default true)"),
3324
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3325
+ }
3326
+ },
3327
+ async ({ area, task, type, mode, scopes, story, join: join11, sync, projectRoot }) => guard(
3328
+ async () => startEffort(await ctxFactory(projectRoot), {
3329
+ area,
3330
+ task,
3331
+ type,
3332
+ mode,
3333
+ scopes,
3334
+ story,
3335
+ join: join11,
3336
+ sync
3337
+ })
3338
+ )
3339
+ );
3340
+ server.registerTool(
3341
+ "work_promote",
3342
+ {
3343
+ description: "Promote an open effort in place to backend or fullstack mode, claiming a slot if needed and starting only newly required services.",
3344
+ inputSchema: {
3345
+ area: z2.string().describe("the area of the open effort"),
3346
+ task: z2.string().optional().describe("optional intra-area task slug"),
3347
+ mode: z2.enum(["backend", "fullstack"]).describe("target mode to promote into"),
3348
+ projectRoot: z2.string().optional().describe("optional repository root path override")
2249
3349
  }
2250
3350
  },
2251
- async ({ area, type, mode, story, join: join9, sync }) => guard(async () => startEffort(await ctxFactory(), { area, type, mode, story, join: join9, sync }))
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
+ )
2252
3380
  );
2253
3381
  server.registerTool(
2254
3382
  "work_status",
2255
3383
  {
2256
3384
  description: "Per area: the open effort, its leases, how far behind the base branch it is, pending notices; plus every lease of the project.",
2257
- inputSchema: { area: z2.string().optional() }
3385
+ inputSchema: {
3386
+ area: z2.string().optional(),
3387
+ task: z2.string().optional(),
3388
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3389
+ }
2258
3390
  },
2259
- async ({ area }) => guard(async () => statusFor(await ctxFactory(), area))
3391
+ async ({ area, task, projectRoot }) => guard(async () => statusFor(await ctxFactory(projectRoot), area, task))
2260
3392
  );
2261
3393
  server.registerTool(
2262
3394
  "work_finish",
2263
3395
  {
2264
- description: "Stop the effort's runner, release its leases, push its branch, leave commons notices. The worktree and branch stay. Refuses DIRTY_WORKTREE unless park=true (commits as wip).",
2265
- inputSchema: { area: z2.string(), park: z2.boolean().optional() }
3396
+ description: "Stop the effort's runner, release its leases, push its branch (or merge --into-area for task sub-efforts), leave commons notices. Refuses DIRTY_WORKTREE unless park=true (commits as wip).",
3397
+ inputSchema: {
3398
+ area: z2.string(),
3399
+ task: z2.string().optional().describe("optional intra-area task slug"),
3400
+ park: z2.boolean().optional(),
3401
+ intoArea: z2.boolean().optional().describe("merge task branch into <type>/<area> instead of pushing to origin"),
3402
+ force: z2.boolean().optional().describe("stop and finish even if other joined participants remain"),
3403
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3404
+ }
2266
3405
  },
2267
- async ({ area, park }) => guard(async () => finishEffort(await ctxFactory(), { area, park }))
3406
+ async ({ area, task, park, intoArea, force, projectRoot }) => guard(
3407
+ async () => finishEffort(await ctxFactory(projectRoot), { area, task, park, intoArea, force })
3408
+ )
2268
3409
  );
2269
3410
  server.registerTool(
2270
3411
  "work_reap",
2271
3412
  {
2272
3413
  description: "Expire idle leases: stop only what they started, release them, park their efforts. dryRun lists without touching.",
2273
- inputSchema: { dryRun: z2.boolean().optional() }
3414
+ inputSchema: {
3415
+ dryRun: z2.boolean().optional(),
3416
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3417
+ }
2274
3418
  },
2275
- async ({ dryRun }) => guard(async () => reapLeases(await ctxFactory(), { dryRun }))
3419
+ async ({ dryRun, projectRoot }) => guard(async () => reapLeases(await ctxFactory(projectRoot), { dryRun }))
2276
3420
  );
2277
3421
  server.registerTool(
2278
3422
  "work_audit",
2279
3423
  {
2280
3424
  description: "Findings: dirty or unpushed area worktrees, foreign worktrees, aging leases, drift from the base branch, stale notices, archive size.",
2281
- inputSchema: {}
3425
+ inputSchema: {
3426
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3427
+ }
2282
3428
  },
2283
- async () => guard(async () => auditProject(await ctxFactory()))
3429
+ async ({ projectRoot }) => guard(async () => auditProject(await ctxFactory(projectRoot)))
2284
3430
  );
2285
3431
  server.registerTool(
2286
3432
  "work_env",
2287
3433
  {
2288
3434
  description: "The slot-derived variables of an area's open effort (PORT_*, SLOT, LOG_DIR, WORKTREE, BRANCH, MODE).",
2289
- inputSchema: { area: z2.string() }
3435
+ inputSchema: {
3436
+ area: z2.string(),
3437
+ task: z2.string().optional(),
3438
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3439
+ }
2290
3440
  },
2291
- async ({ area }) => guard(async () => effortEnv(await ctxFactory(), area))
3441
+ async ({ area, task, projectRoot }) => guard(async () => effortEnv(await ctxFactory(projectRoot), area, task))
2292
3442
  );
2293
3443
  return server;
2294
3444
  }
2295
3445
  async function runStdio() {
2296
- const server = buildMcpServer(() => buildCtx({ quiet: true }));
3446
+ const server = buildMcpServer((projectRoot) => buildCtx({ projectRoot, quiet: true }));
2297
3447
  await server.connect(new StdioServerTransport());
2298
3448
  }
2299
3449
 
@@ -2304,8 +3454,68 @@ function registerMcp(program2) {
2304
3454
  });
2305
3455
  }
2306
3456
 
2307
- // src/commands/reap.ts
3457
+ // src/commands/pr.ts
2308
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";
3492
+ function registerPromote(program2) {
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(
3494
+ async (o) => {
3495
+ try {
3496
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3497
+ const r = await promoteEffort(ctx, {
3498
+ area: o.area,
3499
+ task: o.task,
3500
+ mode: o.mode
3501
+ });
3502
+ if (o.json) printJson(r);
3503
+ else {
3504
+ const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
3505
+ console.log(
3506
+ `${chalk7.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
3507
+ );
3508
+ for (const w of r.warnings) warn(w);
3509
+ }
3510
+ } catch (err) {
3511
+ fail(err, Boolean(o.json));
3512
+ }
3513
+ }
3514
+ );
3515
+ }
3516
+
3517
+ // src/commands/reap.ts
3518
+ import chalk8 from "chalk";
2309
3519
  function registerReap(program2) {
2310
3520
  program2.command("reap").description(
2311
3521
  "Expire idle leases: stop only what they started, release them, park their efforts"
@@ -2314,7 +3524,7 @@ function registerReap(program2) {
2314
3524
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
2315
3525
  const r = await reapLeases(ctx, { dryRun: o.dryRun });
2316
3526
  if (o.json) printJson(r);
2317
- else if (r.reaped.length === 0) console.log(chalk6.gray("nothing to reap"));
3527
+ else if (r.reaped.length === 0) console.log(chalk8.gray("nothing to reap"));
2318
3528
  else
2319
3529
  for (const l of r.reaped)
2320
3530
  console.log(
@@ -2327,7 +3537,7 @@ function registerReap(program2) {
2327
3537
  }
2328
3538
 
2329
3539
  // src/commands/retire.ts
2330
- import chalk7 from "chalk";
3540
+ import chalk9 from "chalk";
2331
3541
  function registerRetire(program2) {
2332
3542
  program2.command("retire").description(
2333
3543
  "Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
@@ -2337,11 +3547,11 @@ function registerRetire(program2) {
2337
3547
  const r = await retireArea(ctx, { area: o.area });
2338
3548
  if (o.json) printJson(r);
2339
3549
  else {
2340
- console.log(`${chalk7.bold(o.area)} retired: removed ${r.removed}`);
3550
+ console.log(`${chalk9.bold(o.area)} retired: removed ${r.removed}`);
2341
3551
  for (const a of r.archived) console.log(` archived ${a}`);
2342
3552
  for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
2343
3553
  for (const b of r.branchesKept)
2344
- console.log(chalk7.yellow(` kept ${b}: not fully on origin`));
3554
+ console.log(chalk9.yellow(` kept ${b}: not fully on origin`));
2345
3555
  }
2346
3556
  } catch (err) {
2347
3557
  fail(err, Boolean(o.json));
@@ -2350,11 +3560,12 @@ function registerRetire(program2) {
2350
3560
  }
2351
3561
 
2352
3562
  // src/commands/start.ts
2353
- import chalk8 from "chalk";
3563
+ import chalk10 from "chalk";
2354
3564
  function printStart(r) {
2355
3565
  const e = r.effort;
3566
+ const label = e.task ? `${e.area}--${e.task}` : e.area;
2356
3567
  console.log(
2357
- `${chalk8.bold(e.area)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk8.yellow(" (joined)") : ""}`
3568
+ `${chalk10.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk10.yellow(" (joined)") : ""}`
2358
3569
  );
2359
3570
  console.log(` worktree ${e.worktree}`);
2360
3571
  console.log(
@@ -2365,12 +3576,12 @@ function printStart(r) {
2365
3576
  console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
2366
3577
  }
2367
3578
  for (const w of r.warnings) warn(w);
2368
- for (const n of r.notices) console.log(chalk8.yellow(` notice ${n.text} (${n.at})`));
3579
+ for (const n of r.notices) console.log(chalk10.yellow(` notice ${n.text} (${n.at})`));
2369
3580
  }
2370
3581
  function registerStart(program2) {
2371
3582
  program2.command("start").description(
2372
- "Start an effort: the area's worktree on <type>/<area>, synced from the base branch, leased and running"
2373
- ).requiredOption("--area <area>", "a leaf area from helmsmith.config (e.g. org/applications)").requiredOption("--type <type>", "feature | chore | fix (as the manifest allows)").requiredOption("--mode <mode>", "fullstack (a slot) | frontend (storybrokr)").option(
3583
+ "Start an effort: the area's worktree on <type>/<area> (or <type>/<area>--<task>), synced from the base branch, leased and running"
3584
+ ).requiredOption("--area <area>", "a leaf area from helmsmith.config (e.g. org/applications)").option("--task <slug>", "intra-area task slug for parallel sub-efforts (<type>/<area>--<slug>)").requiredOption("--type <type>", "feature | chore | fix (as the manifest allows)").requiredOption("--mode <mode>", "fullstack | backend (a slot) | frontend (storybrokr)").option("--scope <glob...>", "narrow exclusive scope globs for this task sub-effort").option(
2374
3585
  "--story <path>",
2375
3586
  "frontend: the story file or component folder storybrokr boots, relative to the Storybook host"
2376
3587
  ).option("--join", "work in the already-open effort on this area instead of refusing").option("--no-sync", "skip the fetch and merge from the base branch (reproducing something)").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
@@ -2379,8 +3590,10 @@ function registerStart(program2) {
2379
3590
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
2380
3591
  const r = await startEffort(ctx, {
2381
3592
  area: o.area,
3593
+ task: o.task,
2382
3594
  type: o.type,
2383
3595
  mode: o.mode,
3596
+ scopes: o.scope,
2384
3597
  story: o.story,
2385
3598
  join: o.join,
2386
3599
  sync: o.sync
@@ -2395,36 +3608,47 @@ function registerStart(program2) {
2395
3608
  }
2396
3609
 
2397
3610
  // src/commands/status.ts
2398
- import chalk9 from "chalk";
3611
+ import chalk11 from "chalk";
2399
3612
  var age = (iso) => {
2400
3613
  const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
2401
3614
  return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
2402
3615
  };
2403
3616
  var expiresIn = (iso) => {
2404
3617
  const mins = Math.round((Date.parse(iso) - Date.now()) / 6e4);
2405
- return mins <= 0 ? chalk9.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`;
2406
3619
  };
2407
3620
  function printStatus(r) {
2408
- console.log(chalk9.bold(`${r.project}`));
2409
- 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
+ ];
2410
3633
  const rows = r.areas.map((a) => [
2411
3634
  a.area,
2412
- a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk9.gray("-"),
2413
- a.effort?.branch ?? chalk9.gray("-"),
2414
- a.effort?.owner.session ?? chalk9.gray("-"),
2415
- a.effort ? age(a.effort.started) : chalk9.gray("-"),
2416
- a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk9.gray("-"),
2417
- a.behindBase > 0 ? chalk9.yellow(String(a.behindBase)) : "0",
2418
- a.notices.length ? chalk9.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"
2419
3643
  ]);
2420
3644
  const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
2421
3645
  const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
2422
3646
  const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
2423
- console.log(chalk9.bold(line(cols)));
3647
+ console.log(chalk11.bold(line(cols)));
2424
3648
  for (const row of rows) console.log(line(row));
2425
3649
  if (r.leases.length) {
2426
3650
  console.log("");
2427
- console.log(chalk9.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
3651
+ console.log(chalk11.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
2428
3652
  for (const l of r.leases) {
2429
3653
  console.log(
2430
3654
  `${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
@@ -2433,10 +3657,10 @@ function printStatus(r) {
2433
3657
  }
2434
3658
  }
2435
3659
  function registerStatus(program2) {
2436
- program2.command("status").description("Open efforts, leases, drift from the base branch and notices, per area").option("--area <area>", "one area only").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the report as JSON").action(async (o) => {
3660
+ program2.command("status").description("Open efforts, leases, drift from the base branch and notices, per area").option("--area <area>", "one area only").option("--task <slug>", "intra-area task slug").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the report as JSON").action(async (o) => {
2437
3661
  try {
2438
3662
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
2439
- const r = statusFor(ctx, o.area);
3663
+ const r = statusFor(ctx, o.area, o.task);
2440
3664
  if (o.json) printJson(r);
2441
3665
  else printStatus(r);
2442
3666
  } catch (err) {
@@ -2449,12 +3673,14 @@ function registerStatus(program2) {
2449
3673
  var { program } = createCli({
2450
3674
  name: "work",
2451
3675
  version: VERSION,
2452
- description: "Area-based work: start, status, finish, reap, retire, audit."
3676
+ description: "Area-based work: start, promote, status, pr, finish, reap, retire, audit."
2453
3677
  });
2454
3678
  registerInit(program);
2455
3679
  registerStart(program);
3680
+ registerPromote(program);
2456
3681
  registerStatus(program);
2457
3682
  registerEnv(program);
3683
+ registerPr(program);
2458
3684
  registerFinish(program);
2459
3685
  registerReap(program);
2460
3686
  registerRetire(program);