@ra3orblade/swarm 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/swarmd.js CHANGED
@@ -327,7 +327,7 @@ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
327
327
  import { join as join2 } from "path";
328
328
  var DEFAULT_CONFIG = {
329
329
  daemon: { port: 7777 },
330
- tasks: { source: null },
330
+ tasks: { source: null, labels: [], team: null },
331
331
  gates: { required: [] },
332
332
  rules: {
333
333
  shared_tree: "ask",
@@ -367,7 +367,9 @@ function validate(c) {
367
367
  ...c,
368
368
  daemon: { port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7777 },
369
369
  tasks: {
370
- source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null
370
+ source: typeof source === "string" && source.trim() && !source.startsWith("/") ? source.trim() : null,
371
+ labels: Array.isArray(c.tasks?.labels) ? c.tasks.labels.filter((l) => typeof l === "string" && l.trim() !== "") : [],
372
+ team: typeof c.tasks?.team === "string" && c.tasks.team.trim() ? c.tasks.team.trim() : null
371
373
  },
372
374
  rules: {
373
375
  ...c.rules,
@@ -396,6 +398,250 @@ function loadConfig(opts = {}) {
396
398
  }
397
399
  return validate(cfg);
398
400
  }
401
+ // packages/core/src/rules.ts
402
+ var LIVE_WINDOW_MS = 10 * 60000;
403
+ function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
404
+ if (!current.toplevel)
405
+ return null;
406
+ for (const s of sessions) {
407
+ if (s.id === current.id)
408
+ continue;
409
+ if (s.state === "ended")
410
+ continue;
411
+ if (s.toplevel !== current.toplevel)
412
+ continue;
413
+ if (now - new Date(s.lastSeenAt).getTime() > withinMs)
414
+ continue;
415
+ return s;
416
+ }
417
+ return null;
418
+ }
419
+ function isBroadStage(cmd) {
420
+ const c = cmd.trim();
421
+ if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
422
+ return true;
423
+ if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
424
+ return true;
425
+ if (/\bgit\s+add\s*$/.test(c))
426
+ return true;
427
+ return false;
428
+ }
429
+ function isDestructiveGit(cmd) {
430
+ const c = cmd.trim();
431
+ return /\bgit\s+reset\s+[^|&;]*--hard\b/.test(c) || /\bgit\s+checkout\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+checkout\s+-f\b/.test(c) || /\bgit\s+restore\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+clean\s+[^|&;]*-[a-zA-Z]*f/.test(c) || /\bgit\s+stash\s+(drop|clear)\b/.test(c) || /\bgit\s+branch\s+[^|&;]*-[a-zA-Z]*D/.test(c);
432
+ }
433
+ function isPatternKill(cmd) {
434
+ return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
435
+ }
436
+ function killedPorts(cmd) {
437
+ const ports = new Set;
438
+ const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
439
+ if (!killy)
440
+ return [];
441
+ for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
442
+ ports.add(Number(m[1]));
443
+ for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
444
+ ports.add(Number(m[1]));
445
+ for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
446
+ ports.add(Number(m[1]));
447
+ return [...ports];
448
+ }
449
+ var DEFAULT_MODES = {
450
+ shared_tree: "ask",
451
+ destructive_git: "ask",
452
+ pattern_kill: "ask",
453
+ protected_ports: "ask",
454
+ no_foreign_worktree: "ask",
455
+ claim_required_to_write: "off",
456
+ protected: { ports: [] }
457
+ };
458
+ function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
459
+ const other = () => otherLiveInSameTree(current, sessions, now);
460
+ const hit = (rule, reason) => {
461
+ const mode = modes[rule];
462
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
463
+ };
464
+ if (modes.protected_ports !== "off" && modes.protected.ports.length) {
465
+ const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
466
+ if (target.length) {
467
+ const d = hit("protected_ports", `Port${target.length > 1 ? "s" : ""} ${target.join(", ")} ${target.length > 1 ? "are" : "is"} protected in the Swarm config \u2014 something the owner relies on is listening there. Don't kill it.`);
468
+ if (d.action !== "allow")
469
+ return d;
470
+ }
471
+ }
472
+ if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
473
+ const d = hit("pattern_kill", "This kills processes by command pattern \u2014 it will match every process on the machine that fits, including other agents' or the owner's. Kill by pid instead.");
474
+ if (d.action !== "allow")
475
+ return d;
476
+ }
477
+ if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
478
+ const o = other();
479
+ if (o) {
480
+ const d = hit("shared_tree", `Another session (${o.id.slice(0, 8)}) is active in this same checkout. \`git add -A\` / \`git commit -a\` will sweep its uncommitted changes into your commit. Stage explicit paths (\`git add <path>\`), or give each session its own git worktree.`);
481
+ if (d.action !== "allow")
482
+ return d;
483
+ }
484
+ }
485
+ if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
486
+ const o = other();
487
+ if (o) {
488
+ const d = hit("destructive_git", `Another session (${o.id.slice(0, 8)}) is active in this same checkout and may have uncommitted work. This command can discard it. Coordinate, or use a separate git worktree.`);
489
+ if (d.action !== "allow")
490
+ return d;
491
+ }
492
+ }
493
+ return { action: "allow" };
494
+ }
495
+ function norm(p) {
496
+ const parts = [];
497
+ for (const seg of p.split("/")) {
498
+ if (seg === "" || seg === ".")
499
+ continue;
500
+ if (seg === "..")
501
+ parts.pop();
502
+ else
503
+ parts.push(seg);
504
+ }
505
+ return `/${parts.join("/")}`;
506
+ }
507
+ function isInside(path, dir) {
508
+ if (!path || !dir)
509
+ return false;
510
+ const a = norm(path);
511
+ const d = norm(dir);
512
+ return a === d || a.startsWith(`${d}/`);
513
+ }
514
+ function absolutePath(path, cwd) {
515
+ if (path.startsWith("/"))
516
+ return path;
517
+ if (path.startsWith("~/"))
518
+ return path;
519
+ return `${cwd.replace(/\/+$/, "")}/${path}`;
520
+ }
521
+ var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
522
+ function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
523
+ const hit = (rule, reason) => {
524
+ const mode = modes[rule];
525
+ return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
526
+ };
527
+ const held = claims.filter((c) => c.worktree);
528
+ const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
529
+ if (modes.no_foreign_worktree !== "off") {
530
+ const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
531
+ if (foreign) {
532
+ const d = hit("no_foreign_worktree", kind === "bash" ? `This command runs inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 work in your own checkout, or claim the task.` : `${target} is inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 edit your own checkout, or claim the task.`);
533
+ if (d.action !== "allow")
534
+ return d;
535
+ }
536
+ }
537
+ if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
538
+ const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
539
+ if (inShared) {
540
+ const d = hit("claim_required_to_write", `This repo requires a claim before writing to its shared checkout. Run \`swarm claim <task>\` (or the swarm_claim MCP tool) and work in the worktree it creates.`);
541
+ if (d.action !== "allow")
542
+ return d;
543
+ }
544
+ }
545
+ return { action: "allow" };
546
+ }
547
+
548
+ // packages/core/src/dryrun.ts
549
+ var RULE_IDS = [
550
+ "pattern_kill",
551
+ "shared_tree",
552
+ "destructive_git",
553
+ "protected_ports",
554
+ "no_foreign_worktree",
555
+ "claim_required_to_write"
556
+ ];
557
+ function normalizeDisplay(s) {
558
+ return s.replace(/\s+/g, " ").trim().slice(0, 160);
559
+ }
560
+ function dryRunRules(calls, modes, ctx) {
561
+ const claims = ctx.claims ?? [];
562
+ const minRepeat = ctx.minRepeat ?? 3;
563
+ const maxHits = ctx.maxHits ?? 200;
564
+ const live = new Map;
565
+ const byRule = Object.fromEntries(RULE_IDS.map((r) => [r, { ask: 0, deny: 0 }]));
566
+ const hits = [];
567
+ const groups = new Map;
568
+ let evaluated = 0;
569
+ const sorted = [...calls].sort((a, b) => a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0);
570
+ const writeRules = modes.no_foreign_worktree !== "off" || modes.claim_required_to_write !== "off";
571
+ for (const c of sorted) {
572
+ const toplevel = ctx.toplevel(c.cwd);
573
+ live.set(c.sessionId, { id: c.sessionId, toplevel, lastSeenAt: c.ts, state: "active" });
574
+ const now = new Date(c.ts).getTime();
575
+ const current = { id: c.sessionId, cwd: c.cwd, toplevel };
576
+ let d = { action: "allow" };
577
+ let display = c.tool;
578
+ const isWrite = WRITE_TOOLS.has(c.tool) && typeof c.filePath === "string";
579
+ if (isWrite) {
580
+ const target = absolutePath(c.filePath, c.cwd);
581
+ display = `${c.tool} ${target}`;
582
+ evaluated++;
583
+ if (writeRules)
584
+ d = guardWrite(target, current, claims, modes, "file");
585
+ } else if (c.tool === "Bash" && c.command) {
586
+ display = c.command;
587
+ evaluated++;
588
+ if (writeRules)
589
+ d = guardWrite(c.cwd, current, claims, modes, "bash");
590
+ if (d.action === "allow") {
591
+ const sessions = [...live.values()].filter((s) => now - new Date(s.lastSeenAt).getTime() <= LIVE_WINDOW_MS);
592
+ d = guardBash(c.command, current, sessions, now, modes);
593
+ }
594
+ } else
595
+ continue;
596
+ if (d.action === "allow")
597
+ continue;
598
+ byRule[d.rule][d.action]++;
599
+ const norm2 = normalizeDisplay(display);
600
+ if (hits.length < maxHits)
601
+ hits.push({
602
+ ts: c.ts,
603
+ sessionId: c.sessionId,
604
+ rule: d.rule,
605
+ action: d.action,
606
+ display: norm2,
607
+ completed: c.completed
608
+ });
609
+ const key = `${d.rule} ${norm2}`;
610
+ const g = groups.get(key) ?? {
611
+ rule: d.rule,
612
+ display: norm2,
613
+ fires: 0,
614
+ completedRatio: 0,
615
+ sessions: 0,
616
+ suggestion: "",
617
+ done: 0,
618
+ sids: new Set
619
+ };
620
+ g.fires++;
621
+ if (c.completed)
622
+ g.done++;
623
+ g.sids.add(c.sessionId);
624
+ groups.set(key, g);
625
+ }
626
+ const flaky = [];
627
+ for (const g of groups.values()) {
628
+ if (g.fires < minRepeat)
629
+ continue;
630
+ const ratio = g.done / g.fires;
631
+ if (ratio < 0.8)
632
+ continue;
633
+ flaky.push({
634
+ rule: g.rule,
635
+ display: g.display,
636
+ fires: g.fires,
637
+ completedRatio: Math.round(ratio * 100) / 100,
638
+ sessions: g.sids.size,
639
+ suggestion: modes[g.rule] === "deny" ? `${g.rule} denies this but it ran ${g.done}/${g.fires} times anyway \u2014 the rule is being bypassed; check the hook is installed, or turn it off here.` : `${g.rule} asked ${g.fires} times on this and it was allowed ${g.done} times \u2014 pure friction here. Turn it off for this repo, or make it deny so it stops asking.`
640
+ });
641
+ }
642
+ flaky.sort((a, b) => b.fires - a.fires);
643
+ return { calls: calls.length, evaluated, hits, byRule, flaky };
644
+ }
399
645
  // packages/core/src/forge.ts
