supipowers 2.2.2 → 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,15 +1,16 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "Workflow extension for OMP coding agents.",
5
5
  "type": "module",
6
6
  "scripts": {
7
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
10
  "test:watch": "bun test --timeout 60000 --parallel --watch tests/",
10
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",
@@ -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/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
+ }
@@ -502,6 +502,7 @@ export function getDoctorRecommendations(): string[] {
502
502
  return [
503
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`.",
504
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.",
505
506
  ];
506
507
  }
507
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) => ({
@@ -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",
@@ -537,6 +537,7 @@ function renderGithubActionsWorkflow(spec: HarnessDesignSpec): string {
537
537
  " runs-on: ubuntu-latest",
538
538
  " steps:",
539
539
  " - name: Reject PRs into main from non-dev branches",
540
+ " shell: bash",
540
541
  " run: |",
541
542
  ` if [ "${PR_HEAD_REF_EXPR}" != "${git.devBranch}" ]; then`,
542
543
  ` echo "PRs into '${git.mainBranch}' must come from '${git.devBranch}'." >&2`,
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
 
17
17
  import type { PlatformPaths } from "../platform/types.js";
18
+ import { ensureTrailingNewline, normalizeLineEndings } from "../text.js";
18
19
  import type {
19
20
  HarnessDesignSpec,
20
21
  HarnessDiscoverArtifact,
@@ -111,7 +112,7 @@ export function writeTextAtomic(
111
112
  try {
112
113
  ensureDir(filePath);
113
114
  const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
114
- fs.writeFileSync(tmpPath, content.endsWith("\n") ? content : `${content}\n`);
115
+ fs.writeFileSync(tmpPath, ensureTrailingNewline(normalizeLineEndings(content)));
115
116
  fs.renameSync(tmpPath, filePath);
116
117
  return success(filePath);
117
118
  } catch (error) {
@@ -0,0 +1,32 @@
1
+ import type { ResolvedMempalaceConfig } from "./config.js";
2
+
3
+ export type MempalaceGuidanceMode = "full" | "refresher";
4
+
5
+ export const MEMPALACE_TOOL_DESCRIPTION = [
6
+ "MemPalace memory dispatcher.",
7
+ "READ: **MUST** call `search` before answering past-fact questions unless the answer is fully derivable from the current turn or already-read files.",
8
+ "WRITE: **MUST NOT** add, update, or delete memory unless the user explicitly asks or the current system instructions direct a specific write.",
9
+ "NEVER call large mutators (`init`, `mine`, `split`, `repair`) unless explicitly instructed or running a setup/hook flow.",
10
+ ].join(" ");
11
+
12
+ export function buildMempalaceGuidance(
13
+ hooks: Pick<ResolvedMempalaceConfig["hooks"], "searchGuidance" | "writeGuidance">,
14
+ mode: MempalaceGuidanceMode,
15
+ ): string[] {
16
+ const lines: string[] = [];
17
+ if (hooks.searchGuidance) {
18
+ lines.push(
19
+ mode === "full"
20
+ ? "## READ\n- You **MUST** call `mempalace(action=\"search\", query=...)` before answering questions about prior decisions, people, projects, past events, or anything you would otherwise answer from memory about this project.\n- You MAY skip search only when the answer is fully derivable from the current turn or files already read this turn. Reuse per-turn search results; do not repeat the same query."
21
+ : "- READ: **MUST** call `mempalace(action=\"search\", query=...)` before past-fact answers unless fully derivable this turn.",
22
+ );
23
+ }
24
+ if (hooks.writeGuidance) {
25
+ lines.push(
26
+ mode === "full"
27
+ ? "## WRITE\n- You **MUST NOT** call write/mutation actions (`add_drawer`, `update_drawer`, `delete_drawer`, `diary_write`, `kg_add`, `kg_invalidate`, `create_tunnel`, `delete_tunnel`) unless the user explicitly asks you to remember/save/log something or the current system instructions direct a specific write.\n- You **MUST NOT** infer that a decision, preference, or observation should be stored. If memory should be updated but no explicit write instruction exists, state that the user can ask you to remember it.\n- You **MUST NOT** call large indexing/repair actions (`init`, `mine`, `split`, `repair`) unless explicitly instructed or running an approved setup/hook flow."
28
+ : "- WRITE: **MUST NOT** mutate memory unless the user explicitly asks or current system instructions direct a specific write.",
29
+ );
30
+ }
31
+ return lines;
32
+ }
@@ -0,0 +1,443 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type { Platform, PlatformPaths } from "../platform/types.js";
5
+ import type { SupipowersConfig } from "../types.js";
6
+ import { resolveMempalaceConfig } from "./config.js";
7
+ import { snapshotMempalaceInstall, type MempalaceInstallSnapshot } from "./installer-helper.js";
8
+
9
+ export const MEM_PALACE_POST_COMMIT_HOOK_MARKER = "supipowers-mempalace-post-commit v1";
10
+ const POST_COMMIT_HOOK_NAME = "post-commit";
11
+ const USER_POST_COMMIT_HOOK_NAME = "post-commit.user";
12
+ const REINDEX_RUNNER_NAME = "supi-mempalace-reindex.py";
13
+ const REINDEX_RUNNER_MARKER = "supipowers-mempalace-reindex-runner v1";
14
+ const DEFAULT_REINDEX_TIMEOUT_SECONDS = 30;
15
+
16
+ type ExecFn = Platform["exec"];
17
+
18
+ export interface MempalaceGitHookContext {
19
+ repoRoot: string;
20
+ hooksDir: string;
21
+ hookPath: string;
22
+ userHookPath: string;
23
+ coreHooksPath: string | null;
24
+ }
25
+
26
+ type MempalaceGitHookContextFailure = { ok: false; code: "not_git_repo" | "git_failed"; message: string };
27
+
28
+ export interface MempalacePostCommitHookStatus extends MempalaceGitHookContext {
29
+ ok: true;
30
+ installed: boolean;
31
+ managed: boolean;
32
+ userHookPresent: boolean;
33
+ runnerPath: string;
34
+ runnerPresent: boolean;
35
+ }
36
+
37
+ export type MempalacePostCommitHookStatusResult =
38
+ | MempalacePostCommitHookStatus
39
+ | { ok: false; code: "not_git_repo" | "git_failed"; message: string };
40
+
41
+ type MempalacePostCommitHookInstallAction = "installed" | "already-installed" | "upgraded" | "chained-user-hook";
42
+ type MempalacePostCommitHookUninstallAction = "uninstalled" | "restored-user-hook" | "already-uninstalled";
43
+
44
+ export type MempalacePostCommitHookInstallResult =
45
+ | (MempalacePostCommitHookStatus & { action: MempalacePostCommitHookInstallAction })
46
+ | { ok: false; code: "mempalace_disabled" | "mempalace_not_ready" | "not_git_repo" | "user_hook_conflict" | "git_failed"; message: string };
47
+
48
+ export type MempalacePostCommitHookUninstallResult =
49
+ | (MempalacePostCommitHookStatus & { action: MempalacePostCommitHookUninstallAction })
50
+ | { ok: false; code: "not_git_repo" | "not_managed" | "git_failed"; message: string };
51
+
52
+ interface BaseHookOptions {
53
+ paths: PlatformPaths;
54
+ cwd: string;
55
+ config: SupipowersConfig;
56
+ exec: ExecFn;
57
+ }
58
+
59
+ export interface InstallMempalacePostCommitHookOptions extends BaseHookOptions {
60
+ snapshot?: MempalaceInstallSnapshot;
61
+ }
62
+
63
+ function trimStdout(value: string): string {
64
+ return value.trim().replace(/\r\n/g, "\n");
65
+ }
66
+
67
+ function resolveMaybeRelative(baseDir: string, value: string): string {
68
+ return path.isAbsolute(value) ? path.normalize(value) : path.resolve(baseDir, value);
69
+ }
70
+
71
+ async function gitValue(exec: ExecFn, cwd: string, args: string[]): Promise<{ ok: true; value: string } | { ok: false; message: string; code: number }> {
72
+ try {
73
+ const result = await exec("git", args, { cwd });
74
+ if (result.code !== 0) {
75
+ const detail = trimStdout(result.stderr || result.stdout) || `git ${args.join(" ")} exited ${result.code}`;
76
+ return { ok: false, message: detail, code: result.code };
77
+ }
78
+ return { ok: true, value: trimStdout(result.stdout) };
79
+ } catch (error) {
80
+ return { ok: false, message: error instanceof Error ? error.message : String(error), code: -1 };
81
+ }
82
+ }
83
+
84
+ async function resolveHookContext(exec: ExecFn, cwd: string): Promise<MempalaceGitHookContext | MempalaceGitHookContextFailure> {
85
+ const repoRootResult = await gitValue(exec, cwd, ["rev-parse", "--show-toplevel"]);
86
+ if (!repoRootResult.ok || repoRootResult.value.length === 0) {
87
+ return { ok: false, code: "not_git_repo", message: repoRootResult.ok ? "Not inside a git repository." : repoRootResult.message };
88
+ }
89
+
90
+ const repoRoot = path.resolve(repoRootResult.value);
91
+ const commonDirResult = await gitValue(exec, repoRoot, ["rev-parse", "--git-common-dir"]);
92
+ if (!commonDirResult.ok || commonDirResult.value.length === 0) {
93
+ return { ok: false, code: "git_failed", message: commonDirResult.ok ? "git rev-parse --git-common-dir returned an empty path." : commonDirResult.message };
94
+ }
95
+
96
+ const coreHooksPathResult = await gitValue(exec, repoRoot, ["config", "--get", "core.hooksPath"]);
97
+ let coreHooksPath: string | null = null;
98
+ if (coreHooksPathResult.ok) {
99
+ coreHooksPath = coreHooksPathResult.value.length > 0 ? coreHooksPathResult.value : null;
100
+ } else if (coreHooksPathResult.code !== 1) {
101
+ // git exits 1 when the config key is unset (expected). Any other code is
102
+ // a real failure (corrupt config, permission error) and must not silently
103
+ // fall back to the default hooks dir.
104
+ return { ok: false, code: "git_failed", message: coreHooksPathResult.message };
105
+ }
106
+ const hooksDir = coreHooksPath !== null
107
+ ? resolveMaybeRelative(repoRoot, coreHooksPath)
108
+ : path.join(resolveMaybeRelative(repoRoot, commonDirResult.value), "hooks");
109
+
110
+ return {
111
+ repoRoot,
112
+ hooksDir,
113
+ hookPath: path.join(hooksDir, POST_COMMIT_HOOK_NAME),
114
+ userHookPath: path.join(hooksDir, USER_POST_COMMIT_HOOK_NAME),
115
+ coreHooksPath,
116
+ };
117
+ }
118
+
119
+ function isHookContextFailure(value: MempalaceGitHookContext | MempalaceGitHookContextFailure): value is MempalaceGitHookContextFailure {
120
+ return "ok" in value && value.ok === false;
121
+ }
122
+
123
+ function readTextIfPresent(filePath: string): string | null {
124
+ try {
125
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function isManagedHook(content: string | null): boolean {
132
+ return content !== null && content.includes(MEM_PALACE_POST_COMMIT_HOOK_MARKER);
133
+ }
134
+
135
+ function writeExecutableFile(filePath: string, content: string): boolean {
136
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
137
+ const existing = readTextIfPresent(filePath);
138
+ if (existing === content) {
139
+ chmodExecutableBestEffort(filePath);
140
+ return false;
141
+ }
142
+ fs.writeFileSync(filePath, content, "utf-8");
143
+ chmodExecutableBestEffort(filePath);
144
+ return true;
145
+ }
146
+
147
+ function chmodExecutableBestEffort(filePath: string): void {
148
+ try {
149
+ fs.chmodSync(filePath, 0o755);
150
+ } catch (error) {
151
+ if (process.platform !== "win32") throw error;
152
+ }
153
+ }
154
+
155
+ export function toHookShellPath(value: string, platform: NodeJS.Platform = process.platform): string {
156
+ return platform === "win32" ? value.replace(/\\/g, "/") : value;
157
+ }
158
+
159
+ function shQuote(value: string): string {
160
+ return `'${value.replace(/'/g, "'\\''")}'`;
161
+ }
162
+
163
+ function buildPostCommitHookScript(snapshot: MempalaceInstallSnapshot, runnerPath: string, palacePath: string, agentName: string): string {
164
+ const python = shQuote(toHookShellPath(snapshot.venvPython));
165
+ const runner = shQuote(toHookShellPath(runnerPath));
166
+ const bridge = shQuote(toHookShellPath(snapshot.bridgePath));
167
+ const palace = shQuote(toHookShellPath(palacePath));
168
+ const agent = shQuote(agentName);
169
+ return `#!/bin/sh
170
+ # ${MEM_PALACE_POST_COMMIT_HOOK_MARKER}
171
+ # Managed by /supi:memory git-hook install. Chains ${USER_POST_COMMIT_HOOK_NAME} when present.
172
+
173
+ USER_HOOK="$(dirname "$0")/${USER_POST_COMMIT_HOOK_NAME}"
174
+ USER_STATUS=0
175
+ if [ -f "$USER_HOOK" ]; then
176
+ if [ -x "$USER_HOOK" ]; then
177
+ "$USER_HOOK" "$@"
178
+ else
179
+ sh "$USER_HOOK" "$@"
180
+ fi
181
+ USER_STATUS=$?
182
+ fi
183
+
184
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit "$USER_STATUS"
185
+ HEAD_COMMIT=$(git rev-parse HEAD 2>/dev/null) || exit "$USER_STATUS"
186
+ PYTHON=${python}
187
+ RUNNER=${runner}
188
+ BRIDGE=${bridge}
189
+ PALACE=${palace}
190
+ AGENT=${agent}
191
+
192
+ [ -f "$PYTHON" ] || exit "$USER_STATUS"
193
+ [ -f "$RUNNER" ] || exit "$USER_STATUS"
194
+ [ -f "$BRIDGE" ] || exit "$USER_STATUS"
195
+
196
+ LOG_DIR="$REPO_ROOT/.omp/supipowers/mempalace"
197
+ LOG="$LOG_DIR/post-commit.log"
198
+ mkdir -p "$LOG_DIR" 2>/dev/null || exit "$USER_STATUS"
199
+ (
200
+ "$PYTHON" "$RUNNER" \
201
+ --cwd "$REPO_ROOT" \
202
+ --commit "$HEAD_COMMIT" \
203
+ --bridge "$BRIDGE" \
204
+ --palace "$PALACE" \
205
+ --agent "$AGENT" \
206
+ --timeout-seconds ${DEFAULT_REINDEX_TIMEOUT_SECONDS} \
207
+ >> "$LOG" 2>&1
208
+ ) &
209
+ exit "$USER_STATUS"
210
+ `;
211
+ }
212
+
213
+ function buildReindexRunnerScript(): string {
214
+ return `#!/usr/bin/env python3
215
+ # ${REINDEX_RUNNER_MARKER}
216
+ from __future__ import annotations
217
+
218
+ import argparse
219
+ import json
220
+ import os
221
+ import subprocess
222
+ import sys
223
+ from typing import Iterable
224
+
225
+
226
+ def _decode_paths(raw: bytes) -> list[str]:
227
+ return [part.decode("utf-8", "surrogateescape") for part in raw.split(b"\\0") if part]
228
+
229
+
230
+ def _changed_files(cwd: str, commit: str, timeout_seconds: int) -> list[str]:
231
+ proc = subprocess.run(
232
+ [
233
+ "git",
234
+ "diff-tree",
235
+ "--root",
236
+ "--no-commit-id",
237
+ "--name-only",
238
+ "-r",
239
+ "-z",
240
+ "--diff-filter=ACMRT",
241
+ commit,
242
+ ],
243
+ cwd=cwd,
244
+ stdout=subprocess.PIPE,
245
+ stderr=subprocess.PIPE,
246
+ timeout=timeout_seconds,
247
+ )
248
+ if proc.returncode != 0:
249
+ stderr = proc.stderr.decode("utf-8", "replace").strip()
250
+ print(f"[supi-mempalace] git diff-tree failed: {stderr}", file=sys.stderr)
251
+ return []
252
+ return _decode_paths(proc.stdout)
253
+
254
+
255
+ def _safe_existing_files(cwd: str, paths: Iterable[str]) -> list[str]:
256
+ root = os.path.abspath(cwd)
257
+ selected: list[str] = []
258
+ for rel in paths:
259
+ abs_path = os.path.abspath(os.path.join(root, rel))
260
+ try:
261
+ if os.path.commonpath([root, abs_path]) != root:
262
+ print(f"[supi-mempalace] skip outside repo: {rel}")
263
+ continue
264
+ except ValueError:
265
+ print(f"[supi-mempalace] skip outside repo: {rel}")
266
+ continue
267
+ if os.path.isfile(abs_path):
268
+ selected.append(rel)
269
+ return selected
270
+
271
+
272
+ def _run_split(args: argparse.Namespace, source_file: str) -> bool:
273
+ request = {
274
+ "action": "split",
275
+ "params": {"source_file": source_file},
276
+ "options": {
277
+ "cwd": args.cwd,
278
+ "palacePath": args.palace,
279
+ "agentName": args.agent,
280
+ },
281
+ }
282
+ try:
283
+ proc = subprocess.run(
284
+ [sys.executable, args.bridge],
285
+ cwd=args.cwd,
286
+ input=json.dumps(request),
287
+ text=True,
288
+ stdout=subprocess.PIPE,
289
+ stderr=subprocess.PIPE,
290
+ timeout=args.timeout_seconds,
291
+ )
292
+ except subprocess.TimeoutExpired:
293
+ print(f"[supi-mempalace] split timed out: {source_file}", file=sys.stderr)
294
+ return False
295
+
296
+ if proc.returncode != 0:
297
+ detail = (proc.stderr or proc.stdout).strip()
298
+ print(f"[supi-mempalace] bridge failed for {source_file}: {detail}", file=sys.stderr)
299
+ return False
300
+
301
+ try:
302
+ payload = json.loads(proc.stdout or "{}")
303
+ except json.JSONDecodeError:
304
+ print(f"[supi-mempalace] bridge returned non-json for {source_file}: {proc.stdout[:300]}", file=sys.stderr)
305
+ return False
306
+
307
+ if payload.get("ok") is True:
308
+ print(f"[supi-mempalace] indexed {source_file}")
309
+ return True
310
+
311
+ error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
312
+ code = error.get("code", "unknown_error")
313
+ message = error.get("message", "MemPalace split failed")
314
+ print(f"[supi-mempalace] split failed for {source_file}: {code}: {message}", file=sys.stderr)
315
+ return False
316
+
317
+
318
+ def main() -> int:
319
+ parser = argparse.ArgumentParser(description="Reindex MemPalace drawers for files changed by one git commit.")
320
+ parser.add_argument("--cwd", required=True)
321
+ parser.add_argument("--commit", required=True)
322
+ parser.add_argument("--bridge", required=True)
323
+ parser.add_argument("--palace", required=True)
324
+ parser.add_argument("--agent", required=True)
325
+ parser.add_argument("--timeout-seconds", type=int, default=30)
326
+ args = parser.parse_args()
327
+
328
+ if not os.path.isfile(args.bridge):
329
+ print(f"[supi-mempalace] bridge missing: {args.bridge}", file=sys.stderr)
330
+ return 0
331
+
332
+ timeout_seconds = max(1, args.timeout_seconds)
333
+ args.timeout_seconds = timeout_seconds
334
+ changed = _changed_files(args.cwd, args.commit, timeout_seconds)
335
+ files = _safe_existing_files(args.cwd, changed)
336
+ if not files:
337
+ print(f"[supi-mempalace] commit {args.commit}: no indexable changed files")
338
+ return 0
339
+
340
+ ok = 0
341
+ for source_file in files:
342
+ if _run_split(args, source_file):
343
+ ok += 1
344
+ print(f"[supi-mempalace] commit {args.commit}: indexed {ok}/{len(files)} file(s)")
345
+ return 0
346
+
347
+
348
+ if __name__ == "__main__":
349
+ raise SystemExit(main())
350
+ `;
351
+ }
352
+
353
+ function buildStatus(context: MempalaceGitHookContext, runnerPath: string): MempalacePostCommitHookStatus {
354
+ const hookContent = readTextIfPresent(context.hookPath);
355
+ return {
356
+ ok: true,
357
+ ...context,
358
+ installed: hookContent !== null,
359
+ managed: isManagedHook(hookContent),
360
+ userHookPresent: fs.existsSync(context.userHookPath),
361
+ runnerPath,
362
+ runnerPresent: fs.existsSync(runnerPath),
363
+ };
364
+ }
365
+
366
+ export async function getMempalacePostCommitHookStatus(options: BaseHookOptions): Promise<MempalacePostCommitHookStatusResult> {
367
+ const context = await resolveHookContext(options.exec, options.cwd);
368
+ if (isHookContextFailure(context)) return context;
369
+ return buildStatus(context, options.paths.global("bin", REINDEX_RUNNER_NAME));
370
+ }
371
+
372
+ export async function installMempalacePostCommitHook(options: InstallMempalacePostCommitHookOptions): Promise<MempalacePostCommitHookInstallResult> {
373
+ if (!options.config.mempalace.enabled) {
374
+ return { ok: false, code: "mempalace_disabled", message: "MemPalace integration is disabled in config." };
375
+ }
376
+
377
+ const snapshot = options.snapshot ?? snapshotMempalaceInstall(options.paths, options.cwd, options.config);
378
+ if (!snapshot.ready) {
379
+ return { ok: false, code: "mempalace_not_ready", message: "MemPalace runtime is not ready. Run /supi:memory setup before installing the git hook." };
380
+ }
381
+
382
+ const context = await resolveHookContext(options.exec, options.cwd);
383
+ if (isHookContextFailure(context)) return context;
384
+
385
+ const resolved = resolveMempalaceConfig(options.config, context.repoRoot, options.paths);
386
+ const runnerPath = options.paths.global("bin", REINDEX_RUNNER_NAME);
387
+
388
+ const desiredHook = buildPostCommitHookScript(snapshot, runnerPath, resolved.palacePath, resolved.defaultAgentName);
389
+ const existingHook = readTextIfPresent(context.hookPath);
390
+
391
+ // Refuse the install before writing any artifacts: when there is a
392
+ // non-managed active hook AND the chained user slot is already taken,
393
+ // we have nowhere to move the existing hook to.
394
+ if (existingHook !== null && !isManagedHook(existingHook) && fs.existsSync(context.userHookPath)) {
395
+ return {
396
+ ok: false,
397
+ code: "user_hook_conflict",
398
+ message: `Cannot install MemPalace post-commit hook because both ${context.hookPath} and ${context.userHookPath} already exist and the active hook is not managed by supipowers.`,
399
+ };
400
+ }
401
+
402
+ writeExecutableFile(runnerPath, buildReindexRunnerScript());
403
+
404
+ if (existingHook === desiredHook) {
405
+ return { ...buildStatus(context, runnerPath), action: "already-installed" };
406
+ }
407
+
408
+ fs.mkdirSync(context.hooksDir, { recursive: true });
409
+ let action: MempalacePostCommitHookInstallAction = "installed";
410
+ if (existingHook !== null && !isManagedHook(existingHook)) {
411
+ fs.renameSync(context.hookPath, context.userHookPath);
412
+ action = "chained-user-hook";
413
+ } else if (existingHook !== null) {
414
+ action = "upgraded";
415
+ }
416
+
417
+ writeExecutableFile(context.hookPath, desiredHook);
418
+ return { ...buildStatus(context, runnerPath), action };
419
+ }
420
+
421
+ export async function uninstallMempalacePostCommitHook(options: BaseHookOptions): Promise<MempalacePostCommitHookUninstallResult> {
422
+ const context = await resolveHookContext(options.exec, options.cwd);
423
+ if (isHookContextFailure(context)) return context;
424
+
425
+ const runnerPath = options.paths.global("bin", REINDEX_RUNNER_NAME);
426
+ const existingHook = readTextIfPresent(context.hookPath);
427
+ if (existingHook !== null && !isManagedHook(existingHook)) {
428
+ return { ok: false, code: "not_managed", message: `${context.hookPath} is not managed by supipowers; refusing to remove it.` };
429
+ }
430
+
431
+ if (existingHook !== null) {
432
+ fs.rmSync(context.hookPath, { force: true });
433
+ }
434
+
435
+ let action: MempalacePostCommitHookUninstallAction = existingHook === null ? "already-uninstalled" : "uninstalled";
436
+ if (fs.existsSync(context.userHookPath) && !fs.existsSync(context.hookPath)) {
437
+ fs.renameSync(context.userHookPath, context.hookPath);
438
+ chmodExecutableBestEffort(context.hookPath);
439
+ action = "restored-user-hook";
440
+ }
441
+
442
+ return { ...buildStatus(context, runnerPath), action };
443
+ }
@@ -7,6 +7,7 @@ import { resolveInstalledBridgeScriptPath } from "./runtime.js";
7
7
  import { getEventStore as getContextEventStore, getSessionId as getContextSessionId } from "../context-mode/hooks.js";
8
8
  import { buildCompactionCheckpoint, buildShutdownDiary } from "./session-summary.js";
9
9
  import { snapshotMempalaceInstall } from "./installer-helper.js";
10
+ import { buildMempalaceGuidance } from "./contract.js";
10
11
 
11
12
  export interface MempalaceHooksDeps {
12
13
  createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
@@ -162,12 +163,9 @@ function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: stri
162
163
  "# MemPalace memory",
163
164
  `- palace: ${resolved.palacePath}`,
164
165
  `- default wing: ${wing}`,
165
- ];
166
- if (resolved.hooks.searchGuidance) {
167
- lines.push(
168
- "- You **MUST** call `mempalace(action=\"search\", query=...)` before answering questions about prior decisions, people, projects, or past events. Skip only when the answer is fully derivable from the current turn or the active codebase.",
169
- );
170
- }
166
+ "",
167
+ ...buildMempalaceGuidance(resolved.hooks, "full"),
168
+ ].filter((line) => line.length > 0);
171
169
  if (excerpt.trim()) {
172
170
  lines.push("", "## Wake-up excerpt", excerpt.trim());
173
171
  }
@@ -175,19 +173,15 @@ function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: stri
175
173
  }
