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.
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/dist/index.js +9154 -0
- package/dist/index.js.map +1 -0
- package/dist/squadrantd.js +3928 -0
- package/dist/squadrantd.js.map +1 -0
- package/package.json +68 -0
- package/plugin/.claude-plugin/plugin.json +5 -0
- package/plugin/skills/add-pick-crew-rule/SKILL.md +88 -0
- package/plugin/skills/captain-ops/SKILL.md +390 -0
- package/plugin/skills/command-ops/SKILL.md +157 -0
- package/plugin/skills/config-doctor/SKILL.md +46 -0
- package/plugin/skills/daily-log/SKILL.md +44 -0
- package/plugin/skills/karpathy-principles/SKILL.md +82 -0
- package/plugin/skills/set-effort/SKILL.md +59 -0
- package/plugin/skills/side-session/SKILL.md +113 -0
- package/plugin/skills/squadrant-effort/SKILL.md +8 -0
- package/plugin/skills/squadrant-new-project/SKILL.md +67 -0
- package/plugin/skills/squadrant-register-project/SKILL.md +60 -0
- package/plugin/skills/where-i-am/SKILL.md +102 -0
- package/plugin/skills/wiki-ops/SKILL.md +96 -0
- package/plugin/skills/wim/SKILL.md +8 -0
- package/scripts/acceptance-interactive-codex.sh +56 -0
- package/scripts/capture-skill.sh +32 -0
- package/scripts/claude-iv-smoke.mjs +133 -0
- package/scripts/fix-skill.sh +39 -0
- package/scripts/gen-codex-types.sh +18 -0
- package/scripts/mailbox-injector-smoke.mjs +124 -0
- package/scripts/mark-learning-useful.sh +22 -0
- package/scripts/migrate-to-squadrant.sh +158 -0
- package/scripts/notify-relay-placement-smoke.mjs +59 -0
- package/scripts/read-handoff.sh +22 -0
- package/scripts/record-learning.sh +31 -0
- package/scripts/record-side-handoff.sh +32 -0
- package/scripts/smoke-push-notify.mjs +147 -0
- package/scripts/spawn-crew-pane.sh +17 -0
- package/scripts/spawn-workspace.sh +206 -0
- package/scripts/wiki-ingest.sh +121 -0
- package/scripts/wiki-log.sh +20 -0
- package/scripts/wiki-query.sh +36 -0
- package/scripts/write-handoff.sh +31 -0
- package/templates/captain.claude.md +44 -0
- package/templates/captain.generic.md +45 -0
- package/templates/command.claude.md +32 -0
- package/templates/crew.claude.md +64 -0
- package/templates/crew.generic.md +51 -0
- package/templates/crew.opencode.md +51 -0
- package/templates/learnings.claude.md +40 -0
- package/templates/side.debug.claude.md +78 -0
- 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"
|