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.
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import type { Platform, PlatformPaths } from "../platform/types.js";
15
15
  import type {
16
16
  HarnessGateMode,
17
+ HarnessPipelineProgressEvent,
17
18
  HarnessStage,
18
19
  ModelConfig,
19
20
  } from "../types.js";
@@ -38,14 +39,6 @@ import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
38
39
  import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
39
40
  import { getProjectStatePath } from "../workspace/state-paths.js";
40
41
 
41
- /** Progress event emitted by the pipeline driver for UI feedback. */
42
- export type HarnessPipelineProgressEvent =
43
- | { type: "stage-started"; stage: HarnessStage }
44
- | { type: "stage-skipped"; stage: HarnessStage }
45
- | { type: "stage-completed"; stage: HarnessStage; detail?: string }
46
- | { type: "stage-blocked"; stage: HarnessStage; detail: string }
47
- | { type: "stage-failed"; stage: HarnessStage; detail: string }
48
- | { type: "awaiting-user"; stage: HarnessStage; detail?: string };
49
42
 
50
43
  const STAGE_ORDER: readonly HarnessStage[] = [
51
44
  "discover",
@@ -301,6 +294,7 @@ export async function runHarnessPipelineUntilGate(
301
294
  sessionId: input.sessionId,
302
295
  modelConfig: input.modelConfig,
303
296
  gateMode: input.gates,
297
+ onProgress: (event) => input.onProgress?.(event),
304
298
  };
305
299
 
306
300
  const result = await runner.run(ctx);
@@ -21,6 +21,7 @@
21
21
  import type { Platform, PlatformPaths } from "../platform/types.js";
22
22
  import type {
23
23
  HarnessGateMode,
24
+ HarnessPipelineProgressEvent,
24
25
  HarnessStage,
25
26
  HarnessStageStatus,
26
27
  ModelConfig,
@@ -46,6 +47,8 @@ export interface HarnessStageRunnerContext {
46
47
  now?: () => string;
47
48
  /** Optional override for the agent session model. Tests use this to bypass resolution. */
48
49
  modelOverride?: { model: string; thinkingLevel: string | null };
50
+ /** Live progress sink for long-running stage internals such as subagent turns. */
51
+ onProgress?: (event: HarnessPipelineProgressEvent) => void;
49
52
  }
50
53
 
51
54
  export type HarnessStageRunStatus =
@@ -90,6 +90,7 @@ export interface DocsStageInput {
90
90
  }
91
91
 
92
92
  interface AgentSessionLike {
93
+ subscribe?: (handler: (event: unknown) => void) => () => void;
93
94
  prompt(text: string, opts?: { expandPromptTemplates?: boolean }): Promise<void>;
94
95
  dispose(): Promise<void>;
95
96
  }
@@ -536,6 +537,60 @@ async function orchestrateLayerSubagent(input: OrchestrateLayerInput): Promise<O
536
537
  }
537
538
  }
538
539
 
540
+ function isRecord(value: unknown): value is Record<string, unknown> {
541
+ return typeof value === "object" && value !== null && !Array.isArray(value);
542
+ }
543
+
544
+ function stringField(record: Record<string, unknown>, key: string): string | null {
545
+ const value = record[key];
546
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
547
+ }
548
+
549
+ function nestedStringField(record: Record<string, unknown>, objectKey: string, fieldKey: string): string | null {
550
+ const nested = record[objectKey];
551
+ if (!isRecord(nested)) return null;
552
+ return stringField(nested, fieldKey);
553
+ }
554
+
555
+ function compactDetail(value: string, maxChars = 180): string {
556
+ const compact = value.replace(/\s+/g, " ").trim();
557
+ if (compact.length <= maxChars) return compact;
558
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
559
+ }
560
+
561
+ function summarizeAgentSessionEvent(event: unknown): string | null {
562
+ if (!isRecord(event)) return null;
563
+ const type = stringField(event, "type") ?? stringField(event, "kind") ?? stringField(event, "event");
564
+ const lowerType = type?.toLowerCase() ?? "";
565
+ const toolName =
566
+ stringField(event, "toolName") ??
567
+ stringField(event, "tool") ??
568
+ stringField(event, "name") ??
569
+ nestedStringField(event, "toolCall", "name") ??
570
+ nestedStringField(event, "tool_call", "name");
571
+ if (toolName && (lowerType.includes("tool") || lowerType.includes("call"))) {
572
+ return `tool ${compactDetail(toolName, 80)}`;
573
+ }
574
+
575
+ const text =
576
+ stringField(event, "text") ??
577
+ stringField(event, "delta") ??
578
+ stringField(event, "thought") ??
579
+ stringField(event, "content") ??
580
+ nestedStringField(event, "message", "text") ??
581
+ nestedStringField(event, "message", "content");
582
+ if (text) {
583
+ const prefix = lowerType.includes("thought") || lowerType.includes("reason")
584
+ ? "thought"
585
+ : lowerType.includes("tool")
586
+ ? "tool"
587
+ : "agent";
588
+ return `${prefix} ${compactDetail(text)}`;
589
+ }
590
+
591
+ return type ? compactDetail(type, 80) : null;
592
+ }
593
+
539
594
  async function dispatchSubagent(input: {
540
595
  platform: Platform;
541
596
  ctx: HarnessStageRunnerContext;
@@ -548,7 +603,13 @@ async function dispatchSubagent(input: {
548
603
  const agentDisplayName = buildHarnessAgentDisplayName("docs", input.entry.layer.layer);
549
604
 
550
605
  let session: AgentSessionLike | null = null;
606
+ let unsubscribe: (() => void) | null = null;
551
607
  try {
608
+ input.ctx.onProgress?.({
609
+ type: "stage-progress",
610
+ stage: "docs",
611
+ detail: `${agentDisplayName}: starting attempt ${input.attempt}`,
612
+ });
552
613
  if (input.factory) {
553
614
  session = await input.factory(input.platform, {
554
615
  cwd: input.ctx.cwd,
@@ -562,7 +623,21 @@ async function dispatchSubagent(input: {
562
623
  agentDisplayName,
563
624
  });
564
625
  }
626
+ unsubscribe = session.subscribe?.((event) => {
627
+ const summary = summarizeAgentSessionEvent(event);
628
+ if (!summary) return;
629
+ input.ctx.onProgress?.({
630
+ type: "stage-progress",
631
+ stage: "docs",
632
+ detail: `${agentDisplayName}: ${summary}`,
633
+ });
634
+ }) ?? null;
565
635
  await session.prompt(input.assignment, { expandPromptTemplates: false });
636
+ input.ctx.onProgress?.({
637
+ type: "stage-progress",
638
+ stage: "docs",
639
+ detail: `${agentDisplayName}: attempt ${input.attempt} complete`,
640
+ });
566
641
  } catch (error) {
567
642
  return {
568
643
  ok: false,
@@ -571,6 +646,13 @@ async function dispatchSubagent(input: {
571
646
  ],
572
647
  };
573
648
  } finally {
649
+ if (unsubscribe) {
650
+ try {
651
+ unsubscribe();
652
+ } catch {
653
+ /* best-effort */
654
+ }
655
+ }
574
656
  if (session) {
575
657
  try {
576
658
  await session.dispose();
@@ -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
+ }