squadrant 0.9.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +268 -0
  3. package/dist/index.js +9154 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/squadrantd.js +3928 -0
  6. package/dist/squadrantd.js.map +1 -0
  7. package/package.json +68 -0
  8. package/plugin/.claude-plugin/plugin.json +5 -0
  9. package/plugin/skills/add-pick-crew-rule/SKILL.md +88 -0
  10. package/plugin/skills/captain-ops/SKILL.md +390 -0
  11. package/plugin/skills/command-ops/SKILL.md +157 -0
  12. package/plugin/skills/config-doctor/SKILL.md +46 -0
  13. package/plugin/skills/daily-log/SKILL.md +44 -0
  14. package/plugin/skills/karpathy-principles/SKILL.md +82 -0
  15. package/plugin/skills/set-effort/SKILL.md +59 -0
  16. package/plugin/skills/side-session/SKILL.md +113 -0
  17. package/plugin/skills/squadrant-effort/SKILL.md +8 -0
  18. package/plugin/skills/squadrant-new-project/SKILL.md +67 -0
  19. package/plugin/skills/squadrant-register-project/SKILL.md +60 -0
  20. package/plugin/skills/where-i-am/SKILL.md +102 -0
  21. package/plugin/skills/wiki-ops/SKILL.md +96 -0
  22. package/plugin/skills/wim/SKILL.md +8 -0
  23. package/scripts/acceptance-interactive-codex.sh +56 -0
  24. package/scripts/capture-skill.sh +32 -0
  25. package/scripts/claude-iv-smoke.mjs +133 -0
  26. package/scripts/fix-skill.sh +39 -0
  27. package/scripts/gen-codex-types.sh +18 -0
  28. package/scripts/mailbox-injector-smoke.mjs +124 -0
  29. package/scripts/mark-learning-useful.sh +22 -0
  30. package/scripts/migrate-to-squadrant.sh +158 -0
  31. package/scripts/notify-relay-placement-smoke.mjs +59 -0
  32. package/scripts/read-handoff.sh +22 -0
  33. package/scripts/record-learning.sh +31 -0
  34. package/scripts/record-side-handoff.sh +32 -0
  35. package/scripts/smoke-push-notify.mjs +147 -0
  36. package/scripts/spawn-crew-pane.sh +17 -0
  37. package/scripts/spawn-workspace.sh +206 -0
  38. package/scripts/wiki-ingest.sh +121 -0
  39. package/scripts/wiki-log.sh +20 -0
  40. package/scripts/wiki-query.sh +36 -0
  41. package/scripts/write-handoff.sh +31 -0
  42. package/templates/captain.claude.md +44 -0
  43. package/templates/captain.generic.md +45 -0
  44. package/templates/command.claude.md +32 -0
  45. package/templates/crew.claude.md +64 -0
  46. package/templates/crew.generic.md +51 -0
  47. package/templates/crew.opencode.md +51 -0
  48. package/templates/learnings.claude.md +40 -0
  49. package/templates/side.debug.claude.md +78 -0
  50. package/templates/side.research.claude.md +63 -0
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # migrate-to-squadrant.sh — one-time live cutover from claude-cockpit → squadrant.
4
+ #
5
+ # Renames the runtime config dir, hub vault, daemon launchd label, and rewrites
6
+ # config.json to the new brand. Idempotent (safe to re-run) and supports
7
+ # --dry-run (prints every action and a concrete config-rewrite preview WITHOUT
8
+ # mutating anything).
9
+ #
10
+ # This is run MANUALLY by the user at cutover — it terminates the live captain
11
+ # session and bounces the daemon. The old daemon keeps running old `dist` until
12
+ # this script runs, so nothing live breaks before then.
13
+ #
14
+ # Usage:
15
+ # scripts/migrate-to-squadrant.sh --dry-run # preview, mutate nothing
16
+ # scripts/migrate-to-squadrant.sh # perform the cutover
17
+ #
18
+ set -euo pipefail
19
+
20
+ DRY_RUN=0
21
+ case "${1:-}" in
22
+ --dry-run) DRY_RUN=1 ;;
23
+ "" ) ;;
24
+ * ) echo "usage: $0 [--dry-run]" >&2; exit 2 ;;
25
+ esac
26
+
27
+ OLD_CONFIG="$HOME/.config/cockpit"
28
+ NEW_CONFIG="$HOME/.config/squadrant"
29
+ OLD_HUB="$HOME/cockpit-hub"
30
+ NEW_HUB="$HOME/squadrant-hub"
31
+ OLD_LABEL="com.cockpit.daemon"
32
+ NEW_LABEL="com.squadrant.daemon"
33
+ OLD_PLIST="$HOME/Library/LaunchAgents/${OLD_LABEL}.plist"
34
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
35
+ UID_NUM="$(id -u)"
36
+ TS="$(date +%Y%m%d-%H%M%S)"
37
+ BACKUP="$HOME/squadrant-migration-backup-${TS}.tgz"
38
+
39
+ step() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
40
+ run() { printf ' $ %s\n' "$*"; [ "$DRY_RUN" -eq 1 ] || eval "$@"; }
41
+ note() { printf ' · %s\n' "$*"; }
42
+
43
+ # ---- config.json rewrite (pure, no claude-cockpit collisions: none of the
44
+ # rules match inside "claude-cockpit", so the real repo path is untouched).
45
+ rewrite_config() { # $1=src json $2=mode (apply <dest> | preview)
46
+ local src="$1" mode="$2" dest="${3:-}"
47
+ SRC="$src" MODE="$mode" DEST="$dest" python3 - <<'PY'
48
+ import json, os, sys
49
+ src, mode, dest = os.environ["SRC"], os.environ["MODE"], os.environ["DEST"]
50
+ with open(src) as f:
51
+ d = json.load(f)
52
+ RULES = [("cockpit-hub", "squadrant-hub"),
53
+ (".config/cockpit", ".config/squadrant"),
54
+ ("⚓ cockpit-captain", "⚓ squadrant-captain")]
55
+ changes = []
56
+ def fix(s):
57
+ out = s
58
+ for a, b in RULES:
59
+ out = out.replace(a, b)
60
+ if out != s:
61
+ changes.append((s, out))
62
+ return out
63
+ def walk(o):
64
+ if isinstance(o, dict):
65
+ return {k: walk(v) for k, v in o.items()}
66
+ if isinstance(o, list):
67
+ return [walk(v) for v in o]
68
+ if isinstance(o, str):
69
+ return fix(o)
70
+ return o
71
+ d = walk(d)
72
+ if "_cockpitVersion" in d:
73
+ d["_squadrantVersion"] = d.pop("_cockpitVersion")
74
+ changes.append(("key:_cockpitVersion", "key:_squadrantVersion"))
75
+ if mode == "preview":
76
+ print(f" rewrites that WOULD apply to {src} ({len(changes)}):")
77
+ for a, b in changes:
78
+ print(f" - {a!r}\n + {b!r}")
79
+ else:
80
+ with open(dest, "w") as f:
81
+ json.dump(d, f, indent=2)
82
+ f.write("\n")
83
+ print(f" rewrote {dest} ({len(changes)} value/key changes)")
84
+ PY
85
+ }
86
+
87
+ printf '\033[1mSquadrant migration%s\033[0m\n' "$([ "$DRY_RUN" -eq 1 ] && echo ' (DRY RUN — nothing will change)')"
88
+ note "repo: $REPO_ROOT"
89
+ note "config: $OLD_CONFIG -> $NEW_CONFIG"
90
+ note "hub: $OLD_HUB -> $NEW_HUB"
91
+ note "daemon: $OLD_LABEL -> $NEW_LABEL"
92
+
93
+ # Already migrated? (new dir present, old gone) — nothing to do.
94
+ if [ -d "$NEW_CONFIG" ] && [ ! -d "$OLD_CONFIG" ]; then
95
+ step "Already migrated — $NEW_CONFIG exists and $OLD_CONFIG is gone. Nothing to do."
96
+ exit 0
97
+ fi
98
+
99
+ step "1. Backup ~/.config/cockpit and ~/cockpit-hub"
100
+ # Archive with paths RELATIVE to $HOME so rollback is `tar xzf <backup> -C $HOME`.
101
+ BACKUP_RELS=()
102
+ [ -d "$OLD_CONFIG" ] && BACKUP_RELS+=(".config/cockpit")
103
+ [ -d "$OLD_HUB" ] && BACKUP_RELS+=("cockpit-hub")
104
+ if [ "${#BACKUP_RELS[@]}" -gt 0 ]; then
105
+ run "tar czf '$BACKUP' -C '$HOME' ${BACKUP_RELS[*]}"
106
+ note "backup -> $BACKUP (rollback: tar xzf '$BACKUP' -C '$HOME' + reload old plist)"
107
+ else
108
+ note "no source dirs to back up (fresh machine?)"
109
+ fi
110
+
111
+ step "2. Stop captains + bootout the old daemon"
112
+ note "Captains are cmux workspaces — close them in cmux, or let the relaunch below recreate them."
113
+ run "launchctl bootout gui/${UID_NUM}/${OLD_LABEL} 2>/dev/null || true"
114
+
115
+ step "3. Move config dir and hub vault"
116
+ if [ -d "$OLD_CONFIG" ] && [ ! -d "$NEW_CONFIG" ]; then run "mv '$OLD_CONFIG' '$NEW_CONFIG'"; else note "config move skipped (old absent or new exists)"; fi
117
+ if [ -d "$OLD_HUB" ] && [ ! -d "$NEW_HUB" ]; then run "mv '$OLD_HUB' '$NEW_HUB'"; else note "hub move skipped (old absent or new exists)"; fi
118
+
119
+ step "4. Rewrite config.json (cockpit-hub→squadrant-hub, .config/cockpit→.config/squadrant, ⚓ cockpit-captain→⚓ squadrant-captain, _cockpitVersion key)"
120
+ if [ "$DRY_RUN" -eq 1 ]; then
121
+ CFG_SRC="$OLD_CONFIG/config.json"; [ -f "$CFG_SRC" ] || CFG_SRC="$NEW_CONFIG/config.json"
122
+ if [ -f "$CFG_SRC" ]; then rewrite_config "$CFG_SRC" preview; else note "no config.json found to preview"; fi
123
+ else
124
+ CFG="$NEW_CONFIG/config.json"
125
+ if [ -f "$CFG" ]; then
126
+ cp "$CFG" "${CFG}.pre-squadrant.bak"
127
+ rewrite_config "$CFG" apply "$CFG"
128
+ python3 -c "import json;json.load(open('$CFG'))" && note "config.json valid JSON"
129
+ else
130
+ note "no config.json found at $CFG — skipping rewrite"
131
+ fi
132
+ fi
133
+
134
+ step "5. Remove old launchd plist (the rebuilt daemon installs com.squadrant.daemon.plist on first run)"
135
+ [ -f "$OLD_PLIST" ] && run "rm -f '$OLD_PLIST'" || note "old plist absent"
136
+
137
+ step "6. Build the rebranded binary + relink the global 'squadrant'/'squad' bin"
138
+ run "pnpm -C '$REPO_ROOT' build"
139
+ run "pnpm -C '$REPO_ROOT' link --global"
140
+ note "removes the old global 'cockpit' bin; 'squadrant' and 'squad' now resolve to $REPO_ROOT/dist/index.js"
141
+
142
+ step "7. Bootstrap the new daemon"
143
+ note "Any 'squadrant' invocation triggers ensureDaemon, which writes com.squadrant.daemon.plist and bootstraps it."
144
+ run "node '$REPO_ROOT/dist/index.js' --version"
145
+
146
+ step "Done"
147
+ cat <<EOF
148
+ Checklist:
149
+ 1. Verify daemon: launchctl print gui/${UID_NUM}/${NEW_LABEL} | head
150
+ 2. Verify CLI: squadrant --version (expect 0.9.0) and squad --help
151
+ 3. Relaunch captains: squadrant launch <project> (recreates the ⚓ squadrant-captain workspaces)
152
+ 4. Tail the log: tail -f ${NEW_CONFIG}/squadrantd.log
153
+ Rollback (if needed):
154
+ launchctl bootout gui/${UID_NUM}/${NEW_LABEL} 2>/dev/null || true
155
+ rm -rf ${NEW_CONFIG} ${NEW_HUB}
156
+ tar xzf ${BACKUP} -C ${HOME}
157
+ # then reinstall the old plist + relink the old 'cockpit' bin
158
+ EOF
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // Live E2E for #117: spawnInjector("hidden") must NOT create a split-pane and
3
+ // must NOT steal focus. Drives the real built cmux driver against a throwaway
4
+ // cmux workspace, then inspects `cmux tree` to assert:
5
+ // 1. the workspace has exactly ONE pane (a split would create a second)
6
+ // 2. the relay surface exists as a background tab in that pane
7
+ // 3. the surface selected before spawn is still [selected] afterward
8
+ import { execFileSync } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+ import { join } from "node:path";
11
+
12
+ const CMUX = "/Applications/cmux.app/Contents/Resources/bin/cmux";
13
+ const cmux = (args) => execFileSync(CMUX, args, { encoding: "utf-8" }).trim();
14
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
15
+ const { createCmuxDriver } = await import(
16
+ "file://" + join(__dirname, "..", "dist", "runtimes", "cmux.js")
17
+ );
18
+
19
+ let failures = 0;
20
+ const assert = (cond, label) => {
21
+ console.log(` ${cond ? "✓" : "✗"} ${label}`);
22
+ if (!cond) failures++;
23
+ };
24
+
25
+ const out = cmux(["new-workspace", "--cwd", "/tmp", "--command", "bash"]);
26
+ const ws = out.match(/workspace:\d+/)?.[0];
27
+ cmux(["rename-workspace", "--workspace", ws, "zz-117-smoke"]);
28
+ const driver = createCmuxDriver();
29
+
30
+ try {
31
+ const treeBefore = cmux(["tree", "--workspace", ws]);
32
+ const capSurface = treeBefore.match(/(surface:\d+)\s+\[terminal\][^\n]*\[selected\]/)?.[1];
33
+ console.log(`workspace=${ws} captain surface=${capSurface}`);
34
+
35
+ const pane = await driver.spawnInjector({
36
+ captainWorkspace: { id: ws, name: "zz-117-smoke", status: "running" },
37
+ command: "echo notify-relay-stub; sleep 30",
38
+ title: "✉ notify-relay",
39
+ placement: "hidden",
40
+ });
41
+ console.log(`spawnInjector returned ${pane.surfaceId}`);
42
+
43
+ const tree = cmux(["tree", "--workspace", ws]);
44
+ console.log("--- tree after spawnInjector(hidden) ---\n" + tree);
45
+
46
+ const paneCount = (tree.match(/^\s*[├└]?[─ ]*pane\s+pane:\d+/gm) || []).length;
47
+ assert(paneCount === 1, `exactly ONE pane — no split (got ${paneCount})`);
48
+
49
+ const surfaces = (tree.match(/surface:\d+/g) || []);
50
+ assert(surfaces.includes(pane.surfaceId), `relay surface ${pane.surfaceId} present as a tab`);
51
+
52
+ const selected = tree.match(/(surface:\d+)\s+\[terminal\][^\n]*\[selected\]/)?.[1];
53
+ assert(selected === capSurface, `captain surface ${capSurface} still [selected] (relay did NOT steal focus; got ${selected})`);
54
+
55
+ console.log(`\n${failures === 0 ? "✔ PLACEMENT SMOKE PASSED" : `✗ ${failures} FAILURES`}`);
56
+ } finally {
57
+ cmux(["close-workspace", "--workspace", ws]);
58
+ process.exit(failures > 0 ? 1 : 0);
59
+ }
@@ -0,0 +1,22 @@
1
+ #!/bin/bash
2
+ # Usage: read-handoff.sh <spoke-vault-path> [--keep]
3
+ # Reads and prints handoff.json, then deletes it (unless --keep).
4
+ # Captain calls this on session startup to load previous context.
5
+ set -euo pipefail
6
+
7
+ VAULT="${1:?Usage: read-handoff.sh <vault-path> [--keep]}"
8
+ KEEP="${2:-}"
9
+ HANDOFF_FILE="$VAULT/handoff.json"
10
+
11
+ if [ ! -f "$HANDOFF_FILE" ]; then
12
+ echo '{"exists": false}'
13
+ exit 0
14
+ fi
15
+
16
+ # Print the handoff content
17
+ cat "$HANDOFF_FILE"
18
+
19
+ # Delete unless --keep flag
20
+ if [ "$KEEP" != "--keep" ]; then
21
+ rm "$HANDOFF_FILE"
22
+ fi
@@ -0,0 +1,31 @@
1
+ #!/bin/bash
2
+ # Usage: record-learning.sh <spoke-vault-path> <category> <content> [tags]
3
+ # Tags: comma-separated keywords for selective loading (e.g., "cairo,escrow,pvp")
4
+ set -euo pipefail
5
+ VAULT="${1:?Usage: record-learning.sh <vault-path> <category> <content> [tags]}"
6
+ CATEGORY="${2:?}"
7
+ CONTENT="${3:?}"
8
+ TAGS="${4:-}"
9
+ DATE=$(date +"%Y-%m-%d")
10
+ TIMESTAMP=$(date +"%H%M%S")
11
+ SLUG=$(echo "$CONTENT" | head -c 40 | tr ' ' '-' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g')
12
+ FILENAME="${VAULT}/learnings/${DATE}-${SLUG}-${TIMESTAMP}.md"
13
+ mkdir -p "${VAULT}/learnings"
14
+ cat > "$FILENAME" << EOF
15
+ ---
16
+ type: learning
17
+ date: ${DATE}
18
+ category: ${CATEGORY}
19
+ applied: false
20
+ times_loaded: 0
21
+ times_useful: 0
22
+ tags: [${TAGS}]
23
+ ---
24
+
25
+ ## What happened
26
+ ${CONTENT}
27
+
28
+ ## Suggestion
29
+
30
+ EOF
31
+ echo "Recorded learning: $FILENAME"
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ # Usage: record-side-handoff.sh <spoke-vault-path> <topic> <role> <summary>
3
+ # Writes a durable handoff record to {spokeVault}/side-handoffs/<topic-slug>.md
4
+ set -euo pipefail
5
+
6
+ VAULT="${1:?Usage: record-side-handoff.sh <vault-path> <topic> <role> <summary>}"
7
+ TOPIC="${2:?}"
8
+ ROLE="${3:?}"
9
+ SUMMARY="${4:?}"
10
+ DATE=$(date +"%Y-%m-%d")
11
+ SLUG=$(echo "$TOPIC" | head -c 60 | tr ' ' '-' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]//g')
12
+ FILENAME="${VAULT}/side-handoffs/${SLUG}.md"
13
+
14
+ mkdir -p "${VAULT}/side-handoffs"
15
+
16
+ cat > "$FILENAME" << EOF
17
+ ---
18
+ type: side-handoff
19
+ role: ${ROLE}
20
+ date: ${DATE}
21
+ topic: ${TOPIC}
22
+ ---
23
+
24
+ ## Summary
25
+ ${SUMMARY}
26
+
27
+ ## Full handoff
28
+
29
+ (Appended by side-session — see squadrant relay for the captain's copy.)
30
+ EOF
31
+
32
+ echo "Recorded side handoff: $FILENAME"
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // scripts/smoke-push-notify.mjs
3
+ //
4
+ // Live(ish) smoke for #109: spins up a real squadrantd from the worktree
5
+ // dist on a temp socket, injects a capturing `notify`, seeds a task, then
6
+ // drives the same control events that `squadrant crew signal done|blocked|
7
+ // failed` would emit. Asserts that each terminal/attention transition
8
+ // produced exactly one captain-bound notification with the spec'd
9
+ // CREW … [<provider>/<taskId-8>]: … format. Also probes redundancy
10
+ // (a second done is a no-op) and notifier-down resilience (a throwing
11
+ // notify must not crash the daemon).
12
+ //
13
+ // We use `notify` capture rather than shelling out to the production
14
+ // `squadrant runtime send` because (a) the unit suite already covers the
15
+ // shell-out path, and (b) restarting the launchd-managed daemon to point
16
+ // at the worktree build would interrupt unrelated in-flight sessions
17
+ // (squadrant memory: "never auto-restart running captain/crew sessions").
18
+ // This smoke validates startSquadrantd → createDaemon → notify wiring
19
+ // end-to-end through the real socket; the shell-out is one execFileSync
20
+ // call away in the default `notify` and is covered by the cmux notifier
21
+ // tests.
22
+
23
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { fileURLToPath, pathToFileURL } from "node:url";
27
+
28
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
29
+ const distEntry = pathToFileURL(join(__dirname, "..", "dist", "control", "squadrantd.js")).href;
30
+ const distProto = pathToFileURL(join(__dirname, "..", "dist", "control", "protocol.js")).href;
31
+ const { startSquadrantd } = await import(distEntry);
32
+ const { sendRequest } = await import(distProto);
33
+
34
+ const evidencePath = join(__dirname, "..", ".phase-3-5-smoke-evidence.local");
35
+ const log = [];
36
+ const record = (m) => { log.push(m); process.stdout.write(m + "\n"); };
37
+
38
+ const dir = mkdtempSync(join(tmpdir(), "cp-phase35-"));
39
+ const sock = join(dir, "c.sock");
40
+ const captured = []; // {project, message}
41
+
42
+ const handle = startSquadrantd({
43
+ stateRoot: join(dir, "state"),
44
+ sockPath: sock,
45
+ sweepMs: 0,
46
+ notify: (args) => { captured.push({ project: args.project, message: args.message }); },
47
+ });
48
+
49
+ const baseRec = (id, overrides = {}) => ({
50
+ id, project: "p", provider: "claude", mode: "interactive",
51
+ state: "submitted", task: "ship the phase 3.5 push notifications",
52
+ createdAt: 1, lastHeartbeat: 1, lastEvent: "",
53
+ heartbeatBudgetMs: 1000,
54
+ attempts: [{ attemptId: "a0", startedAt: 1, lastHeartbeatAt: 1 }],
55
+ ...overrides,
56
+ });
57
+
58
+ let failures = 0;
59
+ const assert = (cond, label) => {
60
+ if (cond) { record(` ✓ ${label}`); }
61
+ else { record(` ✗ ${label}`); failures++; }
62
+ };
63
+
64
+ try {
65
+ record(`# Phase 3.5 smoke (#109) — ${new Date().toISOString()}`);
66
+ record(`socket: ${sock}`);
67
+
68
+ // ── done ──────────────────────────────────────────────────────────────
69
+ record("\n[1] task.done → CREW DONE");
70
+ await sendRequest(sock, { kind: "seed", record: baseRec("done-abc12345", { state: "working" }) });
71
+ await sendRequest(sock, { kind: "event", project: "p",
72
+ event: { type: "task.done", id: "done-abc12345", resultRef: "/tmp/r1" } });
73
+ const done = captured.filter((c) => c.message.startsWith("CREW DONE"));
74
+ assert(done.length === 1, `exactly one CREW DONE captured (got ${done.length})`);
75
+ assert(done[0]?.project === "p", `project=p (got ${done[0]?.project})`);
76
+ assert(/^CREW DONE \[claude\/done-abc/.test(done[0]?.message ?? ""),
77
+ `message tag matches spec: ${done[0]?.message}`);
78
+
79
+ // Re-apply done → state machine absorbs, no second notify.
80
+ await sendRequest(sock, { kind: "event", project: "p",
81
+ event: { type: "task.done", id: "done-abc12345", resultRef: "/tmp/r1" } });
82
+ assert(captured.filter((c) => c.message.startsWith("CREW DONE")).length === 1,
83
+ "redundant task.done does NOT re-notify");
84
+
85
+ // ── blocked ───────────────────────────────────────────────────────────
86
+ record("\n[2] task.blocked → CREW BLOCKED");
87
+ await sendRequest(sock, { kind: "seed", record: baseRec("blocked-1xyz", { state: "working" }) });
88
+ await sendRequest(sock, { kind: "event", project: "p",
89
+ event: { type: "task.blocked", id: "blocked-1xyz", reason: "need-input",
90
+ question: "which database backend should I target?" } });
91
+ const blk = captured.filter((c) => c.message.startsWith("CREW BLOCKED"));
92
+ assert(blk.length === 1, `exactly one CREW BLOCKED captured (got ${blk.length})`);
93
+ assert((blk[0]?.message ?? "").includes("which database backend"),
94
+ `question surfaced: ${blk[0]?.message}`);
95
+
96
+ // ── failed ────────────────────────────────────────────────────────────
97
+ record("\n[3] task.failed → CREW FAILED");
98
+ await sendRequest(sock, { kind: "seed", record: baseRec("failed-9q", { state: "working" }) });
99
+ await sendRequest(sock, { kind: "event", project: "p",
100
+ event: { type: "task.failed", id: "failed-9q", error: "child exited with code 137 (OOM)" } });
101
+ const fld = captured.filter((c) => c.message.startsWith("CREW FAILED"));
102
+ assert(fld.length === 1, `exactly one CREW FAILED captured (got ${fld.length})`);
103
+ assert((fld[0]?.message ?? "").includes("OOM"), `error surfaced: ${fld[0]?.message}`);
104
+
105
+ // ── liveness → no notify ──────────────────────────────────────────────
106
+ record("\n[4] task.progress / heartbeat → no notify");
107
+ await sendRequest(sock, { kind: "seed", record: baseRec("live-7t", { state: "working" }) });
108
+ await sendRequest(sock, { kind: "event", project: "p",
109
+ event: { type: "task.progress", id: "live-7t" } });
110
+ await sendRequest(sock, { kind: "event", project: "p",
111
+ event: { type: "heartbeat", id: "live-7t" } });
112
+ const liveCount = captured.length;
113
+ assert(liveCount === 3, `liveness produced 0 new notifications (total still ${liveCount})`);
114
+
115
+ // ── notifier-down resilience ──────────────────────────────────────────
116
+ // Stop this daemon, restart with a notify that throws — daemon must
117
+ // still apply events to the store.
118
+ record("\n[5] notifier throwing → daemon survives, store still updated");
119
+ handle.stop();
120
+ const handle2 = startSquadrantd({
121
+ stateRoot: join(dir, "state"),
122
+ sockPath: sock,
123
+ sweepMs: 0,
124
+ notify: () => { throw new Error("cmux pane crashed"); },
125
+ });
126
+ try {
127
+ await sendRequest(sock, { kind: "seed", record: baseRec("bang-pq", { state: "working" }) });
128
+ const r = await sendRequest(sock, { kind: "event", project: "p",
129
+ event: { type: "task.done", id: "bang-pq", resultRef: "/tmp/r-bang" } });
130
+ assert(r?.state === "done", `event still applied (state=${r?.state})`);
131
+ const after = await sendRequest(sock, { kind: "status", project: "p", id: "bang-pq" });
132
+ assert(after?.state === "done", `store reflects new state after throwing notify`);
133
+ } finally {
134
+ handle2.stop();
135
+ }
136
+
137
+ record(`\nresult: ${failures === 0 ? "PASS" : "FAIL"} (failures=${failures})`);
138
+ } catch (err) {
139
+ record(`\nUNCAUGHT: ${err?.stack || err}`);
140
+ failures++;
141
+ } finally {
142
+ try { handle.stop?.(); } catch { /* ignore */ }
143
+ writeFileSync(evidencePath, log.join("\n") + "\n");
144
+ record(`\nevidence written: ${evidencePath}`);
145
+ rmSync(dir, { recursive: true, force: true });
146
+ process.exit(failures === 0 ? 0 : 1);
147
+ }
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+ # Deprecated direct script — use `squadrant crew spawn <project> <task>` instead.
3
+ # This shim forwards to the CLI for backward compat with existing call-sites.
4
+ set -euo pipefail
5
+
6
+ if [ "$#" -lt 2 ]; then
7
+ echo "Usage: spawn-crew-pane.sh <project> <task> [direction] [agent]" >&2
8
+ echo "Note: prefer 'squadrant crew spawn <project> \"<task>\"' directly." >&2
9
+ exit 64
10
+ fi
11
+
12
+ PROJECT="$1"
13
+ TASK="$2"
14
+ DIRECTION="${3:-tab}"
15
+ AGENT="${4:-claude}"
16
+
17
+ exec squadrant crew spawn "$PROJECT" "$TASK" --direction "$DIRECTION" --agent "$AGENT"