400
646
  function parseRemote(url) {
401
647
  const m = url.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/) ?? url.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?\/?$/);
@@ -576,6 +822,237 @@ function formatHandoff(h) {
576
822
  return lines.join(`
577
823
  `);
578
824
  }
825
+ var EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
826
+ var VERIFY_RE = /\b(test|tests|typecheck|tsc|lint|biome|eslint|check|build|smoke|pytest|cargo (test|check|build)|go (test|vet|build)|make)\b/;
827
+ function deriveHandoff(task, ev, opts = {}) {
828
+ const files = [];
829
+ let verify = null;
830
+ let lastPrompt = null;
831
+ for (const e of ev) {
832
+ const p = e.payload ?? {};
833
+ if (e.type === "tool.requested" && p.tool) {
834
+ const arg = (p.summary ?? "").slice(p.tool.length).trim();
835
+ if (EDIT_TOOLS.has(p.tool) && arg && !files.includes(arg))
836
+ files.push(arg);
837
+ if (p.tool === "Bash") {
838
+ if (arg && VERIFY_RE.test(arg))
839
+ verify = arg;
840
+ }
841
+ } else if (p.hook === "UserPromptSubmit" && (p.prompt ?? p.summary)) {
842
+ lastPrompt = (p.prompt ?? p.summary ?? "").trim().split(`
843
+ `)[0]?.slice(0, 200) ?? null;
844
+ }
845
+ }
846
+ const said = (opts.lastText ?? "").trim().replace(/\s+/g, " ").slice(0, 600);
847
+ if (!files.length && !said)
848
+ return null;
849
+ const done = said || `edited ${files.length} file${files.length === 1 ? "" : "s"} (no summary)`;
850
+ const remaining = lastPrompt ? `unverified \u2014 session stopped without a manual handoff; last request: "${lastPrompt}". Re-read the files below, run verify, then continue.` : "unverified \u2014 session stopped without a manual handoff. Re-read the files below, run verify, then continue.";
851
+ return {
852
+ task,
853
+ done,
854
+ remaining,
855
+ files: files.slice(-30),
856
+ verify,
857
+ by: `auto${opts.sessionId ? `:${opts.sessionId.slice(0, 8)}` : ""}`,
858
+ createdAt: opts.now ?? new Date().toISOString()
859
+ };
860
+ }
861
+ function isAutoHandoff(h) {
862
+ return h.by === "auto" || (h.by?.startsWith("auto:") ?? false);
863
+ }
864
+ function formatResumePrompt(h, tail) {
865
+ const out = [
866
+ `You are resuming ${h.task}; the previous session on it stopped without finishing.`,
867
+ "",
868
+ formatHandoff(h)
869
+ ];
870
+ if (tail.length)
871
+ out.push("", "Its last actions, oldest first:", ...tail.map((t) => ` - ${t}`));
872
+ out.push("", "Start by reading the files listed, run the verify step if there is one, then continue with `remaining`. Work only inside this worktree; when done commit, push, and call swarm_handoff.");
873
+ return out.join(`
874
+ `);
875
+ }
876
+ // packages/core/src/lessons.ts
877
+ function portsIn(cmd) {
878
+ const ports = new Set;
879
+ for (const m of cmd.matchAll(/(?::|-i\s*:?|kill-port\s+|fuser\s+-[a-z]*k\s+)(\d{2,5})\b/g))
880
+ ports.add(Number(m[1]));
881
+ return [...ports];
882
+ }
883
+ var RECURRING = 3;
884
+ function suggestFromIncident(inc) {
885
+ const n = inc.count ?? 1;
886
+ switch (inc.rule) {
887
+ case "protected_ports": {
888
+ const ports = portsIn(inc.command);
889
+ return {
890
+ title: ports.length ? `Protect port${ports.length > 1 ? "s" : ""} ${ports.join(", ")} for good` : "Protect this port",
891
+ toml: ports.length ? `[rules]
892
+ protected_ports = "deny"
893
+
894
+ [rules.protected]
895
+ ports = [${ports.join(", ")}]` : null,
896
+ lesson: `Never kill the process on port ${ports.join("/") || "the dev server's port"} \u2014 it's someone's running service. Ask them, or use \`swarm serve\` so the port is tracked.`
897
+ };
898
+ }
899
+ case "pattern_kill":
900
+ return {
901
+ title: n >= RECURRING ? "Deny pattern kills (recurring)" : "Discourage pattern kills",
902
+ toml: `[rules]
903
+ pattern_kill = "${n >= RECURRING ? "deny" : "ask"}"`,
904
+ lesson: "Kill processes by pid, never by command pattern (`pkill -f`) \u2014 pattern kills hit every matching process, including other agents' and the owner's."
905
+ };
906
+ case "shared_tree":
907
+ return {
908
+ title: "Deny broad staging in a shared checkout",
909
+ toml: `[rules]
910
+ shared_tree = "deny"`,
911
+ lesson: "Don't `git add -A` / `git commit -a` while another session shares the checkout \u2014 stage explicit paths, or work in your own worktree via `swarm claim`."
912
+ };
913
+ case "destructive_git":
914
+ return {
915
+ title: "Deny destructive git in a shared checkout",
916
+ toml: `[rules]
917
+ destructive_git = "deny"`,
918
+ lesson: "Never run `git reset --hard` / `checkout .` / `clean -f` in a checkout another session shares \u2014 coordinate, or use a separate worktree."
919
+ };
920
+ case "no_foreign_worktree":
921
+ return {
922
+ title: "Deny writes into others' worktrees",
923
+ toml: `[rules]
924
+ no_foreign_worktree = "deny"`,
925
+ lesson: "Never edit inside a worktree you don't hold \u2014 work in your own checkout, or claim the task first."
926
+ };
927
+ case "claim_required_to_write":
928
+ return {
929
+ title: "Require a claim before writing",
930
+ toml: `[rules]
931
+ claim_required_to_write = "deny"`,
932
+ lesson: "Claim a task (`swarm claim`) and work in the worktree it creates before editing this repo."
933
+ };
934
+ case "orphaned_claim":
935
+ return {
936
+ title: "A claim expired with unfinished work",
937
+ toml: null,
938
+ lesson: "Finish and push, or `swarm handoff`, before a lease expires \u2014 an orphaned worktree still holds work nobody owns."
939
+ };
940
+ case "gate_failed":
941
+ return {
942
+ title: "A verification gate failed",
943
+ toml: null,
944
+ lesson: `A gate failed here \u2014 ${inc.reason.slice(0, 120)}. Fix it and re-record the gate before marking the task done.`
945
+ };
946
+ default:
947
+ return {
948
+ title: `Codify the ${inc.rule} intent`,
949
+ toml: null,
950
+ lesson: inc.reason.slice(0, 160)
951
+ };
952
+ }
953
+ }
954
+ function incidentKey(inc) {
955
+ if (inc.rule === "protected_ports")
956
+ return `protected_ports:${portsIn(inc.command).join(",")}`;
957
+ return inc.rule;
958
+ }
959
+ // packages/core/src/memory.ts
960
+ var MEMORY_KINDS = ["handoff", "incident", "gate", "session"];
961
+ function handoffDoc(projectId, id, h, sessionId) {
962
+ return {
963
+ kind: "handoff",
964
+ ref: String(id),
965
+ projectId,
966
+ task: h.task,
967
+ sessionId,
968
+ ts: h.createdAt,
969
+ title: `handoff on ${h.task}${h.by ? ` by ${h.by}` : ""}`,
970
+ text: [
971
+ `done: ${h.done}`,
972
+ `remaining: ${h.remaining}`,
973
+ h.files.length ? `files: ${h.files.join(" ")}` : "",
974
+ h.verify ? `verify: ${h.verify}` : ""
975
+ ].filter(Boolean).join(`
976
+ `)
977
+ };
978
+ }
979
+ function incidentDoc(projectId, seq, p, ts, sessionId) {
980
+ return {
981
+ kind: "incident",
982
+ ref: String(seq),
983
+ projectId,
984
+ task: null,
985
+ sessionId,
986
+ ts,
987
+ title: `${p.action ?? "ask"} \xB7 ${p.rule ?? "rule"}`,
988
+ text: [p.command ? `command: ${p.command}` : "", p.reason ? `reason: ${p.reason}` : ""].filter(Boolean).join(`
989
+ `)
990
+ };
991
+ }
992
+ function gateDoc(projectId, id, g, sessionId) {
993
+ return {
994
+ kind: "gate",
995
+ ref: String(id),
996
+ projectId,
997
+ task: g.task,
998
+ sessionId,
999
+ ts: g.createdAt,
1000
+ title: `${g.gate} ${g.verdict} on ${g.task}`,
1001
+ text: [`rubric: ${g.rubric}`, g.evidence ? `evidence: ${g.evidence}` : ""].filter(Boolean).join(`
1002
+ `)
1003
+ };
1004
+ }
1005
+ function sessionDoc(projectId, s) {
1006
+ const text = (s.lastText ?? "").trim();
1007
+ if (!text)
1008
+ return null;
1009
+ return {
1010
+ kind: "session",
1011
+ ref: s.id,
1012
+ projectId,
1013
+ task: s.task ?? null,
1014
+ sessionId: s.id,
1015
+ ts: s.ts,
1016
+ title: s.title?.trim() || `session ${s.id.slice(0, 8)}`,
1017
+ text: text.slice(0, 4000)
1018
+ };
1019
+ }
1020
+ function parseMemoryQuery(q) {
1021
+ let kind = null;
1022
+ let task = null;
1023
+ const terms = [];
1024
+ const re = /"([^"]+)"|(\S+)/g;
1025
+ for (const m of q.matchAll(re)) {
1026
+ if (m[1] !== undefined) {
1027
+ const phrase = m[1].replace(/"/g, "").trim();
1028
+ if (phrase)
1029
+ terms.push(`"${phrase}"`);
1030
+ continue;
1031
+ }
1032
+ const w = m[2] ?? "";
1033
+ const k = /^kind:(\w+)$/i.exec(w);
1034
+ if (k) {
1035
+ const v = (k[1] ?? "").toLowerCase();
1036
+ if (MEMORY_KINDS.includes(v))
1037
+ kind = v;
1038
+ continue;
1039
+ }
1040
+ const t = /^task:(\S+)$/i.exec(w);
1041
+ if (t) {
1042
+ task = t[1] ?? null;
1043
+ continue;
1044
+ }
1045
+ const clean = w.replace(/"/g, "").replace(/^\*+|\*+$/g, "");
1046
+ if (clean)
1047
+ terms.push(`"${clean}"`);
1048
+ }
1049
+ if (terms.length) {
1050
+ const last = terms[terms.length - 1];
1051
+ if (!/\s/.test(last) && !q.trim().endsWith('"'))
1052
+ terms[terms.length - 1] = `${last}*`;
1053
+ }
1054
+ return { match: terms.join(" "), kind, task };
1055
+ }
579
1056
  // packages/core/src/pricing.ts
580
1057
  var PRICES = {
581
1058
  "claude-opus-4": { input: 15, output: 75, cacheWrite: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
@@ -699,152 +1176,6 @@ function acquireRefusalMessage(holder) {
699
1176
  const via = isTrackedPid(holder.pid) ? `pid ${holder.pid}` : holder.expiresAt ? `lease until ${holder.expiresAt}` : "unbounded";
700
1177
  return `Resource "${holder.name}" is held by ${holder.owner} (${via}).` + ` Pick another name, coordinate with the holder, or wait for release/reap.`;
701
1178
  }
702
- // packages/core/src/rules.ts
703
- var LIVE_WINDOW_MS = 10 * 60000;
704
- function otherLiveInSameTree(current, sessions, now, withinMs = LIVE_WINDOW_MS) {
705
- if (!current.toplevel)
706
- return null;
707
- for (const s of sessions) {
708
- if (s.id === current.id)
709
- continue;
710
- if (s.state === "ended")
711
- continue;
712
- if (s.toplevel !== current.toplevel)
713
- continue;
714
- if (now - new Date(s.lastSeenAt).getTime() > withinMs)
715
- continue;
716
- return s;
717
- }
718
- return null;
719
- }
720
- function isBroadStage(cmd) {
721
- const c = cmd.trim();
722
- if (/\bgit\s+add\s+(-A\b|--all\b|\.(\s|$))/.test(c))
723
- return true;
724
- if (/\bgit\s+commit\b[^|&;]*\s-[a-zA-Z]*a/.test(c))
725
- return true;
726
- if (/\bgit\s+add\s*$/.test(c))
727
- return true;
728
- return false;
729
- }
730
- function isDestructiveGit(cmd) {
731
- const c = cmd.trim();
732
- return /\bgit\s+reset\s+[^|&;]*--hard\b/.test(c) || /\bgit\s+checkout\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+checkout\s+-f\b/.test(c) || /\bgit\s+restore\s+(--\s+)?\.(\s|$)/.test(c) || /\bgit\s+clean\s+[^|&;]*-[a-zA-Z]*f/.test(c) || /\bgit\s+stash\s+(drop|clear)\b/.test(c) || /\bgit\s+branch\s+[^|&;]*-[a-zA-Z]*D/.test(c);
733
- }
734
- function isPatternKill(cmd) {
735
- return /\bpkill\s+-f\b/.test(cmd) || /\bpgrep\s+-f\b[^|]*\|\s*[^|]*\bkill\b/.test(cmd);
736
- }
737
- function killedPorts(cmd) {
738
- const ports = new Set;
739
- const killy = /\b(kill|fuser\s+-[a-z]*k|kill-port)\b/.test(cmd);
740
- if (!killy)
741
- return [];
742
- for (const m of cmd.matchAll(/(?:-i\s*:?|:)(\d{2,5})\b/g))
743
- ports.add(Number(m[1]));
744
- for (const m of cmd.matchAll(/\bkill-port\s+(\d{2,5})/g))
745
- ports.add(Number(m[1]));
746
- for (const m of cmd.matchAll(/\bfuser\s+-[a-z]*k\s+(\d{2,5})/g))
747
- ports.add(Number(m[1]));
748
- return [...ports];
749
- }
750
- var DEFAULT_MODES = {
751
- shared_tree: "ask",
752
- destructive_git: "ask",
753
- pattern_kill: "ask",
754
- protected_ports: "ask",
755
- no_foreign_worktree: "ask",
756
- claim_required_to_write: "off",
757
- protected: { ports: [] }
758
- };
759
- function guardBash(cmd, current, sessions, now, modes = DEFAULT_MODES) {
760
- const other = () => otherLiveInSameTree(current, sessions, now);
761
- const hit = (rule, reason) => {
762
- const mode = modes[rule];
763
- return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
764
- };
765
- if (modes.protected_ports !== "off" && modes.protected.ports.length) {
766
- const target = killedPorts(cmd).filter((p) => modes.protected.ports.includes(p));
767
- if (target.length) {
768
- const d = hit("protected_ports", `Port${target.length > 1 ? "s" : ""} ${target.join(", ")} ${target.length > 1 ? "are" : "is"} protected in the Swarm config \u2014 something the owner relies on is listening there. Don't kill it.`);
769
- if (d.action !== "allow")
770
- return d;
771
- }
772
- }
773
- if (modes.pattern_kill !== "off" && isPatternKill(cmd)) {
774
- const d = hit("pattern_kill", "This kills processes by command pattern \u2014 it will match every process on the machine that fits, including other agents' or the owner's. Kill by pid instead.");
775
- if (d.action !== "allow")
776
- return d;
777
- }
778
- if (modes.shared_tree !== "off" && isBroadStage(cmd)) {
779
- const o = other();
780
- if (o) {
781
- const d = hit("shared_tree", `Another session (${o.id.slice(0, 8)}) is active in this same checkout. \`git add -A\` / \`git commit -a\` will sweep its uncommitted changes into your commit. Stage explicit paths (\`git add <path>\`), or give each session its own git worktree.`);
782
- if (d.action !== "allow")
783
- return d;
784
- }
785
- }
786
- if (modes.destructive_git !== "off" && isDestructiveGit(cmd)) {
787
- const o = other();
788
- if (o) {
789
- const d = hit("destructive_git", `Another session (${o.id.slice(0, 8)}) is active in this same checkout and may have uncommitted work. This command can discard it. Coordinate, or use a separate git worktree.`);
790
- if (d.action !== "allow")
791
- return d;
792
- }
793
- }
794
- return { action: "allow" };
795
- }
796
- function norm(p) {
797
- const parts = [];
798
- for (const seg of p.split("/")) {
799
- if (seg === "" || seg === ".")
800
- continue;
801
- if (seg === "..")
802
- parts.pop();
803
- else
804
- parts.push(seg);
805
- }
806
- return `/${parts.join("/")}`;
807
- }
808
- function isInside(path, dir) {
809
- if (!path || !dir)
810
- return false;
811
- const a = norm(path);
812
- const d = norm(dir);
813
- return a === d || a.startsWith(`${d}/`);
814
- }
815
- function absolutePath(path, cwd) {
816
- if (path.startsWith("/"))
817
- return path;
818
- if (path.startsWith("~/"))
819
- return path;
820
- return `${cwd.replace(/\/+$/, "")}/${path}`;
821
- }
822
- var WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
823
- function guardWrite(target, current, claims, modes = DEFAULT_MODES, kind = "file") {
824
- const hit = (rule, reason) => {
825
- const mode = modes[rule];
826
- return mode === "off" ? { action: "allow" } : { action: mode, rule, reason };
827
- };
828
- const held = claims.filter((c) => c.worktree);
829
- const mine = held.find((c) => isInside(current.cwd, c.worktree)) ?? null;
830
- if (modes.no_foreign_worktree !== "off") {
831
- const foreign = held.find((c) => isInside(target, c.worktree) && c !== mine);
832
- if (foreign) {
833
- const d = hit("no_foreign_worktree", kind === "bash" ? `This command runs inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 work in your own checkout, or claim the task.` : `${target} is inside the worktree for "${foreign.task}", held by ${foreign.owner}. Never touch a worktree you don't hold \u2014 edit your own checkout, or claim the task.`);
834
- if (d.action !== "allow")
835
- return d;
836
- }
837
- }
838
- if (modes.claim_required_to_write !== "off" && kind === "file" && current.toplevel && !mine) {
839
- const inShared = isInside(target, current.toplevel) && !held.some((c) => isInside(target, c.worktree));
840
- if (inShared) {
841
- const d = hit("claim_required_to_write", `This repo requires a claim before writing to its shared checkout. Run \`swarm claim <task>\` (or the swarm_claim MCP tool) and work in the worktree it creates.`);
842
- if (d.action !== "allow")
843
- return d;
844
- }
845
- }
846
- return { action: "allow" };
847
- }
848
1179
  // packages/core/src/tasks.ts
849
1180
  var ID_RE = /^[A-Za-z][A-Za-z0-9_-]*\d[\w.-]*$/;
850
1181
  var DEP_RE = /[A-Za-z][A-Za-z0-9_-]*\d[\w.]*/g;
@@ -945,6 +1276,65 @@ function taskBoard(tasks, activeClaims) {
945
1276
  };
946
1277
  });
947
1278
  }
