supipowers 2.2.1 → 2.2.3

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/README.md CHANGED
@@ -36,6 +36,9 @@ The installer detects Pi (`~/.pi`) and OMP (`~/.omp`) — when both are present
36
36
  | [Bun](https://bun.sh) | Runtime — required for installation and the built-in SQLite FTS index |
37
37
  | [Git](https://git-scm.com) | Used by the installer and git-based workflows |
38
38
 
39
+ > [!TIP]
40
+ > OMP ≥15.1.7 is recommended for best reliability with supipowers command-driven agent handoffs and accurate provider-scoped `/fast` status indicators. Older compatible OMP versions can run supipowers but lack those runtime fixes.
41
+
39
42
  ### Optional dependencies
40
43
 
41
44
  The installer scans for these and offers to install missing tooling where it can. Everything works without them, but each one unlocks additional capabilities.
package/package.json CHANGED
@@ -1,22 +1,23 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Workflow extension for OMP coding agents.",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "bun test --timeout 20000 tests/",
7
+ "test": "bun test --timeout 60000 --parallel tests/",
8
+ "test:windows": "bun test --timeout 60000 --parallel tests/platform/ tests/utils/editor.test.ts tests/utils/exec-cli.test.ts tests/fix-pr/scripts/exec.test.ts tests/mempalace/uv.test.ts tests/mempalace/git-hook.test.ts",
8
9
  "typecheck": "tsc --noEmit",
9
- "test:watch": "bun test --timeout 20000 --watch tests/",
10
- "test:evals": "bun test --timeout 20000 tests/evals/",
10
+ "test:watch": "bun test --timeout 60000 --parallel --watch tests/",
11
+ "test:evals": "bun test --timeout 60000 --parallel tests/evals/",
11
12
  "build": "tsc -p tsconfig.build.json",
12
- "ci": "bun run typecheck && bun run test",
13
+ "ci": "bun run src/ci.ts",
13
14
  "install:visual-server": "npm --prefix src/visual/scripts ci --ignore-scripts --no-audit --no-fund",
14
15
  "postinstall": "bun run install:visual-server",
15
16
  "prepare": "git config core.hooksPath hooks || true",
16
17
  "dev-install": "bun run bin/dev-install.ts"
17
18
  },
18
19
  "engines": {
19
- "bun": ">=1.3.10"
20
+ "bun": ">=1.3.13"
20
21
  },
21
22
  "keywords": [
22
23
  "omp",
@@ -65,7 +66,7 @@
65
66
  ]
66
67
  },
67
68
  "dependencies": {
68
- "@clack/prompts": "^0.10.0",
69
+ "@clack/prompts": "^1.4.0",
69
70
  "handlebars": "^4.7.8",
70
71
  "yaml": "^2.8.3",
71
72
  "zod": "^4.3.0"
@@ -90,8 +91,8 @@
90
91
  "@oh-my-pi/pi-ai": "latest",
91
92
  "@oh-my-pi/pi-coding-agent": "latest",
92
93
  "@oh-my-pi/pi-tui": "latest",
93
- "@types/node": "^22.0.0",
94
+ "@types/node": "^25.8.0",
94
95
  "bun-types": "^1.3.11",
95
- "typescript": "^5.9.3"
96
+ "typescript": "^6.0.3"
96
97
  }
97
98
  }
@@ -63,7 +63,7 @@ When OMP's `shellMinimizer` is active, large bash output ends with a `[raw outpu
63
63
 
64
64
  ### Read
65
65
 
66
- Reads are never blocked — OMP's native open/read tool preserves hashline anchors (e.g., `120th|content` after 14.4.1) for the edit contract. Large reads (>110 lines) are auto-compressed to head (80) + tail (30) with a `sel` hint.
66
+ Reads are never blocked — OMP's native open/read tool preserves hashline anchors (e.g., `120th|content` after 14.4.1) for the edit contract. Copy edit anchors exactly, without the `|content` body, and never fabricate anchors. Edit payload lines must start with `~` immediately followed by intended file content; avoid a readability space after `~` unless that space is intentional file content. Large reads (>110 lines) are auto-compressed to head (80) + tail (30) with a `sel` hint.
67
67
 
