@helmsmith/workbrokr 0.1.0 → 0.2.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",
@@ -76,11 +83,19 @@ function toErrorBody(err) {
76
83
  return { code: "INTERNAL", message: err instanceof Error ? err.message : String(err) };
77
84
  }
78
85
 
79
- // ../../platform/core/workkit-lib/src/git.ts
86
+ // src/core/git.ts
80
87
  import { spawnSync } from "child_process";
81
- import { mkdirSync, realpathSync } from "fs";
82
- import { dirname } from "path";
83
- var Git = class {
88
+ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
89
+ import { dirname, join, resolve, sep } from "path";
90
+ var realpathOr = (p) => {
91
+ try {
92
+ return realpathSync(p);
93
+ } catch {
94
+ return p;
95
+ }
96
+ };
97
+ var SAFE_SUBMODULE_NAME = /^[^\p{Cc}\s=]+$/u;
98
+ var Git = class _Git {
84
99
  constructor(cwd) {
85
100
  this.cwd = cwd;
86
101
  }
@@ -206,9 +221,89 @@ var Git = class {
206
221
  deleteLocalBranch(branch) {
207
222
  this.run(["branch", "-q", "-D", branch]);
208
223
  }
224
+ isAncestor(ancestor, descendant) {
225
+ return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).code === 0;
226
+ }
227
+ /** True when HEAD's tree matches origin/<base>, or every commit on HEAD is patch-equivalent to origin/<base> (squash-merged). */
228
+ isEquivalentToBase(base) {
229
+ const target = `origin/${base}`;
230
+ if (this.tryRun(["diff", "--quiet", target, "HEAD"]).code === 0) return true;
231
+ const cherry = this.tryRun(["cherry", target, "HEAD"]);
232
+ if (cherry.code !== 0) return false;
233
+ const lines = cherry.out.split("\n").map((l) => l.trim()).filter(Boolean);
234
+ return lines.length > 0 && lines.every((l) => l.startsWith("- "));
235
+ }
236
+ /**
237
+ * Give a linked worktree its own clone of every submodule the main checkout has initialized,
238
+ * cloned locally from the main checkout's module directory (no network) and then re-pointed at
239
+ * the real URL. `git worktree add` under submodule.recurse leaves a gitfile aimed at a module
240
+ * directory that was never cloned; a gitfile aimed at the MAIN checkout's module directory is
241
+ * worse, because that module's core.worktree is the main checkout's submodule, so any git
242
+ * command in the worktree's submodule (checkout -f, reset, merge) rewrites the human's files.
243
+ * Both shapes are replaced; a submodule that is already its own working tree is left alone.
244
+ */
245
+ repairSubmodules(mainRoot, log = () => {
246
+ }) {
247
+ const gitmodules = join(this.cwd, ".gitmodules");
248
+ if (!existsSync(gitmodules)) return;
249
+ const cfg = this.tryRun([
250
+ "config",
251
+ "-f",
252
+ ".gitmodules",
253
+ "--get-regexp",
254
+ "^submodule\\..*\\.path$"
255
+ ]);
256
+ if (cfg.code !== 0) return;
257
+ const wtGitDir = this.run(["rev-parse", "--path-format=absolute", "--git-dir"]).trim();
258
+ for (const line of cfg.out.split("\n").filter(Boolean)) {
259
+ const m = /^submodule\.(.+)\.path\s+(.+)$/.exec(line.trim());
260
+ if (!m) continue;
261
+ const [, name, subPath] = m;
262
+ const modulesDir = resolve(wtGitDir, "modules");
263
+ const moduleDir = resolve(modulesDir, name);
264
+ if (!SAFE_SUBMODULE_NAME.test(name) || name.split(/[\\/]/).some((seg) => seg === "" || seg === "." || seg === "..") || !moduleDir.startsWith(modulesDir + sep)) {
265
+ log(`submodule ${subPath}: refusing name ${JSON.stringify(name)}; not repaired`);
266
+ continue;
267
+ }
268
+ const wtSub = join(this.cwd, subPath);
269
+ const wtGit = join(wtSub, ".git");
270
+ if (this.isOwnSubmodule(wtSub, wtGit)) continue;
271
+ const mainSub = join(mainRoot, subPath);
272
+ if (!existsSync(join(mainSub, ".git"))) continue;
273
+ const src = new _Git(mainSub).tryRun(["rev-parse", "--path-format=absolute", "--git-dir"]);
274
+ if (src.code !== 0 || !src.out.trim()) continue;
275
+ if (existsSync(wtGit) && statSync(wtGit).isFile()) rmSync(wtGit);
276
+ rmSync(moduleDir, { recursive: true, force: true });
277
+ const r = this.tryRun([
278
+ "-c",
279
+ "protocol.file.allow=always",
280
+ "-c",
281
+ `submodule.${name}.url=${src.out.trim()}`,
282
+ "submodule",
283
+ "update",
284
+ "--init",
285
+ "--",
286
+ subPath
287
+ ]);
288
+ if (r.code !== 0) {
289
+ log(
290
+ `submodule ${subPath}: not repaired (${(r.err || r.out).trim()}); the project's refresh steps may still set it up`
291
+ );
292
+ continue;
293
+ }
294
+ this.tryRun(["submodule", "sync", "-q", "--", subPath]);
295
+ }
296
+ }
297
+ /** A submodule is the worktree's own when git, run inside it, says its top level IS that directory. */
298
+ isOwnSubmodule(wtSub, wtGit) {
299
+ if (!existsSync(wtGit)) return false;
300
+ const top = new _Git(wtSub).tryRun(["rev-parse", "--show-toplevel"]);
301
+ if (top.code !== 0) return false;
302
+ return realpathOr(top.out.trim()) === realpathOr(wtSub);
303
+ }
209
304
  };
210
305
 
211
- // ../../platform/core/workkit-lib/src/config.ts
306
+ // src/core/config.ts
212
307
  function renderTemplate(s, vars) {
213
308
  return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => {
214
309
  if (!(name in vars)) {
@@ -220,6 +315,10 @@ function renderTemplate(s, vars) {
220
315
  return vars[name];
221
316
  });
222
317
  }
318
+ function storybookHostFor(m, worktree) {
319
+ if (!worktree) return "";
320
+ return m.storybook?.hostRoot ? join2(worktree, m.storybook.hostRoot) : worktree;
321
+ }
223
322
  function slotVars(m, o) {
224
323
  const vars = {
225
324
  SLOT: o.slot === void 0 ? "" : String(o.slot),
@@ -228,7 +327,8 @@ function slotVars(m, o) {
228
327
  BRANCH: o.branch ?? "",
229
328
  WORKTREE: o.worktree ?? "",
230
329
  LOG_DIR: o.logDir ?? "",
231
- PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort)
330
+ PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort),
331
+ STORYBROKR_HOST: storybookHostFor(m, o.worktree)
232
332
  };
233
333
  for (const service of Object.keys(m.ports.services)) {
234
334
  const p = o.ports[service];
@@ -240,16 +340,16 @@ function ensureExcluded(worktree, patterns) {
240
340
  const r = new Git(worktree).tryRun(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
241
341
  if (r.code !== 0) return;
242
342
  const common = realpathSync2(r.out.trim());
243
- const file = join(common, "info", "exclude");
244
- mkdirSync2(join(common, "info"), { recursive: true });
343
+ const file = join2(common, "info", "exclude");
344
+ mkdirSync2(join2(common, "info"), { recursive: true });
245
345
  const present = new Set(
246
- existsSync(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
346
+ existsSync2(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
247
347
  );
248
348
  const missing = patterns.filter((p) => !present.has(p));
249
349
  if (missing.length === 0) return;
250
350
  appendFileSync(
251
351
  file,
252
- `${present.size ? "\n" : ""}# written by @helmsmith/workkit: per-worktree config and logs
352
+ `${present.size ? "\n" : ""}# written by @helmsmith/workbrokr: per-worktree config and logs
253
353
  ${missing.join("\n")}
254
354
  `
255
355
  );
@@ -261,26 +361,26 @@ function emitWorktreeConfig(a) {
261
361
  `${a.m.logs.dir.replace(/\/+$/, "")}/`,
262
362
  `${a.m.worktreeRoot.replace(/\/+$/, "")}/`
263
363
  ]);
264
- const logDir = join(a.worktree, a.m.logs.dir);
364
+ const logDir = join2(a.worktree, a.m.logs.dir);
265
365
  mkdirSync2(logDir, { recursive: true });
266
366
  const vars = slotVars(a.m, { ...a, logDir });
267
- const envFile = join(a.worktree, a.m.env.file);
367
+ const envFile = join2(a.worktree, a.m.env.file);
268
368
  const lines = Object.entries(a.m.env.template).map(([k, v]) => `${k}=${renderTemplate(v, vars)}`);
269
369
  writeFileSync(envFile, `${lines.join("\n")}
270
370
  `);
271
371
  let slotFile;
272
372
  if (a.slot !== void 0) {
273
- slotFile = join(a.worktree, ".slot");
373
+ slotFile = join2(a.worktree, ".slot");
274
374
  writeFileSync(slotFile, `${a.slot}
275
375
  `);
276
376
  }
277
377
  return slotFile ? { envFile, slotFile, logDir, vars } : { envFile, logDir, vars };
278
378
  }
279
379
 
280
- // ../../platform/core/workkit-lib/src/guards.ts
281
- import { existsSync as existsSync2, realpathSync as realpathSync3 } from "fs";
380
+ // src/core/guards.ts
381
+ import { existsSync as existsSync3, realpathSync as realpathSync3 } from "fs";
282
382
 
283
- // ../../platform/core/workkit-lib/src/scopes.ts
383
+ // src/core/scopes.ts
284
384
  import picomatch from "picomatch";
285
385
  function areaVars(area) {
286
386
  const parts = area.split("/");
@@ -298,7 +398,42 @@ function fill(template, vars) {
298
398
  });
299
399
  return missing ? null : out;
300
400
  }
301
- function expandScopes(m, area, mode) {
401
+ function extraScopesForMode(spec, mode) {
402
+ if (!spec.extraScopes) return [];
403
+ if (Array.isArray(spec.extraScopes)) {
404
+ return mode === "frontend" || mode === "fullstack" ? spec.extraScopes : [];
405
+ }
406
+ const out = [];
407
+ if (mode === "frontend" || mode === "fullstack") out.push(...spec.extraScopes.frontend ?? []);
408
+ if (mode === "backend" || mode === "fullstack") out.push(...spec.extraScopes.backend ?? []);
409
+ return out;
410
+ }
411
+ var normalizeGlob = (g) => g.replace(/^\.\//, "").replace(/\/+$/, "");
412
+ var hasWildcard = (s) => /[*?[{]/.test(s);
413
+ function isSubGlob(child, parent) {
414
+ const c = normalizeGlob(child);
415
+ const p = normalizeGlob(parent);
416
+ if (c === p) return true;
417
+ const cSegs = c.split("/");
418
+ const pSegs = p.split("/");
419
+ for (let i = 0; i < pSegs.length; i++) {
420
+ const ps = pSegs[i];
421
+ if (ps === "**") return true;
422
+ const cs = cSegs[i];
423
+ if (cs === void 0 || cs === "**") return false;
424
+ if (!hasWildcard(ps) && !hasWildcard(cs)) {
425
+ if (ps !== cs) return false;
426
+ continue;
427
+ }
428
+ if (!hasWildcard(cs) && hasWildcard(ps)) {
429
+ if (!picomatch.isMatch(cs, ps, { dot: true })) return false;
430
+ continue;
431
+ }
432
+ if (cs !== ps) return false;
433
+ }
434
+ return cSegs.length === pSegs.length;
435
+ }
436
+ function expandScopes(m, area, mode, narrowedScopes, hasTask = false) {
302
437
  const spec = m.areas[area];
303
438
  if (!spec)
304
439
  throw new WorkkitError(
@@ -307,15 +442,17 @@ function expandScopes(m, area, mode) {
307
442
  );
308
443
  const vars = { ...areaVars(area), module: spec.module };
309
444
  const out = [];
310
- for (const t of m.scopes.frontend) {
311
- const g = fill(t, vars);
312
- if (g) out.push(g);
445
+ if (mode === "frontend" || mode === "fullstack") {
446
+ for (const t of m.scopes.frontend) {
447
+ const g = fill(t, vars);
448
+ if (g) out.push(g);
449
+ }
313
450
  }
314
- if (mode === "fullstack") {
451
+ if (mode === "backend" || mode === "fullstack") {
315
452
  if (!spec.module) {
316
453
  throw new WorkkitError(
317
454
  "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`
455
+ `area "${area}" has no module, so a ${mode} effort cannot hold a backend scope; add areas["${area}"].module or use --mode frontend`
319
456
  );
320
457
  }
321
458
  for (const t of m.scopes.backend) {
@@ -323,20 +460,65 @@ function expandScopes(m, area, mode) {
323
460
  if (g) out.push(g);
324
461
  }
325
462
  }
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);
463
+ out.push(...extraScopesForMode(spec, mode));
464
+ if (!narrowedScopes || narrowedScopes.length === 0) return out;
465
+ if (!hasTask) {
466
+ throw new WorkkitError(
467
+ "SCOPE_REQUIRES_TASK",
468
+ "--scope narrowing requires --task <slug> so primary area efforts always protect the full area"
469
+ );
470
+ }
471
+ const allowed = [...out, ...spec.sharedScopes ?? []];
472
+ for (const g of narrowedScopes) {
473
+ if (!allowed.some((base) => isSubGlob(g, base))) {
474
+ throw new WorkkitError(
475
+ "SCOPE_OUTSIDE_AREA",
476
+ `narrowed scope "${g}" is outside ${area}'s ${mode} scopes (${allowed.join(", ")})`,
477
+ { area, mode, scope: g, allowed }
478
+ );
479
+ }
480
+ }
481
+ return narrowedScopes;
482
+ }
483
+ function allowedScopesForEffort(m, effort) {
484
+ const shared = m.areas[effort.area]?.sharedScopes ?? [];
485
+ const allCommons = Object.values(m.commons).flat();
486
+ return [...effort.scopes, ...shared, ...allCommons];
487
+ }
488
+ function segmentsOverlap(sx, sy) {
489
+ if (sx === sy) return true;
490
+ const wx = hasWildcard(sx);
491
+ const wy = hasWildcard(sy);
492
+ if (!wx && !wy) return false;
493
+ if (!wx && wy) return picomatch.isMatch(sx, sy, { dot: true });
494
+ if (wx && !wy) return picomatch.isMatch(sy, sx, { dot: true });
495
+ const starToken = /^\*([^*?[{]+)\*$/;
496
+ const mx = starToken.exec(sx);
497
+ const my = starToken.exec(sy);
498
+ if (mx && my) {
499
+ const tx = mx[1];
500
+ const ty = my[1];
501
+ return tx.includes(ty) || ty.includes(tx);
502
+ }
503
+ return true;
504
+ }
505
+ function twoGlobsOverlap(x, y) {
506
+ const xs = normalizeGlob(x).split("/");
507
+ const ys = normalizeGlob(y).split("/");
508
+ const len = Math.min(xs.length, ys.length);
509
+ for (let i = 0; i < len; i++) {
510
+ if (xs[i] === "**" || ys[i] === "**") return true;
511
+ if (!segmentsOverlap(xs[i], ys[i])) return false;
512
+ }
513
+ if (xs.length === ys.length) return true;
514
+ const longer = xs.length > ys.length ? xs : ys;
515
+ return longer[len] === "**";
332
516
  }
333
517
  function globsOverlap(a, b) {
334
518
  const pairs = [];
335
519
  for (const x of a) {
336
- const px = literalPrefix(x);
337
520
  for (const y of b) {
338
- const py = literalPrefix(y);
339
- if (px.startsWith(py) || py.startsWith(px)) pairs.push(`${x} ~ ${y}`);
521
+ if (twoGlobsOverlap(x, y)) pairs.push(`${x} ~ ${y}`);
340
522
  }
341
523
  }
342
524
  return pairs;
@@ -350,7 +532,7 @@ function commonsTouched(m, paths) {
350
532
  return Object.entries(m.commons).filter(([, globs]) => pathsMatching(paths, globs).length > 0).map(([name]) => name);
351
533
  }
352
534
 
353
- // ../../platform/core/workkit-lib/src/guards.ts
535
+ // src/core/guards.ts
354
536
  function assertNotProtected(m, branch) {
355
537
  if (m.protectedBranches.includes(branch)) {
356
538
  throw new WorkkitError(
@@ -367,19 +549,25 @@ var same = (a, b) => {
367
549
  return a === b;
368
550
  }
369
551
  };
552
+ var effortLabel = (e) => e.task ? `${e.area}--${e.task}` : e.area;
370
553
  function checkCommit(c) {
371
554
  const refusals = [];
555
+ const warnings = [];
372
556
  if (c.m.protectedBranches.includes(c.branch)) {
373
557
  refusals.push(
374
558
  `branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
375
559
  );
376
560
  }
377
- const efforts = new Map(c.registry.efforts({ project: c.project }).map((e) => [e.id, e]));
561
+ const allEfforts = c.registry.efforts({ project: c.project });
562
+ const efforts = new Map(allEfforts.map((e) => [e.id, e]));
378
563
  for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
379
- if (lease.owner.session === c.ownerSession) continue;
380
564
  const holder = efforts.get(lease.effort);
381
- if (holder && c.checkoutRoot && existsSync2(holder.worktree) && same(holder.worktree, c.checkoutRoot))
565
+ const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
566
+ if (holder && holderHasWorktree && c.checkoutRoot) {
567
+ if (same(holder.worktree, c.checkoutRoot)) continue;
568
+ } else if (lease.owner.session === c.ownerSession) {
382
569
  continue;
570
+ }
383
571
  const globs = c.m.commons[String(lease.value)] ?? [];
384
572
  const hit = pathsMatching(c.stagedPaths, globs);
385
573
  if (hit.length > 0) {
@@ -388,7 +576,33 @@ function checkCommit(c) {
388
576
  );
389
577
  }
390
578
  }
391
- return { refusals };
579
+ const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
580
+ for (const other of openEfforts) {
581
+ if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
582
+ continue;
583
+ }
584
+ if (!c.checkoutRoot && other.branch === c.branch) continue;
585
+ const hit = pathsMatching(c.stagedPaths, other.scopes);
586
+ if (hit.length > 0) {
587
+ refusals.push(
588
+ `path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
589
+ );
590
+ }
591
+ }
592
+ const mine = openEfforts.find(
593
+ (e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
594
+ );
595
+ if (mine && c.stagedPaths.length > 0) {
596
+ const allowed = allowedScopesForEffort(c.m, mine);
597
+ const inScope = new Set(pathsMatching(c.stagedPaths, allowed));
598
+ const outside = c.stagedPaths.filter((p) => !inScope.has(p));
599
+ if (outside.length > 0) {
600
+ warnings.push(
601
+ `${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
602
+ );
603
+ }
604
+ }
605
+ return { refusals, warnings };
392
606
  }
393
607
  function checkPush(c) {
394
608
  const refusals = [];
@@ -401,15 +615,15 @@ function checkPush(c) {
401
615
  return { refusals, warnings };
402
616
  }
403
617
  if (c.isDelete) return { refusals, warnings };
404
- const effort = c.registry.efforts({ project: c.project, state: "active" }).find((e) => e.branch === c.branch);
618
+ const effort = c.registry.efforts({ project: c.project }).find((e) => (e.state === "active" || e.state === "parked") && e.branch === c.branch);
405
619
  const commons = commonsTouched(c.m, c.changedPaths);
406
620
  if (effort) {
407
- const allCommonsGlobs = Object.values(c.m.commons).flat();
408
- const inScope = new Set(pathsMatching(c.changedPaths, [...effort.scopes, ...allCommonsGlobs]));
621
+ const allowed = allowedScopesForEffort(c.m, effort);
622
+ const inScope = new Set(pathsMatching(c.changedPaths, allowed));
409
623
  const outside = c.changedPaths.filter((p) => !inScope.has(p));
410
624
  if (outside.length > 0) {
411
625
  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`
626
+ `${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
627
  );
414
628
  }
415
629
  }
@@ -418,13 +632,13 @@ function checkPush(c) {
418
632
  return { refusals, warnings };
419
633
  }
420
634
 
421
- // ../../platform/core/workkit-lib/src/hooks.ts
635
+ // src/core/hooks.ts
422
636
  import { chmodSync, lstatSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
423
- import { join as join3 } from "path";
637
+ import { join as join4 } from "path";
424
638
 
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";
639
+ // src/core/manifest.ts
640
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
641
+ import { join as join3 } from "path";
428
642
  import { pathToFileURL } from "url";
429
643
  import { z } from "zod";
430
644
  var MANIFEST_FILES = [
@@ -434,14 +648,23 @@ var MANIFEST_FILES = [
434
648
  ];
435
649
  var BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
436
650
  var BRANCH_NAME_HINT = 'must be a plain ref name: letters, digits, ".", "_", "/", "-"';
651
+ var TASK_SLUG = /^(?!.*--)[a-z0-9][a-z0-9_-]{0,47}$/;
437
652
  var Service = z.object({
438
653
  start: z.string().min(1),
439
654
  stop: z.string().min(1),
440
655
  health: z.string().min(1),
441
656
  healthyCodes: z.array(z.number().int()).optional(),
442
657
  reset: z.string().optional(),
443
- healthTimeoutMs: z.number().int().positive().optional()
658
+ healthTimeoutMs: z.number().int().positive().optional(),
659
+ modes: z.array(z.enum(["backend", "fullstack"])).optional()
444
660
  });
661
+ var ExtraScopes = z.union([
662
+ z.array(z.string()),
663
+ z.object({
664
+ frontend: z.array(z.string()).optional(),
665
+ backend: z.array(z.string()).optional()
666
+ })
667
+ ]);
445
668
  var Schema = z.object({
446
669
  project: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
447
670
  baseBranch: z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT),
@@ -459,21 +682,24 @@ var Schema = z.object({
459
682
  storybook: z.object({ hostRoot: z.string() }).optional(),
460
683
  logs: z.object({ dir: z.string() }).default({ dir: ".logs" }),
461
684
  env: z.object({ file: z.string(), template: z.record(z.string(), z.string()) }).optional(),
685
+ seed: z.array(z.string().min(1)).optional(),
462
686
  refresh: z.array(
463
687
  z.object({
464
688
  when: z.string().optional(),
465
689
  always: z.boolean().optional(),
466
690
  run: z.string().optional(),
467
- mark: z.string().optional()
691
+ mark: z.string().optional(),
692
+ restoreWhenIfClean: z.boolean().optional()
468
693
  })
469
694
  ).default([]),
470
695
  scopes: z.object({ frontend: z.array(z.string()), backend: z.array(z.string()) }),
471
696
  areas: z.record(
472
- z.string().regex(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/),
697
+ z.string().regex(/^(?!.*--)[a-z0-9-]+(\/[a-z0-9-]+)*$/),
473
698
  z.object({
474
699
  module: z.string().optional(),
475
700
  commons: z.array(z.string()).optional(),
476
- extraScopes: z.array(z.string()).optional()
701
+ extraScopes: ExtraScopes.optional(),
702
+ sharedScopes: z.array(z.string()).optional()
477
703
  })
478
704
  ),
479
705
  commons: z.record(z.string(), z.array(z.string())).default({}),
@@ -496,13 +722,21 @@ function templateVariables(m) {
496
722
  "AREA",
497
723
  "BRANCH",
498
724
  "WORKTREE",
499
- "PORT_STORYBOOK"
725
+ "PORT_STORYBOOK",
726
+ "STORYBROKR_HOST"
500
727
  ]);
501
728
  for (const s of Object.keys(m.ports.services)) vars.add(`PORT_${s.toUpperCase()}`);
502
729
  return vars;
503
730
  }
504
731
  function defaultEnv(services) {
505
- const template = { SLOT: "${SLOT}", LOG_DIR: "${LOG_DIR}" };
732
+ const template = {
733
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
734
+ SLOT: "${SLOT}",
735
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
736
+ LOG_DIR: "${LOG_DIR}",
737
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
738
+ STORYBROKR_HOST: "${STORYBROKR_HOST}"
739
+ };
506
740
  for (const s of services) template[`PORT_${s.toUpperCase()}`] = `\${PORT_${s.toUpperCase()}}`;
507
741
  return { file: ".env.slot", template };
508
742
  }
@@ -555,8 +789,8 @@ function validateManifest(raw) {
555
789
  }
556
790
  async function loadManifest(repoRoot) {
557
791
  for (const name of MANIFEST_FILES) {
558
- const file = join2(repoRoot, name);
559
- if (!existsSync3(file)) continue;
792
+ const file = join3(repoRoot, name);
793
+ if (!existsSync4(file)) continue;
560
794
  if (name.endsWith(".json")) return validateManifest(JSON.parse(readFileSync2(file, "utf8")));
561
795
  const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
562
796
  return validateManifest(mod.default);
@@ -567,7 +801,7 @@ async function loadManifest(repoRoot) {
567
801
  );
568
802
  }
569
803
 
570
- // ../../platform/core/workkit-lib/src/hooks.ts
804
+ // src/core/hooks.ts
571
805
  var HOOKS_DIR = ".helmsmith/hooks";
572
806
  function preCommitScript(protectedBranches) {
573
807
  return `#!/bin/sh
@@ -580,11 +814,21 @@ for p in $PROTECTED; do
580
814
  exit 2
581
815
  fi
582
816
  done
583
- if ! command -v workbrokr >/dev/null 2>&1; then
817
+ if command -v workbrokr >/dev/null 2>&1; then
818
+ workbrokr hook pre-commit "$@" || exit $?
819
+ else
584
820
  echo "work: workbrokr is not on PATH; commons and scope checks skipped (npm i -g @helmsmith/workbrokr)" >&2
585
- exit 0
586
821
  fi
587
- exec workbrokr hook pre-commit "$@"
822
+ COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
823
+ LEGACY="$COMMON_DIR/hooks/pre-commit"
824
+ if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
825
+ SELF_DIR=$(cd "$(dirname "$0")" && pwd)
826
+ LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
827
+ if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
828
+ exec "$LEGACY" "$@"
829
+ fi
830
+ fi
831
+ exit 0
588
832
  `;
589
833
  }
590
834
  function prePushScript(protectedBranches) {
@@ -602,11 +846,21 @@ printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
602
846
  fi
603
847
  done
604
848
  done || exit 2
605
- if ! command -v workbrokr >/dev/null 2>&1; then
849
+ if command -v workbrokr >/dev/null 2>&1; then
850
+ printf '%s\\n' "$input" | workbrokr hook pre-push "$@" || exit $?
851
+ else
606
852
  echo "work: workbrokr is not on PATH; scope and commons warnings skipped" >&2
607
- exit 0
608
853
  fi
609
- printf '%s\\n' "$input" | exec workbrokr hook pre-push "$@"
854
+ COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
855
+ LEGACY="$COMMON_DIR/hooks/pre-push"
856
+ if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
857
+ SELF_DIR=$(cd "$(dirname "$0")" && pwd)
858
+ LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
859
+ if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
860
+ printf '%s\\n' "$input" | exec "$LEGACY" "$@"
861
+ fi
862
+ fi
863
+ exit 0
610
864
  `;
611
865
  }
612
866
  function installHooks(repoRoot, opts) {
@@ -614,7 +868,7 @@ function installHooks(repoRoot, opts) {
614
868
  if (!BRANCH_NAME.test(b))
615
869
  throw new WorkkitError("MANIFEST_INVALID", `protected branch "${b}" is not a plain ref name`);
616
870
  }
617
- const hooksDir = join3(repoRoot, HOOKS_DIR);
871
+ const hooksDir = join4(repoRoot, HOOKS_DIR);
618
872
  mkdirSync3(hooksDir, { recursive: true });
619
873
  const files = [];
620
874
  const scripts = [
@@ -622,7 +876,7 @@ function installHooks(repoRoot, opts) {
622
876
  ["pre-push", prePushScript(opts.protectedBranches)]
623
877
  ];
624
878
  for (const [name, body] of scripts) {
625
- const file = join3(hooksDir, name);
879
+ const file = join4(hooksDir, name);
626
880
  let existing;
627
881
  try {
628
882
  existing = lstatSync(file);
@@ -642,16 +896,16 @@ function installHooks(repoRoot, opts) {
642
896
  return { hooksDir, files };
643
897
  }
644
898
 
645
- // ../../platform/core/workkit-lib/src/paths.ts
899
+ // src/core/paths.ts
646
900
  import { homedir } from "os";
647
- import { join as join4 } from "path";
901
+ import { join as join5 } from "path";
648
902
  function homeDir(env = process.env) {
649
- return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join4(homedir(), ".cache", "helmsmith");
903
+ return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join5(homedir(), ".cache", "helmsmith");
650
904
  }
651
- var registryFile = (home) => join4(home, "registry.json");
652
- var lockDir = (home) => join4(home, "registry.lock");
905
+ var registryFile = (home) => join5(home, "registry.json");
906
+ var lockDir = (home) => join5(home, "registry.lock");
653
907
 
654
- // ../../platform/core/workkit-lib/src/ports.ts
908
+ // src/core/ports.ts
655
909
  function portsForSlot(m, slot) {
656
910
  return Object.fromEntries(
657
911
  Object.entries(m.ports.services).map(([service, { base }]) => [service, base + slot])
@@ -687,25 +941,98 @@ function claimRanges(registry, m, repoRoot) {
687
941
  registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
688
942
  }
689
943
 
690
- // ../../platform/core/workkit-lib/src/refresh.ts
944
+ // src/core/refresh.ts
691
945
  import { spawnSync as spawnSync2 } from "child_process";
946
+ import { createHash } from "crypto";
947
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
948
+ import { dirname as dirname2, join as join6 } from "path";
692
949
  import picomatch2 from "picomatch";
693
950
  function matchesWhen(glob) {
694
951
  const isMatch = picomatch2(glob, { dot: true });
695
952
  return (p) => isMatch(p);
696
953
  }
697
- function selectRefresh(entries, changedPaths) {
954
+ function readStamps(stampFile) {
955
+ if (!stampFile || !existsSync5(stampFile)) return {};
956
+ try {
957
+ return JSON.parse(readFileSync3(stampFile, "utf8"));
958
+ } catch {
959
+ return {};
960
+ }
961
+ }
962
+ function writeStamps(stampFile, stamps) {
963
+ mkdirSync4(dirname2(stampFile), { recursive: true });
964
+ writeFileSync3(stampFile, `${JSON.stringify(stamps, null, 2)}
965
+ `);
966
+ }
967
+ function matchedFilesInWorktree(worktree, whenGlob) {
968
+ if (!/[*?[{]/.test(whenGlob)) {
969
+ return existsSync5(join6(worktree, whenGlob)) ? [whenGlob] : [];
970
+ }
971
+ const r = spawnSync2("git", ["ls-files"], { cwd: worktree, encoding: "utf8" });
972
+ if (r.status !== 0) return [];
973
+ const isMatch = matchesWhen(whenGlob);
974
+ return (r.stdout ?? "").split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && isMatch(l) && existsSync5(join6(worktree, l)));
975
+ }
976
+ function hashFiles(worktree, relPaths) {
977
+ if (relPaths.length === 0) return null;
978
+ const h = createHash("sha256");
979
+ for (const rel of [...relPaths].sort()) {
980
+ const abs = join6(worktree, rel);
981
+ if (!existsSync5(abs)) continue;
982
+ h.update(`${rel}\0`);
983
+ h.update(readFileSync3(abs));
984
+ h.update("\0");
985
+ }
986
+ return h.digest("hex");
987
+ }
988
+ function isFileCleanInGit(worktree, relPath) {
989
+ const r = spawnSync2("git", ["status", "--porcelain", "--", relPath], {
990
+ cwd: worktree,
991
+ encoding: "utf8"
992
+ });
993
+ return r.status === 0 && (r.stdout ?? "").trim().length === 0;
994
+ }
995
+ function selectRefresh(entries, changedPaths, opts) {
698
996
  const runs = [];
699
997
  const marks = [];
998
+ const stored = readStamps(opts?.stampFile);
999
+ const pendingStamps = { ...stored };
1000
+ const cleanWhenFiles = /* @__PURE__ */ new Set();
700
1001
  for (const e of entries) {
701
- const hit = e.always === true || e.when !== void 0 && changedPaths.some(matchesWhen(e.when));
1002
+ let hit = e.always === true;
1003
+ let matchedFiles = [];
1004
+ if (e.when !== void 0) {
1005
+ const byChanged = changedPaths.some(matchesWhen(e.when));
1006
+ if (opts?.worktree) {
1007
+ matchedFiles = matchedFilesInWorktree(opts.worktree, e.when);
1008
+ const digest = hashFiles(opts.worktree, matchedFiles);
1009
+ if (digest !== null) {
1010
+ if (stored[e.when] !== digest) hit = true;
1011
+ pendingStamps[e.when] = digest;
1012
+ }
1013
+ }
1014
+ if (byChanged) hit = true;
1015
+ }
702
1016
  if (!hit) continue;
703
- if (e.run) runs.push(e.run);
1017
+ if (e.run) {
1018
+ runs.push(e.run);
1019
+ if (opts?.worktree && e.when && e.restoreWhenIfClean !== false) {
1020
+ for (const f of matchedFiles) {
1021
+ if (isFileCleanInGit(opts.worktree, f)) cleanWhenFiles.add(f);
1022
+ }
1023
+ }
1024
+ }
704
1025
  if (e.mark) marks.push(e.mark);
705
1026
  }
706
- return { runs, marks };
1027
+ if (!opts?.worktree) return { runs, marks };
1028
+ return {
1029
+ runs,
1030
+ marks,
1031
+ pendingStamps,
1032
+ cleanWhenFiles: [...cleanWhenFiles]
1033
+ };
707
1034
  }
708
- function runRefresh(runs, cwd, log, env = process.env) {
1035
+ function runRefresh(runs, cwd, log, env = process.env, opts) {
709
1036
  for (const cmd of runs) {
710
1037
  log(`$ ${cmd}`);
711
1038
  const r = spawnSync2("sh", ["-c", cmd], { cwd, env, encoding: "utf8" });
@@ -718,18 +1045,28 @@ function runRefresh(runs, cwd, log, env = process.env) {
718
1045
  });
719
1046
  }
720
1047
  }
1048
+ if (opts?.restoreFiles && opts.restoreFiles.length > 0) {
1049
+ for (const rel of opts.restoreFiles) {
1050
+ if (!isFileCleanInGit(cwd, rel)) {
1051
+ spawnSync2("git", ["checkout", "--", rel], { cwd, encoding: "utf8" });
1052
+ }
1053
+ }
1054
+ }
1055
+ if (opts?.stampFile && opts.pendingStamps) {
1056
+ writeStamps(opts.stampFile, opts.pendingStamps);
1057
+ }
721
1058
  }
722
1059
 
723
- // ../../platform/core/workkit-lib/src/registry.ts
1060
+ // src/core/registry.ts
724
1061
  import { randomBytes } from "crypto";
725
1062
  import {
726
- existsSync as existsSync4,
727
- mkdirSync as mkdirSync4,
728
- readFileSync as readFileSync3,
1063
+ existsSync as existsSync6,
1064
+ mkdirSync as mkdirSync5,
1065
+ readFileSync as readFileSync4,
729
1066
  renameSync,
730
- rmSync,
731
- statSync,
732
- writeFileSync as writeFileSync3
1067
+ rmSync as rmSync2,
1068
+ statSync as statSync2,
1069
+ writeFileSync as writeFileSync4
733
1070
  } from "fs";
734
1071
  var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
735
1072
  var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
@@ -746,16 +1083,16 @@ var Registry = class _Registry {
746
1083
  home;
747
1084
  now;
748
1085
  static open(home, now2 = () => /* @__PURE__ */ new Date()) {
749
- mkdirSync4(home, { recursive: true });
1086
+ mkdirSync5(home, { recursive: true });
750
1087
  return new _Registry(home, now2);
751
1088
  }
752
1089
  /** The registry as it is on disk. A file that does not parse is refused, never replaced. */
753
1090
  read() {
754
1091
  const file = registryFile(this.home);
755
- if (!existsSync4(file)) return structuredClone(EMPTY);
1092
+ if (!existsSync6(file)) return structuredClone(EMPTY);
756
1093
  let parsed;
757
1094
  try {
758
- parsed = JSON.parse(readFileSync3(file, "utf8"));
1095
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
759
1096
  } catch (err) {
760
1097
  throw new WorkkitError(
761
1098
  "INTERNAL",
@@ -785,12 +1122,12 @@ var Registry = class _Registry {
785
1122
  const started = Date.now();
786
1123
  for (; ; ) {
787
1124
  try {
788
- mkdirSync4(lock);
1125
+ mkdirSync5(lock);
789
1126
  break;
790
1127
  } catch {
791
1128
  try {
792
- if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
793
- rmSync(lock, { recursive: true, force: true });
1129
+ if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
1130
+ rmSync2(lock, { recursive: true, force: true });
794
1131
  continue;
795
1132
  }
796
1133
  } catch {
@@ -810,12 +1147,12 @@ var Registry = class _Registry {
810
1147
  const out = fn(r);
811
1148
  const file = registryFile(this.home);
812
1149
  const tmp = `${file}.${process.pid}.tmp`;
813
- writeFileSync3(tmp, `${JSON.stringify(r, null, 2)}
1150
+ writeFileSync4(tmp, `${JSON.stringify(r, null, 2)}
814
1151
  `);
815
1152
  renameSync(tmp, file);
816
1153
  return out;
817
1154
  } finally {
818
- rmSync(lock, { recursive: true, force: true });
1155
+ rmSync2(lock, { recursive: true, force: true });
819
1156
  }
820
1157
  }
821
1158
  project(name) {
@@ -836,18 +1173,29 @@ var Registry = class _Registry {
836
1173
  (e) => Object.entries(filter).every(([k, v]) => e[k] === v)
837
1174
  );
838
1175
  }
839
- activeEffortFor(project, area) {
840
- return this.efforts({ project, area, state: "active" })[0];
1176
+ activeEffortFor(project, area, task) {
1177
+ return this.efforts({ project, area, state: "active" }).find(
1178
+ (e) => (e.task ?? void 0) === (task ?? void 0)
1179
+ );
841
1180
  }
842
- /** An effort that is being started counts as open: nobody else may start on the area meanwhile. */
843
- openEffortFor(project, area) {
1181
+ /** An effort that is being started counts as open: nobody else may start on the same (area, task) meanwhile. */
1182
+ openEffortFor(project, area, task) {
844
1183
  return this.efforts({ project, area }).find(
845
- (e) => e.state === "starting" || e.state === "active"
1184
+ (e) => (e.state === "starting" || e.state === "active") && (e.task ?? void 0) === (task ?? void 0)
846
1185
  );
847
1186
  }
848
1187
  addNotice(project, area, notice) {
849
1188
  this.update((r) => {
850
- r.notices = [...r.notices ?? [], { project, area, notice }];
1189
+ const existing = r.notices ?? [];
1190
+ const dup = existing.findIndex(
1191
+ (n) => n.project === project && n.area === area && n.notice.from === notice.from && n.notice.text === notice.text
1192
+ );
1193
+ if (dup >= 0) {
1194
+ existing[dup] = { project, area, notice };
1195
+ r.notices = existing;
1196
+ } else {
1197
+ r.notices = [...existing, { project, area, notice }];
1198
+ }
851
1199
  });
852
1200
  }
853
1201
  notices(project, area) {
@@ -858,6 +1206,21 @@ var Registry = class _Registry {
858
1206
  r.notices = (r.notices ?? []).filter((n) => !(n.project === project && n.area === area));
859
1207
  });
860
1208
  }
1209
+ clearNoticesFrom(project, branches) {
1210
+ const set = new Set(branches);
1211
+ this.update((r) => {
1212
+ r.notices = (r.notices ?? []).filter(
1213
+ (n) => !(n.project === project && set.has(n.notice.from))
1214
+ );
1215
+ });
1216
+ }
1217
+ pruneNoticesWhere(project, area, remove) {
1218
+ this.update((r) => {
1219
+ r.notices = (r.notices ?? []).filter(
1220
+ (n) => !(n.project === project && n.area === area && remove(n.notice))
1221
+ );
1222
+ });
1223
+ }
861
1224
  allocateSlot(project, m, owner, effort, exclude = /* @__PURE__ */ new Set()) {
862
1225
  return this.update((r) => {
863
1226
  const taken = new Set(
@@ -958,10 +1321,10 @@ var Registry = class _Registry {
958
1321
  }
959
1322
  };
960
1323
 
961
- // ../../platform/core/workkit-lib/src/runner.ts
1324
+ // src/core/runner.ts
962
1325
  import { spawn, spawnSync as spawnSync3 } from "child_process";
963
1326
  import { closeSync, openSync } from "fs";
964
- import { join as join5 } from "path";
1327
+ import { join as join7 } from "path";
965
1328
  var DEFAULT_HEALTH_TIMEOUT_MS = 18e4;
966
1329
  var HEALTH_POLL_MS = 1e3;
967
1330
  var PROBE_CAP_MS = 1e4;
@@ -980,6 +1343,10 @@ function pidSignature(pid) {
980
1343
  const r = spawnSync3("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
981
1344
  return r.status === 0 ? (r.stdout ?? "").trim() : "";
982
1345
  }
1346
+ function serviceModes(name, spec) {
1347
+ if (spec.modes && spec.modes.length > 0) return spec.modes;
1348
+ return name === "frontend" ? ["fullstack"] : ["backend", "fullstack"];
1349
+ }
983
1350
  function probe(spec, cwd, env, timeoutMs = PROBE_CAP_MS) {
984
1351
  const r = spawnSync3("sh", ["-c", spec.health], {
985
1352
  cwd,
@@ -1013,7 +1380,7 @@ async function waitHealthy(o) {
1013
1380
  );
1014
1381
  }
1015
1382
  function spawnService(name, spec, a) {
1016
- const fd = openSync(join5(a.logDir, `${name}.log`), "a");
1383
+ const fd = openSync(join7(a.logDir, `${name}.log`), "a");
1017
1384
  try {
1018
1385
  const child = spawn("sh", ["-c", spec.start], {
1019
1386
  cwd: a.cwd,
@@ -1035,7 +1402,12 @@ function spawnService(name, spec, a) {
1035
1402
  async function startServices(a) {
1036
1403
  const pids = {};
1037
1404
  const signatures = {};
1038
- for (const [name, spec] of Object.entries(a.m.services)) {
1405
+ const targetMode = a.mode ?? "fullstack";
1406
+ const entries = Object.entries(a.m.services).filter(([name, spec]) => {
1407
+ if (a.onlyServices) return a.onlyServices.includes(name);
1408
+ return serviceModes(name, spec).includes(targetMode);
1409
+ });
1410
+ for (const [name, spec] of entries) {
1039
1411
  a.onLog?.(`starting ${name}: ${spec.start}`);
1040
1412
  pids[name] = spawnService(name, spec, a);
1041
1413
  signatures[name] = pidSignature(pids[name]);
@@ -1099,7 +1471,7 @@ async function stopServices(o) {
1099
1471
  while (alive().length > 0 && Date.now() < hardDeadline) await sleep2(100);
1100
1472
  }
1101
1473
 
1102
- // ../../platform/core/workkit-lib/src/session.ts
1474
+ // src/core/session.ts
1103
1475
  import { hostname, userInfo } from "os";
1104
1476
  function currentOwner(env = process.env) {
1105
1477
  const named = [env.WORK_SESSION, env.CLAUDE_SESSION_NAME, env.CLAUDE_CODE_SESSION_ID].find(
@@ -1108,11 +1480,19 @@ function currentOwner(env = process.env) {
1108
1480
  return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
1109
1481
  }
1110
1482
 
1111
- // src/lib/context.ts
1112
- import chalk from "chalk";
1113
-
1114
1483
  // src/lib/storybrokr.ts
1115
1484
  import { spawnSync as spawnSync4 } from "child_process";
1485
+ var MIN_STORYBROKR_VERSION = "0.3.0";
1486
+ var versionChecked = false;
1487
+ function compareSemver(a, b) {
1488
+ const pa = a.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
1489
+ const pb = b.trim().replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
1490
+ for (let i = 0; i < 3; i++) {
1491
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
1492
+ if (diff !== 0) return diff;
1493
+ }
1494
+ return 0;
1495
+ }
1116
1496
  function run(args) {
1117
1497
  const r = spawnSync4("storybrokr", args, { encoding: "utf8" });
1118
1498
  if (r.error) {
@@ -1123,9 +1503,24 @@ function run(args) {
1123
1503
  }
1124
1504
  return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
1125
1505
  }
1506
+ function assertStorybrokrVersion() {
1507
+ if (versionChecked) return;
1508
+ const r = run(["--version"]);
1509
+ const found = r.out.trim();
1510
+ if (r.code !== 0 || compareSemver(found, MIN_STORYBROKR_VERSION) < 0) {
1511
+ throw new WorkkitError(
1512
+ "STORYBROKR_FAILED",
1513
+ `storybrokr >= ${MIN_STORYBROKR_VERSION} required (found ${found || "unknown"}); upgrade @helmsmith/storybrokr`
1514
+ );
1515
+ }
1516
+ versionChecked = true;
1517
+ }
1126
1518
  var storybrokrCli = {
1127
- async up(story, hostRoot) {
1128
- const r = run(["up", story, "--host", hostRoot, "--json"]);
1519
+ async up(story, hostRoot, opts) {
1520
+ assertStorybrokrVersion();
1521
+ const args = ["up", story, "--host", hostRoot, "--json"];
1522
+ if (opts?.ttlMinutes !== void 0) args.push("--ttl", String(opts.ttlMinutes));
1523
+ const r = run(args);
1129
1524
  if (r.code !== 0) {
1130
1525
  throw new WorkkitError(
1131
1526
  "STORYBROKR_FAILED",
@@ -1139,6 +1534,14 @@ var storybrokrCli = {
1139
1534
  const r = run(["down", id]);
1140
1535
  if (r.code !== 0)
1141
1536
  throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
1537
+ },
1538
+ async downHost(hostRoot) {
1539
+ const r = spawnSync4("storybrokr", ["down", "--host", hostRoot, "--json"], { encoding: "utf8" });
1540
+ void r;
1541
+ },
1542
+ async touch(id) {
1543
+ const r = spawnSync4("storybrokr", ["touch", id], { encoding: "utf8" });
1544
+ void r;
1142
1545
  }
1143
1546
  };
1144
1547
 
@@ -1170,26 +1573,32 @@ async function buildCtx(opts = {}) {
1170
1573
  import {
1171
1574
  appendFileSync as appendFileSync2,
1172
1575
  cpSync,
1173
- existsSync as existsSync5,
1174
- mkdirSync as mkdirSync5,
1576
+ existsSync as existsSync7,
1577
+ mkdirSync as mkdirSync6,
1175
1578
  readdirSync,
1176
1579
  realpathSync as realpathSync4,
1177
- rmSync as rmSync2,
1178
- statSync as statSync2
1580
+ rmSync as rmSync3,
1581
+ statSync as statSync3
1179
1582
  } from "fs";
1180
1583
  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}`;
1584
+ import { dirname as dirname3, join as join8 } from "path";
1585
+ var worktreePathFor = (ctx, area, task) => task ? join8(ctx.repoRoot, ctx.manifest.worktreeRoot, "_tasks", `${area}--${task}`) : join8(ctx.repoRoot, ctx.manifest.worktreeRoot, area);
1586
+ var branchFor = (type, area, task) => task ? `${type}/${area}--${task}` : `${type}/${area}`;
1587
+ var effortLabel2 = (area, task) => task ? `${area}--${task}` : area;
1184
1588
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
1185
1589
  var STARTING_GRACE_MS = 30 * 6e4;
1186
- var realpathOr = (p) => {
1590
+ var JOIN_WAIT_MS = 6e4;
1591
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1592
+ var realpathOr2 = (p) => {
1187
1593
  try {
1188
1594
  return realpathSync4(p);
1189
1595
  } catch {
1190
1596
  return p;
1191
1597
  }
1192
1598
  };
1599
+ function fetchBaseOnce(ctx) {
1600
+ new Git(ctx.repoRoot).tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
1601
+ }
1193
1602
  function hoursFor(m, kind) {
1194
1603
  return {
1195
1604
  slot: m.expiry.slotHours,
@@ -1197,16 +1606,28 @@ function hoursFor(m, kind) {
1197
1606
  commons: m.expiry.commonsHours
1198
1607
  }[kind];
1199
1608
  }
1609
+ function modeCovers(existing, requested) {
1610
+ if (existing === "fullstack") return true;
1611
+ return existing === requested;
1612
+ }
1613
+ var STORYBROKR_TOUCH_THROTTLE_MS = 6e4;
1200
1614
  function touchEffort(ctx, effort) {
1201
- for (const l of ctx.registry.leases({ effort: effort.id }))
1615
+ const prevMs = Date.parse(effort.lastActivity);
1616
+ const shouldTouchStorybrokr = Number.isNaN(prevMs) || Date.now() - prevMs >= STORYBROKR_TOUCH_THROTTLE_MS;
1617
+ for (const l of ctx.registry.leases({ effort: effort.id })) {
1202
1618
  ctx.registry.touch(l.id, hoursFor(ctx.manifest, l.kind));
1619
+ if (l.kind === "storybook" && shouldTouchStorybrokr) {
1620
+ void Promise.resolve(ctx.storybrokr.touch?.(String(l.value))).catch(() => {
1621
+ });
1622
+ }
1623
+ }
1203
1624
  ctx.registry.updateEffort(effort.id, { lastActivity: now() });
1204
1625
  }
1205
1626
  function portBusy(port) {
1206
- return new Promise((resolve3) => {
1627
+ return new Promise((resolve4) => {
1207
1628
  const srv = createServer();
1208
- srv.once("error", () => resolve3(true));
1209
- srv.listen(port, "127.0.0.1", () => srv.close(() => resolve3(false)));
1629
+ srv.once("error", () => resolve4(true));
1630
+ srv.listen(port, "127.0.0.1", () => srv.close(() => resolve4(false)));
1210
1631
  });
1211
1632
  }
1212
1633
  async function busyPorts(ports) {
@@ -1216,11 +1637,11 @@ async function busyPorts(ports) {
1216
1637
  return out;
1217
1638
  }
1218
1639
  function portAnswers(port) {
1219
- return new Promise((resolve3) => {
1640
+ return new Promise((resolve4) => {
1220
1641
  const sock = connect({ port, host: "127.0.0.1" });
1221
1642
  const done = (v) => {
1222
1643
  sock.destroy();
1223
- resolve3(v);
1644
+ resolve4(v);
1224
1645
  };
1225
1646
  sock.setTimeout(300, () => done(false));
1226
1647
  sock.once("connect", () => done(true));
@@ -1231,6 +1652,23 @@ async function anyPortAnswers(ports) {
1231
1652
  for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
1232
1653
  return false;
1233
1654
  }
1655
+ async function claimFreeSlot(ctx, effortId, warnings) {
1656
+ const m = ctx.manifest;
1657
+ const exclude = /* @__PURE__ */ new Set();
1658
+ const busyNotes = [];
1659
+ for (; ; ) {
1660
+ const candidate = ctx.registry.allocateSlot(m.project, m, ctx.owner, effortId, exclude);
1661
+ const busy = await busyPorts(candidate.ports ?? {});
1662
+ if (busy.length === 0) {
1663
+ if (busyNotes.length) warnings.push(...busyNotes);
1664
+ return candidate;
1665
+ }
1666
+ ctx.registry.release(candidate.id);
1667
+ exclude.add(candidate.value);
1668
+ busyNotes.push(`slot ${candidate.value}: ${busy.join(", ")} already in use`);
1669
+ ctx.log(busyNotes[busyNotes.length - 1]);
1670
+ }
1671
+ }
1234
1672
  function guardStart(ctx, o) {
1235
1673
  const m = ctx.manifest;
1236
1674
  const spec = m.areas[o.area];
@@ -1240,28 +1678,37 @@ function guardStart(ctx, o) {
1240
1678
  `unknown area "${o.area}" (known: ${Object.keys(m.areas).join(", ")})`,
1241
1679
  { area: o.area }
1242
1680
  );
1681
+ if (o.task !== void 0 && !TASK_SLUG.test(o.task))
1682
+ throw new WorkkitError(
1683
+ "TASK_INVALID",
1684
+ `invalid task slug "${o.task}" (must match /^[a-z0-9][a-z0-9_-]{0,47}$/ with no "--")`,
1685
+ { task: o.task }
1686
+ );
1243
1687
  if (!m.branch.types.includes(o.type))
1244
1688
  throw new WorkkitError(
1245
1689
  "TYPE_UNKNOWN",
1246
1690
  `unknown type "${o.type}" (allowed: ${m.branch.types.join(", ")})`,
1247
1691
  { type: o.type }
1248
1692
  );
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);
1693
+ if (o.mode !== "frontend" && o.mode !== "backend" && o.mode !== "fullstack")
1694
+ throw new WorkkitError(
1695
+ "MODE_UNKNOWN",
1696
+ `unknown mode "${o.mode}" (frontend | backend | fullstack)`
1697
+ );
1698
+ const branch = branchFor(o.type, o.area, o.task);
1252
1699
  assertNotProtected(m, branch);
1253
- const scopes = expandScopes(m, o.area, o.mode);
1700
+ const scopes = expandScopes(m, o.area, o.mode, o.scopes, Boolean(o.task));
1254
1701
  const commons = spec.commons ?? [];
1255
1702
  const warnings = [];
1256
1703
  if (commons.length > 0) {
1257
1704
  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))
1705
+ if (e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0) || e.state !== "active" && e.state !== "starting" || !existsSync7(e.worktree))
1259
1706
  continue;
1260
1707
  const changed = new Git(e.worktree).tryRun(["diff", "--name-only", "--no-renames", `origin/${m.baseBranch}...HEAD`]).out.split("\n").filter(Boolean);
1261
1708
  const hit = commonsTouched(m, changed).filter((c) => commons.includes(c));
1262
1709
  if (hit.length > 0)
1263
1710
  warnings.push(
1264
- `${e.area} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
1711
+ `${effortLabel2(e.area, e.task)} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
1265
1712
  );
1266
1713
  }
1267
1714
  }
@@ -1273,10 +1720,11 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1273
1720
  id: newId("eff"),
1274
1721
  project: m.project,
1275
1722
  area: o.area,
1723
+ ...o.task ? { task: o.task } : {},
1276
1724
  type: o.type,
1277
1725
  mode: o.mode,
1278
1726
  branch,
1279
- worktree: worktreePathFor(ctx, o.area),
1727
+ worktree: worktreePathFor(ctx, o.area, o.task),
1280
1728
  owner: ctx.owner,
1281
1729
  state: "starting",
1282
1730
  scopes,
@@ -1290,21 +1738,25 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1290
1738
  const open = r.efforts.filter(
1291
1739
  (e) => e.project === m.project && (e.state === "active" || e.state === "starting")
1292
1740
  );
1293
- const onArea = open.find((e) => e.area === o.area);
1294
- if (onArea) {
1741
+ const onTarget = open.find(
1742
+ (e) => e.area === o.area && (e.task ?? void 0) === (o.task ?? void 0)
1743
+ );
1744
+ if (onTarget) {
1745
+ const label = effortLabel2(o.area, o.task);
1295
1746
  throw new WorkkitError(
1296
1747
  "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 }
1748
+ `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`,
1749
+ { owner: onTarget.owner.session, effort: onTarget.id, branch: onTarget.branch }
1299
1750
  );
1300
1751
  }
1301
1752
  for (const e of open) {
1302
1753
  const pairs = globsOverlap(scopes, e.scopes);
1303
1754
  if (pairs.length > 0) {
1755
+ const otherLabel = effortLabel2(e.area, e.task);
1304
1756
  throw new WorkkitError(
1305
1757
  "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 }
1758
+ `scope overlaps the open effort on ${otherLabel} (${e.branch}, owner ${e.owner.session}): ${pairs.join("; ")}`,
1759
+ { with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
1308
1760
  );
1309
1761
  }
1310
1762
  }
@@ -1312,44 +1764,52 @@ function reserveEffort(ctx, o, branch, scopes, commons) {
1312
1764
  });
1313
1765
  return effort;
1314
1766
  }
1315
- function ensureWorktree(ctx, area, branch) {
1767
+ function ensureWorktree(ctx, area, branch, task) {
1316
1768
  const m = ctx.manifest;
1317
- const path = worktreePathFor(ctx, area);
1769
+ const path = worktreePathFor(ctx, area, task);
1318
1770
  const main = new Git(ctx.repoRoot);
1319
- main.fetch(m.baseBranch);
1320
- if (!existsSync5(path)) {
1771
+ if (!existsSync7(path)) {
1321
1772
  main.worktreeAdd(path, branch, `origin/${m.baseBranch}`);
1322
- return new Git(path);
1773
+ const wg2 = new Git(path);
1774
+ wg2.repairSubmodules(ctx.repoRoot, ctx.log);
1775
+ return { wg: wg2, created: true };
1323
1776
  }
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);
1777
+ const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) => realpathOr2(l.slice(9)));
1778
+ const real = realpathOr2(path);
1326
1779
  const wg = new Git(path);
1327
1780
  const topLevel = wg.tryRun(["rev-parse", "--show-toplevel"]);
1328
- if (!registered.includes(real) || topLevel.code !== 0 || realpathOr(topLevel.out.trim()) !== real || wg.isMainCheckout()) {
1781
+ if (!registered.includes(real) || topLevel.code !== 0 || realpathOr2(topLevel.out.trim()) !== real || wg.isMainCheckout()) {
1329
1782
  throw new WorkkitError(
1330
1783
  "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`,
1784
+ `${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
1785
  { worktree: path }
1333
1786
  );
1334
1787
  }
1788
+ wg.repairSubmodules(ctx.repoRoot, ctx.log);
1335
1789
  const current = wg.currentBranch();
1336
1790
  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
1791
  if (wg.isDirty())
1345
1792
  throw new WorkkitError(
1346
1793
  "DIRTY_WORKTREE",
1347
1794
  `${path} has uncommitted changes on ${current}; commit or park them before switching to ${branch}`,
1348
1795
  { worktree: path }
1349
1796
  );
1797
+ const unshipped = wg.commitsNotOn(m.baseBranch);
1798
+ if (unshipped.length > 0 && !wg.isEquivalentToBase(m.baseBranch)) {
1799
+ throw new WorkkitError(
1800
+ "EFFORT_OPEN",
1801
+ `${current} holds ${unshipped.length} commit(s) not yet on ${m.baseBranch}: ship or finish it first, or --join it`,
1802
+ { branch: current, area }
1803
+ );
1804
+ }
1350
1805
  wg.checkout(branch, `origin/${m.baseBranch}`);
1806
+ if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
1807
+ wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
1808
+ }
1809
+ } else if (!wg.isDirty() && wg.commitsNotOn(m.baseBranch).length > 0 && wg.isEquivalentToBase(m.baseBranch)) {
1810
+ wg.run(["reset", "--hard", `origin/${m.baseBranch}`]);
1351
1811
  }
1352
- return wg;
1812
+ return { wg, created: false };
1353
1813
  }
1354
1814
  function appendLog(file, line) {
1355
1815
  appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
@@ -1357,10 +1817,57 @@ function appendLog(file, line) {
1357
1817
  }
1358
1818
  async function startEffort(ctx, o) {
1359
1819
  const m = ctx.manifest;
1820
+ fetchBaseOnce(ctx);
1360
1821
  const { branch, scopes, commons, warnings } = guardStart(ctx, o);
1361
1822
  const notices = ctx.registry.notices(m.project, o.area);
1362
- const open = ctx.registry.openEffortFor(m.project, o.area);
1823
+ let open = ctx.registry.openEffortFor(m.project, o.area, o.task);
1363
1824
  if (open && o.join) {
1825
+ const deadline = Date.now() + JOIN_WAIT_MS;
1826
+ while (open && open.state === "starting" && Date.now() < deadline) {
1827
+ await sleep3(200);
1828
+ open = ctx.registry.openEffortFor(m.project, o.area, o.task);
1829
+ }
1830
+ if (!open || open.state !== "active") {
1831
+ throw new WorkkitError(
1832
+ "EFFORT_NOT_FOUND",
1833
+ `effort on ${effortLabel2(o.area, o.task)} did not become active in time to join`,
1834
+ { area: o.area, task: o.task }
1835
+ );
1836
+ }
1837
+ if (!modeCovers(open.mode, o.mode)) {
1838
+ const label = effortLabel2(o.area, o.task);
1839
+ throw new WorkkitError(
1840
+ "MODE_MISMATCH",
1841
+ `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`,
1842
+ { area: o.area, task: o.task, openMode: open.mode, requestedMode: o.mode }
1843
+ );
1844
+ }
1845
+ const currentLeases = ctx.registry.leases({ effort: open.id });
1846
+ if (o.story && !currentLeases.some((l) => l.kind === "storybook")) {
1847
+ const hostRoot = storybookHostFor(m, open.worktree);
1848
+ const inst = await ctx.storybrokr.up(o.story, hostRoot, {
1849
+ ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
1850
+ });
1851
+ const sbLease = ctx.registry.addLease(
1852
+ "storybook",
1853
+ m.project,
1854
+ inst.id,
1855
+ ctx.owner,
1856
+ open.id,
1857
+ m.expiry.storybookHours,
1858
+ { ports: { storybook: inst.port } }
1859
+ );
1860
+ open = ctx.registry.updateEffort(open.id, {
1861
+ leases: [...open.leases, sbLease.id]
1862
+ });
1863
+ }
1864
+ const existingParticipants = open.participants ?? [];
1865
+ const isAlreadyMember = open.owner.session === ctx.owner.session || existingParticipants.some((p) => p.session === ctx.owner.session);
1866
+ if (!isAlreadyMember) {
1867
+ open = ctx.registry.updateEffort(open.id, {
1868
+ participants: [...existingParticipants, ctx.owner]
1869
+ });
1870
+ }
1364
1871
  touchEffort(ctx, open);
1365
1872
  return {
1366
1873
  effort: open,
@@ -1386,6 +1893,8 @@ async function startEffort(ctx, o) {
1386
1893
  signatures: l.signatures,
1387
1894
  onLog: ctx.log
1388
1895
  }).catch(() => void 0);
1896
+ } else if (stopPids && l.kind === "storybook") {
1897
+ await ctx.storybrokr.down(String(l.value)).catch(() => void 0);
1389
1898
  }
1390
1899
  ctx.registry.release(l.id);
1391
1900
  }
@@ -1397,17 +1906,28 @@ async function startEffort(ctx, o) {
1397
1906
  process.once("SIGINT", onSignal);
1398
1907
  process.once("SIGTERM", onSignal);
1399
1908
  try {
1400
- const wg = ensureWorktree(ctx, o.area, branch);
1909
+ const { wg, created } = ensureWorktree(ctx, o.area, branch, o.task);
1401
1910
  const worktree = wg.root();
1402
1911
  ctx.registry.updateEffort(effort.id, { worktree });
1403
1912
  effort.worktree = worktree;
1404
- const logDir = join6(worktree, m.logs.dir);
1405
- mkdirSync5(logDir, { recursive: true });
1406
- const refreshLog = join6(logDir, "refresh.log");
1913
+ const logDir = join8(worktree, m.logs.dir);
1914
+ mkdirSync6(logDir, { recursive: true });
1915
+ const refreshLog = join8(logDir, "refresh.log");
1916
+ const stampFile = join8(logDir, ".refresh-stamps.json");
1917
+ if (created && m.seed && m.seed.length > 0) {
1918
+ runRefresh(
1919
+ m.seed,
1920
+ worktree,
1921
+ (l) => {
1922
+ appendLog(refreshLog, l);
1923
+ ctx.log(l);
1924
+ },
1925
+ { ...process.env, WORKTREE: worktree, MAIN_ROOT: ctx.repoRoot }
1926
+ );
1927
+ }
1407
1928
  let sync = "skipped";
1408
1929
  let changed = [];
1409
1930
  if (o.sync !== false) {
1410
- wg.fetch(m.baseBranch);
1411
1931
  const r = wg.syncFrom(m.baseBranch);
1412
1932
  if (r.kind === "conflict") {
1413
1933
  throw new WorkkitError(
@@ -1418,12 +1938,28 @@ async function startEffort(ctx, o) {
1418
1938
  }
1419
1939
  sync = r.kind;
1420
1940
  if (r.kind === "ff" || r.kind === "merge") changed = wg.changedPaths(r.before, r.after);
1941
+ ctx.registry.pruneNoticesWhere(
1942
+ m.project,
1943
+ o.area,
1944
+ (n) => wg.isAncestor(`origin/${n.from}`, "HEAD") || wg.isAncestor(n.from, "HEAD")
1945
+ );
1421
1946
  }
1422
- const sel = selectRefresh(m.refresh, changed);
1423
- runRefresh(sel.runs, worktree, (l) => {
1424
- appendLog(refreshLog, l);
1425
- ctx.log(l);
1426
- });
1947
+ const sel = selectRefresh(m.refresh, changed, { worktree, stampFile });
1948
+ runRefresh(
1949
+ sel.runs,
1950
+ worktree,
1951
+ (l) => {
1952
+ appendLog(refreshLog, l);
1953
+ ctx.log(l);
1954
+ },
1955
+ process.env,
1956
+ {
1957
+ stampFile,
1958
+ pendingStamps: sel.pendingStamps,
1959
+ restoreFiles: sel.cleanWhenFiles
1960
+ }
1961
+ );
1962
+ const baseSha = wg.rev("HEAD");
1427
1963
  for (const name of commons)
1428
1964
  leases.push(
1429
1965
  ctx.registry.addLease(
@@ -1435,25 +1971,9 @@ async function startEffort(ctx, o) {
1435
1971
  m.expiry.commonsHours
1436
1972
  )
1437
1973
  );
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("; "));
1974
+ if (o.mode === "fullstack" || o.mode === "backend") {
1975
+ const slot = await claimFreeSlot(ctx, effort.id, warnings);
1455
1976
  leases.push(slot);
1456
- if (busyNotes.length) warnings.push(...busyNotes);
1457
1977
  const cfg = emitWorktreeConfig({
1458
1978
  m,
1459
1979
  worktree,
@@ -1473,6 +1993,7 @@ async function startEffort(ctx, o) {
1473
1993
  cwd: worktree,
1474
1994
  env,
1475
1995
  logDir,
1996
+ mode: o.mode,
1476
1997
  onLog: ctx.log,
1477
1998
  onSpawn: (service, pid, signature) => {
1478
1999
  pids[service] = pid;
@@ -1487,8 +2008,10 @@ async function startEffort(ctx, o) {
1487
2008
  } else {
1488
2009
  let storybookPort;
1489
2010
  if (o.story) {
1490
- const hostRoot = m.storybook ? join6(worktree, m.storybook.hostRoot) : worktree;
1491
- const inst = await ctx.storybrokr.up(o.story, hostRoot);
2011
+ const hostRoot = storybookHostFor(m, worktree);
2012
+ const inst = await ctx.storybrokr.up(o.story, hostRoot, {
2013
+ ttlMinutes: Math.ceil(m.expiry.storybookHours * 60)
2014
+ });
1492
2015
  storybookPort = inst.port;
1493
2016
  leases.push(
1494
2017
  ctx.registry.addLease(
@@ -1506,6 +2029,7 @@ async function startEffort(ctx, o) {
1506
2029
  }
1507
2030
  const active = ctx.registry.updateEffort(effort.id, {
1508
2031
  state: "active",
2032
+ baseSha,
1509
2033
  leases: leases.map((l) => l.id),
1510
2034
  lastActivity: now()
1511
2035
  });
@@ -1527,28 +2051,124 @@ async function startEffort(ctx, o) {
1527
2051
  process.off("SIGTERM", onSignal);
1528
2052
  }
1529
2053
  }
2054
+ async function promoteEffort(ctx, o) {
2055
+ const m = ctx.manifest;
2056
+ if (o.mode !== "backend" && o.mode !== "fullstack") {
2057
+ throw new WorkkitError("MODE_UNKNOWN", `promote target mode must be backend or fullstack`);
2058
+ }
2059
+ const open = ctx.registry.openEffortFor(m.project, o.area, o.task);
2060
+ if (!open) {
2061
+ throw new WorkkitError(
2062
+ "EFFORT_NOT_FOUND",
2063
+ `no open effort on ${effortLabel2(o.area, o.task)} to promote`,
2064
+ { area: o.area, task: o.task }
2065
+ );
2066
+ }
2067
+ if (open.mode === "fullstack" && o.mode === "backend" || open.mode === "frontend" && o.mode === "backend") {
2068
+ throw new WorkkitError(
2069
+ "MODE_MISMATCH",
2070
+ `cannot transition effort on ${effortLabel2(o.area, o.task)} from ${open.mode} to ${o.mode}`,
2071
+ { from: open.mode, to: o.mode }
2072
+ );
2073
+ }
2074
+ const modeScopes = expandScopes(m, o.area, o.mode);
2075
+ const nextScopes = open.task ? [.../* @__PURE__ */ new Set([...open.scopes, ...modeScopes])] : modeScopes;
2076
+ ctx.registry.update((r) => {
2077
+ const others = r.efforts.filter(
2078
+ (e) => e.project === m.project && e.id !== open.id && (e.state === "active" || e.state === "starting")
2079
+ );
2080
+ for (const e of others) {
2081
+ const pairs = globsOverlap(nextScopes, e.scopes);
2082
+ if (pairs.length > 0) {
2083
+ const otherLabel = effortLabel2(e.area, e.task);
2084
+ throw new WorkkitError(
2085
+ "SCOPE_CONFLICT",
2086
+ `promoting ${effortLabel2(o.area, o.task)} to ${o.mode} overlaps ${otherLabel} (${e.branch}): ${pairs.join("; ")}`,
2087
+ { with: otherLabel, owner: e.owner.session, effort: e.id, pairs }
2088
+ );
2089
+ }
2090
+ }
2091
+ const idx = r.efforts.findIndex((e) => e.id === open.id);
2092
+ if (idx >= 0) {
2093
+ r.efforts[idx] = { ...r.efforts[idx], mode: o.mode, scopes: nextScopes, lastActivity: now() };
2094
+ }
2095
+ });
2096
+ const warnings = [];
2097
+ let slot = ctx.registry.leases({ effort: open.id, kind: "slot" })[0];
2098
+ const sb = ctx.registry.leases({ effort: open.id, kind: "storybook" })[0];
2099
+ if (!slot) {
2100
+ slot = await claimFreeSlot(ctx, open.id, warnings);
2101
+ ctx.registry.updateEffort(open.id, { leases: [...open.leases, slot.id] });
2102
+ }
2103
+ const cfg = emitWorktreeConfig({
2104
+ m,
2105
+ worktree: open.worktree,
2106
+ slot: slot.value,
2107
+ ports: slot.ports ?? {},
2108
+ storybookPort: sb?.ports?.storybook,
2109
+ area: o.area,
2110
+ branch: open.branch
2111
+ });
2112
+ const env = { ...process.env, ...cfg.vars };
2113
+ const logDir = join8(open.worktree, m.logs.dir);
2114
+ mkdirSync6(logDir, { recursive: true });
2115
+ const existingPids = { ...slot.pids ?? {} };
2116
+ const existingSigs = { ...slot.signatures ?? {} };
2117
+ const missingServices = Object.entries(m.services).filter(([name, spec]) => serviceModes(name, spec).includes(o.mode) && !existingPids[name]).map(([name]) => name);
2118
+ if (missingServices.length > 0) {
2119
+ const result = await ctx.runner.start({
2120
+ m,
2121
+ cwd: open.worktree,
2122
+ env,
2123
+ logDir,
2124
+ mode: o.mode,
2125
+ onlyServices: missingServices,
2126
+ onLog: ctx.log,
2127
+ onSpawn: (service, pid, signature) => {
2128
+ existingPids[service] = pid;
2129
+ existingSigs[service] = signature;
2130
+ ctx.registry.patchLease(slot.id, {
2131
+ pids: { ...existingPids },
2132
+ signatures: { ...existingSigs }
2133
+ });
2134
+ }
2135
+ });
2136
+ ctx.registry.patchLease(slot.id, {
2137
+ pids: { ...existingPids, ...result.pids },
2138
+ signatures: { ...existingSigs, ...result.signatures ?? {} }
2139
+ });
2140
+ }
2141
+ const updated = ctx.registry.updateEffort(open.id, { lastActivity: now() });
2142
+ touchEffort(ctx, updated);
2143
+ return {
2144
+ effort: updated,
2145
+ leases: ctx.registry.leases({ effort: open.id }),
2146
+ startedServices: missingServices,
2147
+ warnings
2148
+ };
2149
+ }
1530
2150
  function behindBase(ctx, worktree) {
1531
- if (!existsSync5(worktree)) return 0;
2151
+ if (!existsSync7(worktree)) return 0;
1532
2152
  const g = new Git(worktree);
1533
- g.tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
1534
2153
  const r = g.tryRun(["rev-list", "--count", `HEAD..origin/${ctx.manifest.baseBranch}`]);
1535
2154
  return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
1536
2155
  }
1537
- function statusFor(ctx, area) {
2156
+ function statusFor(ctx, area, task) {
1538
2157
  const m = ctx.manifest;
2158
+ fetchBaseOnce(ctx);
1539
2159
  const names = (area ? [area] : Object.keys(m.areas)).sort();
1540
2160
  const areas = names.map((name) => {
1541
- const effort = ctx.registry.openEffortFor(m.project, name);
2161
+ const effort = ctx.registry.openEffortFor(m.project, name, area ? task : void 0);
1542
2162
  if (effort && area) touchEffort(ctx, effort);
1543
- const worktree = worktreePathFor(ctx, name);
2163
+ const worktree = worktreePathFor(ctx, name, area ? task : void 0);
1544
2164
  const out = {
1545
- area: name,
2165
+ area: area && task ? `${name}--${task}` : name,
1546
2166
  leases: effort ? ctx.registry.leases({ effort: effort.id }) : [],
1547
2167
  behindBase: behindBase(ctx, worktree),
1548
2168
  notices: ctx.registry.notices(m.project, name)
1549
2169
  };
1550
2170
  if (effort) out.effort = effort;
1551
- if (existsSync5(worktree)) out.worktree = worktree;
2171
+ if (existsSync7(worktree)) out.worktree = worktree;
1552
2172
  return out;
1553
2173
  });
1554
2174
  return { project: m.project, areas, leases: ctx.registry.leases({ project: m.project }) };
@@ -1556,14 +2176,14 @@ function statusFor(ctx, area) {
1556
2176
  function slotEnv(m, slot) {
1557
2177
  return slotVars(m, { slot, ports: portsForSlot(m, slot) });
1558
2178
  }
1559
- function effortEnv(ctx, area) {
2179
+ function effortEnv(ctx, area, task) {
1560
2180
  const m = ctx.manifest;
1561
- const effort = ctx.registry.openEffortFor(m.project, area);
2181
+ const effort = ctx.registry.openEffortFor(m.project, area, task);
1562
2182
  if (!effort)
1563
2183
  throw new WorkkitError(
1564
2184
  "EFFORT_NOT_FOUND",
1565
- `no open effort on ${area}; \`work start --area ${area} \u2026\` first`,
1566
- { area }
2185
+ `no open effort on ${effortLabel2(area, task)}; \`work start --area ${area} \u2026\` first`,
2186
+ { area, task }
1567
2187
  );
1568
2188
  touchEffort(ctx, effort);
1569
2189
  const slot = ctx.registry.leases({ effort: effort.id, kind: "slot" })[0];
@@ -1575,7 +2195,7 @@ function effortEnv(ctx, area) {
1575
2195
  area,
1576
2196
  branch: effort.branch,
1577
2197
  worktree: effort.worktree,
1578
- logDir: join6(effort.worktree, m.logs.dir)
2198
+ logDir: join8(effort.worktree, m.logs.dir)
1579
2199
  });
1580
2200
  return { ...vars, MODE: effort.mode };
1581
2201
  }
@@ -1586,14 +2206,14 @@ function leaseEnv(ctx, effort, lease) {
1586
2206
  area: effort.area,
1587
2207
  branch: effort.branch,
1588
2208
  worktree: effort.worktree,
1589
- logDir: join6(effort.worktree, ctx.manifest.logs.dir)
2209
+ logDir: join8(effort.worktree, ctx.manifest.logs.dir)
1590
2210
  });
1591
2211
  return { ...process.env, ...vars };
1592
2212
  }
1593
2213
  async function releaseLease(ctx, effort, lease) {
1594
2214
  try {
1595
2215
  if (lease.kind === "slot" && lease.pids && Object.keys(lease.pids).length > 0) {
1596
- const hasWorktree = effort !== void 0 && existsSync5(effort.worktree);
2216
+ const hasWorktree = effort !== void 0 && existsSync7(effort.worktree);
1597
2217
  await ctx.runner.stop({
1598
2218
  m: ctx.manifest,
1599
2219
  cwd: hasWorktree && effort ? effort.worktree : ctx.repoRoot,
@@ -1630,29 +2250,109 @@ function leaveNotices(ctx, effort, commons) {
1630
2250
  }
1631
2251
  async function finishEffort(ctx, o) {
1632
2252
  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];
2253
+ 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
2254
  if (!effort)
1635
- throw new WorkkitError("EFFORT_NOT_FOUND", `no open or parked effort on ${o.area}`, {
1636
- area: o.area
1637
- });
2255
+ throw new WorkkitError(
2256
+ "EFFORT_NOT_FOUND",
2257
+ `no open or parked effort on ${effortLabel2(o.area, o.task)}`,
2258
+ { area: o.area, task: o.task }
2259
+ );
2260
+ if (!o.force && effort.participants && effort.participants.length > 0) {
2261
+ if (ctx.owner.session === effort.owner.session) {
2262
+ const [nextOwner, ...rest] = effort.participants;
2263
+ const updated = ctx.registry.updateEffort(effort.id, {
2264
+ owner: nextOwner,
2265
+ participants: rest,
2266
+ lastActivity: now()
2267
+ });
2268
+ touchEffort(ctx, updated);
2269
+ return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
2270
+ }
2271
+ if (effort.participants.some((p) => p.session === ctx.owner.session)) {
2272
+ const updated = ctx.registry.updateEffort(effort.id, {
2273
+ participants: effort.participants.filter((p) => p.session !== ctx.owner.session),
2274
+ lastActivity: now()
2275
+ });
2276
+ touchEffort(ctx, updated);
2277
+ return { effort: updated, pushed: false, parked: false, left: true, notices: 0 };
2278
+ }
2279
+ }
2280
+ const label = effortLabel2(effort.area, effort.task);
1638
2281
  const g = new Git(effort.worktree);
1639
2282
  let parked = false;
1640
2283
  if (g.isDirty()) {
1641
2284
  if (!o.park) {
1642
2285
  throw new WorkkitError(
1643
2286
  "DIRTY_WORKTREE",
1644
- `${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${effort.area}) and push`,
2287
+ `${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${label}) and push`,
1645
2288
  { worktree: effort.worktree }
1646
2289
  );
1647
2290
  }
1648
2291
  g.run(["add", "-A"]);
1649
- g.run(["commit", "-q", "-m", `wip(${effort.area}): parked ${now()}`]);
2292
+ g.run(["commit", "-q", "-m", `wip(${label}): parked ${now()}`]);
1650
2293
  parked = true;
1651
2294
  }
2295
+ if (o.intoArea) {
2296
+ if (!effort.task) {
2297
+ throw new WorkkitError(
2298
+ "MANIFEST_INVALID",
2299
+ "--into-area requires a task sub-effort (--task <slug>)",
2300
+ { area: effort.area }
2301
+ );
2302
+ }
2303
+ fetchBaseOnce(ctx);
2304
+ const parentBranch = branchFor(effort.type, effort.area);
2305
+ const { wg: areaGit } = ensureWorktree(ctx, effort.area, parentBranch);
2306
+ if (areaGit.isDirty()) {
2307
+ throw new WorkkitError(
2308
+ "DIRTY_WORKTREE",
2309
+ `parent area worktree ${areaGit.cwd} has uncommitted changes; commit or clean it before merging task ${effort.branch} --into-area`,
2310
+ { worktree: areaGit.cwd }
2311
+ );
2312
+ }
2313
+ const merged = areaGit.tryRun(["merge", "--no-edit", "-q", effort.branch]);
2314
+ if (merged.code !== 0) {
2315
+ const conflicted = areaGit.tryRun(["diff", "--name-only", "--diff-filter=U"]).out.split("\n").map((l) => l.trim()).filter(Boolean);
2316
+ areaGit.tryRun(["merge", "--abort"]);
2317
+ throw new WorkkitError(
2318
+ "SYNC_CONFLICT",
2319
+ `merging ${effort.branch} into ${parentBranch} conflicts in ${conflicted.join(", ")}; resolve in ${effort.worktree} before retrying \`work finish --into-area\``,
2320
+ { conflicted, worktree: areaGit.cwd }
2321
+ );
2322
+ }
2323
+ }
1652
2324
  for (const lease of ctx.registry.leases({ effort: effort.id }))
1653
2325
  await releaseLease(ctx, effort, lease);
1654
- g.push(effort.branch);
1655
- const notices = leaveNotices(ctx, effort, commonsTouched(m, g.changedSince(m.baseBranch)));
2326
+ await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
2327
+ () => void 0
2328
+ );
2329
+ let notices = 0;
2330
+ if (!o.intoArea) {
2331
+ g.push(effort.branch);
2332
+ if (g.commitsNotOn(m.baseBranch).length > 0 && !g.isEquivalentToBase(m.baseBranch)) {
2333
+ const since = g.changedSince(m.baseBranch);
2334
+ const sinceSet = new Set(since);
2335
+ const changed = effort.baseSha ? g.changedPaths(effort.baseSha, "HEAD").filter((p) => sinceSet.has(p)) : since;
2336
+ notices = leaveNotices(ctx, effort, commonsTouched(m, changed));
2337
+ }
2338
+ }
2339
+ if (effort.task) {
2340
+ archiveWorktree(ctx, label, effort.worktree);
2341
+ const main = new Git(ctx.repoRoot);
2342
+ main.worktreeRemove(effort.worktree);
2343
+ if (o.intoArea) {
2344
+ main.deleteLocalBranch(effort.branch);
2345
+ }
2346
+ const finished2 = { ...effort, state: "finished", lastActivity: now() };
2347
+ ctx.registry.removeEfforts((e) => e.id === effort.id);
2348
+ return {
2349
+ effort: finished2,
2350
+ pushed: !o.intoArea,
2351
+ parked,
2352
+ mergedIntoArea: Boolean(o.intoArea),
2353
+ notices
2354
+ };
2355
+ }
1656
2356
  const finished = ctx.registry.updateEffort(effort.id, { state: "finished", lastActivity: now() });
1657
2357
  return { effort: finished, pushed: true, parked, notices };
1658
2358
  }
@@ -1667,7 +2367,7 @@ async function reapLeases(ctx, o = {}) {
1667
2367
  if (effort?.state === "starting" && Date.now() - Date.parse(effort.started) < STARTING_GRACE_MS)
1668
2368
  continue;
1669
2369
  const expired = Date.parse(l.expires) < at.getTime();
1670
- const orphaned = !effort || !existsSync5(effort.worktree);
2370
+ const orphaned = !effort || !existsSync7(effort.worktree);
1671
2371
  const garbage = orphaned && !await anyPortAnswers(l.ports);
1672
2372
  if (expired || garbage) doomed.add(l.effort);
1673
2373
  }
@@ -1676,48 +2376,53 @@ async function reapLeases(ctx, o = {}) {
1676
2376
  for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
1677
2377
  for (const id of doomed) {
1678
2378
  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
- });
2379
+ if (effort) {
2380
+ await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, effort.worktree))).catch(
2381
+ () => void 0
2382
+ );
2383
+ if (effort.state === "active" || effort.state === "starting") {
2384
+ ctx.registry.updateEffort(effort.id, {
2385
+ state: "parked",
2386
+ parkedReason: `idle: a lease expired by ${at.toISOString()}`,
2387
+ lastActivity: now()
2388
+ });
2389
+ }
1685
2390
  }
1686
2391
  }
1687
2392
  return { reaped };
1688
2393
  }
1689
2394
  var ARCHIVE_DIR = ".slot-archive";
1690
2395
  function dirSize(path) {
1691
- if (!existsSync5(path)) return 0;
2396
+ if (!existsSync7(path)) return 0;
1692
2397
  let total = 0;
1693
2398
  for (const entry of readdirSync(path, { withFileTypes: true })) {
1694
- const p = join6(path, entry.name);
1695
- total += entry.isDirectory() ? dirSize(p) : statSync2(p).size;
2399
+ const p = join8(path, entry.name);
2400
+ total += entry.isDirectory() ? dirSize(p) : statSync3(p).size;
1696
2401
  }
1697
2402
  return total;
1698
2403
  }
1699
2404
  function archiveWorktree(ctx, area, worktree) {
1700
2405
  const m = ctx.manifest;
1701
- const areaRoot = join6(ctx.repoRoot, ARCHIVE_DIR, area);
2406
+ const areaRoot = join8(ctx.repoRoot, ARCHIVE_DIR, area);
1702
2407
  const stamp = now().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
1703
- const target = join6(areaRoot, stamp);
2408
+ const target = join8(areaRoot, stamp);
1704
2409
  const archived = [];
1705
2410
  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 });
2411
+ const src = join8(worktree, rel);
2412
+ if (!existsSync7(src)) continue;
2413
+ const dst = join8(target, rel);
2414
+ mkdirSync6(dirname3(dst), { recursive: true });
1710
2415
  cpSync(src, dst, { recursive: true });
1711
2416
  archived.push(dst);
1712
2417
  }
1713
2418
  if (archived.length > 0) ensureExcluded(ctx.repoRoot, [`${ARCHIVE_DIR}/`]);
1714
- if (existsSync5(areaRoot)) {
2419
+ if (existsSync7(areaRoot)) {
1715
2420
  const stamps = readdirSync(areaRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
1716
2421
  const cutoff = Date.now() - m.archive.keepDays * 864e5;
1717
2422
  stamps.forEach((d, i) => {
1718
- const t = statSync2(join6(areaRoot, d)).mtimeMs;
2423
+ const t = statSync3(join8(areaRoot, d)).mtimeMs;
1719
2424
  if (i >= m.archive.keepPerArea || t < cutoff)
1720
- rmSync2(join6(areaRoot, d), { recursive: true, force: true });
2425
+ rmSync3(join8(areaRoot, d), { recursive: true, force: true });
1721
2426
  });
1722
2427
  }
1723
2428
  return archived;
@@ -1726,17 +2431,20 @@ async function retireArea(ctx, o) {
1726
2431
  const m = ctx.manifest;
1727
2432
  if (!m.areas[o.area])
1728
2433
  throw new WorkkitError("AREA_UNKNOWN", `unknown area "${o.area}"`, { area: o.area });
1729
- const open = ctx.registry.openEffortFor(m.project, o.area);
2434
+ const open = ctx.registry.efforts({ project: m.project, area: o.area }).find((e) => e.state === "starting" || e.state === "active");
1730
2435
  if (open)
1731
2436
  throw new WorkkitError(
1732
2437
  "EFFORT_OPEN",
1733
- `an effort is open on ${o.area} (${open.owner.session}); \`work finish\` it first`,
2438
+ `an effort is open on ${effortLabel2(open.area, open.task)} (${open.owner.session}); \`work finish\` it first`,
1734
2439
  { owner: open.owner.session }
1735
2440
  );
1736
- const worktree = worktreePathFor(ctx, o.area);
2441
+ const worktree = realpathOr2(worktreePathFor(ctx, o.area));
1737
2442
  const main = new Git(ctx.repoRoot);
1738
2443
  let archived = [];
1739
- if (existsSync5(worktree)) {
2444
+ await Promise.resolve(ctx.storybrokr.downHost?.(storybookHostFor(m, worktree))).catch(
2445
+ () => void 0
2446
+ );
2447
+ if (existsSync7(worktree)) {
1740
2448
  const g = new Git(worktree);
1741
2449
  if (g.isDirty())
1742
2450
  throw new WorkkitError(
@@ -1756,8 +2464,8 @@ async function retireArea(ctx, o) {
1756
2464
  }
1757
2465
  const branchesDeleted = [];
1758
2466
  const branchesKept = [];
1759
- for (const type of m.branch.types) {
1760
- const branch = `${type}/${o.area}`;
2467
+ const allAreaBranches = m.branch.types.map((type) => `${type}/${o.area}`);
2468
+ for (const branch of allAreaBranches) {
1761
2469
  if (!main.branchExists(branch)) continue;
1762
2470
  const onOrigin = main.remoteBranchExists(branch) && main.tryRun(["rev-list", "--count", `origin/${branch}..${branch}`]).out.trim() === "0";
1763
2471
  if (onOrigin) {
@@ -1767,6 +2475,7 @@ async function retireArea(ctx, o) {
1767
2475
  }
1768
2476
  ctx.registry.removeEfforts((e) => e.project === m.project && e.area === o.area);
1769
2477
  ctx.registry.clearNotices(m.project, o.area);
2478
+ ctx.registry.clearNoticesFrom(m.project, allAreaBranches);
1770
2479
  return { archived, removed: worktree, branchesDeleted, branchesKept };
1771
2480
  }
1772
2481
  var BEHIND_LIMIT = 20;
@@ -1786,15 +2495,16 @@ function registeredWorktrees(ctx) {
1786
2495
  }
1787
2496
  }
1788
2497
  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));
2498
+ const wtRoot = join8(realpathOr2(ctx.repoRoot), ctx.manifest.worktreeRoot);
2499
+ return out.filter((w) => existsSync7(w.path) && realpathOr2(w.path).startsWith(wtRoot));
1791
2500
  }
1792
2501
  async function auditProject(ctx) {
1793
2502
  const m = ctx.manifest;
2503
+ fetchBaseOnce(ctx);
1794
2504
  const findings = [];
1795
2505
  const types = m.branch.types.join("|");
1796
2506
  const areaOf = (branch) => {
1797
- const match = new RegExp(`^(${types})/(.+)$`).exec(branch);
2507
+ const match = new RegExp(`^(${types})/(.+?)(?:--([a-z0-9][a-z0-9_-]*))?$`).exec(branch);
1798
2508
  return match && m.areas[match[2]] ? match[2] : null;
1799
2509
  };
1800
2510
  for (const w of registeredWorktrees(ctx)) {
@@ -1843,7 +2553,7 @@ async function auditProject(ctx) {
1843
2553
  findings.push({ kind: "notice-stale", area, detail: `${n.text} (since ${n.at})` });
1844
2554
  }
1845
2555
  }
1846
- const bytes = dirSize(join6(ctx.repoRoot, ARCHIVE_DIR));
2556
+ const bytes = dirSize(join8(ctx.repoRoot, ARCHIVE_DIR));
1847
2557
  if (bytes > ARCHIVE_LIMIT_BYTES)
1848
2558
  findings.push({
1849
2559
  kind: "archive-size",
@@ -1893,51 +2603,70 @@ function registerAudit(program2) {
1893
2603
 
1894
2604
  // src/commands/env.ts
1895
2605
  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(
2606
+ 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
2607
  "--slot <n>",
1898
2608
  "compute the variables for this slot from the manifest alone",
1899
2609
  (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));
2610
+ ).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(
2611
+ async (o) => {
2612
+ try {
2613
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
2614
+ let vars;
2615
+ if (o.area) vars = effortEnv(ctx, o.area, o.task);
2616
+ else if (o.slot !== void 0 && Number.isInteger(o.slot))
2617
+ vars = slotEnv(ctx.manifest, o.slot);
2618
+ else throw new WorkkitError("EFFORT_NOT_FOUND", "pass --area <area> or --slot <n>");
2619
+ if (o.json) printJson(vars);
2620
+ else for (const [k, v] of Object.entries(vars)) console.log(`${k}=${v}`);
2621
+ } catch (err) {
2622
+ fail(err, Boolean(o.json));
2623
+ }
1912
2624
  }
1913
- });
2625
+ );
1914
2626
  }
1915
2627
 
1916
2628
  // src/commands/finish.ts
1917
2629
  import chalk4 from "chalk";
1918
2630
  function registerFinish(program2) {
1919
2631
  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`);
2632
+ "Stop the effort's runner, release its leases, push its branch (or merge --into-area); area worktrees stay, task worktrees are cleaned up"
2633
+ ).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(
2634
+ async (o) => {
2635
+ try {
2636
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
2637
+ const r = await finishEffort(ctx, {
2638
+ area: o.area,
2639
+ task: o.task,
2640
+ park: o.park,
2641
+ intoArea: o.intoArea,
2642
+ force: o.force
2643
+ });
2644
+ if (o.json) printJson(r);
2645
+ else {
2646
+ const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
2647
+ if (r.left) {
2648
+ console.log(`${chalk4.bold(label)}: left shared effort (other participants remain)`);
2649
+ } else if (r.mergedIntoArea) {
2650
+ console.log(
2651
+ `${chalk4.bold(label)} finished: ${r.effort.branch} merged into ${r.effort.type}/${r.effort.area}`
2652
+ );
2653
+ } else {
2654
+ console.log(
2655
+ `${chalk4.bold(label)} finished: ${r.effort.branch} pushed${r.parked ? " (uncommitted changes parked as wip)" : ""}`
2656
+ );
2657
+ }
2658
+ if (r.notices) console.log(` ${r.notices} area(s) told about commons changes`);
2659
+ }
2660
+ } catch (err) {
2661
+ fail(err, Boolean(o.json));
1931
2662
  }
1932
- } catch (err) {
1933
- fail(err, Boolean(o.json));
1934
2663
  }
1935
- });
2664
+ );
1936
2665
  }
1937
2666
 
1938
2667
  // src/commands/hook.ts
1939
- import { readFileSync as readFileSync4, realpathSync as realpathSync5 } from "fs";
1940
- import { resolve } from "path";
2668
+ import { readFileSync as readFileSync5, realpathSync as realpathSync5 } from "fs";
2669
+ import { resolve as resolve2 } from "path";
1941
2670
  import chalk5 from "chalk";
1942
2671
  var ZEROS = /^0+$/;
1943
2672
  async function hookContext(cwd) {
@@ -1956,7 +2685,7 @@ async function runPreCommit(cwd) {
1956
2685
  if (!c) return 0;
1957
2686
  const root = c.git.root();
1958
2687
  touchByWorktree(c.registry, c.manifest, root);
1959
- const { refusals } = checkCommit({
2688
+ const { refusals, warnings } = checkCommit({
1960
2689
  m: c.manifest,
1961
2690
  registry: c.registry,
1962
2691
  git: c.git,
@@ -1966,6 +2695,7 @@ async function runPreCommit(cwd) {
1966
2695
  ownerSession: currentOwner().session,
1967
2696
  checkoutRoot: root
1968
2697
  });
2698
+ for (const w of warnings) console.error(chalk5.yellow(`work: ${w}`));
1969
2699
  for (const r of refusals) console.error(chalk5.red(`work: ${r}`));
1970
2700
  return refusals.length ? 2 : 0;
1971
2701
  }
@@ -2033,7 +2763,7 @@ function registerHook(program2) {
2033
2763
  "Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
2034
2764
  ).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
2035
2765
  try {
2036
- const stdin = process.stdin.isTTY ? "" : readFileSync4(0, "utf8");
2766
+ const stdin = process.stdin.isTTY ? "" : readFileSync5(0, "utf8");
2037
2767
  process.exit(await runPrePush(process.cwd(), stdin));
2038
2768
  } catch (err) {
2039
2769
  fail(err, false);
@@ -2044,7 +2774,7 @@ function registerHook(program2) {
2044
2774
  "Write .helmsmith/hooks and point this clone's core.hooksPath at them (for a repo whose manifest already exists)"
2045
2775
  ).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
2046
2776
  try {
2047
- const repoRoot = o.projectRoot ? resolve(o.projectRoot) : new Git(process.cwd()).mainRoot();
2777
+ const repoRoot = o.projectRoot ? resolve2(o.projectRoot) : new Git(process.cwd()).mainRoot();
2048
2778
  const manifest = await loadManifest(repoRoot);
2049
2779
  const out = installHooks(repoRoot, { protectedBranches: manifest.protectedBranches });
2050
2780
  if (o.json) printJson(out);
@@ -2056,8 +2786,8 @@ function registerHook(program2) {
2056
2786
  }
2057
2787
 
2058
2788
  // src/commands/init.ts
2059
- import { existsSync as existsSync6, writeFileSync as writeFileSync4 } from "fs";
2060
- import { join as join7, resolve as resolve2 } from "path";
2789
+ import { existsSync as existsSync8, writeFileSync as writeFileSync5 } from "fs";
2790
+ import { join as join9, resolve as resolve3 } from "path";
2061
2791
  var STORYBOOK_BASE = 6100;
2062
2792
  function defaultService(name) {
2063
2793
  const PORT = `\${PORT_${name.toUpperCase()}}`;
@@ -2121,9 +2851,9 @@ var HEADER = `// biome-ignore-all lint/suspicious/noTemplateCurlyInString: \${PO
2121
2851
  // each with its backend module) and the commons. Nothing here is read by the application.
2122
2852
  `;
2123
2853
  function runInit(o) {
2124
- const repoRoot = resolve2(o.repoRoot);
2854
+ const repoRoot = resolve3(o.repoRoot);
2125
2855
  for (const name of MANIFEST_FILES) {
2126
- if (existsSync6(join7(repoRoot, name)) && !o.force) {
2856
+ if (existsSync8(join9(repoRoot, name)) && !o.force) {
2127
2857
  throw new WorkkitError(
2128
2858
  "MANIFEST_INVALID",
2129
2859
  `${name} already exists in ${repoRoot}; pass --force to overwrite`
@@ -2131,8 +2861,8 @@ function runInit(o) {
2131
2861
  }
2132
2862
  }
2133
2863
  const manifest = buildStarterManifest(o);
2134
- const manifestPath = join7(repoRoot, "helmsmith.config.mjs");
2135
- writeFileSync4(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
2864
+ const manifestPath = join9(repoRoot, "helmsmith.config.mjs");
2865
+ writeFileSync5(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
2136
2866
  `);
2137
2867
  claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
2138
2868
  const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
@@ -2162,7 +2892,7 @@ function registerInit(program2) {
2162
2892
  ).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
2893
  (o) => {
2164
2894
  try {
2165
- const repoRoot = o.repoRoot ? resolve2(o.repoRoot) : defaultRepoRoot();
2895
+ const repoRoot = o.repoRoot ? resolve3(o.repoRoot) : defaultRepoRoot();
2166
2896
  if (o.service.length === 0)
2167
2897
  throw new WorkkitError(
2168
2898
  "MANIFEST_INVALID",
@@ -2200,21 +2930,21 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
2200
2930
  import { z as z2 } from "zod";
2201
2931
 
2202
2932
  // src/version.ts
2203
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
2204
- import { dirname as dirname3, join as join8 } from "path";
2933
+ import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
2934
+ import { dirname as dirname4, join as join10 } from "path";
2205
2935
  import { fileURLToPath } from "url";
2206
2936
  function findPackageJson(startDir) {
2207
2937
  let dir = startDir;
2208
2938
  for (; ; ) {
2209
- const candidate = join8(dir, "package.json");
2210
- if (existsSync7(candidate)) return candidate;
2211
- const parent = dirname3(dir);
2939
+ const candidate = join10(dir, "package.json");
2940
+ if (existsSync9(candidate)) return candidate;
2941
+ const parent = dirname4(dir);
2212
2942
  if (parent === dir) throw new Error(`package.json not found above ${startDir}`);
2213
2943
  dir = parent;
2214
2944
  }
2215
2945
  }
2216
- var pkgFile = findPackageJson(dirname3(fileURLToPath(import.meta.url)));
2217
- var VERSION = JSON.parse(readFileSync5(pkgFile, "utf8")).version;
2946
+ var pkgFile = findPackageJson(dirname4(fileURLToPath(import.meta.url)));
2947
+ var VERSION = JSON.parse(readFileSync6(pkgFile, "utf8")).version;
2218
2948
 
2219
2949
  // src/mcp/server.ts
2220
2950
  var ok = (value) => ({
@@ -2236,64 +2966,114 @@ function buildMcpServer(ctxFactory) {
2236
2966
  server.registerTool(
2237
2967
  "work_start",
2238
2968
  {
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).",
2969
+ 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
2970
  inputSchema: {
2241
2971
  area: z2.string().describe("a leaf area from helmsmith.config (e.g. org/applications)"),
2972
+ task: z2.string().optional().describe("optional intra-area task slug for parallel sub-efforts (<type>/<area>--<task>)"),
2242
2973
  type: z2.string().describe("feature | chore | fix"),
2243
- mode: z2.enum(["frontend", "fullstack"]),
2974
+ mode: z2.enum(["frontend", "backend", "fullstack"]),
2975
+ scopes: z2.array(z2.string()).optional().describe("optional narrowed scope globs for a task sub-effort"),
2244
2976
  story: z2.string().optional().describe(
2245
2977
  "frontend: story file or component folder for storybrokr, relative to its host"
2246
2978
  ),
2247
2979
  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)")
2980
+ sync: z2.boolean().optional().describe("fetch and merge the base branch first (default true)"),
2981
+ projectRoot: z2.string().optional().describe("optional repository root path override")
2982
+ }
2983
+ },
2984
+ async ({ area, task, type, mode, scopes, story, join: join11, sync, projectRoot }) => guard(
2985
+ async () => startEffort(await ctxFactory(projectRoot), {
2986
+ area,
2987
+ task,
2988
+ type,
2989
+ mode,
2990
+ scopes,
2991
+ story,
2992
+ join: join11,
2993
+ sync
2994
+ })
2995
+ )
2996
+ );
2997
+ server.registerTool(
2998
+ "work_promote",
2999
+ {
3000
+ description: "Promote an open effort in place to backend or fullstack mode, claiming a slot if needed and starting only newly required services.",
3001
+ inputSchema: {
3002
+ area: z2.string().describe("the area of the open effort"),
3003
+ task: z2.string().optional().describe("optional intra-area task slug"),
3004
+ mode: z2.enum(["backend", "fullstack"]).describe("target mode to promote into"),
3005
+ story: z2.string().optional().describe("optional story path"),
3006
+ projectRoot: z2.string().optional().describe("optional repository root path override")
2249
3007
  }
2250
3008
  },
2251
- async ({ area, type, mode, story, join: join9, sync }) => guard(async () => startEffort(await ctxFactory(), { area, type, mode, story, join: join9, sync }))
3009
+ async ({ area, task, mode, story, projectRoot }) => guard(async () => promoteEffort(await ctxFactory(projectRoot), { area, task, mode, story }))
2252
3010
  );
2253
3011
  server.registerTool(
2254
3012
  "work_status",
2255
3013
  {
2256
3014
  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() }
3015
+ inputSchema: {
3016
+ area: z2.string().optional(),
3017
+ task: z2.string().optional(),
3018
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3019
+ }
2258
3020
  },
2259
- async ({ area }) => guard(async () => statusFor(await ctxFactory(), area))
3021
+ async ({ area, task, projectRoot }) => guard(async () => statusFor(await ctxFactory(projectRoot), area, task))
2260
3022
  );
2261
3023
  server.registerTool(
2262
3024
  "work_finish",
2263
3025
  {
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() }
3026
+ 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).",
3027
+ inputSchema: {
3028
+ area: z2.string(),
3029
+ task: z2.string().optional().describe("optional intra-area task slug"),
3030
+ park: z2.boolean().optional(),
3031
+ intoArea: z2.boolean().optional().describe("merge task branch into <type>/<area> instead of pushing to origin"),
3032
+ force: z2.boolean().optional().describe("stop and finish even if other joined participants remain"),
3033
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3034
+ }
2266
3035
  },
2267
- async ({ area, park }) => guard(async () => finishEffort(await ctxFactory(), { area, park }))
3036
+ async ({ area, task, park, intoArea, force, projectRoot }) => guard(
3037
+ async () => finishEffort(await ctxFactory(projectRoot), { area, task, park, intoArea, force })
3038
+ )
2268
3039
  );
2269
3040
  server.registerTool(
2270
3041
  "work_reap",
2271
3042
  {
2272
3043
  description: "Expire idle leases: stop only what they started, release them, park their efforts. dryRun lists without touching.",
2273
- inputSchema: { dryRun: z2.boolean().optional() }
3044
+ inputSchema: {
3045
+ dryRun: z2.boolean().optional(),
3046
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3047
+ }
2274
3048
  },
2275
- async ({ dryRun }) => guard(async () => reapLeases(await ctxFactory(), { dryRun }))
3049
+ async ({ dryRun, projectRoot }) => guard(async () => reapLeases(await ctxFactory(projectRoot), { dryRun }))
2276
3050
  );
2277
3051
  server.registerTool(
2278
3052
  "work_audit",
2279
3053
  {
2280
3054
  description: "Findings: dirty or unpushed area worktrees, foreign worktrees, aging leases, drift from the base branch, stale notices, archive size.",
2281
- inputSchema: {}
3055
+ inputSchema: {
3056
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3057
+ }
2282
3058
  },
2283
- async () => guard(async () => auditProject(await ctxFactory()))
3059
+ async ({ projectRoot }) => guard(async () => auditProject(await ctxFactory(projectRoot)))
2284
3060
  );
2285
3061
  server.registerTool(
2286
3062
  "work_env",
2287
3063
  {
2288
3064
  description: "The slot-derived variables of an area's open effort (PORT_*, SLOT, LOG_DIR, WORKTREE, BRANCH, MODE).",
2289
- inputSchema: { area: z2.string() }
3065
+ inputSchema: {
3066
+ area: z2.string(),
3067
+ task: z2.string().optional(),
3068
+ projectRoot: z2.string().optional().describe("optional repository root path override")
3069
+ }
2290
3070
  },
2291
- async ({ area }) => guard(async () => effortEnv(await ctxFactory(), area))
3071
+ async ({ area, task, projectRoot }) => guard(async () => effortEnv(await ctxFactory(projectRoot), area, task))
2292
3072
  );
2293
3073
  return server;
2294
3074
  }
2295
3075
  async function runStdio() {
2296
- const server = buildMcpServer(() => buildCtx({ quiet: true }));
3076
+ const server = buildMcpServer((projectRoot) => buildCtx({ projectRoot, quiet: true }));
2297
3077
  await server.connect(new StdioServerTransport());
2298
3078
  }
2299
3079
 
@@ -2304,8 +3084,36 @@ function registerMcp(program2) {
2304
3084
  });
2305
3085
  }
2306
3086
 
2307
- // src/commands/reap.ts
3087
+ // src/commands/promote.ts
2308
3088
  import chalk6 from "chalk";
3089
+ function registerPromote(program2) {
3090
+ program2.command("promote").description("Promote an open effort in place to backend or fullstack mode").requiredOption("--area <area>", "the area of the open effort").option("--task <slug>", "intra-area task slug if promoting a task sub-effort").requiredOption("--mode <mode>", "target mode: backend | fullstack").option("--story <path>", "optional story path to attach via storybrokr").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
3091
+ async (o) => {
3092
+ try {
3093
+ const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
3094
+ const r = await promoteEffort(ctx, {
3095
+ area: o.area,
3096
+ task: o.task,
3097
+ mode: o.mode,
3098
+ story: o.story
3099
+ });
3100
+ if (o.json) printJson(r);
3101
+ else {
3102
+ const label = r.effort.task ? `${r.effort.area}--${r.effort.task}` : r.effort.area;
3103
+ console.log(
3104
+ `${chalk6.bold(label)} promoted to ${r.effort.mode}${r.startedServices.length ? ` (started: ${r.startedServices.join(", ")})` : ""}`
3105
+ );
3106
+ for (const w of r.warnings) warn(w);
3107
+ }
3108
+ } catch (err) {
3109
+ fail(err, Boolean(o.json));
3110
+ }
3111
+ }
3112
+ );
3113
+ }
3114
+
3115
+ // src/commands/reap.ts
3116
+ import chalk7 from "chalk";
2309
3117
  function registerReap(program2) {
2310
3118
  program2.command("reap").description(
2311
3119
  "Expire idle leases: stop only what they started, release them, park their efforts"
@@ -2314,7 +3122,7 @@ function registerReap(program2) {
2314
3122
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
2315
3123
  const r = await reapLeases(ctx, { dryRun: o.dryRun });
2316
3124
  if (o.json) printJson(r);
2317
- else if (r.reaped.length === 0) console.log(chalk6.gray("nothing to reap"));
3125
+ else if (r.reaped.length === 0) console.log(chalk7.gray("nothing to reap"));
2318
3126
  else
2319
3127
  for (const l of r.reaped)
2320
3128
  console.log(
@@ -2327,7 +3135,7 @@ function registerReap(program2) {
2327
3135
  }
2328
3136
 
2329
3137
  // src/commands/retire.ts
2330
- import chalk7 from "chalk";
3138
+ import chalk8 from "chalk";
2331
3139
  function registerRetire(program2) {
2332
3140
  program2.command("retire").description(
2333
3141
  "Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
@@ -2337,11 +3145,11 @@ function registerRetire(program2) {
2337
3145
  const r = await retireArea(ctx, { area: o.area });
2338
3146
  if (o.json) printJson(r);
2339
3147
  else {
2340
- console.log(`${chalk7.bold(o.area)} retired: removed ${r.removed}`);
3148
+ console.log(`${chalk8.bold(o.area)} retired: removed ${r.removed}`);
2341
3149
  for (const a of r.archived) console.log(` archived ${a}`);
2342
3150
  for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
2343
3151
  for (const b of r.branchesKept)
2344
- console.log(chalk7.yellow(` kept ${b}: not fully on origin`));
3152
+ console.log(chalk8.yellow(` kept ${b}: not fully on origin`));
2345
3153
  }
2346
3154
  } catch (err) {
2347
3155
  fail(err, Boolean(o.json));
@@ -2350,11 +3158,12 @@ function registerRetire(program2) {
2350
3158
  }
2351
3159
 
2352
3160
  // src/commands/start.ts
2353
- import chalk8 from "chalk";
3161
+ import chalk9 from "chalk";
2354
3162
  function printStart(r) {
2355
3163
  const e = r.effort;
3164
+ const label = e.task ? `${e.area}--${e.task}` : e.area;
2356
3165
  console.log(
2357
- `${chalk8.bold(e.area)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk8.yellow(" (joined)") : ""}`
3166
+ `${chalk9.bold(label)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk9.yellow(" (joined)") : ""}`
2358
3167
  );
2359
3168
  console.log(` worktree ${e.worktree}`);
2360
3169
  console.log(
@@ -2365,12 +3174,12 @@ function printStart(r) {
2365
3174
  console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
2366
3175
  }
2367
3176
  for (const w of r.warnings) warn(w);
2368
- for (const n of r.notices) console.log(chalk8.yellow(` notice ${n.text} (${n.at})`));
3177
+ for (const n of r.notices) console.log(chalk9.yellow(` notice ${n.text} (${n.at})`));
2369
3178
  }
2370
3179
  function registerStart(program2) {
2371
3180
  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(
3181
+ "Start an effort: the area's worktree on <type>/<area> (or <type>/<area>--<task>), synced from the base branch, leased and running"
3182
+ ).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
3183
  "--story <path>",
2375
3184
  "frontend: the story file or component folder storybrokr boots, relative to the Storybook host"
2376
3185
  ).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 +3188,10 @@ function registerStart(program2) {
2379
3188
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
2380
3189
  const r = await startEffort(ctx, {
2381
3190
  area: o.area,
3191
+ task: o.task,
2382
3192
  type: o.type,
2383
3193
  mode: o.mode,
3194
+ scopes: o.scope,
2384
3195
  story: o.story,
2385
3196
  join: o.join,
2386
3197
  sync: o.sync
@@ -2395,36 +3206,36 @@ function registerStart(program2) {
2395
3206
  }
2396
3207
 
2397
3208
  // src/commands/status.ts
2398
- import chalk9 from "chalk";
3209
+ import chalk10 from "chalk";
2399
3210
  var age = (iso) => {
2400
3211
  const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
2401
3212
  return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
2402
3213
  };
2403
3214
  var expiresIn = (iso) => {
2404
3215
  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`;
3216
+ return mins <= 0 ? chalk10.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
2406
3217
  };
2407
3218
  function printStatus(r) {
2408
- console.log(chalk9.bold(`${r.project}`));
3219
+ console.log(chalk10.bold(`${r.project}`));
2409
3220
  const cols = ["AREA", "TYPE/MODE", "BRANCH", "OWNER", "STARTED", "LEASES", "BEHIND", "NOTICES"];
2410
3221
  const rows = r.areas.map((a) => [
2411
3222
  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"
3223
+ a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk10.gray("-"),
3224
+ a.effort?.branch ?? chalk10.gray("-"),
3225
+ a.effort?.owner.session ?? chalk10.gray("-"),
3226
+ a.effort ? age(a.effort.started) : chalk10.gray("-"),
3227
+ a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk10.gray("-"),
3228
+ a.behindBase > 0 ? chalk10.yellow(String(a.behindBase)) : "0",
3229
+ a.notices.length ? chalk10.yellow(String(a.notices.length)) : "0"
2419
3230
  ]);
2420
3231
  const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
2421
3232
  const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
2422
3233
  const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
2423
- console.log(chalk9.bold(line(cols)));
3234
+ console.log(chalk10.bold(line(cols)));
2424
3235
  for (const row of rows) console.log(line(row));
2425
3236
  if (r.leases.length) {
2426
3237
  console.log("");
2427
- console.log(chalk9.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
3238
+ console.log(chalk10.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
2428
3239
  for (const l of r.leases) {
2429
3240
  console.log(
2430
3241
  `${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
@@ -2433,10 +3244,10 @@ function printStatus(r) {
2433
3244
  }
2434
3245
  }
2435
3246
  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) => {
3247
+ 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
3248
  try {
2438
3249
  const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
2439
- const r = statusFor(ctx, o.area);
3250
+ const r = statusFor(ctx, o.area, o.task);
2440
3251
  if (o.json) printJson(r);
2441
3252
  else printStatus(r);
2442
3253
  } catch (err) {
@@ -2449,10 +3260,11 @@ function registerStatus(program2) {
2449
3260
  var { program } = createCli({
2450
3261
  name: "work",
2451
3262
  version: VERSION,
2452
- description: "Area-based work: start, status, finish, reap, retire, audit."
3263
+ description: "Area-based work: start, promote, status, finish, reap, retire, audit."
2453
3264
  });
2454
3265
  registerInit(program);
2455
3266
  registerStart(program);
3267
+ registerPromote(program);
2456
3268
  registerStatus(program);
2457
3269
  registerEnv(program);
2458
3270
  registerFinish(program);