1279
+ var TASK_SOURCE_KINDS = ["github", "linear"];
1280
+ function taskSourceKind(source) {
1281
+ if (!source)
1282
+ return null;
1283
+ return TASK_SOURCE_KINDS.includes(source) ? source : "markdown";
1284
+ }
1285
+ var ACTIVE_LABEL_RE = /^(in[- ]progress|wip|doing|active|started)$/i;
1286
+ var GH_DEP_RE = /\b(?:depends on|blocked by|after|requires)\b[^\n.]*?((?:#\d+[,\s]*(?:and)?\s*)+)/gi;
1287
+ function normalizeGithubIssues(issues) {
1288
+ return issues.filter((i) => Number.isInteger(i.number) && typeof i.title === "string").sort((a, b) => a.number - b.number).map((i) => {
1289
+ const labels = (i.labels ?? []).map((l) => l.name);
1290
+ const closed = (i.state ?? "").toUpperCase() === "CLOSED";
1291
+ const active = !closed && labels.some((l) => ACTIVE_LABEL_RE.test(l));
1292
+ const depends = [];
1293
+ for (const m of (i.body ?? "").matchAll(GH_DEP_RE))
1294
+ for (const n of (m[1] ?? "").matchAll(/#(\d+)/g)) {
1295
+ const id = `GH-${n[1]}`;
1296
+ if (!depends.includes(id))
1297
+ depends.push(id);
1298
+ }
1299
+ const statusText = closed ? "closed" : active ? `in progress${i.assignees?.length ? ` (${i.assignees.map((a) => a.login).join(", ")})` : ""}` : labels.length ? labels.join(", ") : "open";
1300
+ return {
1301
+ id: `GH-${i.number}`,
1302
+ title: i.title,
1303
+ depends,
1304
+ status: closed ? "done" : active ? "active" : "todo",
1305
+ statusText,
1306
+ milestone: i.milestone?.title ?? null
1307
+ };
1308
+ });
1309
+ }
1310
+ function normalizeLinearIssues(issues) {
1311
+ return issues.filter((i) => typeof i.identifier === "string" && typeof i.title === "string").map((i) => {
1312
+ const type = i.state?.type ?? "unstarted";
1313
+ const done = type === "completed" || type === "canceled";
1314
+ const active = type === "started";
1315
+ const depends = (i.inverseRelations?.nodes ?? []).filter((r) => r.type === "blocks").map((r) => r.issue.identifier);
1316
+ const statusText = `${i.state?.name ?? type}${active && i.assignee ? ` (${i.assignee.name})` : ""}`;
1317
+ return {
1318
+ id: i.identifier,
1319
+ title: i.title,
1320
+ depends: [...new Set(depends)],
1321
+ status: done ? "done" : active ? "active" : "todo",
1322
+ statusText,
1323
+ milestone: i.cycle?.name ?? (i.cycle ? `Cycle ${i.cycle.number}` : i.project?.name ?? null)
1324
+ };
1325
+ });
1326
+ }
1327
+ function linearIssuesQuery(teamKey, first = 200) {
1328
+ const filter = teamKey ? `, filter: { team: { key: { eq: "${teamKey.replace(/"/g, "")}" } } }` : "";
1329
+ return `{ issues(first: ${first}, orderBy: createdAt${filter}) { nodes {
1330
+ identifier title sortOrder
1331
+ state { name type }
1332
+ assignee { name }
1333
+ project { name }
1334
+ cycle { name number }
1335
+ inverseRelations { nodes { type issue { identifier } } }
1336
+ } } }`;
1337
+ }
948
1338
  // packages/daemon/src/app.ts
949
1339
  import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
950
1340
  import { homedir as homedir4 } from "os";
@@ -3145,6 +3535,83 @@ function heldWork(path) {
3145
3535
  return { dirty, unpushed };
3146
3536
  }
3147
3537
 
3538
+ // packages/daemon/src/task-sources.ts
3539
+ class TaskSources {
3540
+ env;
3541
+ cache = new Map;
3542
+ inflight = new Set;
3543
+ constructor(env = process.env) {
3544
+ this.env = env;
3545
+ }
3546
+ get(projectId, kind, root, opts, ttlMs = 60000) {
3547
+ const hit = this.cache.get(projectId);
3548
+ if (!hit || Date.now() - hit.at >= ttlMs)
3549
+ this.refresh(projectId, kind, root, opts);
3550
+ return hit ?? { at: 0, tasks: [], error: null };
3551
+ }
3552
+ async refresh(projectId, kind, root, opts) {
3553
+ if (this.inflight.has(projectId))
3554
+ return this.cache.get(projectId) ?? { at: 0, tasks: [], error: null };
3555
+ this.inflight.add(projectId);
3556
+ const prev = this.cache.get(projectId);
3557
+ let entry;
3558
+ try {
3559
+ const tasks2 = kind === "github" ? await this.github(root, opts.labels) : await this.linear(opts.team);
3560
+ entry = { at: Date.now(), tasks: tasks2, error: null };
3561
+ } catch (e) {
3562
+ entry = { at: Date.now(), tasks: prev?.tasks ?? [], error: e.message };
3563
+ } finally {
3564
+ this.inflight.delete(projectId);
3565
+ }
3566
+ this.cache.set(projectId, entry);
3567
+ return entry;
3568
+ }
3569
+ async github(root, labels) {
3570
+ const bin = findBin("gh");
3571
+ if (!bin)
3572
+ throw new Error("gh not installed \u2014 GitHub Issues need the gh CLI (brew install gh)");
3573
+ const args = [
3574
+ bin,
3575
+ "issue",
3576
+ "list",
3577
+ "--state",
3578
+ "all",
3579
+ "--limit",
3580
+ "300",
3581
+ "--json",
3582
+ "number,title,state,labels,body,assignees,milestone"
3583
+ ];
3584
+ for (const l of labels)
3585
+ args.push("--label", l);
3586
+ const proc = Bun.spawn(args, { cwd: root, stdout: "pipe", stderr: "pipe" });
3587
+ const [out, err, code] = await Promise.all([
3588
+ new Response(proc.stdout).text(),
3589
+ new Response(proc.stderr).text(),
3590
+ proc.exited
3591
+ ]);
3592
+ if (code !== 0)
3593
+ throw new Error(`gh issue list failed: ${err.trim().split(`
3594
+ `)[0] ?? code}`);
3595
+ return normalizeGithubIssues(JSON.parse(out));
3596
+ }
3597
+ async linear(team) {
3598
+ const key = this.env.LINEAR_API_KEY;
3599
+ if (!key)
3600
+ throw new Error("LINEAR_API_KEY not set \u2014 export it in the environment swarmd starts from (never stored)");
3601
+ const r = await fetch("https://api.linear.app/graphql", {
3602
+ method: "POST",
3603
+ headers: { "content-type": "application/json", authorization: key },
3604
+ body: JSON.stringify({ query: linearIssuesQuery(team) })
3605
+ });
3606
+ if (!r.ok)
3607
+ throw new Error(`Linear API ${r.status}`);
3608
+ const j = await r.json();
3609
+ if (j.errors?.length)
3610
+ throw new Error(`Linear: ${j.errors[0]?.message}`);
3611
+ return normalizeLinearIssues(j.data?.issues?.nodes ?? []);
3612
+ }
3613
+ }
3614
+
3148
3615
  // packages/daemon/src/store.ts
3149
3616
  var SCHEMA = `
3150
3617
  CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, root TEXT, common_dir TEXT, name TEXT, discovered INTEGER, created_at TEXT);
@@ -3171,6 +3638,10 @@ CREATE TABLE IF NOT EXISTS resources (
3171
3638
  PRIMARY KEY (name, project_id)
3172
3639
  );
3173
3640
  CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
3641
+ CREATE VIRTUAL TABLE IF NOT EXISTS memory USING fts5(
3642
+ kind UNINDEXED, ref UNINDEXED, project_id UNINDEXED, task, session_id UNINDEXED, ts UNINDEXED,
3643
+ title, text, tokenize = 'unicode61 remove_diacritics 2'
3644
+ );
3174
3645
  CREATE TABLE IF NOT EXISTS incident_acks (seq INTEGER PRIMARY KEY, acked_at TEXT);
3175
3646
  CREATE TABLE IF NOT EXISTS processes (
3176
3647
  pid INTEGER, start_time TEXT, project_id TEXT, session_id TEXT, kind TEXT, name TEXT, port INTEGER,
@@ -3219,6 +3690,7 @@ class Store {
3219
3690
  this.reconcileMovedProjects();
3220
3691
  this.slimExistingEvents();
3221
3692
  this.retypeNotificationIncidents();
3693
+ this.backfillMemory();
3222
3694
  }
3223
3695
  retypeNotificationIncidents() {
3224
3696
  if (this.meta("notifications_retyped") === "1")
@@ -3345,8 +3817,9 @@ class Store {
3345
3817
  createdAt: new Date().toISOString()
3346
3818
  };
3347
3819
  const sessionId = this.knownSession(h.sessionId);
3348
- this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3820
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3349
3821
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, handoff.task, handoff.done, handoff.remaining, JSON.stringify(handoff.files), handoff.verify, handoff.by, sessionId, handoff.createdAt);
3822
+ this.remember(handoffDoc(projectId, Number(ins.lastInsertRowid), handoff, sessionId));
3350
3823
  this.append({
3351
3824
  ts: handoff.createdAt,
3352
3825
  type: "handoff.recorded",
@@ -3383,6 +3856,138 @@ class Store {
3383
3856
  sessionId: r.session_id ?? null
3384
3857
  }));
3385
3858
  }
3859
+ autoHandoff(sessionId, cwd) {
3860
+ const held = this.heldClaimsWithWorktree().find((c) => isInside(cwd, c.worktree));
3861
+ if (!held)
3862
+ return null;
3863
+ const manual = this.db.query("SELECT id, by FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ?").all(held.projectId, held.task, sessionId);
3864
+ if (manual.some((h2) => !isAutoHandoff(h2)))
3865
+ return null;
3866
+ const row = this.db.query("SELECT last_text FROM sessions WHERE id = ?").get(sessionId);
3867
+ const h = deriveHandoff(held.task, this.sessionEvents(sessionId, 2000), { lastText: row?.last_text ?? null, sessionId });
3868
+ if (!h)
3869
+ return null;
3870
+ this.db.query("DELETE FROM handoffs WHERE project_id = ? AND task = ? AND session_id = ? AND by LIKE 'auto%'").run(held.projectId, held.task, sessionId);
3871
+ const ins = this.db.query(`INSERT INTO handoffs (project_id, task, done, remaining, files, verify, by, session_id, created_at)
3872
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(held.projectId, held.task, h.done, h.remaining, JSON.stringify(h.files), h.verify, h.by, sessionId, h.createdAt);
3873
+ this.remember(handoffDoc(held.projectId, Number(ins.lastInsertRowid), h, sessionId));
3874
+ this.touch();
3875
+ return h;
3876
+ }
3877
+ resumePlan(sessionId) {
3878
+ const s = this.db.query("SELECT project_id, cwd, last_text FROM sessions WHERE id = ?").get(sessionId);
3879
+ if (!s)
3880
+ return { ok: false, reason: "unknown session" };
3881
+ const byHandoff = this.db.query("SELECT project_id, task FROM handoffs WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
3882
+ const claim = this.claimRows(s.project_id).find((c) => c.worktree && s.cwd && isInside(s.cwd, c.worktree));
3883
+ const task = byHandoff?.task ?? claim?.task;
3884
+ const projectId = byHandoff?.project_id ?? s.project_id;
3885
+ if (!task)
3886
+ return { ok: false, reason: "this session was not working on a claimed task" };
3887
+ const ev = this.sessionEvents(sessionId, 2000);
3888
+ let handoff = this.latestHandoff(projectId, task);
3889
+ if (!handoff)
3890
+ handoff = deriveHandoff(task, ev, {
3891
+ lastText: s.last_text,
3892
+ sessionId
3893
+ });
3894
+ if (!handoff)
3895
+ return { ok: false, reason: "nothing to resume \u2014 the session left no trail" };
3896
+ const tail = ev.filter((e) => e.type === "tool.requested" || e.type === "prompt.submitted").slice(-12).map((e) => (e.payload.summary ?? e.type).slice(0, 160));
3897
+ const owner = claim && claim.state === "held" ? claim.owner : null;
3898
+ return { ok: true, projectId, task, owner, prompt: formatResumePrompt(handoff, tail), handoff };
3899
+ }
3900
+ remember(doc) {
3901
+ if (!doc)
3902
+ return;
3903
+ this.db.query("DELETE FROM memory WHERE kind = ? AND ref = ?").run(doc.kind, doc.ref);
3904
+ this.db.query("INSERT INTO memory (kind, ref, project_id, task, session_id, ts, title, text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(doc.kind, doc.ref, doc.projectId, doc.task, doc.sessionId, doc.ts, doc.title, doc.text);
3905
+ }
3906
+ rememberSession(sessionId) {
3907
+ const r = this.db.query("SELECT id, project_id, title, last_text, last_seen_at, cwd FROM sessions WHERE id = ?").get(sessionId);
3908
+ if (!r)
3909
+ return;
3910
+ const held = r.cwd ? this.heldClaimsWithWorktree().find((c) => isInside(r.cwd, c.worktree)) : null;
3911
+ this.remember(sessionDoc(r.project_id, {
3912
+ id: r.id,
3913
+ title: r.title,
3914
+ lastText: r.last_text,
3915
+ ts: r.last_seen_at,
3916
+ task: held?.task ?? null
3917
+ }));
3918
+ }
3919
+ backfillMemory() {
3920
+ if (this.db.query("SELECT value FROM meta WHERE key = 'memory_backfilled'").get())
3921
+ return;
3922
+ const tx = this.db.transaction(() => {
3923
+ for (const r of this.db.query("SELECT * FROM handoffs").all())
3924
+ this.remember(handoffDoc(r.project_id, r.id, {
3925
+ task: r.task,
3926
+ done: r.done,
3927
+ remaining: r.remaining,
3928
+ files: JSON.parse(r.files || "[]"),
3929
+ verify: r.verify ?? null,
3930
+ by: r.by ?? null,
3931
+ createdAt: r.created_at
3932
+ }, r.session_id ?? null));
3933
+ for (const r of this.db.query("SELECT * FROM gates").all())
3934
+ this.remember(gateDoc(r.project_id, r.id, this.rowToGate(r), r.session_id ?? null));
3935
+ for (const r of this.db.query("SELECT seq, ts, project_id, session_id, payload FROM events WHERE type = 'incident.opened'").all()) {
3936
+ let p = {};
3937
+ try {
3938
+ p = JSON.parse(r.payload || "{}");
3939
+ } catch {}
3940
+ this.remember(incidentDoc(r.project_id, r.seq, p, r.ts, r.session_id ?? null));
3941
+ }
3942
+ for (const r of this.db.query("SELECT id, project_id, title, last_text, last_seen_at FROM sessions WHERE last_text IS NOT NULL AND last_text != ''").all())
3943
+ this.remember(sessionDoc(r.project_id, {
3944
+ id: r.id,
3945
+ title: r.title ?? null,
3946
+ lastText: r.last_text,
3947
+ ts: r.last_seen_at
3948
+ }));
3949
+ this.db.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('memory_backfilled', ?)").run(new Date().toISOString());
3950
+ });
3951
+ tx();
3952
+ }
3953
+ memorySearch(q, opts = {}) {
3954
+ const parsed = parseMemoryQuery(q);
3955
+ if (!parsed.match)
3956
+ return [];
3957
+ const kind = opts.kind ?? parsed.kind;
3958
+ const task = opts.task ?? parsed.task;
3959
+ const where = ["memory MATCH ?"];
3960
+ const args = [parsed.match];
3961
+ if (opts.projectId) {
3962
+ where.push("project_id = ?");
3963
+ args.push(opts.projectId);
3964
+ }
3965
+ if (kind) {
3966
+ where.push("kind = ?");
3967
+ args.push(kind);
3968
+ }
3969
+ if (task) {
3970
+ where.push("task = ?");
3971
+ args.push(task);
3972
+ }
3973
+ args.push(Math.min(200, Math.max(1, opts.limit ?? 30)));
3974
+ const rows = this.db.query(`SELECT kind, ref, project_id, task, session_id, ts, title, text,
3975
+ bm25(memory, 0, 0, 0, 2.0, 0, 0, 4.0, 1.0) AS score,
3976
+ snippet(memory, 7, '\x01', '\x02', ' \u2026 ', 24) AS snippet
3977
+ FROM memory WHERE ${where.join(" AND ")} ORDER BY score LIMIT ?`).all(...args);
3978
+ return rows.map((r) => ({
3979
+ kind: r.kind,
3980
+ ref: r.ref,
3981
+ projectId: r.project_id,
3982
+ task: r.task ?? null,
3983
+ sessionId: r.session_id ?? null,
3984
+ ts: r.ts,
3985
+ title: r.title,
3986
+ text: r.text,
3987
+ score: -r.score,
3988
+ snippet: r.snippet
3989
+ }));
3990
+ }
3386
3991
  sessionContext(cwd) {
3387
3992
  if (!cwd || !existsSync4(cwd))
3388
3993
  return null;
@@ -3458,6 +4063,7 @@ class Store {
3458
4063
  const r = this.db.query(`INSERT INTO gates (project_id, task, gate, verdict, rubric, evidence, session_id, created_at)
3459
4064
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(projectId, input.task.trim(), input.gate, input.verdict, input.rubric.trim(), input.evidence?.trim() || null, sessionId, createdAt);
3460
4065
  const run2 = this.rowToGate(this.db.query("SELECT * FROM gates WHERE id = ?").get(Number(r.lastInsertRowid)));
4066
+ this.remember(gateDoc(projectId, run2.id, run2, sessionId));
3461
4067
  this.append({
3462
4068
  ts: createdAt,
3463
4069
  type: "gate.recorded",
@@ -3487,21 +4093,36 @@ class Store {
3487
4093
  return { ok: true, run: run2 };
3488
4094
  }
3489
4095
  taskCache = new Map;
4096
+ taskSources = new TaskSources;
3490
4097
  tasks(projectId) {
3491
4098
  const p = this.project(projectId);
3492
4099
  if (!p)
3493
4100
  return null;
3494
- const source = loadConfig({ repoRoot: p.root, home: this.home }).tasks.source;
4101
+ const cfg = loadConfig({ repoRoot: p.root, home: this.home }).tasks;
4102
+ const source = cfg.source;
3495
4103
  if (!source)
3496
4104
  return null;
3497
- const path = join6(p.root, source);
3498
- if (!existsSync4(path))
3499
- return { source, required: this.requiredGates(projectId), tasks: [] };
3500
- const mtime = statSync(path).mtimeMs;
3501
- let hit = this.taskCache.get(projectId);
3502
- if (!hit || hit.path !== path || hit.mtime !== mtime) {
3503
- hit = { path, mtime, tasks: parseMarkdownTasks(readFileSync3(path, "utf8")) };
3504
- this.taskCache.set(projectId, hit);
4105
+ let hit;
4106
+ let error = null;
4107
+ const kind = taskSourceKind(source);
4108
+ if (kind === "github" || kind === "linear") {
4109
+ const e = this.taskSources.get(projectId, kind, p.root, {
4110
+ labels: cfg.labels,
4111
+ team: cfg.team
4112
+ });
4113
+ hit = { tasks: e.tasks };
4114
+ error = e.error;
4115
+ } else {
4116
+ const path = join6(p.root, source);
4117
+ if (!existsSync4(path))
4118
+ return { source, required: this.requiredGates(projectId), tasks: [] };
4119
+ const mtime = statSync(path).mtimeMs;
4120
+ let md = this.taskCache.get(projectId);
4121
+ if (!md || md.path !== path || md.mtime !== mtime) {
4122
+ md = { path, mtime, tasks: parseMarkdownTasks(readFileSync3(path, "utf8")) };
4123
+ this.taskCache.set(projectId, md);
4124
+ }
4125
+ hit = md;
3505
4126
  }
3506
4127
  const now = Date.now();
3507
4128
  const active = this.claimRows(projectId).filter((c) => isActive(c, now));
@@ -3523,7 +4144,7 @@ class Store {
3523
4144
  gated: gatesSatisfied(tr, required)
3524
4145
  };
3525
4146
  });
3526
- return { source, required, tasks: board };
4147
+ return { source, required, tasks: board, error };
3527
4148
  }
3528
4149
  rulesFor(repoRoot) {
3529
4150
  const key = repoRoot ?? "";
@@ -3625,6 +4246,51 @@ class Store {
3625
4246
  return d;
3626
4247
  }
3627
4248
  heldWorktreesCache = null;
4249
+ dryRun(projectId, overrides = {}, limit = 5000) {
4250
+ const project = this.project(projectId);
4251
+ const modes = { ...this.rulesFor(project?.root ?? null), ...overrides };
4252
+ const rows = this.db.query(`SELECT * FROM (SELECT seq, ts, type, session_id, payload FROM events
4253
+ WHERE project_id = ? AND type IN ('tool.requested', 'tool.completed')
4254
+ ORDER BY seq DESC LIMIT ?) ORDER BY seq`).all(projectId, limit);
4255
+ const calls = [];
4256
+ const pending = new Map;
4257
+ for (const r of rows) {
4258
+ let p;
4259
+ try {
4260
+ p = JSON.parse(r.payload);
4261
+ } catch {
4262
+ continue;
4263
+ }
4264
+ if (!p.tool || !r.session_id)
4265
+ continue;
4266
+ const key = `${r.session_id} ${p.summary ?? p.tool}`;
4267
+ if (r.type === "tool.requested") {
4268
+ const input = p.toolInput ?? {};
4269
+ const call = {
4270
+ ts: r.ts,
4271
+ sessionId: r.session_id,
4272
+ cwd: p.cwd ?? "",
4273
+ tool: p.tool,
4274
+ command: typeof input.command === "string" ? input.command : undefined,
4275
+ filePath: typeof input.file_path === "string" ? input.file_path : undefined,
4276
+ completed: false
4277
+ };
4278
+ calls.push(call);
4279
+ pending.set(key, call);
4280
+ } else {
4281
+ const c = pending.get(key);
4282
+ if (c) {
4283
+ c.completed = true;
4284
+ pending.delete(key);
4285
+ }
4286
+ }
4287
+ }
4288
+ const report = dryRunRules(calls, modes, {
4289
+ toplevel: (cwd) => cwd && existsSync4(cwd) ? this.toplevel(cwd) : null,
4290
+ claims: this.heldWorktrees()
4291
+ });
4292
+ return { ...report, modes };
4293
+ }
3628
4294
  heldWorktrees() {
3629
4295
  if (this.heldWorktreesCache && Date.now() - this.heldWorktreesCache.at < 2000)
3630
4296
  return this.heldWorktreesCache.v;
@@ -3773,6 +4439,8 @@ class Store {
3773
4439
  const slim = slimForStorage(e);
3774
4440
  const r = this.db.query("INSERT INTO events (ts, type, project_id, session_id, payload, raw) VALUES (?, ?, ?, ?, ?, ?)").run(e.ts, e.type, e.projectId, e.sessionId, JSON.stringify(slim.payload ?? null), slim.raw === undefined ? null : JSON.stringify(slim.raw));
3775
4441
  const stored = { ...e, seq: Number(r.lastInsertRowid) };
4442
+ if (stored.type === "incident.opened")
4443
+ this.remember(incidentDoc(stored.projectId, stored.seq, stored.payload, stored.ts, stored.sessionId));
3776
4444
  this.projectSession(stored);
3777
4445
  this.touch();
3778
4446
  const wire = toWire(stored);
@@ -3795,6 +4463,11 @@ class Store {
3795
4463
  const cwd = typeof raw2.cwd === "string" ? raw2.cwd : process.cwd();
3796
4464
  const project = existsSync4(cwd) ? this.resolveProject(cwd) : null;
3797
4465
  const e = this.append(normalizeHook(event, raw2, project?.id ?? "p_unknown"));
4466
+ if ((event === "Stop" || event === "SessionEnd") && e.sessionId) {
4467
+ if (existsSync4(cwd))
4468
+ this.autoHandoff(e.sessionId, cwd);
4469
+ this.rememberSession(e.sessionId);
4470
+ }
3798
4471
  if (e.sessionId && typeof raw2.transcript_path === "string") {
3799
4472
  this.db.query("UPDATE sessions SET transcript_path = ? WHERE id = ? AND transcript_path IS NULL").run(raw2.transcript_path, e.sessionId);
3800
4473
  const last = this.lastTail.get(e.sessionId) ?? 0;
@@ -4448,6 +5121,23 @@ class Store {
4448
5121
  daily
4449
5122
  };
4450
5123
  }
5124
+ attribution(projectId) {
5125
+ const claims = this.db.query("SELECT task, owner, worktree, state FROM claims WHERE project_id = ? AND worktree != ''").all(projectId);
5126
+ const byTask = claims.map((c) => {
5127
+ const r = this.db.query(`SELECT COALESCE(SUM(t.cost_usd),0) AS cost, COALESCE(SUM(t.output),0) AS output, COUNT(*) AS turns,
5128
+ COUNT(DISTINCT s.id) AS sessions
5129
+ FROM sessions s JOIN turns t ON t.session_id = s.id
5130
+ WHERE s.cwd = ? OR s.cwd LIKE ?`).get(c.worktree, `${c.worktree}/%`);
5131
+ return { task: c.task, owner: c.owner, state: c.state, worktree: c.worktree, ...r };
5132
+ }).filter((t) => t.turns > 0).sort((a, b) => b.cost - a.cost);
5133
+ const contextBudget = this.db.query(`SELECT s.id, s.title, s.project_id AS projectId,
5134
+ COALESCE(SUM(t.cache_read),0) AS cacheRead,
5135
+ COALESCE(SUM(t.input + t.cache_write + t.cache_read),0) AS input,
5136
+ COALESCE(SUM(t.cost_usd),0) AS cost, COUNT(*) AS turns
5137
+ FROM sessions s JOIN turns t ON t.session_id = s.id
5138
+ WHERE s.project_id = ? GROUP BY s.id HAVING turns > 3 ORDER BY cacheRead DESC LIMIT 12`).all(projectId).map((r) => ({ ...r, reuse: r.input ? r.cacheRead / r.input : 0 }));
5139
+ return { byTask, contextBudget };
5140
+ }
4451
5141
  stats(projectId) {
4452
5142
  const scope = projectId ? "s.project_id = ?" : "? IS NULL";
4453
5143
  const arg = projectId ?? null;
@@ -4522,7 +5212,7 @@ class Store {
4522
5212
  args.push(limit);
4523
5213
  const rows = this.db.query(`SELECT e.seq, e.ts, e.project_id, e.session_id, e.payload, a.acked_at FROM events e
4524
5214
  LEFT JOIN incident_acks a ON a.seq = e.seq WHERE ${where.join(" AND ")} ORDER BY e.seq DESC LIMIT ?`).all(...args);
4525
- return rows.map((r) => ({
5215
+ const list = rows.map((r) => ({
4526
5216
  seq: r.seq,
4527
5217
  ts: r.ts,
4528
5218
  projectId: r.project_id,
@@ -4530,6 +5220,25 @@ class Store {
4530
5220
  acked: r.acked_at,
4531
5221
  ...JSON.parse(r.payload || "{}")
4532
5222
  }));
5223
+ const counts = new Map;
5224
+ for (const i of list) {
5225
+ const key = incidentKey(i);
5226
+ counts.set(key, (counts.get(key) ?? 0) + 1);
5227
+ }
5228
+ return list.map((i) => {
5229
+ const incident = i;
5230
+ if (!incident.rule)
5231
+ return i;
5232
+ const key = incidentKey(incident);
5233
+ const suggestion = suggestFromIncident({
5234
+ rule: incident.rule,
5235
+ action: incident.action ?? "",
5236
+ command: incident.command ?? "",
5237
+ reason: incident.reason ?? "",
5238
+ count: counts.get(key) ?? 1
5239
+ });
5240
+ return { ...i, count: counts.get(key) ?? 1, suggestion };
5241
+ });
4533
5242
  }
4534
5243
  openIncidents(projectId) {
4535
5244
  const r = this.db.query(`SELECT COUNT(*) AS n FROM events e LEFT JOIN incident_acks a ON a.seq = e.seq
@@ -4918,7 +5627,7 @@ function rowToEvent(r) {
4918
5627
  }
4919
5628
 
4920
5629
  // packages/daemon/src/app.ts
4921
- var VERSION = "0.5.0";
5630
+ var VERSION = "0.6.0";
4922
5631
  var WEB_DIR = (() => {
4923
5632
  if (process.env.SWARM_WEB_DIR)
4924
5633
  return process.env.SWARM_WEB_DIR;
@@ -4986,6 +5695,30 @@ function createApp(store = new Store) {
4986
5695
  open: c.req.query("open") === "1",
4987
5696
  projectId: c.req.query("project") || undefined
4988
5697
  })));
5698
+ app.get("/v1/memory", (c) => {
5699
+ const q = c.req.query("q") ?? "";
5700
+ const kind = c.req.query("kind");
5701
+ return c.json({
5702
+ q,
5703
+ hits: store.memorySearch(q, {
5704
+ projectId: c.req.query("project") || null,
5705
+ kind: MEMORY_KINDS.includes(kind) ? kind : null,
5706
+ task: c.req.query("task") || null,
5707
+ limit: Number(c.req.query("limit")) || 30
5708
+ })
5709
+ });
5710
+ });
5711
+ app.get("/v1/rules/dryrun", (c) => {
5712
+ const projectId = c.req.query("project");
5713
+ if (!projectId)
5714
+ return c.json({ ok: false, error: "project required" }, 400);
5715
+ const overrides = {};
5716
+ for (const [k, v] of Object.entries(c.req.query()))
5717
+ if (RULE_IDS.includes(k) && ["ask", "deny", "off"].includes(v))
5718
+ overrides[k] = v;
5719
+ const limit = Math.min(20000, Math.max(100, Number(c.req.query("limit")) || 5000));
5720
+ return c.json(store.dryRun(projectId, overrides, limit));
5721
+ });
4989
5722
  app.post("/v1/incidents/ack", async (c) => {
4990
5723
  const body = await c.req.json().catch(() => ({}));
4991
5724
  return c.json({ ok: true, acked: store.ackAllIncidents(body.project || undefined) });
@@ -5177,6 +5910,10 @@ function createApp(store = new Store) {
5177
5910
  return e ? c.json(e) : c.json({ error: "not found" }, 404);
5178
5911
  });
5179
5912
  app.get("/v1/spend", (c) => c.json(store.spend()));
5913
+ app.get("/v1/attribution", (c) => {
5914
+ const project = c.req.query("project");
5915
+ return project ? c.json(store.attribution(project)) : c.json({ error: "project required" }, 400);
5916
+ });
5180
5917
  app.post("/v1/pricing/refresh", async (c) => {
5181
5918
  try {
5182
5919
  await store.refreshPricing();
@@ -5186,6 +5923,27 @@ function createApp(store = new Store) {
5186
5923
  }
5187
5924
  });
5188
5925
  app.get("/v1/pricing", (c) => c.json(store.prices));
5926
+ app.get("/v1/sessions/:id/resume", (c) => {
5927
+ const r = store.resumePlan(c.req.param("id"));
5928
+ return r.ok ? c.json(r) : c.json({ ok: false, error: r.reason }, 404);
5929
+ });
5930
+ app.post("/v1/sessions/:id/resume", async (c) => {
5931
+ const b = await c.req.json().catch(() => ({}));
5932
+ const plan = store.resumePlan(c.req.param("id"));
5933
+ if (!plan.ok)
5934
+ return c.json({ ok: false, error: plan.reason }, 404);
5935
+ const r = await runner.start({
5936
+ projectId: plan.projectId,
5937
+ task: plan.task,
5938
+ prompt: plan.prompt,
5939
+ owner: b.owner ?? plan.owner ?? "dashboard",
5940
+ model: b.model,
5941
+ permissionMode: b.permissionMode,
5942
+ allowedTools: b.allowedTools,
5943
+ maxTurns: b.maxTurns
5944
+ });
5945
+ return r.ok ? c.json({ ...r, resumedFrom: c.req.param("id") }, 201) : c.json({ ok: false, error: r.reason }, 409);
5946
+ });
5189
5947
  app.post("/v1/sessions/:id/tail", (c) => c.json({ turns: store.tailSession(c.req.param("id")) }));
5190
5948
  app.post("/v1/hook/:event", async (c) => {
5191
5949
  const event = c.req.param("event");