68
68
  For analysis-only reads where anchors are not needed, prefer `ctx_execute_file(path, language, code)` — only your printed summary enters context.
69
69
 
package/src/bootstrap.ts CHANGED
@@ -12,6 +12,7 @@ import { registerAiReviewCommand, handleAiReview } from "./commands/ai-review.js
12
12
  import { registerQaCommand } from "./commands/qa.js";
13
13
  import { registerReleaseCommand, handleRelease } from "./commands/release.js";
14
14
  import { registerUpdateCommand, handleUpdate } from "./commands/update.js";
15
+ import { execCli } from "./utils/exec-cli.js";
15
16
  import { registerDoctorCommand, handleDoctor } from "./commands/doctor.js";
16
17
  import { registerModelCommand, handleModel } from "./commands/model.js";
17
18
  import { registerFixPrCommand } from "./commands/fix-pr.js";
@@ -175,7 +176,7 @@ export function bootstrap(platform: Platform): void {
175
176
  const currentVersion = getInstalledVersion(platform);
176
177
  if (!currentVersion) return;
177
178
 
178
- platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
179
+ execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
179
180
  .then((result) => {
180
181
  if (result.code !== 0) return;
181
182
  const latest = result.stdout.trim();
package/src/ci.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type CiProfile = "default" | "windows-fast";
2
+
3
+ export interface CiCommand {
4
+ label: string;
5
+ args: string[];
6
+ }
7
+
8
+ export function resolveCiProfile(value: string | undefined): CiProfile {
9
+ if (value === undefined || value === "" || value === "default") return "default";
10
+ if (value === "windows-fast") return "windows-fast";
11
+ throw new Error(`Unsupported SUPIPOWERS_CI_PROFILE: ${value}`);
12
+ }
13
+
14
+ export function getCiPlan(profile: CiProfile): CiCommand[] {
15
+ const typecheck = { label: "Typecheck", args: ["bun", "run", "typecheck"] };
16
+ if (profile === "windows-fast") {
17
+ return [
18
+ typecheck,
19
+ { label: "Windows portability tests", args: ["bun", "run", "test:windows"] },
20
+ ];
21
+ }
22
+
23
+ return [
24
+ typecheck,
25
+ { label: "Test", args: ["bun", "run", "test"] },
26
+ ];
27
+ }
28
+
29
+ export function runCi(profileValue: string | undefined = process.env.SUPIPOWERS_CI_PROFILE): number {
30
+ const profile = resolveCiProfile(profileValue);
31
+ for (const command of getCiPlan(profile)) {
32
+ console.log(`\n> ${command.label}`);
33
+ const result = Bun.spawnSync({
34
+ cmd: command.args,
35
+ stdin: "inherit",
36
+ stdout: "inherit",
37
+ stderr: "inherit",
38
+ env: process.env,
39
+ });
40
+ if (!result.success) return result.exitCode;
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ if (import.meta.main) {
46
+ process.exitCode = runCi();
47
+ }
@@ -11,6 +11,7 @@ import { formatReliabilitySection, loadReliabilitySummaries } from "../storage/r
11
11
  import { getMetricsStore, getSessionId } from "../context-mode/hooks.js";
12
12
  import { getProjectStatePath, getProjectStateDir } from "../workspace/state-paths.js";
13
13
  import { basename } from "node:path";
14
+ import { execCli } from "../utils/exec-cli.js";
14
15
 
15
16
  export interface CheckResult {
16
17
  name: string;
@@ -435,14 +436,14 @@ export function checkMetrics(
435
436
 
436
437
  export async function checkNpm(platform: Platform): Promise<CheckResult> {
437
438
  try {
438
- const vResult = await platform.exec("npm", ["--version"]);
439
+ const vResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["--version"]);
439
440
  if (vResult.code !== 0) {
440
441
  return { name: "npm", presence: { ok: false, detail: "npm not found" } };
441
442
  }
442
443
  const version = vResult.stdout.trim();
443
444
  const presence = { ok: true, detail: `v${version}` };
444
445
 
445
- const pingResult = await platform.exec("npm", ["ping"]);
446
+ const pingResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["ping"]);
446
447
  if (pingResult.code === 0) {
447
448
  return { name: "npm", presence, functional: { ok: true, detail: "Registry reachable" } };
448
449
  }
@@ -501,6 +502,7 @@ export function getDoctorRecommendations(): string[] {
501
502
  return [
502
503
  "Set `tools.elideFileMutationInputs: true` (OMP ≥14.7.0) — elides `write`/`edit`/`apply_patch` payloads from history after success. Saves significant context on long sessions like `/supi:ultraplan execute` and `/supi:harness implement`.",
503
504
  "Update to OMP ≥14.7.2 — fixes the `Working…` spinner staying active after read-only commands such as `/supi:status`, `/supi:doctor`, `/supi:context`, and `/supi:clear`. (oh-my-pi#927)",
505
+ "Use OMP ≥15.1.7 for best reliability — includes ACP fixes for command-driven agent handoffs and permission prompts, plus accurate provider-scoped `/fast` status-line indicators.",
504
506
  ];
505
507
  }
506
508
 
@@ -6,17 +6,33 @@ import {
6
6
  snapshotMempalaceInstall,
7
7
  steerMempalaceInitialization,
8
8
  } from "../mempalace/installer-helper.js";
9
+ import {
10
+ getMempalacePostCommitHookStatus,
11
+ installMempalacePostCommitHook,
12
+ uninstallMempalacePostCommitHook,
13
+ type MempalacePostCommitHookStatusResult,
14
+ } from "../mempalace/git-hook.js";
9
15
 
10
16
  const SUBCOMMANDS = [
11
17
  { name: "status", description: "Show palace path, managed venv, and install status" },
12
18
  { name: "setup", description: "Install or repair the managed Python environment and MemPalace package" },
19
+ { name: "git-hook", description: "Manage the opt-in post-commit MemPalace reindex hook" },
20
+ ] as const;
21
+
22
+ const GIT_HOOK_ACTIONS = [
23
+ { name: "status", description: "Show post-commit reindex hook status" },
24
+ { name: "install", description: "Install or update the post-commit reindex hook" },
25
+ { name: "uninstall", description: "Remove the managed hook and restore a chained user hook" },
13
26
  ] as const;
14
27
 
15
28
  const HELP = [
16
29
  "/supi:memory — native MemPalace integration",
17
30
  "",
18
31
  "Subcommands:",
19
- ...SUBCOMMANDS.map((subcommand) => ` ${subcommand.name.padEnd(8)} ${subcommand.description}`),
32
+ ...SUBCOMMANDS.map((subcommand) => ` ${subcommand.name.padEnd(10)} ${subcommand.description}`),
33
+ "",
34
+ "Git hook:",
35
+ " git-hook status|install|uninstall",
20
36
  "",
21
37
  "Memory APIs are exposed to the agent via the `mempalace` tool.",
22
38
  ].join("\n");
@@ -43,6 +59,80 @@ function statusReport(platform: Platform, cwd: string): string {
43
59
  return lines.join("\n");
44
60
  }
45
61
 
62
+ function gitHookStatusReport(result: MempalacePostCommitHookStatusResult): string {
63
+ if (!result.ok) {
64
+ return [
65
+ "/supi:memory git-hook status",
66
+ "",
67
+ `status: unavailable (${result.code})`,
68
+ result.message,
69
+ ].join("\n");
70
+ }
71
+
72
+ return [
73
+ "/supi:memory git-hook status",
74
+ "",
75
+ `repo root: ${result.repoRoot}`,
76
+ `hooks dir: ${result.hooksDir}`,
77
+ `core.hooksPath: ${result.coreHooksPath ?? "(default .git/hooks)"}`,
78
+ `post-commit hook: ${result.installed ? "present" : "missing"}`,
79
+ `managed by supipowers: ${result.managed}`,
80
+ `chained user hook: ${result.userHookPresent ? result.userHookPath : "none"}`,
81
+ `reindex runner: ${result.runnerPresent ? result.runnerPath : `${result.runnerPath} (missing)`}`,
82
+ ].join("\n");
83
+ }
84
+
85
+ async function handleGitHook(platform: Platform, ctx: PlatformContext, action: string): Promise<void> {
86
+ const config = loadConfig(platform.paths, ctx.cwd);
87
+ const command = action || "status";
88
+
89
+ if (command === "status") {
90
+ const status = await getMempalacePostCommitHookStatus({
91
+ paths: platform.paths,
92
+ cwd: ctx.cwd,
93
+ config,
94
+ exec: platform.exec,
95
+ });
96
+ ctx.ui.notify(gitHookStatusReport(status), status.ok ? "info" : "warning");
97
+ return;
98
+ }
99
+
100
+ if (command === "install") {
101
+ const result = await installMempalacePostCommitHook({
102
+ paths: platform.paths,
103
+ cwd: ctx.cwd,
104
+ config,
105
+ exec: platform.exec,
106
+ });
107
+ if (result.ok) {
108
+ ctx.ui.notify(
109
+ `MemPalace post-commit reindex hook ${result.action}: ${result.hookPath}`,
110
+ "info",
111
+ );
112
+ } else {
113
+ ctx.ui.notify(`MemPalace post-commit hook install failed (${result.code}): ${result.message}`, "warning");
114
+ }
115
+ return;
116
+ }
117
+
118
+ if (command === "uninstall") {
119
+ const result = await uninstallMempalacePostCommitHook({
120
+ paths: platform.paths,
121
+ cwd: ctx.cwd,
122
+ config,
123
+ exec: platform.exec,
124
+ });
125
+ if (result.ok) {
126
+ ctx.ui.notify(`MemPalace post-commit reindex hook ${result.action}.`, "info");
127
+ } else {
128
+ ctx.ui.notify(`MemPalace post-commit hook uninstall failed (${result.code}): ${result.message}`, "warning");
129
+ }
130
+ return;
131
+ }
132
+
133
+ ctx.ui.notify(`Unknown /supi:memory git-hook action: ${command}\n\n${HELP}`, "warning");
134
+ }
135
+
46
136
  async function runSetup(platform: Platform, ctx: PlatformContext): Promise<void> {
47
137
  const config = loadConfig(platform.paths, ctx.cwd);
48
138
  if (!config.mempalace.enabled) {
@@ -105,6 +195,26 @@ async function runSetup(platform: Platform, ctx: PlatformContext): Promise<void>
105
195
  "info",
106
196
  );
107
197
 
198
+
199
+ if (config.mempalace.hooks.postCommitReindex) {
200
+ const hookResult = await installMempalacePostCommitHook({
201
+ paths: platform.paths,
202
+ cwd: ctx.cwd,
203
+ config,
204
+ exec: platform.exec,
205
+ });
206
+ if (hookResult.ok) {
207
+ ctx.ui.notify(
208
+ `MemPalace post-commit reindex hook ${hookResult.action}: ${hookResult.hookPath}`,
209
+ "info",
210
+ );
211
+ } else {
212
+ ctx.ui.notify(
213
+ `MemPalace setup completed, but post-commit hook install failed (${hookResult.code}): ${hookResult.message}`,
214
+ "warning",
215
+ );
216
+ }
217
+ }
108
218
  // Check if the current project's wing is already initialized; if not, steer
109
219
  // the model to run init + mine through the mempalace tool.
110
220
  const initState = await checkMempalaceProjectInitialized({
@@ -141,8 +251,8 @@ async function runSetup(platform: Platform, ctx: PlatformContext): Promise<void>
141
251
  export function handleMemory(platform: Platform, ctx: PlatformContext, args?: string): void {
142
252
  if (!ctx.hasUI) return;
143
253
 
144
- const sub = (args ?? "").trim().split(/\s+/)[0] ?? "";
145
-
254
+ const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
255
+ const sub = parts[0] ?? "";
146
256
  if (sub === "" || sub === "help" || sub === "--help" || sub === "-h") {
147
257
  ctx.ui.notify(HELP, "info");
148
258
  return;
@@ -168,14 +278,37 @@ export function handleMemory(platform: Platform, ctx: PlatformContext, args?: st
168
278
  return;
169
279
  }
170
280
 
281
+ if (sub === "git-hook") {
282
+ void (async () => {
283
+ try {
284
+ await handleGitHook(platform, ctx, parts[1] ?? "status");
285
+ } catch (err) {
286
+ ctx.ui.notify(`MemPalace git-hook command crashed: ${(err as Error).message}`, "error");
287
+ }
288
+ })();
289
+ return;
290
+ }
291
+
171
292
  ctx.ui.notify(`Unknown /supi:memory subcommand: ${sub}\n\n${HELP}`, "warning");
172
293
  }
173
294
 
174
295
  export function registerMemoryCommand(platform: Platform): void {
175
296
  platform.registerCommand("supi:memory", {
176
- description: "Manage native MemPalace integration (status, setup)",
297
+ description: "Manage native MemPalace integration (status, setup, git-hook)",
177
298
  getArgumentCompletions(prefix: string) {
178
- const lower = prefix.trim().toLowerCase();
299
+ const rawLower = prefix.toLowerCase();
300
+ const lower = rawLower.trim();
301
+ if (rawLower.startsWith("git-hook ")) {
302
+ const actionPrefix = rawLower.slice("git-hook ".length).trimStart();
303
+ const matches = GIT_HOOK_ACTIONS
304
+ .filter((action) => action.name.startsWith(actionPrefix))
305
+ .map((action) => ({
306
+ value: `git-hook ${action.name} `,
307
+ label: action.name,
308
+ description: action.description,
309
+ }));
310
+ return matches.length > 0 ? matches : null;
311
+ }
179
312
  const matches = SUBCOMMANDS
180
313
  .filter((subcommand) => subcommand.name.startsWith(lower))
181
314
  .map((subcommand) => ({
@@ -19,6 +19,7 @@ import { loadModelConfig } from "../config/model-config.js";
19
19
  import { getProjectStatePath } from "../workspace/state-paths.js";
20
20
  import { cancelPlanTracking, startPlanTracking } from "../planning/approval-flow.js";
21
21
  import { stopVisualServer } from "../visual/stop-server.js";
22
+ import { execCli } from "../utils/exec-cli.js";
22
23
 
23
24
  modelRegistry.register({
24
25
  id: "plan",
@@ -111,7 +112,7 @@ export function registerPlanCommand(platform: Platform): void {
111
112
  const nodeModules = path.join(scriptsDir, "node_modules");
112
113
  if (!fs.existsSync(nodeModules)) {
113
114
  notifyInfo(ctx, "Installing visual companion dependencies...");
114
- const installResult = await platform.exec("npm", ["install", "--production"], { cwd: scriptsDir });
115
+ const installResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--production"], { cwd: scriptsDir });
115
116
  if (installResult.code !== 0) {
116
117
  notifyError(ctx, "Failed to install visual companion dependencies", installResult.stderr);
117
118
  debugLogger.log("visual_companion_dependency_install_failed", {
@@ -11,6 +11,7 @@ import {
11
11
  steerMempalaceInitialization,
12
12
  } from "../mempalace/installer-helper.js";
13
13
  import { loadConfig } from "../config/loader.js";
14
+ import { execCli, wrapExecForCli } from "../utils/exec-cli.js";
14
15
 
15
16
  // ── Options builder ──────────────────────────────────────
16
17
 
@@ -59,7 +60,7 @@ async function updateSupipowers(
59
60
  ctx.ui.notify(`Current version: v${currentVersion}`, "info");
60
61
 
61
62
  // Check latest version on npm
62
- const checkResult = await platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() });
63
+ const checkResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() });
63
64
  if (checkResult.code !== 0) {
64
65
  ctx.ui.notify("Failed to check for updates — npm view failed", "error");
65
66
  return null;
@@ -78,7 +79,8 @@ async function updateSupipowers(
78
79
  mkdirSync(tempDir, { recursive: true });
79
80
 
80
81
  try {
81
- const installResult = await platform.exec(
82
+ const installResult = await execCli(
83
+ (cmd, args, opts) => platform.exec(cmd, args, opts),
82
84
  "npm", ["install", "--prefix", tempDir, `supipowers@${latestVersion}`],
83
85
  { cwd: tempDir },
84
86
  );
@@ -130,10 +132,10 @@ async function updateSupipowers(
130
132
  // Install runtime dependencies (handlebars, etc.)
131
133
  // Without this, the extension fails to load because node_modules/ was deleted above.
132
134
  ctx.ui.notify("Installing dependencies...", "info");
133
- const bunInstall = await platform.exec("bun", ["install", "--production"], { cwd: extDir });
135
+ const bunInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "bun", ["install", "--production"], { cwd: extDir });
134
136
  if (bunInstall.code !== 0) {
135
137
  // Fallback to npm if bun is not available (e.g. Windows without global bun)
136
- const npmInstall = await platform.exec("npm", ["install", "--omit=dev"], { cwd: extDir });
138
+ const npmInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--omit=dev"], { cwd: extDir });
137
139
  if (npmInstall.code !== 0) {
138
140
  ctx.ui.notify(
139
141
  "Could not install extension dependencies.\n" +
@@ -177,7 +179,7 @@ async function updateSupipowers(
177
179
 
178
180
  export function handleUpdate(platform: Platform, ctx: PlatformContext): void {
179
181
  void (async () => {
180
- const exec = (cmd: string, args: string[]) => platform.exec(cmd, args);
182
+ const exec = wrapExecForCli((cmd: string, args: string[]) => platform.exec(cmd, args));
181
183
 
182
184
  // 1. Scan all dependencies
183
185
  const allStatuses = await scanAll(exec);
@@ -74,9 +74,11 @@ export const DEFAULT_CONFIG: SupipowersConfig = {
74
74
  hooks: {
75
75
  wakeUp: true,
76
76
  searchGuidance: true,
77
+ writeGuidance: true,
77
78
  autoSearchOnPrompt: true,
78
79
  compactionCheckpoint: true,
79
80
  shutdownDiary: true,
81
+ postCommitReindex: false,
80
82
  },
81
83
  budgets: {
82
84
  wakeUpTokens: 1200,
@@ -109,9 +109,11 @@ export const ConfigSchema = z.object(
109
109
  {
110
110
  wakeUp: z.boolean(),
111
111
  searchGuidance: z.boolean(),
112
+ writeGuidance: z.boolean(),
112
113
  autoSearchOnPrompt: z.boolean(),
113
114
  compactionCheckpoint: z.boolean(),
114
115
  shutdownDiary: z.boolean(),
116
+ postCommitReindex: z.boolean(),
115
117
  },
116
118
  ).strict(),
117
119
  budgets: z.object(
@@ -20,6 +20,31 @@ export interface ExecuteResult {
20
20
 
21
21
  const DEFAULT_TIMEOUT = 30_000;
22
22
 
23
+ const MISSING_BASH_EXIT_CODE = 127;
24
+ function buildMissingBashMessage(platform: NodeJS.Platform): string {
25
+ const base = "bash is required to execute shell snippets.";
26
+ switch (platform) {
27
+ case "win32":
28
+ return `${base} Install Git for Windows or WSL, then retry.`;
29
+ case "darwin":
30
+ return `${base} Install bash via Homebrew (\`brew install bash\`) or Xcode Command Line Tools, then retry.`;
31
+ case "linux":
32
+ return `${base} Install bash via your distro's package manager (apt/yum/pacman), then retry.`;
33
+ default:
34
+ return `${base} Install bash for your platform and retry.`;
35
+ }
36
+ }
37
+ const MISSING_BASH_MESSAGE = buildMissingBashMessage(process.platform);
38
+
39
+ function missingBashResult(startedAt: number): ExecuteResult {
40
+ return {
41
+ stdout: "",
42
+ stderr: MISSING_BASH_MESSAGE,
43
+ exitCode: MISSING_BASH_EXIT_CODE,
44
+ duration: performance.now() - startedAt,
45
+ };
46
+ }
47
+
23
48
  export async function executeCode(
24
49
  language: string,
25
50
  code: string,
@@ -29,6 +54,11 @@ export async function executeCode(
29
54
  const opts = options ?? {};
30
55
  const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
31
56
  const cwd = opts.cwd ?? process.cwd();
57
+ const start = performance.now();
58
+
59
+ if (language === "shell" && Bun.which("bash") === null) {
60
+ return missingBashResult(start);
61
+ }
32
62
 
33
63
  const id = randomUUID();
34
64
  const srcPath = path.join(os.tmpdir(), `ctx-exec-${id}${runner.fileExt}`);
@@ -37,7 +67,6 @@ export async function executeCode(
37
67
  : undefined;
38
68
 
39
69
  fs.writeFileSync(srcPath, code);
40
- const start = performance.now();
41
70
 
42
71
  try {
43
72
  // Compile step for compiled languages
@@ -2,6 +2,35 @@ import { spawnSync } from "node:child_process";
2
2
  import type { ExecOptions, ExecResult, Platform } from "../../platform/types.js";
3
3
  import { findExecutable } from "../../utils/executable.js";
4
4
 
5
+ export interface CliInvocation {
6
+ cmd: string;
7
+ args: string[];
8
+ }
9
+
10
+ function quoteCmdArgument(arg: string): string {
11
+ return `"${arg.replace(/"/g, '""')}"`;
12
+ }
13
+
14
+ export function buildCliInvocation(
15
+ resolvedCommand: string,
16
+ args: string[],
17
+ platform: NodeJS.Platform = process.platform,
18
+ ): CliInvocation {
19
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(resolvedCommand)) {
20
+ return {
21
+ cmd: "cmd.exe",
22
+ args: [
23
+ "/d",
24
+ "/s",
25
+ "/c",
26
+ `"${[resolvedCommand, ...args].map(quoteCmdArgument).join(" ")}"`,
27
+ ],
28
+ };
29
+ }
30
+
31
+ return { cmd: resolvedCommand, args };
32
+ }
33
+
5
34
  export function runCliCommand(
6
35
  command: string,
7
36
  args: string[],
@@ -13,7 +42,9 @@ export function runCliCommand(
13
42
  pathext: env.PATHEXT,
14
43
  }) ?? command;
15
44
 
16
- const result = spawnSync(resolvedCommand, args, {
45
+ const invocation = buildCliInvocation(resolvedCommand, args);
46
+
47
+ const result = spawnSync(invocation.cmd, invocation.args, {
17
48
  cwd: options?.cwd,
18
49
  env,
19
50
  encoding: "utf8",
@@ -25,6 +25,7 @@ import {
25
25
  type SlopBackendResult,
26
26
  type SlopFinding,
27
27
  } from "./backend.js";
28
+ import { execCli } from "../../utils/exec-cli.js";
28
29
 
29
30
  const DEFAULT_TIMEOUT_MS = 60_000;
30
31
 
@@ -149,7 +150,7 @@ async function resolveInvocation(
149
150
  }
150
151
 
151
152
  try {
152
- const probe = await platform.exec("npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
153
+ const probe = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
153
154
  if (probe.code === 0) {
154
155
  availabilityCache = { ok: true, via: "npx" };
155
156
  return { ok: true, cmd: "npx", baseArgs: ["--no-install", "fallow"], via: "npx" };
@@ -187,7 +188,7 @@ async function runFallow(
187
188
  const startedAt = Date.now();
188
189
  let result;
189
190
  try {
190
- result = await platform.exec(invocation.cmd, args, {
191
+ result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
191
192
  cwd: opts.cwd,
192
193
  timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
193
194
  });
@@ -269,7 +270,7 @@ export class FallowAdapter implements SlopBackend {
269
270
  if (opts.subtree) args.push("--path", opts.subtree);
270
271
 
271
272
  try {
272
- const result = await platform.exec(invocation.cmd, args, {
273
+ const result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
273
274
  cwd: opts.cwd,
274
275
  timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
275
276
  });
@@ -45,7 +45,6 @@ import {
45
45
  import { computeScore } from "./anti_slop/score.js";
46
46
  import {
47
47
  type BuildRunnerInput,
48
- type HarnessPipelineProgressEvent,
49
48
  type PipelineRunOutcome,
50
49
  HARNESS_STAGE_ORDER,
51
50
  runHarnessPipelineUntilGate,
@@ -58,7 +57,7 @@ import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
58
57
  import { handlePrComment } from "./pr-comment/handler.js";
59
58
  import { runGitVerificationQa } from "./git-verify-qa.js";
60
59
  import { getHarnessSessionDir } from "./project-paths.js";
61
- import type { HarnessDesignSpec, HarnessGateMode, HarnessSession, HarnessStage } from "../types.js";
60
+ import type { HarnessDesignSpec, HarnessGateMode, HarnessPipelineProgressEvent, HarnessSession, HarnessStage } from "../types.js";
62
61
 
63
62
  modelRegistry.register({
64
63
  id: "harness",
@@ -133,9 +132,11 @@ function createHarnessProgress(ctx: HarnessCommandContext) {
133
132
  let done = 0;
134
133
  let cur: HarnessStage | null = null;
135
134
  const completed: string[] = [];
135
+ let liveDetail: string | null = null;
136
136
 
137
137
  function refresh() {
138
- const label = cur ? HARNESS_STAGE_LABELS[cur] : "Complete";
138
+ const baseLabel = cur ? HARNESS_STAGE_LABELS[cur] : "Complete";
139
+ const label = cur && liveDetail ? `${baseLabel} — ${liveDetail}` : baseLabel;
139
140
  const spinner = cur ? "\u25cc" : "\u2713";
140
141
  (ctx.ui as any).setStatus?.("supi-harness", ` ${spinner} harness: ${label} (${done}/${SO.length})`);
141
142
  }
@@ -147,22 +148,26 @@ function createHarnessProgress(ctx: HarnessCommandContext) {
147
148
  case "stage-started":
148
149
  cur = event.stage;
149
150
  break;
151
+ case "stage-progress":
152
+ cur = event.stage;
153
+ liveDetail = event.detail;
154
+ break;
150
155
  case "stage-completed": {
151
- done += 1; cur = null;
156
+ done += 1; cur = null; liveDetail = null;
152
157
  const mark = "\u2713";
153
158
  completed.push(`${mark} ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "done"}`);
154
159
  break;
155
160
  }
156
161
  case "stage-skipped":
157
- done += 1; cur = null;
162
+ done += 1; cur = null; liveDetail = null;
158
163
  completed.push(`\u2013 ${HARNESS_STAGE_LABELS[event.stage]}: skipped`);
159
164
  break;
160
165
  case "awaiting-user":
161
- done += 1; cur = null;
166
+ done += 1; cur = null; liveDetail = null;
162
167
  completed.push(`\u25cb ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "awaiting review"}`);
163
168
  break;
164
169
  case "stage-failed": case "stage-blocked":
165
- cur = null;
170
+ cur = null; liveDetail = null;
166
171
  completed.push(`\u2717 ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "failed"}`);
167
172
  break;
168
173
  }