diff --git a/.github/workflows/runtime-live-e2e.yml b/.github/workflows/runtime-live-e2e.yml index d3318594..eaf8d8ce 100644 --- a/.github/workflows/runtime-live-e2e.yml +++ b/.github/workflows/runtime-live-e2e.yml @@ -367,7 +367,10 @@ jobs: node -e "const p=require('$pi_npm_root/node_modules/pi-intercom/package.json'); if (p.name !== 'pi-intercom') throw new Error('unexpected pi-intercom package name '+p.name); console.log('verified '+p.name+'@'+p.version)" test -f "$pi_npm_root/node_modules/pi-subagents/src/extension/index.ts" test -f "$pi_npm_root/node_modules/pi-subagents/skills/pi-subagents/SKILL.md" + test -f "$pi_npm_root/node_modules/pi-subagents/src/intercom/intercom-bridge.ts" + test -f "$pi_npm_root/node_modules/pi-intercom/skills/pi-intercom/SKILL.md" echo "PI_SUBAGENTS_PACKAGE_ROOT=$pi_npm_root/node_modules/pi-subagents" >> "$GITHUB_ENV" + echo "PI_INTERCOM_PACKAGE_ROOT=$pi_npm_root/node_modules/pi-intercom" >> "$GITHUB_ENV" - name: Build spacedock binary run: | @@ -390,6 +393,8 @@ jobs: test -f "$GITHUB_WORKSPACE/skills/ensign/references/pi-ensign-runtime.md" test -f "$PI_SUBAGENTS_PACKAGE_ROOT/src/extension/index.ts" test -f "$PI_SUBAGENTS_PACKAGE_ROOT/skills/pi-subagents/SKILL.md" + test -f "$PI_SUBAGENTS_PACKAGE_ROOT/src/intercom/intercom-bridge.ts" + test -f "$PI_INTERCOM_PACKAGE_ROOT/skills/pi-intercom/SKILL.md" - name: Show tool versions run: | diff --git a/docs/runtime-support.md b/docs/runtime-support.md index 643052d8..539d01f5 100644 --- a/docs/runtime-support.md +++ b/docs/runtime-support.md @@ -185,4 +185,4 @@ For Pi, `spacedock pi` launches the proven front door by loading local resources ~/.pi/agent/npm/node_modules/pi-subagents/src/extension/index.ts ``` -`spacedock install --host pi` is an idempotent readiness check and setup guide for that substrate; it does not install a Claude/Codex-style marketplace plugin and does not accept `--plugin-dir`. Resolve the local skill checkout by running it from the checkout or setting `SPACEDOCK_REPO_ROOT`. `spacedock doctor --host pi` reports the Pi CLI, auth file, `pi-subagents` extension/skill, and local Spacedock skill health; it still accepts `--plugin-dir ` for local skill checkout diagnostics. Live tests should not mutate global `~/.pi/agent`; they should keep using isolated Pi homes with copied auth. +`spacedock install --host pi` is an idempotent readiness check and setup guide for that substrate; it does not install a Claude/Codex-style marketplace plugin and does not accept `--plugin-dir`. Resolve the local skill checkout by running it from the checkout or setting `SPACEDOCK_REPO_ROOT`. `spacedock doctor --host pi` reports the Pi CLI, auth file, `pi-subagents` extension/skill, local Spacedock skill health, and supervisor-talkback setup prerequisites: the `pi-subagents` intercom bridge source, the resolved `PI_INTERCOM_PACKAGE_ROOT` package root, and the `pi-intercom` skill resource. Current `pi-subagents`/`pi-intercom` packages do not expose stable `pi-intercom` or `subagents-doctor` PATH commands, so readiness is based on package/resource paths instead of command shims. These doctor/install checks are necessary setup checks but insufficient to prove live supervisor talkback. Live proof still requires the cq-style `pi-intercom-supervisor-talkback` probe: progress update -> decision request -> supervisor reply -> child resume -> durable marker evidence. Live tests should not mutate global `~/.pi/agent`; they should keep using isolated Pi homes with copied auth. diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 5b59d8a7..fd564507 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -27,28 +27,39 @@ func (execPiRuntimeOps) Stat(path string) error { _, err := os.Sta func (execPiRuntimeOps) Launch(argv []string) error { return execHost{}.Launch(argv, os.Environ()) } type piRuntimeConfig struct { - repoRoot string - packageRoot string - extensionPath string - subagentsSkill string - firstOfficer string - ensign string - authPath string - openAIAPIKey string - pluginDirSource string + repoRoot string + packageRoot string + intercomPackageRoot string + extensionPath string + subagentsSkill string + firstOfficer string + ensign string + authPath string + openAIAPIKey string + sessionDir string + pluginDirSource string + packageRootSource string + intercomPackageSource string + authPathSource string + sessionDirSource string } type piCheckResult struct { - piBinOK bool - piBin string - authOK bool - extensionOK bool - subagentsSkillOK bool - firstOfficerOK bool - ensignOK bool - packageRoot string - repoRoot string - authPath string + piBinOK bool + piBin string + authOK bool + extensionOK bool + subagentsSkillOK bool + subagentsIntercomBridgeOK bool + intercomPackageOK bool + intercomSkillOK bool + firstOfficerOK bool + ensignOK bool + packageRoot string + intercomPackageRoot string + repoRoot string + authPath string + sessionDir string } func runPi(ctx context.Context, args []string, dir string, env []string, ops piRuntimeOps, stdout, stderr io.Writer) int { @@ -92,21 +103,23 @@ func runInitWithPi(ctx context.Context, args []string, hostOps hostOps, piOps pi } cfg := piRuntimeConfigFromEnv(env, cwd(), pluginDir) check := checkPiRuntime(piOps, cfg) - if piRuntimeLaunchReady(check) { - fmt.Fprintf(stdout, "Pi runtime ready.\n pi-subagents: %s\n Spacedock skills: %s\n", check.packageRoot, check.repoRoot) - return 0 - } if checkOnly { printPiDoctorReport(stdout, check) return piDoctorExit(check) } + if piRuntimeLaunchReady(check) { + fmt.Fprintf(stdout, "Pi runtime ready.\n pi-subagents: %s\n pi-intercom: %s\n Spacedock skills: %s\n", check.packageRoot, check.intercomPackageRoot, check.repoRoot) + printPiSupervisorTalkbackBoundary(stdout) + return 0 + } fmt.Fprintf(stdout, "Pi runtime setup incomplete.\n\n"+ "Required next steps:\n"+ " 1. Install Pi and authenticate so %s exists.\n"+ " 2. Install the subagent substrate, for example: pi install npm:pi-subagents\n"+ - " 3. If pi-subagents is installed outside the default location, set PI_SUBAGENTS_PACKAGE_ROOT.\n"+ - " 4. Re-run: spacedock doctor --host pi\n\n", check.authPath) + " 3. Install the supervisor-talkback substrate, for example: pi install npm:pi-intercom or npm install pi-intercom into the Pi npm root.\n"+ + " 4. If pi-subagents or pi-intercom are installed outside the default locations, set PI_SUBAGENTS_PACKAGE_ROOT and PI_INTERCOM_PACKAGE_ROOT.\n"+ + " 5. Re-run: spacedock doctor --host pi\n\n", check.authPath) printPiDoctorReport(stdout, check) return 0 } @@ -196,6 +209,10 @@ func parsePiSetupArgs(command string, args []string, stderr io.Writer) (host str func piRuntimeConfigFromEnv(env []string, dir, pluginDir string) piRuntimeConfig { envMap := envMap(env) + home := envMap["HOME"] + if home == "" { + home = os.Getenv("HOME") + } repo := pluginDir pluginDirSource := "--plugin-dir" if repo == "" { @@ -207,50 +224,75 @@ func piRuntimeConfigFromEnv(env []string, dir, pluginDir string) piRuntimeConfig pluginDirSource = "working directory" } pkg := envMap["PI_SUBAGENTS_PACKAGE_ROOT"] + pkgSource := "PI_SUBAGENTS_PACKAGE_ROOT" if pkg == "" { - home := envMap["HOME"] - if home == "" { - home = os.Getenv("HOME") - } pkg = filepath.Join(home, ".pi", "agent", "npm", "node_modules", "pi-subagents") + pkgSource = "default ~/.pi/agent/npm/node_modules/pi-subagents" + } + intercomPkg := envMap["PI_INTERCOM_PACKAGE_ROOT"] + intercomPkgSource := "PI_INTERCOM_PACKAGE_ROOT" + if intercomPkg == "" { + intercomPkg = filepath.Join(home, ".pi", "agent", "npm", "node_modules", "pi-intercom") + intercomPkgSource = "default ~/.pi/agent/npm/node_modules/pi-intercom" } authRoot := envMap["PI_CODING_AGENT_DIR"] authPath := "" + authPathSource := "PI_CODING_AGENT_DIR" if authRoot != "" { authPath = filepath.Join(authRoot, "auth.json") } else { - home := envMap["HOME"] - if home == "" { - home = os.Getenv("HOME") - } authPath = filepath.Join(home, ".pi", "agent", "auth.json") + authPathSource = "default ~/.pi/agent/auth.json" + } + sessionDir := envMap["PI_CODING_AGENT_SESSION_DIR"] + sessionDirSource := "PI_CODING_AGENT_SESSION_DIR" + if sessionDir == "" { + sessionDir = filepath.Join(home, ".pi", "agent", "sessions") + sessionDirSource = "default ~/.pi/agent/sessions" } return piRuntimeConfig{ - repoRoot: repo, - packageRoot: pkg, - extensionPath: filepath.Join(pkg, "src", "extension", "index.ts"), - subagentsSkill: filepath.Join(pkg, "skills", "pi-subagents"), - firstOfficer: filepath.Join(repo, "skills", "first-officer", "SKILL.md"), - ensign: filepath.Join(repo, "skills", "ensign", "SKILL.md"), - authPath: authPath, - openAIAPIKey: envMap["OPENAI_API_KEY"], - pluginDirSource: pluginDirSource, + repoRoot: repo, + packageRoot: pkg, + intercomPackageRoot: intercomPkg, + extensionPath: filepath.Join(pkg, "src", "extension", "index.ts"), + subagentsSkill: filepath.Join(pkg, "skills", "pi-subagents"), + firstOfficer: filepath.Join(repo, "skills", "first-officer", "SKILL.md"), + ensign: filepath.Join(repo, "skills", "ensign", "SKILL.md"), + authPath: authPath, + openAIAPIKey: envMap["OPENAI_API_KEY"], + sessionDir: sessionDir, + pluginDirSource: pluginDirSource, + packageRootSource: pkgSource, + intercomPackageSource: intercomPkgSource, + authPathSource: authPathSource, + sessionDirSource: sessionDirSource, } } func checkPiRuntime(ops piRuntimeOps, cfg piRuntimeConfig) piCheckResult { bin, err := ops.LookPath("pi") - res := piCheckResult{piBinOK: err == nil, piBin: bin, packageRoot: cfg.packageRoot, repoRoot: cfg.repoRoot, authPath: cfg.authPath} + res := piCheckResult{ + piBinOK: err == nil, + piBin: bin, + packageRoot: cfg.packageRoot, + intercomPackageRoot: cfg.intercomPackageRoot, + repoRoot: cfg.repoRoot, + authPath: cfg.authPath, + sessionDir: cfg.sessionDir, + } res.authOK = ops.Stat(cfg.authPath) == nil || strings.TrimSpace(cfg.openAIAPIKey) != "" res.extensionOK = ops.Stat(cfg.extensionPath) == nil res.subagentsSkillOK = ops.Stat(filepath.Join(cfg.subagentsSkill, "SKILL.md")) == nil + res.subagentsIntercomBridgeOK = ops.Stat(filepath.Join(cfg.packageRoot, "src", "intercom", "intercom-bridge.ts")) == nil + res.intercomPackageOK = ops.Stat(cfg.intercomPackageRoot) == nil + res.intercomSkillOK = ops.Stat(filepath.Join(cfg.intercomPackageRoot, "skills", "pi-intercom", "SKILL.md")) == nil res.firstOfficerOK = ops.Stat(cfg.firstOfficer) == nil res.ensignOK = ops.Stat(cfg.ensign) == nil return res } func piRuntimeLaunchReady(c piCheckResult) bool { - return c.piBinOK && c.extensionOK && c.subagentsSkillOK && c.firstOfficerOK && c.ensignOK + return c.piBinOK && c.extensionOK && c.subagentsSkillOK && c.subagentsIntercomBridgeOK && c.intercomPackageOK && c.intercomSkillOK && c.firstOfficerOK && c.ensignOK } func piDoctorHealthy(c piCheckResult) bool { @@ -270,8 +312,19 @@ func printPiDoctorReport(w io.Writer, c piCheckResult) { printPiCheck(w, c.authOK, "Pi auth", c.authPath, "run `pi` login/auth flow; live tests copy this file into an isolated PI_CODING_AGENT_DIR") printPiCheck(w, c.extensionOK, "pi-subagents extension", filepath.Join(c.packageRoot, "src", "extension", "index.ts"), "run `pi install npm:pi-subagents` or set PI_SUBAGENTS_PACKAGE_ROOT") printPiCheck(w, c.subagentsSkillOK, "pi-subagents skill", filepath.Join(c.packageRoot, "skills", "pi-subagents"), "run `pi install npm:pi-subagents` or set PI_SUBAGENTS_PACKAGE_ROOT") + fmt.Fprintf(w, "INFO Pi auth/session dirs: auth=%s session=%s\n", c.authPath, c.sessionDir) + fmt.Fprintln(w, "Supervisor-talkback setup prerequisites") + printPiCheck(w, c.subagentsIntercomBridgeOK, "pi-subagents intercom bridge", filepath.Join(c.packageRoot, "src", "intercom", "intercom-bridge.ts"), "install/update pi-subagents or set PI_SUBAGENTS_PACKAGE_ROOT to a package root containing the intercom bridge") + printPiCheck(w, c.intercomPackageOK, "pi-intercom package root", c.intercomPackageRoot, "set PI_INTERCOM_PACKAGE_ROOT to the installed pi-intercom package root") + printPiCheck(w, c.intercomSkillOK, "pi-intercom skill", filepath.Join(c.intercomPackageRoot, "skills", "pi-intercom"), "install pi-intercom or set PI_INTERCOM_PACKAGE_ROOT to a package root containing skills/pi-intercom/SKILL.md") printPiCheck(w, c.firstOfficerOK, "Spacedock first-officer skill", filepath.Join(c.repoRoot, "skills", "first-officer"), "pass --plugin-dir or set SPACEDOCK_REPO_ROOT") printPiCheck(w, c.ensignOK, "Spacedock ensign skill", filepath.Join(c.repoRoot, "skills", "ensign"), "pass --plugin-dir or set SPACEDOCK_REPO_ROOT") + printPiSupervisorTalkbackBoundary(w) +} + +func printPiSupervisorTalkbackBoundary(w io.Writer) { + fmt.Fprintln(w, "NOTE: These checks verify necessary supervisor-talkback setup prerequisites only; they are insufficient to prove live child talkback.") + fmt.Fprintln(w, "NOTE: Live proof still requires the cq-style progress -> decision -> supervisor reply -> child resume -> durable marker probe for pi-intercom-supervisor-talkback.") } func printPiCheck(w io.Writer, ok bool, label, path, remedy string) { diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index aa8fb756..b91dc189 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -61,7 +61,7 @@ func TestPiFrontDoorLaunchesWithNativeResourcePaths(t *testing.T) { pkg := t.TempDir() writePiSubagentsFixtures(t, pkg) ops := &fakePiRuntimeOps{ - lookPath: map[string]string{"pi": "/bin/pi"}, + lookPath: piHealthyPathFixtures(), statOK: statOKForPiResources(repo, pkg), } var stdout, stderr bytes.Buffer @@ -124,7 +124,7 @@ func TestPiInstallAcceptedAndDoesNotUsePluginCommands(t *testing.T) { var stdout, stderr bytes.Buffer code := runInitWithPi(context.Background(), []string{"--host", "pi"}, ops, &fakePiRuntimeOps{ - lookPath: map[string]string{"pi": "/bin/pi"}, + lookPath: piHealthyPathFixtures(), statOK: statOKForPiResources(repo, pkg), }, append(piTestEnv(pkg, t.TempDir()), "SPACEDOCK_REPO_ROOT="+repo), &stdout, &stderr) if code != 0 { @@ -134,7 +134,7 @@ func TestPiInstallAcceptedAndDoesNotUsePluginCommands(t *testing.T) { t.Fatalf("install --host pi called host plugin install seam: %v", ops.installCmds) } out := stdout.String() - for _, want := range []string{"Pi runtime ready", "pi-subagents", pkg, repo} { + for _, want := range []string{"Pi runtime ready", "pi-subagents", "pi-intercom", pkg, repo, "necessary supervisor-talkback setup prerequisites only"} { if !strings.Contains(out, want) { t.Fatalf("install --host pi output missing %q:\n%s", want, out) } @@ -158,7 +158,7 @@ func TestPiInstallMissingSubagentsPrintsActionableInstructions(t *testing.T) { t.Fatalf("install --host pi should be idempotent/instructive, exit=%d stderr=%q", code, stderr.String()) } out := stdout.String() - for _, want := range []string{"Pi runtime setup incomplete", "pi install npm:pi-subagents", "PI_SUBAGENTS_PACKAGE_ROOT"} { + for _, want := range []string{"Pi runtime setup incomplete", "pi install npm:pi-subagents", "PI_SUBAGENTS_PACKAGE_ROOT", "pi-intercom", "PI_INTERCOM_PACKAGE_ROOT"} { if !strings.Contains(out, want) { t.Fatalf("missing-subagents output missing %q:\n%s", want, out) } @@ -218,6 +218,116 @@ func TestNonPiSetupRejectsPluginDir(t *testing.T) { } } +func TestPiInstallCheckFailsForMissingSupervisorTalkbackPrerequisites(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + writePiSubagentsFixtures(t, pkg) + home := t.TempDir() + statOK := statOKForPiResources(repo, pkg) + statOK[filepath.Join(home, ".pi", "agent", "auth.json")] = true + delete(statOK, pkg+"-intercom") + delete(statOK, filepath.Join(pkg+"-intercom", "skills", "pi-intercom", "SKILL.md")) + var stdout, stderr bytes.Buffer + + code := runInitWithPi(context.Background(), []string{"--host", "pi", "--check"}, &fakeHost{}, &fakePiRuntimeOps{ + lookPath: map[string]string{"pi": "/bin/pi"}, + statOK: statOK, + }, append(piTestEnv(pkg, home), "SPACEDOCK_REPO_ROOT="+repo), &stdout, &stderr) + if code == 0 { + t.Fatalf("install --host pi --check exit=0 want non-zero for missing supervisor-talkback prerequisites; stdout=%q", stdout.String()) + } + out := stdout.String() + for _, want := range []string{"OK pi-subagents intercom bridge", "MISSING pi-intercom package root", "MISSING pi-intercom skill", "PI_INTERCOM_PACKAGE_ROOT"} { + if !strings.Contains(out, want) { + t.Fatalf("install check output missing %q:\n%s", want, out) + } + } +} + +func TestPiInstallCheckFailsForMissingAuthLikeDoctor(t *testing.T) { + repo := t.TempDir() + writePiSkillFixtures(t, repo) + pkg := t.TempDir() + writePiSubagentsFixtures(t, pkg) + home := t.TempDir() + var stdout, stderr bytes.Buffer + + code := runInitWithPi(context.Background(), []string{"--host", "pi", "--check"}, &fakeHost{}, &fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOKForPiResources(repo, pkg), + }, append(piTestEnv(pkg, home), "SPACEDOCK_REPO_ROOT="+repo), &stdout, &stderr) + if code == 0 { + t.Fatalf("install --host pi --check exit=0 want non-zero for missing Pi auth; stdout=%q", stdout.String()) + } + out := stdout.String() + for _, want := range []string{"Pi runtime check", "MISSING Pi auth", filepath.Join(home, ".pi", "agent", "auth.json"), "OK pi-intercom package root", "OK pi-intercom skill"} { + if !strings.Contains(out, want) { + t.Fatalf("install check missing-auth output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "Pi runtime ready") { + t.Fatalf("install check with missing auth should not print ready:\n%s", out) + } +} + +func TestPiRuntimeConfigResolvesEnvPathsForSubagentsIntercomAuthAndSessions(t *testing.T) { + repo := filepath.Join(t.TempDir(), "repo") + subagents := filepath.Join(t.TempDir(), "pi-subagents") + intercom := filepath.Join(t.TempDir(), "pi-intercom") + authRoot := filepath.Join(t.TempDir(), "coding-agent") + sessionDir := filepath.Join(t.TempDir(), "sessions") + + cfg := piRuntimeConfigFromEnv([]string{ + "SPACEDOCK_REPO_ROOT=" + repo, + "PI_SUBAGENTS_PACKAGE_ROOT=" + subagents, + "PI_INTERCOM_PACKAGE_ROOT=" + intercom, + "PI_CODING_AGENT_DIR=" + authRoot, + "PI_CODING_AGENT_SESSION_DIR=" + sessionDir, + }, t.TempDir(), "") + + assertEqual(t, cfg.repoRoot, repo) + assertEqual(t, cfg.packageRoot, subagents) + assertEqual(t, cfg.intercomPackageRoot, intercom) + assertEqual(t, cfg.extensionPath, filepath.Join(subagents, "src", "extension", "index.ts")) + assertEqual(t, cfg.subagentsSkill, filepath.Join(subagents, "skills", "pi-subagents")) + assertEqual(t, cfg.authPath, filepath.Join(authRoot, "auth.json")) + assertEqual(t, cfg.sessionDir, sessionDir) + assertEqual(t, cfg.packageRootSource, "PI_SUBAGENTS_PACKAGE_ROOT") + assertEqual(t, cfg.intercomPackageSource, "PI_INTERCOM_PACKAGE_ROOT") + assertEqual(t, cfg.authPathSource, "PI_CODING_AGENT_DIR") + assertEqual(t, cfg.sessionDirSource, "PI_CODING_AGENT_SESSION_DIR") +} + +func TestPiRuntimeConfigDefaultsIntercomAndAuthPathsUnderHome(t *testing.T) { + home := t.TempDir() + cfg := piRuntimeConfigFromEnv([]string{"HOME=" + home}, "/checkout", "") + + assertEqual(t, cfg.packageRoot, filepath.Join(home, ".pi", "agent", "npm", "node_modules", "pi-subagents")) + assertEqual(t, cfg.intercomPackageRoot, filepath.Join(home, ".pi", "agent", "npm", "node_modules", "pi-intercom")) + assertEqual(t, cfg.authPath, filepath.Join(home, ".pi", "agent", "auth.json")) + assertEqual(t, cfg.sessionDir, filepath.Join(home, ".pi", "agent", "sessions")) +} + +func TestRuntimeSupportDocsKeepPiDoctorVsLiveTalkbackBoundary(t *testing.T) { + doc, err := os.ReadFile(filepath.Join("..", "..", "docs", "runtime-support.md")) + if err != nil { + t.Fatal(err) + } + text := string(doc) + for _, want := range []string{ + "pi-intercom", + "intercom bridge", + "necessary setup checks but insufficient to prove live supervisor talkback", + "progress update -> decision request -> supervisor reply -> child resume -> durable marker evidence", + "pi-intercom-supervisor-talkback", + } { + if !strings.Contains(text, want) { + t.Fatalf("runtime-support.md missing %q", want) + } + } +} + func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { repo := t.TempDir() writePiSkillFixtures(t, repo) @@ -233,17 +343,22 @@ func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { t.Fatalf("exit=0 want non-zero for missing pi runtime") } out := stdout.String() - for _, want := range []string{"Pi runtime check", "MISSING pi CLI", "MISSING Pi auth", "MISSING pi-subagents"} { + for _, want := range []string{"Pi runtime check", "MISSING pi CLI", "MISSING Pi auth", "MISSING pi-subagents", "Supervisor-talkback setup prerequisites", "MISSING pi-subagents intercom bridge", "MISSING pi-intercom package root", "MISSING pi-intercom skill", "necessary supervisor-talkback setup prerequisites only"} { if !strings.Contains(out, want) { t.Fatalf("missing doctor output missing %q:\n%s", want, out) } } + for _, notWant := range []string{"pi-intercom command", "subagents-doctor bridge-health command"} { + if strings.Contains(out, notWant) { + t.Fatalf("doctor output should not require unstable command contract %q:\n%s", notWant, out) + } + } }) t.Run("openai-api-key-auth", func(t *testing.T) { var stdout, stderr bytes.Buffer code := runDoctorWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, &fakeHost{}, &fakePiRuntimeOps{ - lookPath: map[string]string{"pi": "/bin/pi"}, + lookPath: piHealthyPathFixtures(), statOK: statOKForPiResources(repo, pkg), }, append(piTestEnv(pkg, home), "OPENAI_API_KEY=test-key"), &stdout, &stderr) if code != 0 { @@ -259,14 +374,14 @@ func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { statOK := statOKForPiResources(repo, pkg) statOK[auth] = true code := runDoctorWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", repo}, &fakeHost{}, &fakePiRuntimeOps{ - lookPath: map[string]string{"pi": "/bin/pi"}, + lookPath: piHealthyPathFixtures(), statOK: statOK, }, piTestEnv(pkg, home), &stdout, &stderr) if code != 0 { t.Fatalf("exit=%d stderr=%q stdout=%q", code, stderr.String(), stdout.String()) } out := stdout.String() - for _, want := range []string{"OK pi CLI", "OK Pi auth", "OK pi-subagents extension", "OK Spacedock first-officer skill", "OK Spacedock ensign skill"} { + for _, want := range []string{"OK pi CLI", "OK Pi auth", "OK pi-subagents extension", "OK pi-subagents intercom bridge", "OK pi-intercom package root", "OK pi-intercom skill", "OK Spacedock first-officer skill", "OK Spacedock ensign skill", "live child talkback", "durable marker probe"} { if !strings.Contains(out, want) { t.Fatalf("healthy doctor output missing %q:\n%s", want, out) } @@ -274,6 +389,13 @@ func TestPiDoctorReportsMissingAndHealthyRuntime(t *testing.T) { }) } +func assertEqual(t *testing.T, got, want string) { + t.Helper() + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + func writePiSkillFixtures(t *testing.T, repo string) { t.Helper() writeFileWithDirs(t, filepath.Join(repo, "skills", "first-officer", "SKILL.md"), "---\nname: first-officer\ndescription: test\n---\n") @@ -296,16 +418,26 @@ func writeFileWithDirs(t *testing.T, path, content string) { func statOKForPiResources(repo, pkg string) map[string]bool { return map[string]bool{ - filepath.Join(pkg, "src", "extension", "index.ts"): true, - filepath.Join(pkg, "skills", "pi-subagents", "SKILL.md"): true, - filepath.Join(repo, "skills", "first-officer", "SKILL.md"): true, - filepath.Join(repo, "skills", "ensign", "SKILL.md"): true, + filepath.Join(pkg, "src", "extension", "index.ts"): true, + filepath.Join(pkg, "skills", "pi-subagents", "SKILL.md"): true, + filepath.Join(pkg, "src", "intercom", "intercom-bridge.ts"): true, + filepath.Join(repo, "skills", "first-officer", "SKILL.md"): true, + filepath.Join(repo, "skills", "ensign", "SKILL.md"): true, + pkg + "-intercom": true, + filepath.Join(pkg+"-intercom", "skills", "pi-intercom", "SKILL.md"): true, + } +} + +func piHealthyPathFixtures() map[string]string { + return map[string]string{ + "pi": "/bin/pi", } } func piTestEnv(pkg, home string) []string { return []string{ "PI_SUBAGENTS_PACKAGE_ROOT=" + pkg, + "PI_INTERCOM_PACKAGE_ROOT=" + pkg + "-intercom", "HOME=" + home, } } diff --git a/internal/release/workflow_exec_guard_test.go b/internal/release/workflow_exec_guard_test.go index c10e5c0c..42747c82 100644 --- a/internal/release/workflow_exec_guard_test.go +++ b/internal/release/workflow_exec_guard_test.go @@ -58,8 +58,8 @@ func assertRuntimeLiveWorkflowUploadsRawJourneyMetrics(workflow string) error { if !workflowHasExecutableCommandContaining(workflow, `npm install --prefix "$pi_npm_root" pi-subagents pi-intercom --before="$NPM_BEFORE" --ignore-scripts --no-audit --no-fund --omit=dev`) { return fmt.Errorf("runtime-live-e2e.yml Pi live job does not directly install required Pi substrates with npm --before and safer npm flags") } - if !workflowHasExecutableCommandContaining(workflow, `test -x "$(command -v pi)"`) || !workflowHasExecutableCommandContaining(workflow, `p.name !== '@earendil-works/pi-coding-agent'`) || !workflowHasExecutableCommandContaining(workflow, `p.bin.pi !== 'dist/cli.js'`) || !workflowHasExecutableCommandContaining(workflow, `p.name !== 'pi-subagents'`) || !workflowHasExecutableCommandContaining(workflow, `p.name !== 'pi-intercom'`) || !workflowHasExecutableCommandContaining(workflow, `test -f "$pi_npm_root/node_modules/pi-subagents/src/extension/index.ts"`) || !workflowHasExecutableCommandContaining(workflow, `test -f "$pi_npm_root/node_modules/pi-subagents/skills/pi-subagents/SKILL.md"`) { - return fmt.Errorf("runtime-live-e2e.yml Pi live job does not verify installed Pi package names, versions, bin, and resource paths") + if !workflowHasExecutableCommandContaining(workflow, `test -x "$(command -v pi)"`) || !workflowHasExecutableCommandContaining(workflow, `p.name !== '@earendil-works/pi-coding-agent'`) || !workflowHasExecutableCommandContaining(workflow, `p.bin.pi !== 'dist/cli.js'`) || !workflowHasExecutableCommandContaining(workflow, `p.name !== 'pi-subagents'`) || !workflowHasExecutableCommandContaining(workflow, `p.name !== 'pi-intercom'`) || !workflowHasExecutableCommandContaining(workflow, `test -f "$pi_npm_root/node_modules/pi-subagents/src/extension/index.ts"`) || !workflowHasExecutableCommandContaining(workflow, `test -f "$pi_npm_root/node_modules/pi-subagents/skills/pi-subagents/SKILL.md"`) || !workflowHasExecutableCommandContaining(workflow, `test -f "$pi_npm_root/node_modules/pi-subagents/src/intercom/intercom-bridge.ts"`) || !workflowHasExecutableCommandContaining(workflow, `test -f "$pi_npm_root/node_modules/pi-intercom/skills/pi-intercom/SKILL.md"`) || !workflowHasExecutableCommandContaining(workflow, `echo "PI_INTERCOM_PACKAGE_ROOT=$pi_npm_root/node_modules/pi-intercom" >> "$GITHUB_ENV"`) { + return fmt.Errorf("runtime-live-e2e.yml Pi live job does not verify installed Pi package names, versions, bin, resource paths, and intercom package env") } if !workflowHasExecutableCommandContaining(workflow, `spacedock doctor --host pi --plugin-dir "$GITHUB_WORKSPACE"`) { return fmt.Errorf("runtime-live-e2e.yml Pi live job does not verify current-checkout Spacedock skills")