176
174
 
177
175
  /**
178
- * Compact one-line refresher injected on turns where we skip the full
179
- * wake-up dump. Keeps the model oriented (palace/wing) and re-asserts the
180
- * RFC-2119 search nudge in ~140 chars instead of ~870 tokens.
176
+ * Compact refresher injected on turns where we skip the full wake-up dump.
177
+ * Keeps the model oriented (palace/wing) and re-asserts the MemPalace
178
+ * read/write contract without paying for the wake-up excerpt.
181
179
  */
182
180
  function wakeUpRefresher(resolved: ResolvedMempalaceConfig, wing: string): string {
183
181
  const lines = [
184
182
  `# MemPalace memory: wing=${wing}`,
183
+ ...buildMempalaceGuidance(resolved.hooks, "refresher"),
185
184
  ];
186
- if (resolved.hooks.searchGuidance) {
187
- lines.push(
188
- "- You **MUST** call `mempalace(action=\"search\", query=...)` before answering past-fact questions; per-turn search results appear below when relevant.",
189
- );
190
- }
191
185
  return lines.join("\n");
192
186
  }
193
187
 
@@ -326,7 +320,8 @@ export function registerMempalaceHooks(
326
320
  const wakeUpEnabled = config.mempalace.hooks.wakeUp;
327
321
  const guidanceEnabled = config.mempalace.hooks.searchGuidance;
328
322
  const autoSearchEnabled = config.mempalace.hooks.autoSearchOnPrompt;
329
- if (!wakeUpEnabled && !guidanceEnabled && !autoSearchEnabled) return undefined;
323
+ const writeGuidanceEnabled = config.mempalace.hooks.writeGuidance;
324
+ if (!wakeUpEnabled && !guidanceEnabled && !writeGuidanceEnabled && !autoSearchEnabled) return undefined;
330
325
 
331
326
  const cwd = contextCwd(ctx);
332
327
  const resolved = resolveMempalaceConfig(config, cwd, platform.paths);
@@ -15,6 +15,7 @@ import {
15
15
  snapshotMempalaceInstall,
16
16
  type MempalaceInstallSnapshot,
17
17
  } from "./installer-helper.js";
18
+ import { MEMPALACE_TOOL_DESCRIPTION } from "./contract.js";
18
19
 
19
20
  export interface MempalaceToolDeps {
20
21
  createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
@@ -130,8 +131,7 @@ export function registerMempalaceTool(
130
131
  platform.registerTool({
131
132
  name: "mempalace",
132
133
  label: "MemPalace",
133
- description:
134
- "MemPalace memory dispatcher. **MUST** call `search` before answering past-fact questions; write only on explicit user request.",
134
+ description: MEMPALACE_TOOL_DESCRIPTION,
135
135
  parameters: mempalaceToolParameters,
136
136
  async execute(_toolCallId: string, rawParams: unknown, _signal: AbortSignal, onUpdate: unknown, toolCtx: unknown) {
137
137
  try {
@@ -12,6 +12,10 @@ export const PINNED_UV_VERSION = "0.5.30";
12
12
 
13
13
  const UV_BASE_URL = "https://github.com/astral-sh/uv/releases/download";
14
14
 
15
+ const TAR_PREFLIGHT_TIMEOUT_MS = 3_000;
16
+ const TAR_REMEDIATION =
17
+ "Install GNU tar / BSD tar (built-in on macOS, Linux, and Windows 10+). On older Windows, install Git for Windows or run `/supi:memory setup` after adding tar to PATH.";
18
+
15
19
  export type UvPlatform =
16
20
  | "darwin-arm64"
17
21
  | "darwin-x64"
@@ -118,6 +122,34 @@ function writeVersionStamp(managedBinDir: string, version: string): void {
118
122
  fs.writeFileSync(versionStampPath(managedBinDir), `${version}\n`, "utf8");
119
123
  }
120
124
 
125
+ function tarUnavailable(message: string): EnsureUvResult {
126
+ return {
127
+ ok: false,
128
+ error: {
129
+ code: "uv_extract_failed",
130
+ message,
131
+ remediation: TAR_REMEDIATION,
132
+ },
133
+ };
134
+ }
135
+
136
+ async function verifyTarAvailable(runner: ProcessRunner): Promise<EnsureUvResult | null> {
137
+ let result;
138
+ try {
139
+ result = await runner("tar", ["--version"], { timeoutMs: TAR_PREFLIGHT_TIMEOUT_MS });
140
+ } catch (error) {
141
+ return tarUnavailable(
142
+ `tar is required to extract the uv archive but could not be launched: ${error instanceof Error ? error.message : String(error)}`,
143
+ );
144
+ }
145
+
146
+ if (result.code !== 0) {
147
+ return tarUnavailable("tar is required to extract the uv archive but is not on PATH.");
148
+ }
149
+
150
+ return null;
151
+ }
152
+
121
153
  export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult> {
122
154
  const version = options.version ?? PINNED_UV_VERSION;
123
155
  const uvPlatform = options.platform === undefined ? detectUvPlatform() : options.platform;
@@ -139,6 +171,9 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
139
171
  return { ok: true, uvPath: managedPath, version, source: "cached" };
140
172
  }
141
173
 
174
+ const tarPreflight = await verifyTarAvailable(options.runner);
175
+ if (tarPreflight) return tarPreflight;
176
+
142
177
  const fetcher = options.fetcher ?? defaultFetcher;
143
178
  options.onProgress?.(`Downloading uv ${version} for ${targetSpec.triple}`);
144
179
 
@@ -215,7 +250,7 @@ export async function ensureUv(options: EnsureUvOptions): Promise<EnsureUvResult
215
250
  message: `tar failed to extract uv archive (code ${extract.code}): ${
216
251
  extract.stderr.trim() || extract.stdout.trim() || "no output"
217
252
  }`,
218
- remediation: "Ensure tar is on PATH (built-in on macOS, Linux, and Windows 10+).",
253
+ remediation: TAR_REMEDIATION,
219
254
  },
220
255
  };
221
256
  }
@@ -129,11 +129,11 @@ function getAskRedirectReason(): string | null {
129
129
  */
130
130
  export function registerPlanningAskToolGuard(platform: Platform): void {
131
131
  platform.on("tool_call", (event) => {
132
- if (event.toolName === "resolve" && isPlanApprovalResolveInput(event.input) && isPlanningActive()) {
132
+ if (event.toolName === "resolve" && isResolveApplyInput(event.input) && isPlanningActive()) {
133
133
  return {
134
134
  block: true,
135
135
  reason:
136
- "Planning mode: /supi:plan uses a file-based approval hook. Do not call `resolve` with `extra.title` because it is OMP's native approval path and bypasses supipowers plan tracking.",
136
+ "Planning mode: /supi:plan uses a file-based approval hook. Native OMP plan approval is blocked because it bypasses supipowers plan tracking.",
137
137
  };
138
138
  }
139
139
 
@@ -149,13 +149,7 @@ export function registerPlanningAskToolGuard(platform: Platform): void {
149
149
  });
150
150
  }
151
151
 
152
- function isPlanApprovalResolveInput(input: unknown): boolean {
152
+ function isResolveApplyInput(input: unknown): boolean {
153
153
  if (input === null || typeof input !== "object" || Array.isArray(input)) return false;
154
- const candidate = input as { action?: unknown; extra?: unknown };
155
- if (candidate.action !== "apply") return false;
156
- const extra = candidate.extra;
157
- return extra !== null
158
- && typeof extra === "object"
159
- && !Array.isArray(extra)
160
- && typeof (extra as { title?: unknown }).title === "string";
154
+ return (input as { action?: unknown }).action === "apply";
161
155
  }
@@ -116,7 +116,8 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
116
116
  } else {
117
117
  console.log(`[dry-run] Would git tag -a ${tagName}`);
118
118
  }
119
- console.log(`[dry-run] Would git push origin HEAD --follow-tags`);
119
+ console.log(`[dry-run] Would git push origin HEAD`);
120
+ console.log(`[dry-run] Would git push origin ${tagName}`);
120
121
  for (const channel of channels) {
121
122
  console.log(`[dry-run] Would publish to channel: ${channel}`);
122
123
  }
@@ -199,8 +200,8 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
199
200
 
200
201
  let pushAttempt = 0;
201
202
  while (true) {
202
- progress("git-push", "active", pushAttempt === 0 ? "Pushing to origin" : "Retrying push after rebase");
203
- const gitPush = await exec("git", ["push", "origin", "HEAD", "--follow-tags"], { cwd });
203
+ progress("git-push", "active", pushAttempt === 0 ? "Pushing commit to origin" : "Retrying commit push after rebase");
204
+ const gitPush = await exec("git", ["push", "origin", "HEAD"], { cwd });
204
205
  if (gitPush.code === 0) {
205
206
  progress("git-push", "done");
206
207
  break;
@@ -228,6 +229,15 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
228
229
  }
229
230
  }
230
231
 
232
+ progress("git-push-tags", "active", `Pushing ${tagName} to origin`);
233
+ const gitPushTag = await exec("git", ["push", "origin", tagName], { cwd });
234
+ if (gitPushTag.code !== 0) {
235
+ const detail = gitPushTag.stderr || gitPushTag.stdout || `exit code ${gitPushTag.code}`;
236
+ progress("git-push-tags", "error", detail);
237
+ return { version, tagCreated: true, pushed: false, channels: [], error: `git push tag: ${detail}` };
238
+ }
239
+ progress("git-push-tags", "done");
240
+
231
241
  const channelResults: ReleaseResult["channels"] = [];
232
242
  for (const channel of channels) {
233
243
  progress(`publish-${channel}`, "active", `Publishing to ${channel}`);
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { normalizeLineEndings } from "../text.js";
3
+ import { ensureTrailingNewline, normalizeLineEndings } from "../text.js";
4
4
  import type { Plan, PlanTask, TaskComplexity, WorkspaceTarget } from "../types.js";
5
5
  import type { PlatformPaths } from "../platform/types.js";
6
6
  import { getProjectStatePath, getProjectTargetStatePath } from "../workspace/state-paths.js";
@@ -40,7 +40,7 @@ export function savePlan(paths: PlatformPaths, cwd: string, filename: string, co
40
40
  const dir = getPlansDir(paths, cwd);
41
41
  fs.mkdirSync(dir, { recursive: true });
42
42
  const filePath = path.join(dir, filename);
43
- fs.writeFileSync(filePath, content);
43
+ fs.writeFileSync(filePath, ensureTrailingNewline(normalizeLineEndings(content)));
44
44
  return filePath;
45
45
  }
46
46
 
package/src/text.ts CHANGED
@@ -1,6 +1,11 @@
1
- /** Normalizes Windows CRLF line endings to LF. */
1
+ /** Normalizes CRLF and lone CR line endings to LF for deterministic artifacts. */
2
2
  export function normalizeLineEndings(text: string): string {
3
- return text.replace(/\r\n/g, "\n");
3
+ return text.replace(/\r\n?/g, "\n");
4
+ }
5
+
6
+ /** Ensures text files have a trailing LF without changing already-terminated content. */
7
+ export function ensureTrailingNewline(text: string): string {
8
+ return text.endsWith("\n") ? text : `${text}\n`;
4
9
  }
5
10
 
6
11
  /** Removes a single outer markdown code fence wrapper when present. */
package/src/types.ts CHANGED
@@ -572,9 +572,11 @@ export interface MempalaceConfig {
572
572
  hooks: {
573
573
  wakeUp: boolean;
574
574
  searchGuidance: boolean;
575
+ writeGuidance: boolean;
575
576
  autoSearchOnPrompt: boolean;
576
577
  compactionCheckpoint: boolean;
577
578
  shutdownDiary: boolean;
579
+ postCommitReindex: boolean;
578
580
  };
579
581
  budgets: {
580
582
  wakeUpTokens: number;
@@ -6,7 +6,7 @@ import type { Platform } from "../platform/types.js";
6
6
  * Resolution order:
7
7
  * 1. `$VISUAL`
8
8
  * 2. `$EDITOR`
9
- * 3. OS default opener (`open` on darwin, `start` on win32, `xdg-open` elsewhere)
9
+ * 3. OS default opener (`open` on darwin, `cmd /d /s /c start` on win32, `xdg-open` elsewhere)
10
10
  *
11
11
  * `platform.exec` blocks until the spawned editor process exits, which is what
12
12
  * the synthesize stage needs for its `$EDITOR` round-trip. For OS-default openers
@@ -18,7 +18,34 @@ import type { Platform } from "../platform/types.js";
18
18
  * without throwing. Callers that need to verify the user actually edited the file
19
19
  * should detect changes by comparing mtime / contents before and after.
20
20
  */
21
- export async function openInEditor(platform: Platform, filePath: string): Promise<void> {
21
+ export interface EditorInvocation {
22
+ command: string;
23
+ args: string[];
24
+ }
25
+
26
+
27
+ function quoteCmdArgument(arg: string): string {
28
+ return `"${arg.replace(/"/g, '""')}"`;
29
+ }
30
+
31
+ export function resolveDefaultEditorInvocation(
32
+ hostPlatform: NodeJS.Platform,
33
+ filePath: string,
34
+ ): EditorInvocation {
35
+ if (hostPlatform === "win32") {
36
+ return { command: "cmd", args: ["/d", "/s", "/c", `start "" ${quoteCmdArgument(filePath)}`] };
37
+ }
38
+
39
+ return hostPlatform === "darwin"
40
+ ? { command: "open", args: [filePath] }
41
+ : { command: "xdg-open", args: [filePath] };
42
+ }
43
+
44
+ export async function openInEditor(
45
+ platform: Platform,
46
+ filePath: string,
47
+ hostPlatform: NodeJS.Platform = process.platform,
48
+ ): Promise<void> {
22
49
  const editor = process.env.VISUAL || process.env.EDITOR;
23
50
  try {
24
51
  if (editor) {
@@ -28,9 +55,8 @@ export async function openInEditor(platform: Platform, filePath: string): Promis
28
55
  const args = [...tokens.slice(1), filePath];
29
56
  await platform.exec(cmd, args);
30
57
  } else {
31
- const cmd = process.platform === "darwin" ? "open"
32
- : process.platform === "win32" ? "start" : "xdg-open";
33
- await platform.exec(cmd, [filePath]);
58
+ const invocation = resolveDefaultEditorInvocation(hostPlatform, filePath);
59
+ await platform.exec(invocation.command, invocation.args);
34
60
  }
35
61
  } catch {
36
62
  // Editor open failed — non-fatal, file was still written
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "chokidar": "^4.0.0",
12
12
  "express": "^4.21.0",
13
- "ws": "^8.18.0"
13
+ "ws": "^8.20.1"
14
14
  }
15
15
  },
16
16
  "node_modules/accepts": {
@@ -854,9 +854,9 @@
854
854
  }
855
855
  },
856
856
  "node_modules/ws": {
857
- "version": "8.20.0",
858
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
859
- "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
857
+ "version": "8.20.1",
858
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
859
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
860
860
  "license": "MIT",
861
861
  "engines": {
862
862
  "node": ">=10.0.0"
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "chokidar": "^4.0.0",
12
12
  "express": "^4.21.0",
13
- "ws": "^8.18.0"
13
+ "ws": "^8.20.1"
14
14
  }
15
15
  },
16
16
  "node_modules/accepts": {
@@ -854,9 +854,9 @@
854
854
  }
855
855
  },
856
856
  "node_modules/ws": {
857
- "version": "8.20.0",
858
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
859
- "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
857
+ "version": "8.20.1",
858
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
859
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
860
860
  "license": "MIT",
861
861
  "engines": {
862
862
  "node": ">=10.0.0"
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "dependencies": {
6
6
  "express": "^4.21.0",
7
- "ws": "^8.18.0",
7
+ "ws": "^8.20.1",
8
8
  "chokidar": "^4.0.0"
9
9
  },
10
10
  "overrides": {