@helmsmith/workbrokr 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1576 @@
1
+ // src/core/config.ts
2
+ import {
3
+ appendFileSync,
4
+ existsSync as existsSync2,
5
+ mkdirSync as mkdirSync2,
6
+ readFileSync,
7
+ realpathSync as realpathSync2,
8
+ writeFileSync
9
+ } from "fs";
10
+ import { join as join2 } from "path";
11
+
12
+ // src/core/errors.ts
13
+ var REFUSALS = /* @__PURE__ */ new Set([
14
+ "MANIFEST_INVALID",
15
+ "AREA_UNKNOWN",
16
+ "TASK_INVALID",
17
+ "TYPE_UNKNOWN",
18
+ "MODE_UNKNOWN",
19
+ "MODE_MISMATCH",
20
+ "SCOPE_REQUIRES_TASK",
21
+ "SCOPE_OUTSIDE_AREA",
22
+ "MAIN_CHECKOUT",
23
+ "WORKTREE_INVALID",
24
+ "PROTECTED_BRANCH",
25
+ "EFFORT_OPEN",
26
+ "EFFORT_NOT_FOUND",
27
+ "SCOPE_CONFLICT",
28
+ "COMMONS_LOCKED",
29
+ "NO_FREE_SLOT",
30
+ "LEASE_NOT_FOUND",
31
+ "PORT_RANGE_TAKEN",
32
+ "SYNC_CONFLICT",
33
+ "DIRTY_WORKTREE",
34
+ "UNPUSHED",
35
+ "PR_CLOSED"
36
+ ]);
37
+ function exitCodeFor(code) {
38
+ return REFUSALS.has(code) ? 2 : 1;
39
+ }
40
+ var WorkkitError = class extends Error {
41
+ code;
42
+ details;
43
+ constructor(code, message, details) {
44
+ super(message);
45
+ this.name = "WorkkitError";
46
+ this.code = code;
47
+ if (details) this.details = details;
48
+ }
49
+ };
50
+ function toErrorBody(err) {
51
+ if (err instanceof WorkkitError) {
52
+ return err.details ? { code: err.code, message: err.message, details: err.details } : { code: err.code, message: err.message };
53
+ }
54
+ return { code: "INTERNAL", message: err instanceof Error ? err.message : String(err) };
55
+ }
56
+
57
+ // src/core/git.ts
58
+ import { spawnSync } from "child_process";
59
+ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
60
+ import { dirname, join, resolve, sep } from "path";
61
+ var realpathOr = (p) => {
62
+ try {
63
+ return realpathSync(p);
64
+ } catch {
65
+ return p;
66
+ }
67
+ };
68
+ var SAFE_SUBMODULE_NAME = /^[^\p{Cc}\s=]+$/u;
69
+ var Git = class _Git {
70
+ constructor(cwd) {
71
+ this.cwd = cwd;
72
+ }
73
+ cwd;
74
+ tryRun(args) {
75
+ const r = spawnSync("git", args, { cwd: this.cwd, encoding: "utf8" });
76
+ return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
77
+ }
78
+ run(args) {
79
+ const r = this.tryRun(args);
80
+ if (r.code !== 0) {
81
+ throw new WorkkitError(
82
+ "GIT_FAILED",
83
+ `git ${args.join(" ")} (in ${this.cwd}): ${r.err.trim() || r.out.trim()}`,
84
+ {
85
+ args,
86
+ cwd: this.cwd
87
+ }
88
+ );
89
+ }
90
+ return r.out;
91
+ }
92
+ lines(args) {
93
+ return this.run(args).split("\n").map((l) => l.trim()).filter(Boolean);
94
+ }
95
+ /** The checkout's top level (symlinks resolved). */
96
+ root() {
97
+ return realpathSync(this.run(["rev-parse", "--show-toplevel"]).trim());
98
+ }
99
+ /** The main checkout's top level, from any worktree of the repo. */
100
+ mainRoot() {
101
+ const common = realpathSync(
102
+ this.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim()
103
+ );
104
+ return dirname(common);
105
+ }
106
+ /** The main checkout is the one whose git dir IS the common dir (compared as absolute realpaths). */
107
+ isMainCheckout() {
108
+ const gitDir = realpathSync(
109
+ this.run(["rev-parse", "--path-format=absolute", "--git-dir"]).trim()
110
+ );
111
+ const common = realpathSync(
112
+ this.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim()
113
+ );
114
+ return gitDir === common;
115
+ }
116
+ currentBranch() {
117
+ return this.run(["branch", "--show-current"]).trim();
118
+ }
119
+ branchExists(name) {
120
+ return this.tryRun(["rev-parse", "--verify", "-q", `refs/heads/${name}`]).code === 0;
121
+ }
122
+ remoteBranchExists(name) {
123
+ return this.run(["ls-remote", "--heads", "origin", name]).trim().length > 0;
124
+ }
125
+ fetch(branch) {
126
+ this.run(["fetch", "-q", "origin", branch]);
127
+ }
128
+ hasUpstream() {
129
+ return this.tryRun(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).code === 0;
130
+ }
131
+ /** Commits on HEAD that origin does not have: against the upstream, else the same-named remote branch, else origin/<base>. */
132
+ unpushedCount(base) {
133
+ let ref = `origin/${base}`;
134
+ if (this.hasUpstream()) ref = "@{u}";
135
+ else if (this.remoteBranchExists(this.currentBranch())) ref = `origin/${this.currentBranch()}`;
136
+ return this.lines(["rev-list", `${ref}..HEAD`]).length;
137
+ }
138
+ /** Commits on origin/<base> that HEAD does not have (0 when the ref is unknown). */
139
+ behindCount(base) {
140
+ const r = this.tryRun(["rev-list", "--count", `HEAD..origin/${base}`]);
141
+ return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
142
+ }
143
+ isDirty() {
144
+ return this.run(["status", "--porcelain"]).trim().length > 0;
145
+ }
146
+ changedPathsStaged() {
147
+ return this.lines(["diff", "--cached", "--name-only"]);
148
+ }
149
+ /** The other parent of a merge in progress, or undefined when none is. */
150
+ mergeHead() {
151
+ const r = this.tryRun(["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
152
+ return r.code === 0 ? r.out.trim() : void 0;
153
+ }
154
+ /** Staged paths whose index content differs from `ref` (so, authored here rather than taken from `ref`). */
155
+ stagedDifferingFrom(ref, paths) {
156
+ if (paths.length === 0) return [];
157
+ return this.lines(["diff", "--cached", "--name-only", "--no-renames", ref, "--", ...paths]);
158
+ }
159
+ changedPaths(from, to) {
160
+ return this.lines(["diff", "--name-only", "--no-renames", from, to]);
161
+ }
162
+ /** Paths this branch changed since it diverged from origin/<base> (three-dot), without the base's own changes. */
163
+ changedSince(base) {
164
+ return this.lines(["diff", "--name-only", "--no-renames", `origin/${base}...HEAD`]);
165
+ }
166
+ commitsNotOn(base) {
167
+ return this.lines(["rev-list", `origin/${base}..HEAD`]);
168
+ }
169
+ rev(ref) {
170
+ return this.run(["rev-parse", ref]).trim();
171
+ }
172
+ /** Fast-forward when HEAD has no own commits, otherwise merge; never rebase. A conflict is left in place. */
173
+ syncFrom(base) {
174
+ const target = `origin/${base}`;
175
+ const before = this.rev("HEAD");
176
+ const targetRev = this.rev(target);
177
+ if (before === targetRev) return { kind: "up-to-date", before, after: before, conflicted: [] };
178
+ if (this.tryRun(["merge-base", "--is-ancestor", "HEAD", target]).code === 0) {
179
+ this.run(["merge", "--ff-only", "-q", target]);
180
+ return { kind: "ff", before, after: this.rev("HEAD"), conflicted: [] };
181
+ }
182
+ if (this.tryRun(["merge-base", "--is-ancestor", target, "HEAD"]).code === 0) {
183
+ return { kind: "up-to-date", before, after: before, conflicted: [] };
184
+ }
185
+ const merged = this.tryRun(["merge", "--no-edit", "-q", target]);
186
+ if (merged.code === 0)
187
+ return { kind: "merge", before, after: this.rev("HEAD"), conflicted: [] };
188
+ const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
189
+ return { kind: "conflict", before, after: before, conflicted };
190
+ }
191
+ /** `origin/<branch>` after a fetch attempt, when the remote has it; the local copy may have been retired. */
192
+ remoteRef(branch) {
193
+ this.tryRun(["fetch", "-q", "origin", branch]);
194
+ const ref = `refs/remotes/origin/${branch}`;
195
+ return this.tryRun(["rev-parse", "--verify", "-q", ref]).code === 0 ? `origin/${branch}` : void 0;
196
+ }
197
+ /**
198
+ * Add a worktree on `branch`: the local branch if it exists, else a tracking
199
+ * branch from origin/<branch> if the remote has it (so retiring the local
200
+ * branch while its PR was open never recreates it from the base), else a
201
+ * new branch from `startPoint`.
202
+ */
203
+ worktreeAdd(path, branch, startPoint) {
204
+ mkdirSync(dirname(path), { recursive: true });
205
+ if (this.branchExists(branch)) {
206
+ this.run(["worktree", "add", "-q", path, branch]);
207
+ return;
208
+ }
209
+ const remote = this.remoteRef(branch);
210
+ if (remote) {
211
+ this.run(["worktree", "add", "-q", "--track", "-b", branch, path, remote]);
212
+ return;
213
+ }
214
+ this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
215
+ }
216
+ worktreeRemove(path) {
217
+ this.run(["worktree", "remove", "--force", path]);
218
+ }
219
+ checkout(branch, createFrom) {
220
+ if (this.branchExists(branch)) {
221
+ this.run(["checkout", "-q", branch]);
222
+ return;
223
+ }
224
+ const remote = this.remoteRef(branch);
225
+ if (remote) {
226
+ this.run(["checkout", "-q", "--track", "-b", branch, remote]);
227
+ return;
228
+ }
229
+ this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
230
+ }
231
+ push(branch) {
232
+ this.run(["push", "-q", "-u", "origin", branch]);
233
+ }
234
+ deleteLocalBranch(branch) {
235
+ this.run(["branch", "-q", "-D", branch]);
236
+ }
237
+ isAncestor(ancestor, descendant) {
238
+ return this.tryRun(["merge-base", "--is-ancestor", ancestor, descendant]).code === 0;
239
+ }
240
+ /** True when HEAD's tree matches origin/<base>, or every commit on HEAD is patch-equivalent to origin/<base> (squash-merged). */
241
+ isEquivalentToBase(base) {
242
+ const target = `origin/${base}`;
243
+ if (this.tryRun(["diff", "--quiet", target, "HEAD"]).code === 0) return true;
244
+ const cherry = this.tryRun(["cherry", target, "HEAD"]);
245
+ if (cherry.code !== 0) return false;
246
+ const lines = cherry.out.split("\n").map((l) => l.trim()).filter(Boolean);
247
+ return lines.length > 0 && lines.every((l) => l.startsWith("- "));
248
+ }
249
+ /**
250
+ * Give a linked worktree its own clone of every submodule the main checkout has initialized,
251
+ * cloned locally from the main checkout's module directory (no network) and then re-pointed at
252
+ * the real URL. `git worktree add` under submodule.recurse leaves a gitfile aimed at a module
253
+ * directory that was never cloned; a gitfile aimed at the MAIN checkout's module directory is
254
+ * worse, because that module's core.worktree is the main checkout's submodule, so any git
255
+ * command in the worktree's submodule (checkout -f, reset, merge) rewrites the human's files.
256
+ * Both shapes are replaced; a submodule that is already its own working tree is left alone.
257
+ */
258
+ repairSubmodules(mainRoot, log = () => {
259
+ }) {
260
+ const gitmodules = join(this.cwd, ".gitmodules");
261
+ if (!existsSync(gitmodules)) return;
262
+ const cfg = this.tryRun([
263
+ "config",
264
+ "-f",
265
+ ".gitmodules",
266
+ "--get-regexp",
267
+ "^submodule\\..*\\.path$"
268
+ ]);
269
+ if (cfg.code !== 0) return;
270
+ const wtGitDir = this.run(["rev-parse", "--path-format=absolute", "--git-dir"]).trim();
271
+ for (const line of cfg.out.split("\n").filter(Boolean)) {
272
+ const m = /^submodule\.(.+)\.path\s+(.+)$/.exec(line.trim());
273
+ if (!m) continue;
274
+ const [, name, subPath] = m;
275
+ const modulesDir = resolve(wtGitDir, "modules");
276
+ const moduleDir = resolve(modulesDir, name);
277
+ if (!SAFE_SUBMODULE_NAME.test(name) || name.split(/[\\/]/).some((seg) => seg === "" || seg === "." || seg === "..") || !moduleDir.startsWith(modulesDir + sep)) {
278
+ log(`submodule ${subPath}: refusing name ${JSON.stringify(name)}; not repaired`);
279
+ continue;
280
+ }
281
+ const wtSub = join(this.cwd, subPath);
282
+ const wtGit = join(wtSub, ".git");
283
+ if (this.isOwnSubmodule(wtSub, wtGit)) continue;
284
+ const mainSub = join(mainRoot, subPath);
285
+ if (!existsSync(join(mainSub, ".git"))) continue;
286
+ const src = new _Git(mainSub).tryRun(["rev-parse", "--path-format=absolute", "--git-dir"]);
287
+ if (src.code !== 0 || !src.out.trim()) continue;
288
+ if (existsSync(wtGit) && statSync(wtGit).isFile()) rmSync(wtGit);
289
+ rmSync(moduleDir, { recursive: true, force: true });
290
+ const r = this.tryRun([
291
+ "-c",
292
+ "protocol.file.allow=always",
293
+ "-c",
294
+ `submodule.${name}.url=${src.out.trim()}`,
295
+ "submodule",
296
+ "update",
297
+ "--init",
298
+ "--",
299
+ subPath
300
+ ]);
301
+ if (r.code !== 0) {
302
+ log(
303
+ `submodule ${subPath}: not repaired (${(r.err || r.out).trim()}); the project's refresh steps may still set it up`
304
+ );
305
+ continue;
306
+ }
307
+ this.tryRun(["submodule", "sync", "-q", "--", subPath]);
308
+ }
309
+ }
310
+ /** A submodule is the worktree's own when git, run inside it, says its top level IS that directory. */
311
+ isOwnSubmodule(wtSub, wtGit) {
312
+ if (!existsSync(wtGit)) return false;
313
+ const top = new _Git(wtSub).tryRun(["rev-parse", "--show-toplevel"]);
314
+ if (top.code !== 0) return false;
315
+ return realpathOr(top.out.trim()) === realpathOr(wtSub);
316
+ }
317
+ };
318
+
319
+ // src/core/config.ts
320
+ function renderTemplate(s, vars) {
321
+ return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => {
322
+ if (!(name in vars)) {
323
+ throw new WorkkitError(
324
+ "MANIFEST_INVALID",
325
+ `template references ${name}, which this effort does not define`
326
+ );
327
+ }
328
+ return vars[name];
329
+ });
330
+ }
331
+ function storybookHostFor(m, worktree) {
332
+ if (!worktree) return "";
333
+ return m.storybook?.hostRoot ? join2(worktree, m.storybook.hostRoot) : worktree;
334
+ }
335
+ function slotVars(m, o) {
336
+ const vars = {
337
+ SLOT: o.slot === void 0 ? "" : String(o.slot),
338
+ PROJECT: m.project,
339
+ AREA: o.area ?? "",
340
+ BRANCH: o.branch ?? "",
341
+ WORKTREE: o.worktree ?? "",
342
+ LOG_DIR: o.logDir ?? "",
343
+ STORYBROKR_HOST: storybookHostFor(m, o.worktree)
344
+ };
345
+ for (const service of Object.keys(m.ports.services)) {
346
+ const p = o.ports[service];
347
+ vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
348
+ }
349
+ if (o.storybookPort !== void 0) vars.PORT_STORYBOOK = String(o.storybookPort);
350
+ else if (vars.PORT_STORYBOOK === void 0) vars.PORT_STORYBOOK = "";
351
+ return vars;
352
+ }
353
+ function ensureExcluded(worktree, patterns) {
354
+ const r = new Git(worktree).tryRun(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
355
+ if (r.code !== 0) return;
356
+ const common = realpathSync2(r.out.trim());
357
+ const file = join2(common, "info", "exclude");
358
+ mkdirSync2(join2(common, "info"), { recursive: true });
359
+ const present = new Set(
360
+ existsSync2(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
361
+ );
362
+ const missing = patterns.filter((p) => !present.has(p));
363
+ if (missing.length === 0) return;
364
+ appendFileSync(
365
+ file,
366
+ `${present.size ? "\n" : ""}# written by @helmsmith/workbrokr: per-worktree config and logs
367
+ ${missing.join("\n")}
368
+ `
369
+ );
370
+ }
371
+ function emitWorktreeConfig(a) {
372
+ ensureExcluded(a.worktree, [
373
+ ".slot",
374
+ a.m.env.file,
375
+ `${a.m.logs.dir.replace(/\/+$/, "")}/`,
376
+ `${a.m.worktreeRoot.replace(/\/+$/, "")}/`
377
+ ]);
378
+ const logDir = join2(a.worktree, a.m.logs.dir);
379
+ mkdirSync2(logDir, { recursive: true });
380
+ const vars = slotVars(a.m, { ...a, logDir });
381
+ const envFile = join2(a.worktree, a.m.env.file);
382
+ const lines = Object.entries(a.m.env.template).map(([k, v]) => `${k}=${renderTemplate(v, vars)}`);
383
+ writeFileSync(envFile, `${lines.join("\n")}
384
+ `);
385
+ let slotFile;
386
+ if (a.slot !== void 0) {
387
+ slotFile = join2(a.worktree, ".slot");
388
+ writeFileSync(slotFile, `${a.slot}
389
+ `);
390
+ }
391
+ return slotFile ? { envFile, slotFile, logDir, vars } : { envFile, logDir, vars };
392
+ }
393
+
394
+ // src/core/guards.ts
395
+ import { existsSync as existsSync3, realpathSync as realpathSync3 } from "fs";
396
+
397
+ // src/core/scopes.ts
398
+ import picomatch from "picomatch";
399
+ function areaVars(area) {
400
+ const parts = area.split("/");
401
+ return { group: parts.slice(0, -1).join("/"), section: parts[parts.length - 1] };
402
+ }
403
+ function fill(template, vars) {
404
+ let missing = false;
405
+ const out = template.replace(/\{(group|section|module)\}/g, (_, name) => {
406
+ const v = vars[name];
407
+ if (v === void 0 || v === "") {
408
+ missing = true;
409
+ return "";
410
+ }
411
+ return v;
412
+ });
413
+ return missing ? null : out;
414
+ }
415
+ function extraScopesForMode(spec, mode) {
416
+ if (!spec.extraScopes) return [];
417
+ if (Array.isArray(spec.extraScopes)) {
418
+ return mode === "frontend" || mode === "fullstack" ? spec.extraScopes : [];
419
+ }
420
+ const out = [];
421
+ if (mode === "frontend" || mode === "fullstack") out.push(...spec.extraScopes.frontend ?? []);
422
+ if (mode === "backend" || mode === "fullstack") out.push(...spec.extraScopes.backend ?? []);
423
+ return out;
424
+ }
425
+ var normalizeGlob = (g) => g.replace(/^\.\//, "").replace(/\/+$/, "");
426
+ var hasWildcard = (s) => /[*?[{]/.test(s);
427
+ function isSubGlob(child, parent) {
428
+ const c = normalizeGlob(child);
429
+ const p = normalizeGlob(parent);
430
+ if (c === p) return true;
431
+ const cSegs = c.split("/");
432
+ const pSegs = p.split("/");
433
+ for (let i = 0; i < pSegs.length; i++) {
434
+ const ps = pSegs[i];
435
+ if (ps === "**") return true;
436
+ const cs = cSegs[i];
437
+ if (cs === void 0 || cs === "**") return false;
438
+ if (!hasWildcard(ps) && !hasWildcard(cs)) {
439
+ if (ps !== cs) return false;
440
+ continue;
441
+ }
442
+ if (!hasWildcard(cs) && hasWildcard(ps)) {
443
+ if (!picomatch.isMatch(cs, ps, { dot: true })) return false;
444
+ continue;
445
+ }
446
+ if (cs !== ps) return false;
447
+ }
448
+ return cSegs.length === pSegs.length;
449
+ }
450
+ function expandScopes(m, area, mode, narrowedScopes, hasTask = false) {
451
+ const spec = m.areas[area];
452
+ if (!spec)
453
+ throw new WorkkitError(
454
+ "AREA_UNKNOWN",
455
+ `unknown area "${area}" (known: ${Object.keys(m.areas).join(", ")})`
456
+ );
457
+ const vars = { ...areaVars(area), module: spec.module };
458
+ const out = [];
459
+ if (mode === "frontend" || mode === "fullstack") {
460
+ for (const t of m.scopes.frontend) {
461
+ const g = fill(t, vars);
462
+ if (g) out.push(g);
463
+ }
464
+ }
465
+ if (mode === "backend" || mode === "fullstack") {
466
+ if (!spec.module) {
467
+ throw new WorkkitError(
468
+ "MANIFEST_INVALID",
469
+ `area "${area}" has no module, so a ${mode} effort cannot hold a backend scope; add areas["${area}"].module or use --mode frontend`
470
+ );
471
+ }
472
+ for (const t of m.scopes.backend) {
473
+ const g = fill(t, vars);
474
+ if (g) out.push(g);
475
+ }
476
+ }
477
+ out.push(...extraScopesForMode(spec, mode));
478
+ if (!narrowedScopes || narrowedScopes.length === 0) return out;
479
+ if (!hasTask) {
480
+ throw new WorkkitError(
481
+ "SCOPE_REQUIRES_TASK",
482
+ "--scope narrowing requires --task <slug> so primary area efforts always protect the full area"
483
+ );
484
+ }
485
+ const allowed = [...out, ...spec.sharedScopes ?? []];
486
+ for (const g of narrowedScopes) {
487
+ if (!allowed.some((base) => isSubGlob(g, base))) {
488
+ throw new WorkkitError(
489
+ "SCOPE_OUTSIDE_AREA",
490
+ `narrowed scope "${g}" is outside ${area}'s ${mode} scopes (${allowed.join(", ")})`,
491
+ { area, mode, scope: g, allowed }
492
+ );
493
+ }
494
+ }
495
+ return narrowedScopes;
496
+ }
497
+ function allowedScopesForEffort(m, effort) {
498
+ const shared = m.areas[effort.area]?.sharedScopes ?? [];
499
+ const allCommons = Object.values(m.commons).flat();
500
+ return [...effort.scopes, ...shared, ...allCommons];
501
+ }
502
+ function literalPrefix(glob) {
503
+ const i = glob.search(/[*?[{]/);
504
+ return i < 0 ? glob : glob.slice(0, i);
505
+ }
506
+ function segmentsOverlap(sx, sy) {
507
+ if (sx === sy) return true;
508
+ const wx = hasWildcard(sx);
509
+ const wy = hasWildcard(sy);
510
+ if (!wx && !wy) return false;
511
+ if (!wx && wy) return picomatch.isMatch(sx, sy, { dot: true });
512
+ if (wx && !wy) return picomatch.isMatch(sy, sx, { dot: true });
513
+ const starToken = /^\*([^*?[{]+)\*$/;
514
+ const mx = starToken.exec(sx);
515
+ const my = starToken.exec(sy);
516
+ if (mx && my) {
517
+ const tx = mx[1];
518
+ const ty = my[1];
519
+ return tx.includes(ty) || ty.includes(tx);
520
+ }
521
+ return true;
522
+ }
523
+ function twoGlobsOverlap(x, y) {
524
+ const xs = normalizeGlob(x).split("/");
525
+ const ys = normalizeGlob(y).split("/");
526
+ const len = Math.min(xs.length, ys.length);
527
+ for (let i = 0; i < len; i++) {
528
+ if (xs[i] === "**" || ys[i] === "**") return true;
529
+ if (!segmentsOverlap(xs[i], ys[i])) return false;
530
+ }
531
+ if (xs.length === ys.length) return true;
532
+ const longer = xs.length > ys.length ? xs : ys;
533
+ return longer[len] === "**";
534
+ }
535
+ function globsOverlap(a, b) {
536
+ const pairs = [];
537
+ for (const x of a) {
538
+ for (const y of b) {
539
+ if (twoGlobsOverlap(x, y)) pairs.push(`${x} ~ ${y}`);
540
+ }
541
+ }
542
+ return pairs;
543
+ }
544
+ function pathsMatching(paths, globs) {
545
+ if (globs.length === 0) return [];
546
+ const isMatch = picomatch(globs, { dot: true });
547
+ return paths.filter((p) => isMatch(p));
548
+ }
549
+ function commonsTouched(m, paths) {
550
+ return Object.entries(m.commons).filter(([, globs]) => pathsMatching(paths, globs).length > 0).map(([name]) => name);
551
+ }
552
+
553
+ // src/core/guards.ts
554
+ function assertNotProtected(m, branch) {
555
+ if (m.protectedBranches.includes(branch)) {
556
+ throw new WorkkitError(
557
+ "PROTECTED_BRANCH",
558
+ `branch "${branch}" is protected (${m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`,
559
+ { branch }
560
+ );
561
+ }
562
+ }
563
+ function assertNotMainCheckout(git) {
564
+ if (git.isMainCheckout()) {
565
+ throw new WorkkitError(
566
+ "MAIN_CHECKOUT",
567
+ `${git.cwd} is the main checkout, which is a mirror of the base branch: run \`work start\` to get an area worktree`,
568
+ { cwd: git.cwd }
569
+ );
570
+ }
571
+ }
572
+ var same = (a, b) => {
573
+ try {
574
+ return realpathSync3(a) === realpathSync3(b);
575
+ } catch {
576
+ return a === b;
577
+ }
578
+ };
579
+ var effortLabel = (e) => e.task ? `${e.area}--${e.task}` : e.area;
580
+ function checkCommit(c) {
581
+ const refusals = [];
582
+ const warnings = [];
583
+ if (c.m.protectedBranches.includes(c.branch)) {
584
+ refusals.push(
585
+ `branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
586
+ );
587
+ }
588
+ const mergeHead = c.git.mergeHead();
589
+ const authored = mergeHead ? new Set(c.git.stagedDifferingFrom(mergeHead, c.stagedPaths)) : new Set(c.stagedPaths);
590
+ const ownPaths = c.stagedPaths.filter((p) => authored.has(p));
591
+ const allEfforts = c.registry.efforts({ project: c.project });
592
+ const efforts = new Map(allEfforts.map((e) => [e.id, e]));
593
+ const openEfforts = allEfforts.filter((e) => e.state === "active" || e.state === "starting");
594
+ const mine = openEfforts.find(
595
+ (e) => c.checkoutRoot && existsSync3(e.worktree) ? same(e.worktree, c.checkoutRoot) : e.branch === c.branch
596
+ );
597
+ for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
598
+ const holder = efforts.get(lease.effort);
599
+ const holderHasWorktree = Boolean(holder && existsSync3(holder.worktree));
600
+ if (holder && holderHasWorktree && c.checkoutRoot) {
601
+ if (same(holder.worktree, c.checkoutRoot)) continue;
602
+ } else if (lease.owner.session === c.ownerSession) {
603
+ continue;
604
+ }
605
+ if (holder && mine && holder.area === mine.area) continue;
606
+ const globs = c.m.commons[String(lease.value)] ?? [];
607
+ const hit = pathsMatching(ownPaths, globs);
608
+ if (hit.length > 0) {
609
+ refusals.push(
610
+ `commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
611
+ );
612
+ }
613
+ }
614
+ for (const other of openEfforts) {
615
+ if (c.checkoutRoot && existsSync3(other.worktree) && same(other.worktree, c.checkoutRoot)) {
616
+ continue;
617
+ }
618
+ if (!c.checkoutRoot && other.branch === c.branch) continue;
619
+ const hit = pathsMatching(ownPaths, other.scopes);
620
+ if (hit.length > 0) {
621
+ refusals.push(
622
+ `path(s) ${hit.join(", ")} are inside the active scope of ${effortLabel(other)} (${other.branch}, owned by ${other.owner.session})`
623
+ );
624
+ }
625
+ }
626
+ if (mine && ownPaths.length > 0) {
627
+ const allowed = allowedScopesForEffort(c.m, mine);
628
+ const inScope = new Set(pathsMatching(ownPaths, allowed));
629
+ const outside = ownPaths.filter((p) => !inScope.has(p));
630
+ if (outside.length > 0) {
631
+ warnings.push(
632
+ `${outside.length} staged path(s) are outside the effort's scope (${effortLabel(mine)}): ${outside.join(", ")}`
633
+ );
634
+ }
635
+ }
636
+ return { refusals, warnings };
637
+ }
638
+ function checkPush(c) {
639
+ const refusals = [];
640
+ const warnings = [];
641
+ const destination = (c.remoteRef ?? `refs/heads/${c.branch}`).replace(/^refs\/heads\//, "");
642
+ if (c.m.protectedBranches.includes(destination)) {
643
+ refusals.push(
644
+ c.isDelete ? `refusing to delete protected branch "${destination}"` : `"${destination}" is protected (${c.m.protectedBranches.join(", ")}): nothing is pushed to it directly, open a PR from ${c.branch || "an area branch"}`
645
+ );
646
+ return { refusals, warnings };
647
+ }
648
+ if (c.isDelete) return { refusals, warnings };
649
+ const effort = c.registry.efforts({ project: c.project }).find((e) => (e.state === "active" || e.state === "parked") && e.branch === c.branch);
650
+ const commons = commonsTouched(c.m, c.changedPaths);
651
+ if (effort) {
652
+ const allowed = allowedScopesForEffort(c.m, effort);
653
+ const inScope = new Set(pathsMatching(c.changedPaths, allowed));
654
+ const outside = c.changedPaths.filter((p) => !inScope.has(p));
655
+ if (outside.length > 0) {
656
+ warnings.push(
657
+ `${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`
658
+ );
659
+ }
660
+ }
661
+ for (const name of commons)
662
+ warnings.push(`commons ${name} touched: the other areas are told at \`work finish\``);
663
+ return { refusals, warnings };
664
+ }
665
+
666
+ // src/core/hooks.ts
667
+ import { chmodSync, lstatSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
668
+ import { join as join4 } from "path";
669
+
670
+ // src/core/manifest.ts
671
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
672
+ import { join as join3 } from "path";
673
+ import { pathToFileURL } from "url";
674
+ import { z } from "zod";
675
+ var MANIFEST_FILES = [
676
+ "helmsmith.config.ts",
677
+ "helmsmith.config.mjs",
678
+ "helmsmith.config.json"
679
+ ];
680
+ var BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
681
+ var BRANCH_NAME_HINT = 'must be a plain ref name: letters, digits, ".", "_", "/", "-"';
682
+ var TASK_SLUG = /^(?!.*--)[a-z0-9][a-z0-9_-]{0,47}$/;
683
+ var Service = z.object({
684
+ start: z.string().min(1),
685
+ stop: z.string().min(1),
686
+ health: z.string().min(1),
687
+ healthyCodes: z.array(z.number().int()).optional(),
688
+ reset: z.string().optional(),
689
+ healthTimeoutMs: z.number().int().positive().optional(),
690
+ modes: z.array(z.enum(["backend", "fullstack"])).optional()
691
+ });
692
+ var ExtraScopes = z.union([
693
+ z.array(z.string()),
694
+ z.object({
695
+ frontend: z.array(z.string()).optional(),
696
+ backend: z.array(z.string()).optional()
697
+ })
698
+ ]);
699
+ var Schema = z.object({
700
+ project: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
701
+ baseBranch: z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT),
702
+ protectedBranches: z.array(z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT)).optional(),
703
+ worktreeRoot: z.string().default(".claude/worktrees"),
704
+ branch: z.object({ types: z.array(z.string().regex(/^[a-z]+$/)).min(1) }).default({ types: ["feature", "chore", "fix"] }),
705
+ slots: z.object({
706
+ count: z.number().int().min(1).max(100),
707
+ reserved: z.array(z.number().int().min(0))
708
+ }).default({ count: 10, reserved: [0] }),
709
+ ports: z.object({
710
+ services: z.record(z.string(), z.object({ base: z.number().int().min(1024).max(65535) }))
711
+ }),
712
+ services: z.record(z.string(), Service),
713
+ storybook: z.object({ hostRoot: z.string() }).optional(),
714
+ logs: z.object({ dir: z.string() }).default({ dir: ".logs" }),
715
+ env: z.object({ file: z.string(), template: z.record(z.string(), z.string()) }).optional(),
716
+ seed: z.array(z.string().min(1)).optional(),
717
+ refresh: z.array(
718
+ z.object({
719
+ when: z.string().optional(),
720
+ always: z.boolean().optional(),
721
+ run: z.string().optional(),
722
+ mark: z.string().optional(),
723
+ restoreWhenIfClean: z.boolean().optional()
724
+ })
725
+ ).default([]),
726
+ scopes: z.object({ frontend: z.array(z.string()), backend: z.array(z.string()) }),
727
+ areas: z.record(
728
+ z.string().regex(/^(?!.*--)[a-z0-9-]+(\/[a-z0-9-]+)*$/),
729
+ z.object({
730
+ module: z.string().optional(),
731
+ commons: z.array(z.string()).optional(),
732
+ extraScopes: ExtraScopes.optional(),
733
+ sharedScopes: z.array(z.string()).optional()
734
+ })
735
+ ),
736
+ commons: z.record(z.string(), z.array(z.string())).default({}),
737
+ expiry: z.object({
738
+ slotHours: z.number().positive(),
739
+ commonsHours: z.number().positive(),
740
+ storybookHours: z.number().positive()
741
+ }).default({ slotHours: 12, commonsHours: 4, storybookHours: 2 }),
742
+ archive: z.object({
743
+ paths: z.array(z.string()),
744
+ keepDays: z.number().int().positive(),
745
+ keepPerArea: z.number().int().positive()
746
+ }).default({ paths: [], keepDays: 30, keepPerArea: 5 })
747
+ });
748
+ function templateVariables(m) {
749
+ const vars = /* @__PURE__ */ new Set([
750
+ "SLOT",
751
+ "LOG_DIR",
752
+ "PROJECT",
753
+ "AREA",
754
+ "BRANCH",
755
+ "WORKTREE",
756
+ "PORT_STORYBOOK",
757
+ "STORYBROKR_HOST"
758
+ ]);
759
+ for (const s of Object.keys(m.ports.services)) vars.add(`PORT_${s.toUpperCase()}`);
760
+ return vars;
761
+ }
762
+ function defaultEnv(services) {
763
+ const template = {
764
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
765
+ SLOT: "${SLOT}",
766
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
767
+ LOG_DIR: "${LOG_DIR}",
768
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: manifest template placeholder
769
+ STORYBROKR_HOST: "${STORYBROKR_HOST}"
770
+ };
771
+ for (const s of services) template[`PORT_${s.toUpperCase()}`] = `\${PORT_${s.toUpperCase()}}`;
772
+ return { file: ".env.slot", template };
773
+ }
774
+ function validateManifest(raw) {
775
+ const parsed = Schema.safeParse(raw);
776
+ if (!parsed.success) {
777
+ throw new WorkkitError(
778
+ "MANIFEST_INVALID",
779
+ parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
780
+ );
781
+ }
782
+ const m = parsed.data;
783
+ const problems = [];
784
+ const areas = Object.keys(m.areas).sort();
785
+ for (const a of areas) {
786
+ for (const b of areas) {
787
+ if (a !== b && b.startsWith(`${a}/`)) {
788
+ problems.push(`area "${a}" is an ancestor of "${b}": areas must be leaves`);
789
+ }
790
+ }
791
+ }
792
+ for (const s of Object.keys(m.services)) {
793
+ if (!m.ports.services[s]) problems.push(`service "${s}" has no port base under ports.services`);
794
+ }
795
+ for (const s of Object.keys(m.ports.services)) {
796
+ if (!m.services[s] && s !== "storybook") problems.push(`port "${s}" names no service`);
797
+ }
798
+ for (const r of m.slots.reserved) {
799
+ if (r >= m.slots.count) problems.push(`reserved slot ${r} is outside 0..${m.slots.count - 1}`);
800
+ }
801
+ const env = m.env ?? defaultEnv(Object.keys(m.ports.services));
802
+ const allowed = templateVariables(m);
803
+ for (const [k, v] of Object.entries(env.template)) {
804
+ for (const [, name] of v.matchAll(/\$\{([A-Z0-9_]+)\}/g)) {
805
+ if (!allowed.has(name)) {
806
+ problems.push(
807
+ `env.template.${k} references ${name}, which no slot derives (allowed: ${[...allowed].join(", ")})`
808
+ );
809
+ }
810
+ }
811
+ }
812
+ for (const [area, spec] of Object.entries(m.areas)) {
813
+ for (const c of spec.commons ?? []) {
814
+ if (!m.commons[c])
815
+ problems.push(`area "${area}" names commons "${c}", which is not declared`);
816
+ }
817
+ }
818
+ if (problems.length) throw new WorkkitError("MANIFEST_INVALID", problems.join("; "));
819
+ return { ...m, protectedBranches: m.protectedBranches ?? [m.baseBranch], env };
820
+ }
821
+ async function loadManifest(repoRoot) {
822
+ for (const name of MANIFEST_FILES) {
823
+ const file = join3(repoRoot, name);
824
+ if (!existsSync4(file)) continue;
825
+ if (name.endsWith(".json")) return validateManifest(JSON.parse(readFileSync2(file, "utf8")));
826
+ const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
827
+ return validateManifest(mod.default);
828
+ }
829
+ throw new WorkkitError(
830
+ "MANIFEST_NOT_FOUND",
831
+ `no ${MANIFEST_FILES.join(" | ")} in ${repoRoot}; run \`work init\``
832
+ );
833
+ }
834
+
835
+ // src/core/hooks.ts
836
+ var HOOKS_DIR = ".helmsmith/hooks";
837
+ function preCommitScript(protectedBranches) {
838
+ return `#!/bin/sh
839
+ # Installed by \`work init\` (@helmsmith/workbrokr). The protected-branch rule lives here; the rest in workbrokr.
840
+ PROTECTED="${protectedBranches.join(" ")}"
841
+ branch=$(git symbolic-ref --short -q HEAD)
842
+ for p in $PROTECTED; do
843
+ if [ "$branch" = "$p" ]; then
844
+ echo "work: branch \\"$branch\\" is protected ($PROTECTED): work happens on <type>/<area> branches, never here" >&2
845
+ exit 2
846
+ fi
847
+ done
848
+ if command -v workbrokr >/dev/null 2>&1; then
849
+ workbrokr hook pre-commit "$@" || exit $?
850
+ else
851
+ echo "work: workbrokr is not on PATH; commons and scope checks skipped (npm i -g @helmsmith/workbrokr)" >&2
852
+ fi
853
+ COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
854
+ LEGACY="$COMMON_DIR/hooks/pre-commit"
855
+ if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
856
+ SELF_DIR=$(cd "$(dirname "$0")" && pwd)
857
+ LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
858
+ if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
859
+ exec "$LEGACY" "$@"
860
+ fi
861
+ fi
862
+ exit 0
863
+ `;
864
+ }
865
+ function prePushScript(protectedBranches) {
866
+ return `#!/bin/sh
867
+ # Installed by \`work init\` (@helmsmith/workbrokr). Refuses a push to, or a delete of, a protected branch here; the rest in workbrokr.
868
+ PROTECTED="${protectedBranches.join(" ")}"
869
+ input=$(cat)
870
+ printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
871
+ [ -n "$rref" ] || continue
872
+ dest=\${rref#refs/heads/}
873
+ for p in $PROTECTED; do
874
+ if [ "$dest" = "$p" ]; then
875
+ echo "work: \\"$dest\\" is protected ($PROTECTED): nothing is pushed to it directly, open a PR from an area branch" >&2
876
+ exit 2
877
+ fi
878
+ done
879
+ done || exit 2
880
+ if command -v workbrokr >/dev/null 2>&1; then
881
+ printf '%s\\n' "$input" | workbrokr hook pre-push "$@" || exit $?
882
+ else
883
+ echo "work: workbrokr is not on PATH; scope and commons warnings skipped" >&2
884
+ fi
885
+ COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
886
+ LEGACY="$COMMON_DIR/hooks/pre-push"
887
+ if [ -n "$COMMON_DIR" ] && [ -x "$LEGACY" ]; then
888
+ SELF_DIR=$(cd "$(dirname "$0")" && pwd)
889
+ LEGACY_DIR=$(cd "$(dirname "$LEGACY")" && pwd)
890
+ if [ "$SELF_DIR" != "$LEGACY_DIR" ]; then
891
+ printf '%s\\n' "$input" | exec "$LEGACY" "$@"
892
+ fi
893
+ fi
894
+ exit 0
895
+ `;
896
+ }
897
+ function installHooks(repoRoot, opts) {
898
+ for (const b of opts.protectedBranches) {
899
+ if (!BRANCH_NAME.test(b))
900
+ throw new WorkkitError("MANIFEST_INVALID", `protected branch "${b}" is not a plain ref name`);
901
+ }
902
+ const hooksDir = join4(repoRoot, HOOKS_DIR);
903
+ mkdirSync3(hooksDir, { recursive: true });
904
+ const files = [];
905
+ const scripts = [
906
+ ["pre-commit", preCommitScript(opts.protectedBranches)],
907
+ ["pre-push", prePushScript(opts.protectedBranches)]
908
+ ];
909
+ for (const [name, body] of scripts) {
910
+ const file = join4(hooksDir, name);
911
+ let existing;
912
+ try {
913
+ existing = lstatSync(file);
914
+ } catch {
915
+ existing = void 0;
916
+ }
917
+ if (existing?.isSymbolicLink()) {
918
+ throw new WorkkitError("INTERNAL", `refusing to write hook ${file}: it is a symlink`, {
919
+ file
920
+ });
921
+ }
922
+ writeFileSync2(file, body);
923
+ chmodSync(file, 493);
924
+ files.push(file);
925
+ }
926
+ new Git(repoRoot).run(["config", "core.hooksPath", HOOKS_DIR]);
927
+ return { hooksDir, files };
928
+ }
929
+
930
+ // src/core/paths.ts
931
+ import { homedir } from "os";
932
+ import { join as join5 } from "path";
933
+ function homeDir(env = process.env) {
934
+ return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join5(homedir(), ".cache", "helmsmith");
935
+ }
936
+ var registryFile = (home) => join5(home, "registry.json");
937
+ var lockDir = (home) => join5(home, "registry.lock");
938
+
939
+ // src/core/ports.ts
940
+ function portsForSlot(m, slot) {
941
+ return Object.fromEntries(
942
+ Object.entries(m.ports.services).map(([service, { base }]) => [service, base + slot])
943
+ );
944
+ }
945
+ function rangesFor(m) {
946
+ return Object.fromEntries(
947
+ Object.entries(m.ports.services).map(([service, { base }]) => [
948
+ service,
949
+ { base, count: m.slots.count }
950
+ ])
951
+ );
952
+ }
953
+ function rangesOverlap(a, b) {
954
+ return a.base < b.base + b.count && b.base < a.base + a.count;
955
+ }
956
+ function ensureRanges(registry, m, repoRoot) {
957
+ const mine = rangesFor(m);
958
+ assertNoOverlap(registry, m, mine);
959
+ const current = registry.project(m.project);
960
+ if (current && JSON.stringify(current.ranges) === JSON.stringify(mine)) return;
961
+ registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
962
+ }
963
+ function claimRanges(registry, m, repoRoot) {
964
+ const mine = rangesFor(m);
965
+ assertNoOverlap(registry, m, mine);
966
+ registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
967
+ }
968
+ function assertNoOverlap(registry, m, mine) {
969
+ const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
970
+ for (const [service, range] of Object.entries(mine)) {
971
+ for (const [name, entry] of others) {
972
+ for (const [theirService, theirs] of Object.entries(entry.ranges)) {
973
+ if (rangesOverlap(range, theirs)) {
974
+ throw new WorkkitError(
975
+ "PORT_RANGE_TAKEN",
976
+ `ports ${range.base}-${range.base + range.count - 1} for "${service}" overlap project "${name}" (${theirService}: ${theirs.base}-${theirs.base + theirs.count - 1}); pick another base in ports.services.${service}`,
977
+ { project: name, service, theirs }
978
+ );
979
+ }
980
+ }
981
+ }
982
+ }
983
+ }
984
+
985
+ // src/core/refresh.ts
986
+ import { spawnSync as spawnSync2 } from "child_process";
987
+ import { createHash } from "crypto";
988
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
989
+ import { dirname as dirname2, join as join6 } from "path";
990
+ import picomatch2 from "picomatch";
991
+ function matchesWhen(glob) {
992
+ const isMatch = picomatch2(glob, { dot: true });
993
+ return (p) => isMatch(p);
994
+ }
995
+ function readStamps(stampFile) {
996
+ if (!stampFile || !existsSync5(stampFile)) return {};
997
+ try {
998
+ return JSON.parse(readFileSync3(stampFile, "utf8"));
999
+ } catch {
1000
+ return {};
1001
+ }
1002
+ }
1003
+ function writeStamps(stampFile, stamps) {
1004
+ mkdirSync4(dirname2(stampFile), { recursive: true });
1005
+ writeFileSync3(stampFile, `${JSON.stringify(stamps, null, 2)}
1006
+ `);
1007
+ }
1008
+ function matchedFilesInWorktree(worktree, whenGlob) {
1009
+ if (!/[*?[{]/.test(whenGlob)) {
1010
+ return existsSync5(join6(worktree, whenGlob)) ? [whenGlob] : [];
1011
+ }
1012
+ const r = spawnSync2("git", ["ls-files"], { cwd: worktree, encoding: "utf8" });
1013
+ if (r.status !== 0) return [];
1014
+ const isMatch = matchesWhen(whenGlob);
1015
+ return (r.stdout ?? "").split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && isMatch(l) && existsSync5(join6(worktree, l)));
1016
+ }
1017
+ function hashFiles(worktree, relPaths) {
1018
+ if (relPaths.length === 0) return null;
1019
+ const h = createHash("sha256");
1020
+ for (const rel of [...relPaths].sort()) {
1021
+ const abs = join6(worktree, rel);
1022
+ if (!existsSync5(abs)) continue;
1023
+ h.update(`${rel}\0`);
1024
+ h.update(readFileSync3(abs));
1025
+ h.update("\0");
1026
+ }
1027
+ return h.digest("hex");
1028
+ }
1029
+ function isFileCleanInGit(worktree, relPath) {
1030
+ const r = spawnSync2("git", ["status", "--porcelain", "--", relPath], {
1031
+ cwd: worktree,
1032
+ encoding: "utf8"
1033
+ });
1034
+ return r.status === 0 && (r.stdout ?? "").trim().length === 0;
1035
+ }
1036
+ function selectRefresh(entries, changedPaths, opts) {
1037
+ const runs = [];
1038
+ const marks = [];
1039
+ const stored = readStamps(opts?.stampFile);
1040
+ const pendingStamps = { ...stored };
1041
+ const cleanWhenFiles = /* @__PURE__ */ new Set();
1042
+ for (const e of entries) {
1043
+ let hit = e.always === true;
1044
+ let matchedFiles = [];
1045
+ if (e.when !== void 0) {
1046
+ const byChanged = changedPaths.some(matchesWhen(e.when));
1047
+ if (opts?.worktree) {
1048
+ matchedFiles = matchedFilesInWorktree(opts.worktree, e.when);
1049
+ const digest = hashFiles(opts.worktree, matchedFiles);
1050
+ if (digest !== null) {
1051
+ if (stored[e.when] !== digest) hit = true;
1052
+ pendingStamps[e.when] = digest;
1053
+ }
1054
+ }
1055
+ if (byChanged) hit = true;
1056
+ }
1057
+ if (!hit) continue;
1058
+ if (e.run) {
1059
+ runs.push(e.run);
1060
+ if (opts?.worktree && e.when && e.restoreWhenIfClean !== false) {
1061
+ for (const f of matchedFiles) {
1062
+ if (isFileCleanInGit(opts.worktree, f)) cleanWhenFiles.add(f);
1063
+ }
1064
+ }
1065
+ }
1066
+ if (e.mark) marks.push(e.mark);
1067
+ }
1068
+ if (!opts?.worktree) return { runs, marks };
1069
+ return {
1070
+ runs,
1071
+ marks,
1072
+ pendingStamps,
1073
+ cleanWhenFiles: [...cleanWhenFiles]
1074
+ };
1075
+ }
1076
+ function runRefresh(runs, cwd, log, env = process.env, opts) {
1077
+ for (const cmd of runs) {
1078
+ log(`$ ${cmd}`);
1079
+ const r = spawnSync2("sh", ["-c", cmd], { cwd, env, encoding: "utf8" });
1080
+ if (r.stdout) log(r.stdout.trimEnd());
1081
+ if (r.stderr) log(r.stderr.trimEnd());
1082
+ if (r.status !== 0) {
1083
+ throw new WorkkitError("INTERNAL", `refresh command failed (${r.status}): ${cmd}`, {
1084
+ cmd,
1085
+ status: r.status
1086
+ });
1087
+ }
1088
+ }
1089
+ if (opts?.restoreFiles && opts.restoreFiles.length > 0) {
1090
+ for (const rel of opts.restoreFiles) {
1091
+ if (!isFileCleanInGit(cwd, rel)) {
1092
+ spawnSync2("git", ["checkout", "--", rel], { cwd, encoding: "utf8" });
1093
+ }
1094
+ }
1095
+ }
1096
+ if (opts?.stampFile && opts.pendingStamps) {
1097
+ writeStamps(opts.stampFile, opts.pendingStamps);
1098
+ }
1099
+ }
1100
+
1101
+ // src/core/registry.ts
1102
+ import { randomBytes } from "crypto";
1103
+ import {
1104
+ existsSync as existsSync6,
1105
+ mkdirSync as mkdirSync5,
1106
+ readFileSync as readFileSync4,
1107
+ renameSync,
1108
+ rmSync as rmSync2,
1109
+ statSync as statSync2,
1110
+ writeFileSync as writeFileSync4
1111
+ } from "fs";
1112
+ var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
1113
+ var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
1114
+ var LOCK_STALE_MS = 1e4;
1115
+ var LOCK_WAIT_MS = 15e3;
1116
+ function sleep(ms) {
1117
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1118
+ }
1119
+ var Registry = class _Registry {
1120
+ constructor(home, now) {
1121
+ this.home = home;
1122
+ this.now = now;
1123
+ }
1124
+ home;
1125
+ now;
1126
+ static open(home, now = () => /* @__PURE__ */ new Date()) {
1127
+ mkdirSync5(home, { recursive: true });
1128
+ return new _Registry(home, now);
1129
+ }
1130
+ /** The registry as it is on disk. A file that does not parse is refused, never replaced. */
1131
+ read() {
1132
+ const file = registryFile(this.home);
1133
+ if (!existsSync6(file)) return structuredClone(EMPTY);
1134
+ let parsed;
1135
+ try {
1136
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
1137
+ } catch (err) {
1138
+ throw new WorkkitError(
1139
+ "INTERNAL",
1140
+ `${file} is corrupt (${err instanceof Error ? err.message : String(err)}); nothing was changed. Move it aside (mv ${file} ${file}.corrupt-$(date +%s)) to start fresh, knowing its leases and running pids are lost`,
1141
+ { file }
1142
+ );
1143
+ }
1144
+ const r = parsed;
1145
+ if (!r || typeof r !== "object" || r.version !== 1) {
1146
+ throw new WorkkitError(
1147
+ "INTERNAL",
1148
+ `${file} has an unknown registry version (${String(r?.version)}); this build reads version 1`,
1149
+ { file }
1150
+ );
1151
+ }
1152
+ return {
1153
+ version: 1,
1154
+ projects: r.projects ?? {},
1155
+ leases: r.leases ?? [],
1156
+ efforts: r.efforts ?? [],
1157
+ notices: r.notices ?? []
1158
+ };
1159
+ }
1160
+ /** Lock, re-read, mutate, write atomically, unlock. The only way state changes. */
1161
+ update(fn) {
1162
+ const lock = lockDir(this.home);
1163
+ const started = Date.now();
1164
+ for (; ; ) {
1165
+ try {
1166
+ mkdirSync5(lock);
1167
+ break;
1168
+ } catch {
1169
+ try {
1170
+ if (Date.now() - statSync2(lock).mtimeMs > LOCK_STALE_MS) {
1171
+ const claimed = `${lock}.stale.${process.pid}.${Date.now()}`;
1172
+ renameSync(lock, claimed);
1173
+ rmSync2(claimed, { recursive: true, force: true });
1174
+ continue;
1175
+ }
1176
+ } catch {
1177
+ continue;
1178
+ }
1179
+ if (Date.now() - started > LOCK_WAIT_MS) {
1180
+ throw new WorkkitError(
1181
+ "INTERNAL",
1182
+ `registry lock ${lock} held for over ${LOCK_WAIT_MS} ms`
1183
+ );
1184
+ }
1185
+ sleep(25);
1186
+ }
1187
+ }
1188
+ try {
1189
+ const r = this.read();
1190
+ const out = fn(r);
1191
+ const file = registryFile(this.home);
1192
+ const tmp = `${file}.${process.pid}.tmp`;
1193
+ writeFileSync4(tmp, `${JSON.stringify(r, null, 2)}
1194
+ `);
1195
+ renameSync(tmp, file);
1196
+ return out;
1197
+ } finally {
1198
+ rmSync2(lock, { recursive: true, force: true });
1199
+ }
1200
+ }
1201
+ project(name) {
1202
+ return this.read().projects[name];
1203
+ }
1204
+ upsertProject(name, entry) {
1205
+ this.update((r) => {
1206
+ r.projects[name] = entry;
1207
+ });
1208
+ }
1209
+ leases(filter = {}) {
1210
+ return this.read().leases.filter(
1211
+ (l) => Object.entries(filter).every(([k, v]) => l[k] === v)
1212
+ );
1213
+ }
1214
+ efforts(filter = {}) {
1215
+ return this.read().efforts.filter(
1216
+ (e) => Object.entries(filter).every(([k, v]) => e[k] === v)
1217
+ );
1218
+ }
1219
+ activeEffortFor(project, area, task) {
1220
+ return this.efforts({ project, area, state: "active" }).find(
1221
+ (e) => (e.task ?? void 0) === (task ?? void 0)
1222
+ );
1223
+ }
1224
+ /** An effort that is being started counts as open: nobody else may start on the same (area, task) meanwhile. */
1225
+ openEffortFor(project, area, task) {
1226
+ return this.efforts({ project, area }).find(
1227
+ (e) => (e.state === "starting" || e.state === "active") && (e.task ?? void 0) === (task ?? void 0)
1228
+ );
1229
+ }
1230
+ addNotice(project, area, notice) {
1231
+ this.update((r) => {
1232
+ const existing = r.notices ?? [];
1233
+ const dup = existing.findIndex(
1234
+ (n) => n.project === project && n.area === area && n.notice.from === notice.from && n.notice.text === notice.text
1235
+ );
1236
+ if (dup >= 0) {
1237
+ existing[dup] = { project, area, notice };
1238
+ r.notices = existing;
1239
+ } else {
1240
+ r.notices = [...existing, { project, area, notice }];
1241
+ }
1242
+ });
1243
+ }
1244
+ notices(project, area) {
1245
+ return (this.read().notices ?? []).filter((n) => n.project === project && n.area === area).map((n) => n.notice);
1246
+ }
1247
+ clearNotices(project, area) {
1248
+ this.update((r) => {
1249
+ r.notices = (r.notices ?? []).filter((n) => !(n.project === project && n.area === area));
1250
+ });
1251
+ }
1252
+ clearNoticesFrom(project, branches) {
1253
+ const set = new Set(branches);
1254
+ this.update((r) => {
1255
+ r.notices = (r.notices ?? []).filter(
1256
+ (n) => !(n.project === project && set.has(n.notice.from))
1257
+ );
1258
+ });
1259
+ }
1260
+ pruneNoticesWhere(project, area, remove) {
1261
+ this.update((r) => {
1262
+ r.notices = (r.notices ?? []).filter(
1263
+ (n) => !(n.project === project && n.area === area && remove(n.notice))
1264
+ );
1265
+ });
1266
+ }
1267
+ allocateSlot(project, m, owner, effort, exclude = /* @__PURE__ */ new Set()) {
1268
+ return this.update((r) => {
1269
+ const taken = new Set(
1270
+ r.leases.filter((l) => l.project === project && l.kind === "slot").map((l) => l.value)
1271
+ );
1272
+ const reserved = new Set(m.slots.reserved);
1273
+ let slot = -1;
1274
+ for (let n = 0; n < m.slots.count; n++) {
1275
+ if (!taken.has(n) && !reserved.has(n) && !exclude.has(n)) {
1276
+ slot = n;
1277
+ break;
1278
+ }
1279
+ }
1280
+ if (slot < 0) {
1281
+ throw new WorkkitError(
1282
+ "NO_FREE_SLOT",
1283
+ `no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")}): \`work status\` lists the efforts holding them; \`work finish\` or \`work reap\` frees one`
1284
+ );
1285
+ }
1286
+ const lease = this.lease(
1287
+ project,
1288
+ "slot",
1289
+ slot,
1290
+ owner,
1291
+ effort,
1292
+ m.expiry.slotHours,
1293
+ portsForSlot(m, slot)
1294
+ );
1295
+ r.leases.push(lease);
1296
+ return lease;
1297
+ });
1298
+ }
1299
+ addLease(kind, project, value, owner, effort, hours, extra = {}) {
1300
+ return this.update((r) => {
1301
+ const l = { ...this.lease(project, kind, value, owner, effort, hours), ...extra };
1302
+ r.leases.push(l);
1303
+ return l;
1304
+ });
1305
+ }
1306
+ lease(project, kind, value, owner, effort, hours, ports) {
1307
+ const now = this.now();
1308
+ return {
1309
+ id: newId("lease"),
1310
+ project,
1311
+ kind,
1312
+ value,
1313
+ ...ports ? { ports } : {},
1314
+ owner,
1315
+ effort,
1316
+ created: now.toISOString(),
1317
+ touched: now.toISOString(),
1318
+ expires: new Date(now.getTime() + hours * 36e5).toISOString()
1319
+ };
1320
+ }
1321
+ touch(leaseId, hours) {
1322
+ this.update((r) => {
1323
+ const l = r.leases.find((x) => x.id === leaseId);
1324
+ if (!l) throw new WorkkitError("LEASE_NOT_FOUND", leaseId);
1325
+ const now = this.now();
1326
+ l.touched = now.toISOString();
1327
+ l.expires = new Date(now.getTime() + hours * 36e5).toISOString();
1328
+ });
1329
+ }
1330
+ patchLease(leaseId, patch) {
1331
+ this.update((r) => {
1332
+ const i = r.leases.findIndex((x) => x.id === leaseId);
1333
+ if (i < 0) throw new WorkkitError("LEASE_NOT_FOUND", leaseId);
1334
+ r.leases[i] = { ...r.leases[i], ...patch };
1335
+ });
1336
+ }
1337
+ release(leaseId) {
1338
+ this.update((r) => {
1339
+ r.leases = r.leases.filter((l) => l.id !== leaseId);
1340
+ });
1341
+ }
1342
+ expired(now = this.now()) {
1343
+ return this.read().leases.filter((l) => Date.parse(l.expires) < now.getTime());
1344
+ }
1345
+ addEffort(e) {
1346
+ this.update((r) => {
1347
+ r.efforts.push(e);
1348
+ });
1349
+ }
1350
+ updateEffort(id, patch) {
1351
+ return this.update((r) => {
1352
+ const i = r.efforts.findIndex((e) => e.id === id);
1353
+ if (i < 0) throw new WorkkitError("EFFORT_NOT_FOUND", `no effort ${id}`);
1354
+ r.efforts[i] = { ...r.efforts[i], ...patch };
1355
+ return r.efforts[i];
1356
+ });
1357
+ }
1358
+ removeEfforts(predicate) {
1359
+ return this.update((r) => {
1360
+ const before = r.efforts.length;
1361
+ r.efforts = r.efforts.filter((e) => !predicate(e));
1362
+ return before - r.efforts.length;
1363
+ });
1364
+ }
1365
+ };
1366
+
1367
+ // src/core/runner.ts
1368
+ import { spawn, spawnSync as spawnSync3 } from "child_process";
1369
+ import { closeSync, openSync } from "fs";
1370
+ import { join as join7 } from "path";
1371
+ var DEFAULT_HEALTH_TIMEOUT_MS = 18e4;
1372
+ var HEALTH_POLL_MS = 1e3;
1373
+ var PROBE_CAP_MS = 1e4;
1374
+ var STOP_COMMAND_TIMEOUT_MS = 15e3;
1375
+ var STOP_GRACE_MS = 5e3;
1376
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1377
+ function isAlive(pid) {
1378
+ try {
1379
+ process.kill(pid, 0);
1380
+ return true;
1381
+ } catch {
1382
+ return false;
1383
+ }
1384
+ }
1385
+ function pidSignature(pid) {
1386
+ const r = spawnSync3("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
1387
+ return r.status === 0 ? (r.stdout ?? "").trim() : "";
1388
+ }
1389
+ function serviceModes(name, spec) {
1390
+ if (spec.modes && spec.modes.length > 0) return spec.modes;
1391
+ return name === "frontend" ? ["fullstack"] : ["backend", "fullstack"];
1392
+ }
1393
+ function probe(spec, cwd, env, timeoutMs = PROBE_CAP_MS) {
1394
+ const r = spawnSync3("sh", ["-c", spec.health], {
1395
+ cwd,
1396
+ env,
1397
+ encoding: "utf8",
1398
+ timeout: Math.max(100, timeoutMs)
1399
+ });
1400
+ const out = (r.stdout ?? "").trim();
1401
+ const last = out.split(/\s+/).filter(Boolean).pop() ?? "";
1402
+ if (r.error) return { healthy: false, last: `probe ${r.error.message}` };
1403
+ if (r.status !== 0) return { healthy: false, last: (r.stderr ?? "").trim() || out };
1404
+ if (spec.healthyCodes && spec.healthyCodes.length > 0)
1405
+ return { healthy: spec.healthyCodes.includes(Number(last)), last };
1406
+ return { healthy: true, last };
1407
+ }
1408
+ async function waitHealthy(o) {
1409
+ const budget = o.timeoutMs ?? o.spec.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS;
1410
+ const deadline = Date.now() + budget;
1411
+ let last = "";
1412
+ while (Date.now() < deadline) {
1413
+ const p = probe(o.spec, o.cwd, o.env, Math.min(PROBE_CAP_MS, deadline - Date.now()));
1414
+ if (p.healthy) return;
1415
+ last = p.last;
1416
+ if (Date.now() >= deadline) break;
1417
+ await sleep2(Math.min(HEALTH_POLL_MS, Math.max(0, deadline - Date.now())));
1418
+ }
1419
+ throw new WorkkitError(
1420
+ "HEALTH_TIMEOUT",
1421
+ `service "${o.name}" was not healthy within ${(budget / 1e3).toFixed(0)} s (last probe: ${last || "no output"})`,
1422
+ { service: o.name, last }
1423
+ );
1424
+ }
1425
+ function spawnService(name, spec, a) {
1426
+ const fd = openSync(join7(a.logDir, `${name}.log`), "a");
1427
+ try {
1428
+ const child = spawn("sh", ["-c", spec.start], {
1429
+ cwd: a.cwd,
1430
+ env: a.env,
1431
+ detached: true,
1432
+ stdio: ["ignore", fd, fd]
1433
+ });
1434
+ child.unref();
1435
+ if (child.pid === void 0) {
1436
+ throw new WorkkitError("SERVICE_FAILED", `service "${name}" did not spawn: ${spec.start}`, {
1437
+ service: name
1438
+ });
1439
+ }
1440
+ return child.pid;
1441
+ } finally {
1442
+ closeSync(fd);
1443
+ }
1444
+ }
1445
+ async function startServices(a) {
1446
+ const pids = {};
1447
+ const signatures = {};
1448
+ const targetMode = a.mode ?? "fullstack";
1449
+ const entries = Object.entries(a.m.services).filter(([name, spec]) => {
1450
+ if (a.onlyServices) return a.onlyServices.includes(name);
1451
+ return serviceModes(name, spec).includes(targetMode);
1452
+ });
1453
+ for (const [name, spec] of entries) {
1454
+ a.onLog?.(`starting ${name}: ${spec.start}`);
1455
+ pids[name] = spawnService(name, spec, a);
1456
+ signatures[name] = pidSignature(pids[name]);
1457
+ a.onSpawn?.(name, pids[name], signatures[name]);
1458
+ try {
1459
+ await waitHealthy({ name, spec, cwd: a.cwd, env: a.env });
1460
+ } catch (err) {
1461
+ await stopServices({ m: a.m, cwd: a.cwd, env: a.env, pids, signatures, onLog: a.onLog });
1462
+ throw err;
1463
+ }
1464
+ a.onLog?.(`${name} healthy (pid ${pids[name]})`);
1465
+ }
1466
+ return { pids, signatures };
1467
+ }
1468
+ function killGroup(pid, signal) {
1469
+ try {
1470
+ process.kill(-pid, signal);
1471
+ } catch {
1472
+ try {
1473
+ process.kill(pid, signal);
1474
+ } catch {
1475
+ }
1476
+ }
1477
+ }
1478
+ async function stopServices(o) {
1479
+ const names = Object.keys(o.m.services).filter((n) => o.pids[n] !== void 0).reverse();
1480
+ if (!o.skipStopCommands) {
1481
+ for (const name of names) {
1482
+ const spec = o.m.services[name];
1483
+ o.onLog?.(`stopping ${name}: ${spec.stop}`);
1484
+ const r = spawnSync3("sh", ["-c", spec.stop], {
1485
+ cwd: o.cwd,
1486
+ env: o.env,
1487
+ encoding: "utf8",
1488
+ timeout: STOP_COMMAND_TIMEOUT_MS
1489
+ });
1490
+ if (r.error) o.onLog?.(`stop command for ${name} ${r.error.message}`);
1491
+ }
1492
+ }
1493
+ const ours = names.filter((n) => {
1494
+ const pid = o.pids[n];
1495
+ if (!isAlive(pid)) return false;
1496
+ const expected = o.signatures?.[n];
1497
+ if (expected === void 0) return true;
1498
+ const actual = pidSignature(pid);
1499
+ if (actual !== expected) {
1500
+ o.onLog?.(
1501
+ `not signalling pid ${pid} (${n}): it is no longer the process started for this lease`
1502
+ );
1503
+ return false;
1504
+ }
1505
+ return true;
1506
+ });
1507
+ const alive = () => ours.filter((n) => isAlive(o.pids[n]));
1508
+ if (alive().length === 0) return;
1509
+ for (const n of alive()) killGroup(o.pids[n], "SIGTERM");
1510
+ const deadline = Date.now() + STOP_GRACE_MS;
1511
+ while (alive().length > 0 && Date.now() < deadline) await sleep2(200);
1512
+ for (const n of alive()) killGroup(o.pids[n], "SIGKILL");
1513
+ const hardDeadline = Date.now() + STOP_GRACE_MS;
1514
+ while (alive().length > 0 && Date.now() < hardDeadline) await sleep2(100);
1515
+ }
1516
+
1517
+ // src/core/session.ts
1518
+ import { hostname, userInfo } from "os";
1519
+ function currentOwner(env = process.env) {
1520
+ const named = [env.WORK_SESSION, env.CLAUDE_SESSION_NAME, env.CLAUDE_CODE_SESSION_ID].find(
1521
+ (v) => v !== void 0 && v.length > 0
1522
+ );
1523
+ return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
1524
+ }
1525
+ export {
1526
+ BRANCH_NAME,
1527
+ Git,
1528
+ HOOKS_DIR,
1529
+ MANIFEST_FILES,
1530
+ Registry,
1531
+ TASK_SLUG,
1532
+ WorkkitError,
1533
+ allowedScopesForEffort,
1534
+ areaVars,
1535
+ assertNotMainCheckout,
1536
+ assertNotProtected,
1537
+ checkCommit,
1538
+ checkPush,
1539
+ claimRanges,
1540
+ commonsTouched,
1541
+ currentOwner,
1542
+ emitWorktreeConfig,
1543
+ ensureExcluded,
1544
+ ensureRanges,
1545
+ exitCodeFor,
1546
+ expandScopes,
1547
+ globsOverlap,
1548
+ homeDir,
1549
+ installHooks,
1550
+ isAlive,
1551
+ isSubGlob,
1552
+ literalPrefix,
1553
+ loadManifest,
1554
+ lockDir,
1555
+ newId,
1556
+ pathsMatching,
1557
+ pidSignature,
1558
+ portsForSlot,
1559
+ probe,
1560
+ rangesFor,
1561
+ rangesOverlap,
1562
+ registryFile,
1563
+ renderTemplate,
1564
+ runRefresh,
1565
+ selectRefresh,
1566
+ serviceModes,
1567
+ slotVars,
1568
+ startServices,
1569
+ stopServices,
1570
+ storybookHostFor,
1571
+ templateVariables,
1572
+ toErrorBody,
1573
+ validateManifest,
1574
+ waitHealthy
1575
+ };
1576
+ //# sourceMappingURL=index.js.map