@ra3orblade/swarm 0.11.3 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/swarmd.js CHANGED
@@ -830,6 +830,9 @@ var AUDIT_TYPES = new Set([
830
830
  "claim.released",
831
831
  "claim.expired",
832
832
  "claim.orphaned",
833
+ "claim.denied",
834
+ "rules.changed",
835
+ "worktree.reclaimed",
833
836
  "worktree.created",
834
837
  "worktree.removed",
835
838
  "worktree.bootstrapped",
@@ -2202,6 +2205,82 @@ function collisionGraph(rows, writeTools = WRITE_TOOLS) {
2202
2205
  contested: out.filter((f) => f.contested).length
2203
2206
  };
2204
2207
  }
2208
+ // packages/core/src/heat.ts
2209
+ var HEAT_DEFAULTS = {
2210
+ floor: 2,
2211
+ candidateSessions: 2,
2212
+ candidateRereads: 3,
2213
+ candidateWriteShare: 0.2,
2214
+ top: 40
2215
+ };
2216
+ function dirOf(path) {
2217
+ const i = path.lastIndexOf("/");
2218
+ return i <= 0 ? i === 0 ? "/" : "." : path.slice(0, i);
2219
+ }
2220
+ function fileHeat(rows, opts = {}, writeTools = WRITE_TOOLS) {
2221
+ const o = { ...HEAT_DEFAULTS, ...opts };
2222
+ const files = new Map;
2223
+ for (const r of rows) {
2224
+ if (!r.path || !r.sessionId)
2225
+ continue;
2226
+ const f = files.get(r.path) ?? { touches: 0, reads: 0, writes: 0, perSession: new Map };
2227
+ f.touches++;
2228
+ if (writeTools.has(r.tool))
2229
+ f.writes++;
2230
+ else
2231
+ f.reads++;
2232
+ f.perSession.set(r.sessionId, (f.perSession.get(r.sessionId) ?? 0) + 1);
2233
+ files.set(r.path, f);
2234
+ }
2235
+ const all = [...files.entries()].map(([path, f]) => {
2236
+ const rereads = [...f.perSession.values()].reduce((n, c) => n + (c - 1), 0);
2237
+ const writeShare = f.touches ? f.writes / f.touches : 0;
2238
+ return {
2239
+ path,
2240
+ touches: f.touches,
2241
+ sessions: f.perSession.size,
2242
+ reads: f.reads,
2243
+ writes: f.writes,
2244
+ rereads,
2245
+ candidate: f.perSession.size >= o.candidateSessions && rereads >= o.candidateRereads && writeShare <= o.candidateWriteShare
2246
+ };
2247
+ });
2248
+ const byTouches = (a, b) => b.touches - a.touches || b.sessions - a.sessions || a.path.localeCompare(b.path);
2249
+ const dirs = new Map;
2250
+ for (const [path, f] of files) {
2251
+ const d = dirOf(path);
2252
+ const cur = dirs.get(d) ?? {
2253
+ touches: 0,
2254
+ writes: 0,
2255
+ sessions: new Set,
2256
+ files: new Set
2257
+ };
2258
+ cur.touches += f.touches;
2259
+ cur.writes += f.writes;
2260
+ cur.files.add(path);
2261
+ for (const s of f.perSession.keys())
2262
+ cur.sessions.add(s);
2263
+ dirs.set(d, cur);
2264
+ }
2265
+ return {
2266
+ files: all.filter((f) => f.touches >= o.floor).sort(byTouches).slice(0, o.top),
2267
+ dirs: [...dirs.entries()].map(([dir, d]) => ({
2268
+ dir,
2269
+ touches: d.touches,
2270
+ sessions: d.sessions.size,
2271
+ files: d.files.size,
2272
+ writes: d.writes
2273
+ })).sort((a, b) => b.touches - a.touches || a.dir.localeCompare(b.dir)).slice(0, o.top),
2274
+ candidates: all.filter((f) => f.candidate).sort((a, b) => b.rereads - a.rereads || byTouches(a, b)).slice(0, o.top),
2275
+ totals: {
2276
+ files: all.length,
2277
+ touches: all.reduce((n, f) => n + f.touches, 0),
2278
+ sessions: new Set(rows.filter((r) => r.path && r.sessionId).map((r) => r.sessionId)).size,
2279
+ rereads: all.reduce((n, f) => n + f.rereads, 0),
2280
+ cold: all.filter((f) => f.touches === 1).length
2281
+ }
2282
+ };
2283
+ }
2205
2284
  // packages/core/src/worktree.ts
2206
2285
  import { join as join3 } from "path";
2207
2286
  function planBootstrap(cfg, repoRoot, worktree) {
@@ -2278,7 +2357,7 @@ function planGc(worktrees, claims) {
2278
2357
 
2279
2358
  // packages/core/src/hygiene.ts
2280
2359
  var HYGIENE_DEFAULTS = {
2281
- staleDays: 7,
2360
+ staleDays: 2,
2282
2361
  abandonedDays: 30,
2283
2362
  hungryRssKb: 1024 * 1024,
2284
2363
  heavyKb: 2 * 1024 * 1024
@@ -2309,6 +2388,30 @@ function classifyProcess(p, opts = {}) {
2309
2388
  };
2310
2389
  return { ...p, issue: null, note: null, reclaimable: false };
2311
2390
  }
2391
+ var BUILD_DIRS = ["node_modules", "target", "dist", ".next", ".turbo"];
2392
+ function reclaimPlan(w, dirs, others = []) {
2393
+ const refusals = [];
2394
+ if (w.main)
2395
+ refusals.push("this is the main checkout");
2396
+ if (w.liveSessions > 0)
2397
+ refusals.push(`${w.liveSessions} live session${w.liveSessions === 1 ? "" : "s"} in it`);
2398
+ if (w.heldByClaim)
2399
+ refusals.push(`claimed by ${w.heldByClaim}`);
2400
+ const inside = dirs.filter((d) => d.path.startsWith(`${w.path}/`));
2401
+ if (inside.length !== dirs.length)
2402
+ refusals.push("a candidate lay outside the worktree");
2403
+ const named = inside.filter((d) => BUILD_DIRS.includes(d.path.split("/").pop() ?? ""));
2404
+ if (named.length !== inside.length)
2405
+ refusals.push("a candidate was not a build directory");
2406
+ const nested = others.filter((o) => o !== w.path && o.startsWith(`${w.path}/`));
2407
+ const mine = named.filter((d) => !nested.some((n) => d.path.startsWith(`${n}/`)));
2408
+ return {
2409
+ path: w.path,
2410
+ dirs: mine.map((d) => d.path),
2411
+ kb: mine.reduce((n, d) => n + d.kb, 0),
2412
+ refusals
2413
+ };
2414
+ }
2312
2415
  function classifyWorktree(w, opts = {}) {
2313
2416
  const o = { ...HYGIENE_DEFAULTS, ...opts };
2314
2417
  const kb = w.diskKb ?? 0;
@@ -3366,6 +3469,107 @@ function formatOpenQuestions(qs) {
3366
3469
  return null;
3367
3470
  return `[swarm] waiting on a human for: ${open.map((q) => `#${q.id} "${q.text.slice(0, 120)}"`).join("; ")} \u2014 the answer arrives as context on a later tool call, or via swarm_inbox`;
3368
3471
  }
3472
+ // packages/core/src/resourcegraph.ts
3473
+ var nodeId = (kind, name, projectId) => `${kind}:${projectId ?? ""}:${name}`;
3474
+ function resourceGraph(held, wanted = [], now = Date.now()) {
3475
+ const resources = new Map;
3476
+ const holders = new Map;
3477
+ for (const h of held) {
3478
+ if (!h.name || !h.owner)
3479
+ continue;
3480
+ const id = nodeId(h.kind, h.name, h.projectId);
3481
+ const expired = h.expiresAt ? Date.parse(h.expiresAt) < now : false;
3482
+ const orphaned = Boolean(h.sessionEndedAt) || expired;
3483
+ if (!resources.has(id))
3484
+ resources.set(id, {
3485
+ id,
3486
+ kind: h.kind,
3487
+ name: h.name,
3488
+ holder: h.owner,
3489
+ orphaned,
3490
+ wanted: [],
3491
+ projectId: h.projectId ?? null
3492
+ });
3493
+ const o = holders.get(h.owner) ?? { holds: 0, wants: 0, live: 0 };
3494
+ o.holds++;
3495
+ if (!orphaned)
3496
+ o.live++;
3497
+ holders.set(h.owner, o);
3498
+ }
3499
+ for (const w of wanted) {
3500
+ if (!w.name || !w.owner)
3501
+ continue;
3502
+ const r = resources.get(nodeId(w.kind, w.name, w.projectId));
3503
+ if (!r || r.holder === w.owner)
3504
+ continue;
3505
+ if (!r.wanted.includes(w.owner))
3506
+ r.wanted.push(w.owner);
3507
+ const o = holders.get(w.owner) ?? { holds: 0, wants: 0, live: 0 };
3508
+ o.wants++;
3509
+ holders.set(w.owner, o);
3510
+ }
3511
+ for (const r of resources.values())
3512
+ r.wanted.sort();
3513
+ const edges = [];
3514
+ for (const r of [...resources.values()].sort((a, b) => a.id.localeCompare(b.id))) {
3515
+ if (r.holder)
3516
+ edges.push({ from: r.holder, to: r.id, kind: "holds" });
3517
+ for (const w of r.wanted)
3518
+ edges.push({ from: w, to: r.id, kind: "wants" });
3519
+ }
3520
+ const resourceList = [...resources.values()].sort((a, b) => Number(b.wanted.length > 0) - Number(a.wanted.length > 0) || Number(b.orphaned) - Number(a.orphaned) || a.id.localeCompare(b.id));
3521
+ const holderList = [...holders.entries()].map(([id, o]) => ({ id, holds: o.holds, wants: o.wants, gone: o.holds > 0 && o.live === 0 })).sort((a, b) => b.holds + b.wants - (a.holds + a.wants) || a.id.localeCompare(b.id));
3522
+ return {
3523
+ holders: holderList,
3524
+ resources: resourceList,
3525
+ edges,
3526
+ contention: findContention(resourceList),
3527
+ totals: {
3528
+ held: resourceList.length,
3529
+ orphaned: resourceList.filter((r) => r.orphaned).length,
3530
+ contested: resourceList.filter((r) => r.wanted.length > 0).length
3531
+ }
3532
+ };
3533
+ }
3534
+ function findContention(resources) {
3535
+ const next = new Map;
3536
+ for (const r of resources) {
3537
+ if (!r.holder)
3538
+ continue;
3539
+ for (const w of r.wanted) {
3540
+ const list = next.get(w) ?? [];
3541
+ list.push({ to: r.holder, via: r.name });
3542
+ next.set(w, list);
3543
+ }
3544
+ }
3545
+ for (const list of next.values())
3546
+ list.sort((a, b) => a.to.localeCompare(b.to) || a.via.localeCompare(b.via));
3547
+ const found = new Map;
3548
+ const walk = (start, at, owners, vias, depth) => {
3549
+ if (depth > 6)
3550
+ return;
3551
+ for (const step of next.get(at) ?? []) {
3552
+ if (step.to === start) {
3553
+ const ring = [...owners];
3554
+ const res = [...vias, step.via];
3555
+ const pivot = ring.indexOf([...ring].sort()[0]);
3556
+ const key = [...ring.slice(pivot), ...ring.slice(0, pivot)].join(">");
3557
+ if (!found.has(key))
3558
+ found.set(key, {
3559
+ owners: [...ring.slice(pivot), ...ring.slice(0, pivot)],
3560
+ resources: [...res.slice(pivot), ...res.slice(0, pivot)]
3561
+ });
3562
+ continue;
3563
+ }
3564
+ if (owners.includes(step.to))
3565
+ continue;
3566
+ walk(start, step.to, [...owners, step.to], [...vias, step.via], depth + 1);
3567
+ }
3568
+ };
3569
+ for (const owner of [...next.keys()].sort())
3570
+ walk(owner, owner, [owner], [], 0);
3571
+ return [...found.values()].sort((a, b) => a.owners.length - b.owners.length || a.owners.join().localeCompare(b.owners.join()));
3572
+ }
3369
3573
  // packages/core/src/resources.ts
3370
3574
  var DEFAULT_RESOURCE_LEASE_MINUTES = 60;
3371
3575
  function isTrackedPid(pid) {
@@ -3499,6 +3703,202 @@ ${lines.join(`
3499
3703
  `)}` : ""}`
3500
3704
  };
3501
3705
  }
3706
+ // packages/core/src/ruleeffect.ts
3707
+ var DAY = 86400000;
3708
+ var dayOf = (iso) => iso.slice(0, 10);
3709
+ function commandSignature(command) {
3710
+ const segments = (command ?? "").split(/&&|\|\||[;\n|]/).map((x) => x.trim()).filter(Boolean);
3711
+ const first = segments.find((seg) => !/^(cd|pushd|export|source|\.)\b/.test(seg)) ?? segments[0] ?? "";
3712
+ if (!first)
3713
+ return "(none)";
3714
+ const tokens = first.split(/\s+/).filter(Boolean);
3715
+ const head = tokens[0] ?? "";
3716
+ const name = head.includes("/") ? head.split("/").pop() ?? head : head;
3717
+ const second = tokens[1];
3718
+ const more = tokens.length > 2;
3719
+ return `${name}${second ? ` ${second}` : ""}${more ? " \u2026" : ""}`;
3720
+ }
3721
+ function ruleEffect(incidents, changes = [], now = Date.now(), days3 = 30) {
3722
+ const since = now - days3 * DAY;
3723
+ const rows = incidents.filter((i) => i.rule && Date.parse(i.at) >= since);
3724
+ const byRule = new Map;
3725
+ for (const i of rows)
3726
+ byRule.set(i.rule, [...byRule.get(i.rule) ?? [], i]);
3727
+ const dayList = [];
3728
+ for (let t = since;t <= now; t += DAY)
3729
+ dayList.push(dayOf(new Date(t).toISOString()));
3730
+ const rules = [...byRule.entries()].map(([rule, list]) => {
3731
+ const sorted = [...list].sort((a, b) => a.at.localeCompare(b.at));
3732
+ const counts = new Map;
3733
+ for (const i of sorted)
3734
+ counts.set(dayOf(i.at), (counts.get(dayOf(i.at)) ?? 0) + 1);
3735
+ const perDay = dayList.map((day) => ({ day, n: counts.get(day) ?? 0 }));
3736
+ const half = Math.floor(perDay.length / 2);
3737
+ const early = perDay.slice(0, half).reduce((n, d) => n + d.n, 0);
3738
+ const late = perDay.slice(half).reduce((n, d) => n + d.n, 0);
3739
+ const trend = Math.abs(late - early) <= 1 ? "steady" : late > early ? "rising" : "falling";
3740
+ const sig = new Map;
3741
+ for (const i of sorted) {
3742
+ const key = commandSignature(i.command);
3743
+ const cur = sig.get(key) ?? { hits: 0, example: i.command };
3744
+ cur.hits++;
3745
+ sig.set(key, cur);
3746
+ }
3747
+ const clusters = [...sig.entries()].map(([signature, c]) => ({ signature, hits: c.hits, example: c.example })).sort((a, b) => b.hits - a.hits || a.signature.localeCompare(b.signature));
3748
+ const added = changes.filter((c) => c.added.includes(rule) && Date.parse(c.at) >= since).sort((a, b) => b.at.localeCompare(a.at))[0];
3749
+ let landed = null;
3750
+ if (added) {
3751
+ const at = Date.parse(added.at);
3752
+ const beforeDays = Math.max(1, (at - since) / DAY);
3753
+ const afterDays = Math.max(1, (now - at) / DAY);
3754
+ landed = {
3755
+ at: added.at,
3756
+ beforePerDay: sorted.filter((i) => Date.parse(i.at) < at).length / beforeDays,
3757
+ afterPerDay: sorted.filter((i) => Date.parse(i.at) >= at).length / afterDays
3758
+ };
3759
+ }
3760
+ return {
3761
+ rule,
3762
+ total: sorted.length,
3763
+ acked: sorted.filter((i) => i.acked).length,
3764
+ firstAt: sorted[0].at,
3765
+ lastAt: sorted.at(-1).at,
3766
+ perDay,
3767
+ trend,
3768
+ concentration: sorted.length ? (clusters[0]?.hits ?? 0) / sorted.length : 0,
3769
+ clusters: clusters.slice(0, 5),
3770
+ landed
3771
+ };
3772
+ });
3773
+ rules.sort((a, b) => b.total - a.total || a.rule.localeCompare(b.rule));
3774
+ return {
3775
+ rules,
3776
+ totals: {
3777
+ incidents: rows.length,
3778
+ rules: rules.length,
3779
+ acked: rows.filter((i) => i.acked).length,
3780
+ unchanged: rules.filter((r) => r.trend !== "falling" && r.total > 1).length
3781
+ },
3782
+ noChangeHistory: !changes.some((c) => Date.parse(c.at) >= since)
3783
+ };
3784
+ }
3785
+ // packages/core/src/security.ts
3786
+ var LOCAL = /^(localhost|127\.|0\.0\.0\.0|::1|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i;
3787
+ function hostsIn(text) {
3788
+ const out = new Set;
3789
+ for (const m of text.matchAll(/\bhttps?:\/\/(\[[^\]]+\][^/\s"'`)}>,;]*|[^/\s"'`)\]}>,;]+)/gi)) {
3790
+ const hostPort = m[1].replace(/^[^@]*@/, "");
3791
+ const bracket = /^\[([^\]]+)\]/.exec(hostPort);
3792
+ const host = (bracket?.[1] ?? hostPort.split(":")[0]).toLowerCase();
3793
+ if (host)
3794
+ out.add(host);
3795
+ }
3796
+ return [...out].sort();
3797
+ }
3798
+ var INSTALLERS = [
3799
+ { ecosystem: "npm", re: /^npm\s+(?:i|install|add)\b(.*)$/i },
3800
+ { ecosystem: "pnpm", re: /^pnpm\s+(?:i|install|add)\b(.*)$/i },
3801
+ { ecosystem: "yarn", re: /^yarn\s+add\b(.*)$/i },
3802
+ { ecosystem: "bun", re: /^bun\s+(?:i|install|add)\b(.*)$/i },
3803
+ { ecosystem: "pip", re: /^pip3?\s+install\b(.*)$/i },
3804
+ { ecosystem: "cargo", re: /^cargo\s+(?:add|install)\b(.*)$/i },
3805
+ { ecosystem: "go", re: /^go\s+(?:get|install)\b(.*)$/i },
3806
+ { ecosystem: "gem", re: /^gem\s+install\b(.*)$/i },
3807
+ { ecosystem: "brew", re: /^brew\s+install\b(.*)$/i },
3808
+ { ecosystem: "apt", re: /^(?:sudo\s+)?apt(?:-get)?\s+install\b(.*)$/i }
3809
+ ];
3810
+ var PKG_NAME = /^(@[\w.-]+\/)?[a-z0-9][\w.\-/]*([@=<>~^]=?[\w.\-^~*]+)?$/i;
3811
+ function installsIn(text) {
3812
+ const out = [];
3813
+ for (const raw of text.split(/&&|\|\||[;\n|]/)) {
3814
+ const segment = raw.trim();
3815
+ if (!segment)
3816
+ continue;
3817
+ for (const { ecosystem, re } of INSTALLERS) {
3818
+ const m = re.exec(segment);
3819
+ if (!m)
3820
+ continue;
3821
+ const args = [];
3822
+ for (const tok of (m[1] ?? "").split(/\s+/)) {
3823
+ const a = tok.trim();
3824
+ if (!a)
3825
+ continue;
3826
+ if (/^\d*[<>&]/.test(a))
3827
+ break;
3828
+ if (a.startsWith("-"))
3829
+ continue;
3830
+ if (PKG_NAME.test(a))
3831
+ args.push(a);
3832
+ }
3833
+ out.push(...args.length ? args.map((pkg) => ({ ecosystem, pkg })) : [{ ecosystem, pkg: "(from manifest)" }]);
3834
+ }
3835
+ }
3836
+ return out;
3837
+ }
3838
+ var SECRETS = [
3839
+ { what: ".env file", re: /(^|[/\s"'`])\.env(\.[\w-]+)?\b/i },
3840
+ { what: "SSH private key", re: /(^|[/\s"'`])(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b/i },
3841
+ { what: "AWS credentials", re: /\.aws\/(credentials|config)\b/i },
3842
+ { what: "npm token", re: /(^|[/\s"'`])\.npmrc\b/i },
3843
+ { what: "kubeconfig", re: /\.kube\/config\b/i },
3844
+ { what: "Google cloud credentials", re: /gcloud\/[\w-]*credential/i },
3845
+ { what: "PEM or key file", re: /\.(pem|p12|pfx|key)\b/i },
3846
+ { what: "macOS keychain", re: /\bsecurity\s+find-(generic|internet)-password\b/i },
3847
+ { what: "netrc", re: /(^|[/\s"'`])\.netrc\b/i }
3848
+ ];
3849
+ function secretsIn(text) {
3850
+ return SECRETS.filter((s) => s.re.test(text)).map((s) => s.what);
3851
+ }
3852
+ function securityScan(rows) {
3853
+ const egress = new Map;
3854
+ const installs = new Map;
3855
+ const secrets = new Map;
3856
+ let scanned = 0;
3857
+ for (const r of rows) {
3858
+ if (!r.sessionId)
3859
+ continue;
3860
+ const text = `${r.command ?? ""} ${r.path ?? ""}`.trim();
3861
+ if (!text)
3862
+ continue;
3863
+ scanned++;
3864
+ for (const host of hostsIn(text)) {
3865
+ const e = egress.get(host) ?? { hits: 0, sessions: new Set };
3866
+ e.hits++;
3867
+ e.sessions.add(r.sessionId);
3868
+ egress.set(host, e);
3869
+ }
3870
+ for (const i of installsIn(r.command ?? "")) {
3871
+ const key = `${i.ecosystem} ${i.pkg}`;
3872
+ const cur = installs.get(key) ?? { ...i, hits: 0, sessions: new Set };
3873
+ cur.hits++;
3874
+ cur.sessions.add(r.sessionId);
3875
+ installs.set(key, cur);
3876
+ }
3877
+ for (const what of secretsIn(text)) {
3878
+ const cur = secrets.get(what) ?? { hits: 0, sessions: new Set };
3879
+ cur.hits++;
3880
+ cur.sessions.add(r.sessionId);
3881
+ secrets.set(what, cur);
3882
+ }
3883
+ }
3884
+ const egressList = [...egress.entries()].map(([host, e]) => ({
3885
+ host,
3886
+ hits: e.hits,
3887
+ sessions: e.sessions.size,
3888
+ local: LOCAL.test(host)
3889
+ })).sort((a, b) => Number(a.local) - Number(b.local) || b.hits - a.hits || a.host.localeCompare(b.host));
3890
+ return {
3891
+ egress: egressList,
3892
+ installs: [...installs.values()].map((i) => ({ ecosystem: i.ecosystem, pkg: i.pkg, hits: i.hits, sessions: i.sessions.size })).sort((a, b) => b.hits - a.hits || a.ecosystem.localeCompare(b.ecosystem) || a.pkg.localeCompare(b.pkg)),
3893
+ secrets: [...secrets.entries()].map(([what, s]) => ({ what, hits: s.hits, sessions: s.sessions.size })).sort((a, b) => b.hits - a.hits || a.what.localeCompare(b.what)),
3894
+ totals: {
3895
+ scanned,
3896
+ remoteHosts: egressList.filter((h) => !h.local).length,
3897
+ installs: [...installs.values()].reduce((n, i) => n + i.hits, 0),
3898
+ secrets: [...secrets.values()].reduce((n, s) => n + s.hits, 0)
3899
+ }
3900
+ };
3901
+ }
3502
3902
  // packages/core/src/stall.ts
3503
3903
  var STALL_DEFAULTS = { window: 12, repeat: 3, repeatErrors: 2, errors: 4 };
3504
3904
  function toolResponseErrored(resp) {
@@ -3730,6 +4130,70 @@ function clusterProjectKey(remoteUrl) {
3730
4130
  return null;
3731
4131
  return `${host}/${m[2]}`;
3732
4132
  }
4133
+ // packages/core/src/transitions.ts
4134
+ function transitionGraph(steps, { minWeight = 1 } = {}) {
4135
+ const calls = new Map;
4136
+ const selfLoops = new Map;
4137
+ const edges = new Map;
4138
+ const bySession = new Map;
4139
+ let transitions = 0;
4140
+ for (const s of steps) {
4141
+ if (!s.sessionId || !s.tool)
4142
+ continue;
4143
+ calls.set(s.tool, (calls.get(s.tool) ?? 0) + 1);
4144
+ const prev = bySession.get(s.sessionId);
4145
+ if (prev !== undefined) {
4146
+ const key = `${prev}\x00${s.tool}`;
4147
+ const e = edges.get(key) ?? {
4148
+ from: prev,
4149
+ to: s.tool,
4150
+ weight: 0,
4151
+ sessions: new Set
4152
+ };
4153
+ e.weight++;
4154
+ e.sessions.add(s.sessionId);
4155
+ edges.set(key, e);
4156
+ if (prev === s.tool)
4157
+ selfLoops.set(s.tool, (selfLoops.get(s.tool) ?? 0) + 1);
4158
+ transitions++;
4159
+ }
4160
+ bySession.set(s.sessionId, s.tool);
4161
+ }
4162
+ const edgeList = [...edges.values()].filter((e) => e.weight >= minWeight).map((e) => ({ from: e.from, to: e.to, weight: e.weight, sessions: e.sessions.size })).sort((a, b) => b.weight - a.weight || a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
4163
+ const nodeList = [...calls.entries()].map(([tool, n]) => ({ tool, calls: n, selfLoops: selfLoops.get(tool) ?? 0 })).sort((a, b) => b.calls - a.calls || a.tool.localeCompare(b.tool));
4164
+ return {
4165
+ nodes: nodeList,
4166
+ edges: edgeList,
4167
+ loops: findLoops(edgeList),
4168
+ sessions: bySession.size,
4169
+ steps: steps.filter((s) => s.sessionId && s.tool).length,
4170
+ transitions
4171
+ };
4172
+ }
4173
+ function findLoops(edges) {
4174
+ const at = new Map(edges.map((e) => [`${e.from}\x00${e.to}`, e]));
4175
+ const out = [];
4176
+ const seen = new Set;
4177
+ for (const e of edges) {
4178
+ if (e.from === e.to) {
4179
+ out.push({ tools: [e.from], weight: e.weight, sessions: e.sessions });
4180
+ continue;
4181
+ }
4182
+ const back = at.get(`${e.to}\x00${e.from}`);
4183
+ if (!back)
4184
+ continue;
4185
+ const pair = [e.from, e.to].sort().join("\x00");
4186
+ if (seen.has(pair))
4187
+ continue;
4188
+ seen.add(pair);
4189
+ out.push({
4190
+ tools: [e.from, e.to],
4191
+ weight: Math.min(e.weight, back.weight),
4192
+ sessions: Math.max(e.sessions, back.sessions)
4193
+ });
4194
+ }
4195
+ return out.sort((a, b) => b.weight - a.weight || a.tools.join().localeCompare(b.tools.join()));
4196
+ }
3733
4197
  // packages/core/src/waiting.ts
3734
4198
  var emptyByKind = () => ({
3735
4199
  permission: { episodes: 0, blockedMs: 0 },
@@ -6028,12 +6492,19 @@ function applyStatus(w, st, ah) {
6028
6492
  const a = ah?.trim();
6029
6493
  w.ahead = a === undefined || a === "" ? -1 : Number(a);
6030
6494
  }
6031
- function applyDrift(w, behind, ancestor, firstParents) {
6495
+ function applyDrift(w, behind, ancestor, firstParents, cherry = null) {
6032
6496
  const b = behind?.trim();
6033
6497
  w.behind = b === undefined || b === "" ? -1 : Number(b);
6034
6498
  const onLine = firstParents?.split(`
6035
6499
  `).some((sha) => sha.startsWith(w.head)) ?? true;
6036
- w.merged = ancestor && !onLine;
6500
+ w.merged = (ancestor || squashed(cherry)) && !onLine;
6501
+ }
6502
+ function squashed(cherry) {
6503
+ if (!cherry)
6504
+ return false;
6505
+ const lines = cherry.split(`
6506
+ `).filter((l) => l.trim());
6507
+ return lines.length > 0 && lines.every((l) => l.startsWith("-"));
6037
6508
  }
6038
6509
  var FIRST_PARENT_DEPTH = "5000";
6039
6510
  var baseOf = (wts) => wts[0]?.main ? wts[0].branch : null;
@@ -6055,15 +6526,16 @@ async function listWorktreesAsync(root) {
6055
6526
  const line = base ? await gitAsync(root, ["rev-list", "--first-parent", "-n", FIRST_PARENT_DEPTH, base]) : null;
6056
6527
  await Promise.all(wts.map(async (w) => {
6057
6528
  const drift = base && !w.main;
6058
- const [st, ah, be, mg] = await Promise.all([
6529
+ const [st, ah, be, mg, ch] = await Promise.all([
6059
6530
  gitAsync(w.path, ["status", "--porcelain", "--untracked-files=no"]),
6060
6531
  gitAsync(w.path, ["rev-list", "--count", "@{upstream}..HEAD"]),
6061
6532
  drift ? gitAsync(w.path, ["rev-list", "--count", `HEAD..${base}`]) : null,
6062
- drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null
6533
+ drift ? gitAsync(w.path, ["merge-base", "--is-ancestor", "HEAD", base]) : null,
6534
+ drift ? gitAsync(w.path, ["cherry", base, "HEAD"]) : null
6063
6535
  ]);
6064
6536
  applyStatus(w, st, ah);
6065
6537
  if (drift)
6066
- applyDrift(w, be, mg !== null, line);
6538
+ applyDrift(w, be, mg !== null, line, ch);
6067
6539
  }));
6068
6540
  return wts;
6069
6541
  }
@@ -6533,6 +7005,7 @@ import {
6533
7005
  readSync,
6534
7006
  realpathSync as realpathSync2,
6535
7007
  renameSync,
7008
+ rmSync as rmSync2,
6536
7009
  statSync,
6537
7010
  unlinkSync,
6538
7011
  writeFileSync as writeFileSync2
@@ -7891,8 +8364,46 @@ ${err}
7891
8364
  const loaded = loadConfigDetailed({ repoRoot, home: this.home });
7892
8365
  this.policyCache.set(key, { at: Date.now(), loaded });
7893
8366
  this.writePolicyCache(loaded);
8367
+ this.noteRuleChange(key, loaded.config.rules);
7894
8368
  return loaded;
7895
8369
  }
8370
+ projectIdForRoot(root) {
8371
+ if (!root)
8372
+ return null;
8373
+ const row = this.db.query("SELECT id FROM projects WHERE root = ?").get(root);
8374
+ return row?.id ?? null;
8375
+ }
8376
+ noteRuleChange(key, rules2) {
8377
+ const sig = JSON.stringify(Object.entries(rules2).sort());
8378
+ const metaKey = `rules.sig:${key}`;
8379
+ const prev = this.db.query("SELECT value FROM meta WHERE key = ?").get(metaKey)?.value;
8380
+ if (prev === sig)
8381
+ return;
8382
+ this.db.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(metaKey, sig);
8383
+ if (prev === undefined)
8384
+ return;
8385
+ const before = new Map(JSON.parse(prev));
8386
+ const after = new Map(Object.entries(rules2));
8387
+ const added = [...after.keys()].filter((r) => !before.has(r)).sort();
8388
+ const removed = [...before.keys()].filter((r) => !after.has(r)).sort();
8389
+ const retuned = [...after.entries()].filter(([r, mode]) => before.has(r) && before.get(r) !== mode).map(([r, mode]) => `${r}=${mode}`).sort();
8390
+ if (!added.length && !removed.length && !retuned.length)
8391
+ return;
8392
+ this.append({
8393
+ ts: new Date().toISOString(),
8394
+ type: "rules.changed",
8395
+ projectId: this.projectIdForRoot(key) ?? "",
8396
+ sessionId: null,
8397
+ payload: {
8398
+ repo: key || null,
8399
+ added,
8400
+ removed,
8401
+ retuned,
8402
+ rules: [...after.keys()].sort(),
8403
+ summary: `rules changed${added.length ? ` +${added.join(",")}` : ""}${removed.length ? ` -${removed.join(",")}` : ""}${retuned.length ? ` ~${retuned.join(",")}` : ""}`
8404
+ }
8405
+ });
8406
+ }
7896
8407
  writePolicyCache(loaded) {
7897
8408
  const file = join8(this.home, POLICY_CACHE_FILE);
7898
8409
  try {
@@ -8928,8 +9439,24 @@ ${p2.reason ?? ""}`.trim(),
8928
9439
  return { ok: false, error: "unknown project" };
8929
9440
  const now = Date.now();
8930
9441
  const decision = canClaim(this.claimRows(projectId), task, owner, now);
8931
- if (!decision.ok)
9442
+ if (!decision.ok) {
9443
+ if (decision.heldBy !== owner)
9444
+ this.append({
9445
+ ts: new Date(now).toISOString(),
9446
+ type: "claim.denied",
9447
+ projectId,
9448
+ sessionId,
9449
+ actor: this.actorFor(owner, sessionId),
9450
+ payload: {
9451
+ task,
9452
+ owner,
9453
+ heldBy: decision.heldBy,
9454
+ until: decision.until,
9455
+ summary: `${owner} was refused ${task} \u2014 held by ${decision.heldBy}`
9456
+ }
9457
+ });
8932
9458
  return { ok: false, error: claimRefusalMessage(decision, task) };
9459
+ }
8933
9460
  const branch = `task/${task}`;
8934
9461
  const worktree2 = this.worktreePath(projectId, task);
8935
9462
  if (existsSync6(worktree2))
@@ -9129,7 +9656,34 @@ ${p2.reason ?? ""}`.trim(),
9129
9656
  return newest ? Math.max(0, Date.now() - newest) : null;
9130
9657
  }
9131
9658
  duCache = new Map;
9659
+ buildCache = new Map;
9132
9660
  duInflight = null;
9661
+ async measureBuild(path) {
9662
+ const args = ["-maxdepth", "4", "("];
9663
+ BUILD_DIRS.forEach((d, i) => {
9664
+ if (i)
9665
+ args.push("-o");
9666
+ args.push("-name", d);
9667
+ });
9668
+ args.push(")", "-type", "d", "-prune");
9669
+ const out = [];
9670
+ try {
9671
+ const find = Bun.spawn(["find", path, ...args], { stdout: "pipe", stderr: "ignore" });
9672
+ const found = (await new Response(find.stdout).text()).split(`
9673
+ `).filter(Boolean);
9674
+ await find.exited;
9675
+ for (const dir of found) {
9676
+ const du = Bun.spawn(["du", "-sk", "-x", dir], { stdout: "pipe", stderr: "ignore" });
9677
+ const text = await new Response(du.stdout).text();
9678
+ if (await du.exited === 0) {
9679
+ const kb = Number.parseInt(text.trim().split(/\s+/)[0] ?? "", 10);
9680
+ if (Number.isFinite(kb))
9681
+ out.push({ path: dir, kb });
9682
+ }
9683
+ }
9684
+ } catch {}
9685
+ return out;
9686
+ }
9133
9687
  refreshDisk(paths, ttlMs) {
9134
9688
  if (this.duInflight)
9135
9689
  return;
@@ -9152,6 +9706,7 @@ ${p2.reason ?? ""}`.trim(),
9152
9706
  }
9153
9707
  } catch {}
9154
9708
  this.duCache.set(path, { v, t: Date.now() });
9709
+ this.buildCache.set(path, { dirs: await this.measureBuild(path), t: Date.now() });
9155
9710
  }
9156
9711
  })().finally(() => {
9157
9712
  this.duInflight = null;
@@ -9211,6 +9766,7 @@ ${p2.reason ?? ""}`.trim(),
9211
9766
  merged: w.merged,
9212
9767
  idleMs: Store.worktreeIdleMs(w.path),
9213
9768
  diskKb: this.duCache.get(w.path)?.v ?? null,
9769
+ buildKb: this.buildCache.has(w.path) ? this.buildCache.get(w.path).dirs.reduce((n, d) => n + d.kb, 0) : null,
9214
9770
  heldByClaim: held?.task ?? null,
9215
9771
  liveSessions: this.sessions().filter((s) => s.cwd?.startsWith(w.path) && !s.endedAt && s.state !== "ended").length
9216
9772
  });
@@ -9491,6 +10047,151 @@ ${p2.reason ?? ""}`.trim(),
9491
10047
  })
9492
10048
  };
9493
10049
  }
10050
+ transitions(projectId, days3 = 7, minWeight = 1) {
10051
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
10052
+ const rows = this.db.query(`SELECT session_id, json_extract(payload,'$.tool') AS tool
10053
+ FROM events
10054
+ WHERE type = 'tool.requested' AND ts >= ?
10055
+ AND json_extract(payload,'$.tool') IS NOT NULL${projectId ? " AND project_id = ?" : ""}
10056
+ ORDER BY session_id, seq`).all(...projectId ? [since, projectId] : [since]);
10057
+ return transitionGraph(rows.map((r) => ({ sessionId: r.session_id ?? "", tool: r.tool ?? "" })), { minWeight });
10058
+ }
10059
+ resourceHolding(projectId, days3 = 3) {
10060
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
10061
+ const p = projectId ? " AND c.project_id = ?" : "";
10062
+ const args = projectId ? [projectId] : [];
10063
+ const ended = new Map(this.db.query("SELECT id, ended_at FROM sessions WHERE ended_at IS NOT NULL").all().map((r) => [r.id, r.ended_at]));
10064
+ const claims = this.db.query(`SELECT c.task AS name, c.owner, c.project_id, c.expires_at, c.actor_id AS session_id
10065
+ FROM claims c WHERE c.state = 'held' AND c.released_at IS NULL${p}`).all(...args);
10066
+ const resources2 = this.db.query(`SELECT c.name, c.owner, c.project_id, c.expires_at, c.session_id, c.port
10067
+ FROM resources c WHERE c.released = 0${p}`).all(...args);
10068
+ const procs = this.db.query(`SELECT c.name, c.owner, c.project_id, c.session_id, c.port
10069
+ FROM processes c WHERE c.ended_at IS NULL${p}`).all(...args);
10070
+ const denials = this.db.query(`SELECT json_extract(payload,'$.task') AS name, json_extract(payload,'$.owner') AS owner,
10071
+ json_extract(payload,'$.heldBy') AS held_by, ts, project_id
10072
+ FROM events WHERE type = 'claim.denied' AND ts >= ?${projectId ? " AND project_id = ?" : ""}`).all(since, ...args);
10073
+ const held = [
10074
+ ...claims.map((r) => ({
10075
+ kind: "claim",
10076
+ name: r.name,
10077
+ owner: r.owner,
10078
+ sessionId: r.session_id,
10079
+ sessionEndedAt: r.session_id ? ended.get(r.session_id) ?? null : null,
10080
+ expiresAt: r.expires_at,
10081
+ projectId: r.project_id
10082
+ })),
10083
+ ...resources2.map((r) => ({
10084
+ kind: r.port ? "port" : "lease",
10085
+ name: r.port ? String(r.port) : r.name,
10086
+ owner: r.owner ?? "unknown",
10087
+ sessionId: r.session_id,
10088
+ sessionEndedAt: r.session_id ? ended.get(r.session_id) ?? null : null,
10089
+ expiresAt: r.expires_at,
10090
+ projectId: r.project_id
10091
+ })),
10092
+ ...procs.map((r) => ({
10093
+ kind: "process",
10094
+ name: r.name ?? (r.port ? `:${r.port}` : "process"),
10095
+ owner: r.owner ?? "unknown",
10096
+ sessionId: r.session_id,
10097
+ sessionEndedAt: r.session_id ? ended.get(r.session_id) ?? null : null,
10098
+ expiresAt: null,
10099
+ projectId: r.project_id
10100
+ }))
10101
+ ];
10102
+ const wanted = denials.filter((d) => d.name && d.owner && d.held_by).map((d) => ({
10103
+ kind: "claim",
10104
+ name: d.name,
10105
+ owner: d.owner,
10106
+ heldBy: d.held_by,
10107
+ at: d.ts,
10108
+ projectId: d.project_id
10109
+ }));
10110
+ return resourceGraph(held, wanted);
10111
+ }
10112
+ fileHeat(projectId, days3 = 14) {
10113
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
10114
+ const rows = this.db.query(`SELECT session_id, json_extract(payload,'$.tool') AS tool,
10115
+ json_extract(payload,'$.toolInput.file_path') AS path
10116
+ FROM events
10117
+ WHERE type = 'tool.requested' AND ts >= ?
10118
+ AND json_extract(payload,'$.toolInput.file_path') IS NOT NULL${projectId ? " AND project_id = ?" : ""}`).all(...projectId ? [since, projectId] : [since]);
10119
+ return fileHeat(rows.map((r) => ({ sessionId: r.session_id ?? "", tool: r.tool ?? "", path: r.path ?? "" })));
10120
+ }
10121
+ security(projectId, days3 = 14) {
10122
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
10123
+ const rows = this.db.query(`SELECT session_id, ts, json_extract(payload,'$.tool') AS tool,
10124
+ COALESCE(json_extract(payload,'$.toolInput.command'),
10125
+ json_extract(payload,'$.toolInput.url'), '') AS command,
10126
+ json_extract(payload,'$.toolInput.file_path') AS path
10127
+ FROM events
10128
+ WHERE type = 'tool.requested' AND ts >= ?${projectId ? " AND project_id = ?" : ""}`).all(...projectId ? [since, projectId] : [since]);
10129
+ return securityScan(rows.map((r) => ({
10130
+ sessionId: r.session_id ?? "",
10131
+ tool: r.tool ?? "",
10132
+ command: r.command ?? "",
10133
+ path: r.path,
10134
+ at: r.ts
10135
+ })));
10136
+ }
10137
+ ruleEffect(projectId, days3 = 30) {
10138
+ const since = new Date(Date.now() - days3 * 86400000).toISOString();
10139
+ const rows = this.db.query(`SELECT e.seq, e.ts,
10140
+ json_extract(e.payload,'$.rule') AS rule,
10141
+ COALESCE(json_extract(e.payload,'$.command'), '') AS command,
10142
+ (a.seq IS NOT NULL) AS acked
10143
+ FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
10144
+ WHERE e.type = 'incident.opened' AND e.ts >= ?${projectId ? " AND e.project_id = ?" : ""}`).all(...projectId ? [since, projectId] : [since]);
10145
+ const changes = this.db.query(`SELECT ts, COALESCE(json_extract(payload,'$.added'), '[]') AS added
10146
+ FROM events WHERE type = 'rules.changed' AND ts >= ?`).all(since);
10147
+ return ruleEffect(rows.map((r) => ({
10148
+ rule: r.rule ?? "",
10149
+ command: r.command ?? "",
10150
+ at: r.ts,
10151
+ acked: Boolean(r.acked)
10152
+ })), changes.map((c) => ({ at: c.ts, added: JSON.parse(c.added) })), Date.now(), days3);
10153
+ }
10154
+ reclaimBuild(path, { dryRun = false } = {}) {
10155
+ const report = this.hygiene();
10156
+ const w = report.worktrees.find((x) => x.path === path);
10157
+ if (!w)
10158
+ return { ok: false, error: `not a tracked worktree: ${path}` };
10159
+ const cached = this.buildCache.get(path);
10160
+ if (!cached)
10161
+ return { ok: false, error: "not measured yet \u2014 try again in a moment" };
10162
+ const plan = reclaimPlan(w, cached.dirs, report.worktrees.map((x) => x.path));
10163
+ if (plan.refusals.length)
10164
+ return { ok: false, error: plan.refusals.join("; "), plan };
10165
+ if (dryRun)
10166
+ return { ok: true, plan, removed: 0, freedKb: 0 };
10167
+ let removed = 0;
10168
+ let freedKb = 0;
10169
+ for (const dir of plan.dirs) {
10170
+ if (!dir.startsWith(`${path}/`))
10171
+ continue;
10172
+ try {
10173
+ rmSync2(dir, { recursive: true, force: true });
10174
+ removed++;
10175
+ freedKb += cached.dirs.find((d) => d.path === dir)?.kb ?? 0;
10176
+ } catch {}
10177
+ }
10178
+ this.duCache.delete(path);
10179
+ this.buildCache.delete(path);
10180
+ this.append({
10181
+ ts: new Date().toISOString(),
10182
+ type: "worktree.reclaimed",
10183
+ projectId: w.projectId,
10184
+ sessionId: null,
10185
+ payload: {
10186
+ path,
10187
+ branch: w.branch,
10188
+ dirs: removed,
10189
+ freedKb,
10190
+ summary: `reclaimed ${Math.round(freedKb / 1024)} MB of build output from ${w.branch ?? path}`
10191
+ }
10192
+ });
10193
+ return { ok: true, plan, removed, freedKb };
10194
+ }
9494
10195
  stalls = new Map;
9495
10196
  checkStalls() {
9496
10197
  const live = this.db.query("SELECT id, project_id FROM sessions WHERE state IN ('active','waiting') AND ended_at IS NULL AND last_seen_at >= ?").all(new Date(Date.now() - IDLE_MS).toISOString());
@@ -11120,7 +11821,7 @@ class WorkflowEngine {
11120
11821
  }
11121
11822
 
11122
11823
  // packages/daemon/src/app.ts
11123
- var VERSION = "0.11.3";
11824
+ var VERSION = "0.12.1";
11124
11825
  var WEB_DIR = (() => {
11125
11826
  if (process.env.SWARM_WEB_DIR)
11126
11827
  return process.env.SWARM_WEB_DIR;
@@ -11247,6 +11948,18 @@ function createApp(store = new Store, hooks2 = {}) {
11247
11948
  app.get("/v1/stats", (c) => c.json(store.stats(c.req.query("project") || undefined)));
11248
11949
  app.get("/v1/graphs/collisions", (c) => c.json(store.collisions(c.req.query("project") || undefined)));
11249
11950
  app.get("/v1/graphs/lineage", (c) => c.json(store.lineage(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 14) || 14)), c.req.queries("expand") ?? [])));
11951
+ app.get("/v1/rules/effect", (c) => c.json(store.ruleEffect(c.req.query("project") || undefined, Math.max(1, Math.min(365, Number(c.req.query("days") ?? 30) || 30)))));
11952
+ app.post("/v1/hygiene/reclaim", async (c) => {
11953
+ const body = await c.req.json().catch(() => ({}));
11954
+ if (!body.path)
11955
+ return c.json({ ok: false, error: "path required" }, 400);
11956
+ const r = store.reclaimBuild(body.path, { dryRun: Boolean(body.dry) });
11957
+ return c.json(r, r.ok ? 200 : 409);
11958
+ });
11959
+ app.get("/v1/security", (c) => c.json(store.security(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 14) || 14)))));
11960
+ app.get("/v1/heat", (c) => c.json(store.fileHeat(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 14) || 14)))));
11961
+ app.get("/v1/graphs/resources", (c) => c.json(store.resourceHolding(c.req.query("project") || undefined, Math.max(1, Math.min(30, Number(c.req.query("days") ?? 3) || 3)))));
11962
+ app.get("/v1/graphs/transitions", (c) => c.json(store.transitions(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 7) || 7)), Math.max(1, Math.min(50, Number(c.req.query("min") ?? 2) || 2)))));
11250
11963
  app.get("/v1/context", (c) => c.json(store.context(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 7) || 7)))));
11251
11964
  app.get("/v1/mcp/health", (c) => c.json(store.mcpHealth(c.req.query("project") || undefined, Math.max(1, Math.min(90, Number(c.req.query("days") ?? 7) || 7)))));
11252
11965
  app.get("/v1/hygiene", (c) => c.json(store.hygiene(c.req.query("project") || undefined)));