supipowers 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +5 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +5 -133
  5. package/src/config/defaults.ts +5 -5
  6. package/src/config/loader.ts +1 -0
  7. package/src/config/schema.ts +2 -6
  8. package/src/context-mode/knowledge/store.ts +381 -43
  9. package/src/context-mode/tools.ts +41 -3
  10. package/src/deps/registry.ts +1 -12
  11. package/src/fix-pr/assessment.ts +1 -0
  12. package/src/fix-pr/prompt-builder.ts +1 -0
  13. package/src/git/commit.ts +76 -18
  14. package/src/harness/command.ts +103 -6
  15. package/src/harness/default-agents/docs.md +39 -0
  16. package/src/harness/docs/config.ts +29 -0
  17. package/src/harness/docs/glob-match.ts +27 -0
  18. package/src/harness/docs/index-renderer.ts +82 -0
  19. package/src/harness/docs/provenance.ts +125 -0
  20. package/src/harness/docs/regen-decision.ts +167 -0
  21. package/src/harness/docs/representative-files.ts +175 -0
  22. package/src/harness/docs/source-hash.ts +106 -0
  23. package/src/harness/docs/validator.ts +233 -0
  24. package/src/harness/hooks/layer-context-inject.ts +35 -1
  25. package/src/harness/hooks/register.ts +24 -3
  26. package/src/harness/pipeline.ts +20 -5
  27. package/src/harness/pr-comment/baseline.ts +105 -0
  28. package/src/harness/pr-comment/ci-env.ts +120 -0
  29. package/src/harness/pr-comment/gh-poster.ts +227 -0
  30. package/src/harness/pr-comment/handler.ts +198 -0
  31. package/src/harness/pr-comment/render.ts +297 -0
  32. package/src/harness/pr-comment/status.ts +95 -0
  33. package/src/harness/pr-comment/types.ts +73 -0
  34. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  35. package/src/harness/project-paths.ts +95 -0
  36. package/src/harness/stages/design.ts +1 -0
  37. package/src/harness/stages/discover.ts +1 -13
  38. package/src/harness/stages/docs.ts +708 -0
  39. package/src/harness/stages/implement-apply.ts +877 -0
  40. package/src/harness/stages/implement.ts +64 -51
  41. package/src/harness/stages/plan.ts +25 -16
  42. package/src/harness/stages/validate.ts +370 -0
  43. package/src/harness/storage.ts +142 -0
  44. package/src/harness/tools.ts +130 -0
  45. package/src/mempalace/bridge.ts +207 -41
  46. package/src/mempalace/config.ts +10 -4
  47. package/src/mempalace/format.ts +122 -6
  48. package/src/mempalace/hooks.ts +204 -56
  49. package/src/mempalace/installer-helper.ts +18 -4
  50. package/src/mempalace/python/mempalace_bridge.py +128 -3
  51. package/src/mempalace/runtime.ts +53 -16
  52. package/src/mempalace/schema.ts +151 -30
  53. package/src/mempalace/session-summary.ts +5 -0
  54. package/src/mempalace/tool.ts +17 -4
  55. package/src/mempalace/upstream-limits.ts +69 -0
  56. package/src/planning/approval-flow.ts +25 -2
  57. package/src/planning/planning-ask-tool.ts +34 -4
  58. package/src/planning/system-prompt.ts +1 -1
  59. package/src/tool-catalog/active-tool-controller.ts +0 -22
  60. package/src/tool-catalog/active-tool-planner.ts +0 -26
  61. package/src/tool-catalog/tool-groups.ts +1 -9
  62. package/src/types.ts +87 -8
  63. package/src/ui-design/session.ts +114 -8
  64. package/src/utils/executable.ts +10 -1
  65. package/src/workspace/state-paths.ts +1 -1
  66. package/src/commands/mcp.ts +0 -814
  67. package/src/mcp/activation.ts +0 -77
  68. package/src/mcp/config.ts +0 -223
  69. package/src/mcp/docs.ts +0 -154
  70. package/src/mcp/gateway.ts +0 -103
  71. package/src/mcp/lifecycle.ts +0 -79
  72. package/src/mcp/manager-tool.ts +0 -104
  73. package/src/mcp/mcpc.ts +0 -113
  74. package/src/mcp/registry.ts +0 -98
  75. package/src/mcp/triggers.ts +0 -62
  76. package/src/mcp/types.ts +0 -95
@@ -29,15 +29,22 @@ import {
29
29
  getHarnessDecisionsPath,
30
30
  getHarnessDesignSpecJsonPath,
31
31
  getHarnessDiscoverPath,
32
+ getHarnessDocsStagingDir,
33
+ getHarnessDocsStagingLayerPath,
34
+ getHarnessDocsStagingReadmePath,
32
35
  getHarnessImplementLogPath,
33
36
  getHarnessManifestPath,
34
37
  getHarnessPipelineLogPath,
35
38
  getHarnessQueuePath,
39
+ getHarnessRepoDocsLayerPath,
40
+ getHarnessRepoDocsLayersDir,
41
+ getHarnessRepoDocsReadmePath,
36
42
  getHarnessRepoScorePath,
37
43
  getHarnessResearchTopicPath,
38
44
  getHarnessScoreHistoryPath,
39
45
  getHarnessSessionDir,
40
46
  getHarnessValidateReportPath,
47
+ HARNESS_DOCS_LAYERS_DIRNAME,
41
48
  } from "./project-paths.js";
42
49
 
43
50
  // ---------------------------------------------------------------------------
@@ -418,6 +425,44 @@ export function appendImplementLog(
418
425
  return appendJsonl(getHarnessImplementLogPath(paths, cwd, sessionId), record);
419
426
  }
420
427
 
428
+ /**
429
+ * Return true if the implement log records a successful programmatic apply for this
430
+ * session: the most recent record has `kind: "applied"` and an empty `errors` array.
431
+ * Used by `HarnessImplementStage.isComplete` to fast-skip reruns.
432
+ */
433
+ export function hasSuccessfulImplementApply(
434
+ paths: PlatformPaths,
435
+ cwd: string,
436
+ sessionId: string,
437
+ ): boolean {
438
+ const logPath = getHarnessImplementLogPath(paths, cwd, sessionId);
439
+ if (!fs.existsSync(logPath)) return false;
440
+ let raw: string;
441
+ try {
442
+ raw = fs.readFileSync(logPath, "utf8");
443
+ } catch {
444
+ return false;
445
+ }
446
+ // Scan from the end so a later failed re-apply correctly overrides an earlier success.
447
+ const lines = raw.split("\n");
448
+ for (let i = lines.length - 1; i >= 0; i--) {
449
+ const line = lines[i].trim();
450
+ if (line.length === 0) continue;
451
+ let record: unknown;
452
+ try {
453
+ record = JSON.parse(line);
454
+ } catch {
455
+ continue;
456
+ }
457
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
458
+ const r = record as { kind?: unknown; errors?: unknown };
459
+ if (r.kind !== "applied") continue;
460
+ const errCount = Array.isArray(r.errors) ? r.errors.length : 0;
461
+ return errCount === 0;
462
+ }
463
+ return false;
464
+ }
465
+
421
466
  // ---------------------------------------------------------------------------
422
467
  // Project-scoped queue + score (shared across worktrees)
423
468
  // ---------------------------------------------------------------------------
@@ -465,3 +510,100 @@ export function appendScoreHistory(
465
510
  ): UltraPlanStorageResult<string> {
466
511
  return appendJsonl(getHarnessScoreHistoryPath(paths, cwd), record);
467
512
  }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Docs stage — staging + repo promotion.
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /** Save a single layer doc into the session's staging area. Atomic write. */
519
+ export function saveHarnessDocsLayerStaging(
520
+ paths: PlatformPaths,
521
+ cwd: string,
522
+ sessionId: string,
523
+ layerId: string,
524
+ markdown: string,
525
+ ): UltraPlanStorageResult<string> {
526
+ return writeTextAtomic(
527
+ getHarnessDocsStagingLayerPath(paths, cwd, sessionId, layerId),
528
+ markdown,
529
+ );
530
+ }
531
+
532
+ /** Read a single staged layer doc. */
533
+ export function loadHarnessDocsLayerStaging(
534
+ paths: PlatformPaths,
535
+ cwd: string,
536
+ sessionId: string,
537
+ layerId: string,
538
+ ): UltraPlanStorageResult<string> {
539
+ return readTextFile(getHarnessDocsStagingLayerPath(paths, cwd, sessionId, layerId));
540
+ }
541
+
542
+ /** List staged layer ids (file basenames without `.md`). Returns [] when dir is absent. */
543
+ export function listHarnessDocsLayerStaging(
544
+ paths: PlatformPaths,
545
+ cwd: string,
546
+ sessionId: string,
547
+ ): string[] {
548
+ const dir = path.join(
549
+ getHarnessDocsStagingDir(paths, cwd, sessionId),
550
+ HARNESS_DOCS_LAYERS_DIRNAME,
551
+ );
552
+ if (!fs.existsSync(dir)) return [];
553
+ try {
554
+ return fs
555
+ .readdirSync(dir)
556
+ .filter((name) => name.endsWith(".md"))
557
+ .map((name) => name.slice(0, -3))
558
+ .sort();
559
+ } catch {
560
+ return [];
561
+ }
562
+ }
563
+
564
+ /** Save the staged docs index. Atomic write. */
565
+ export function saveHarnessDocsIndexStaging(
566
+ paths: PlatformPaths,
567
+ cwd: string,
568
+ sessionId: string,
569
+ markdown: string,
570
+ ): UltraPlanStorageResult<string> {
571
+ return writeTextAtomic(getHarnessDocsStagingReadmePath(paths, cwd, sessionId), markdown);
572
+ }
573
+
574
+ /**
575
+ * Promote staged docs to the repo-local docs/ tree.
576
+ *
577
+ * Atomicity contract: layer docs are written first (each via temp → rename); the index
578
+ * is written last so an observer reading mid-promotion never sees an index pointing at
579
+ * a yet-to-land layer doc. A failure midway leaves the previous repo state in place for
580
+ * already-rewritten files only when their layer was earlier in the list — callers must
581
+ * therefore treat partial failures as a "blocked" outcome and rely on the next run to
582
+ * re-promote from staging.
583
+ */
584
+ export function promoteHarnessDocsToRepo(
585
+ paths: PlatformPaths,
586
+ cwd: string,
587
+ sessionId: string,
588
+ layerIds: readonly string[],
589
+ ): UltraPlanStorageResult<{ layerPaths: string[]; indexPath: string }> {
590
+ fs.mkdirSync(getHarnessRepoDocsLayersDir(paths, cwd), { recursive: true });
591
+
592
+ const layerPaths: string[] = [];
593
+ for (const layerId of layerIds) {
594
+ const staged = loadHarnessDocsLayerStaging(paths, cwd, sessionId, layerId);
595
+ if (!staged.ok) return staged;
596
+ const repoPath = getHarnessRepoDocsLayerPath(paths, cwd, layerId);
597
+ const wrote = writeTextAtomic(repoPath, staged.value);
598
+ if (!wrote.ok) return wrote;
599
+ layerPaths.push(wrote.value);
600
+ }
601
+
602
+ const indexStaged = readTextFile(getHarnessDocsStagingReadmePath(paths, cwd, sessionId));
603
+ if (!indexStaged.ok) return indexStaged;
604
+ const indexRepo = getHarnessRepoDocsReadmePath(paths, cwd);
605
+ const wroteIndex = writeTextAtomic(indexRepo, indexStaged.value);
606
+ if (!wroteIndex.ok) return wroteIndex;
607
+
608
+ return success({ layerPaths, indexPath: wroteIndex.value });
609
+ }
@@ -28,6 +28,7 @@ import {
28
28
  appendImplementLog,
29
29
  saveHarnessDesignSpec,
30
30
  saveHarnessDiscover,
31
+ saveHarnessDocsLayerStaging,
31
32
  saveHarnessResearchTopic,
32
33
  } from "./storage.js";
33
34
  import {
@@ -36,6 +37,8 @@ import {
36
37
  resolve as resolveQueueEntry,
37
38
  markWontfix as markQueueWontfix,
38
39
  } from "./anti_slop/queue.js";
40
+ import { validateLayerDocMarkdown } from "./docs/validator.js";
41
+ import { isSafeLayerId } from "./project-paths.js";
39
42
 
40
43
  // ---------------------------------------------------------------------------
41
44
  // Helpers
@@ -410,6 +413,79 @@ export function registerHarnessPipelineTools(platform: Platform): void {
410
413
  return toolResult({ ok: true, id, details: { state } });
411
414
  },
412
415
  });
416
+
417
+ // -------- harness_docs_record ----------
418
+ platform.registerTool({
419
+ name: "harness_docs_record",
420
+ label: "Harness Docs Record",
421
+ description: "Record one per-layer agent knowledge document for the docs stage.",
422
+ parameters: {
423
+ type: "object",
424
+ properties: {
425
+ sessionId: SESSION_ID_PROP,
426
+ layerId: {
427
+ type: "string",
428
+ description: "Layer id this doc covers (matches the assigned layer rule).",
429
+ },
430
+ markdown: {
431
+ type: "string",
432
+ description: "Full markdown body, including the provenance marker, frontmatter, and required headings.",
433
+ },
434
+ },
435
+ required: ["sessionId", "layerId", "markdown"],
436
+ },
437
+ async execute(_id: string, params: unknown, _signal: AbortSignal, _onUpdate: unknown, toolCtx: unknown) {
438
+ const cwdR = readCwd(toolCtx, "harness_docs_record");
439
+ if (!cwdR.ok) return toolResult(cwdR);
440
+ const sidR = readSessionId(params, "harness_docs_record");
441
+ if (!sidR.ok) return toolResult(sidR);
442
+ if (!isRecord(params)) {
443
+ return toolResult({ ok: false, message: "harness_docs_record requires an object payload" });
444
+ }
445
+ const p = params as Record<string, unknown>;
446
+ const layerId = typeof p.layerId === "string" ? p.layerId : "";
447
+ if (!layerId) {
448
+ return toolResult({ ok: false, message: "harness_docs_record requires a non-empty layerId" });
449
+ }
450
+ if (!isSafeLayerId(layerId)) {
451
+ return toolResult({
452
+ ok: false,
453
+ message: `harness_docs_record: layerId ${JSON.stringify(layerId)} is not a safe filename stem (alnum/_/-/non-leading dots, ≤64 chars, no '..' or path separators)`,
454
+ });
455
+ }
456
+ const markdown = typeof p.markdown === "string" ? p.markdown : "";
457
+ if (markdown.length === 0) {
458
+ return toolResult({ ok: false, message: "harness_docs_record requires a non-empty markdown body" });
459
+ }
460
+
461
+ const expectation = lookupDocsLayerExpectation(sidR.sessionId, layerId);
462
+ if (!expectation) {
463
+ return toolResult({
464
+ ok: false,
465
+ message: `harness_docs_record: no expectation registered for session ${sidR.sessionId} layer ${layerId}. The docs stage is the only valid invocation context.`,
466
+ });
467
+ }
468
+
469
+ const validation = validateLayerDocMarkdown(markdown, {
470
+ expectedLayerId: layerId,
471
+ expectedSourceHash: expectation.expectedSourceHash,
472
+ maxDocLoc: expectation.maxDocLoc,
473
+ maxAgentContextLoc: expectation.maxAgentContextLoc,
474
+ });
475
+ if (!validation.ok) {
476
+ return toolResult({
477
+ ok: false,
478
+ message: `harness_docs_record rejected: ${validation.errors.join("; ")}`,
479
+ details: { errors: validation.errors },
480
+ });
481
+ }
482
+
483
+ const persisted = saveHarnessDocsLayerStaging(platform.paths, cwdR.cwd, sidR.sessionId, layerId, markdown);
484
+ const r = unwrap(persisted, "harness_docs_record");
485
+ if (!r.ok) return toolResult(r);
486
+ return toolResult({ ok: true, path: r.value });
487
+ },
488
+ });
413
489
  }
414
490
 
415
491
  /** Names exposed for hook-bridge correlation. */
@@ -421,6 +497,60 @@ export const HARNESS_PIPELINE_TOOL_NAMES = [
421
497
  "harness_validate_finding",
422
498
  "harness_slop_queue_append",
423
499
  "harness_slop_queue_resolve",
500
+ "harness_docs_record",
424
501
  ] as const;
425
502
 
426
503
  export type HarnessPipelineToolName = (typeof HARNESS_PIPELINE_TOOL_NAMES)[number];
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Docs stage expectations registry.
507
+ //
508
+ // The docs stage registers per-(session, layer) expectations before dispatching each
509
+ // subagent so the `harness_docs_record` handler can validate `layerId`, `sourceHash`,
510
+ // and LOC caps synchronously. Module-scoped Map is intentional: registration and
511
+ // consumption both run in the same process within a single pipeline driver invocation.
512
+ // ---------------------------------------------------------------------------
513
+
514
+ export interface HarnessDocsLayerExpectation {
515
+ expectedSourceHash: string;
516
+ maxDocLoc: number;
517
+ maxAgentContextLoc: number;
518
+ }
519
+
520
+ const docsLayerExpectations = new Map<string, HarnessDocsLayerExpectation>();
521
+
522
+ function docsKey(sessionId: string, layerId: string): string {
523
+ return `${sessionId}::${layerId}`;
524
+ }
525
+
526
+ /** Register the expectations the docs tool will validate on the next record call. */
527
+ export function registerDocsLayerExpectation(
528
+ sessionId: string,
529
+ layerId: string,
530
+ expectation: HarnessDocsLayerExpectation,
531
+ ): void {
532
+ if (!isSafeLayerId(layerId)) {
533
+ throw new Error(
534
+ `registerDocsLayerExpectation: refusing to register unsafe layerId ${JSON.stringify(layerId)}`,
535
+ );
536
+ }
537
+ docsLayerExpectations.set(docsKey(sessionId, layerId), expectation);
538
+ }
539
+
540
+ /** Remove a registered expectation. Idempotent. */
541
+ export function clearDocsLayerExpectation(sessionId: string, layerId: string): void {
542
+ docsLayerExpectations.delete(docsKey(sessionId, layerId));
543
+ }
544
+
545
+ /** Lookup the registered expectation (returns null when none is registered). */
546
+ export function lookupDocsLayerExpectation(
547
+ sessionId: string,
548
+ layerId: string,
549
+ ): HarnessDocsLayerExpectation | null {
550
+ return docsLayerExpectations.get(docsKey(sessionId, layerId)) ?? null;
551
+ }
552
+
553
+ /** Clear every registration (test helper). */
554
+ export function _clearAllDocsLayerExpectationsForTests(): void {
555
+ docsLayerExpectations.clear();
556
+ }
@@ -1,5 +1,8 @@
1
+ import { homedir } from "node:os";
2
+ import * as path from "node:path";
3
+
1
4
  import type { ResolvedMempalaceConfig } from "./config.js";
2
- import type { MempalaceParams } from "./schema.js";
5
+ import { MEMPALACE_ACTIONS, type MempalaceAction, type MempalaceParams } from "./schema.js";
3
6
  import {
4
7
  resolveBridgeScriptPath,
5
8
  resolveManagedVenvPaths,
@@ -24,6 +27,11 @@ export type MempalaceBridgeCallResult =
24
27
  diagnostics: Record<string, unknown>;
25
28
  };
26
29
 
30
+ interface BridgeDispatchResult {
31
+ result: MempalaceBridgeCallResult;
32
+ release: Promise<void>;
33
+ }
34
+
27
35
  export interface MempalaceBridgeRuntimeDeps {
28
36
  resolveBridgeScriptPath?: () => BridgePathResult;
29
37
  runBridgeRequest?: (options: RunBridgeRequestOptions) => Promise<RunBridgeRequestResult>;
@@ -54,6 +62,102 @@ function mergeDiagnostics(...parts: Array<Record<string, unknown> | undefined>):
54
62
  return Object.assign({}, ...parts.filter(Boolean));
55
63
  }
56
64
 
65
+ // Per-action mutation classification. `write` actions are serialized per palace
66
+ // to avoid ChromaDB's sqlite "database is locked" errors; `read` actions run in
67
+ // parallel. The classification is exhaustive (enforced by `satisfies`) so any
68
+ // new action added to MEMPALACE_ACTIONS must be assigned a kind here — no
69
+ // silent default to the read path. `setup` is intercepted before the bridge and
70
+ // never reaches this map; it is still listed for exhaustiveness.
71
+ const ACTION_KIND = {
72
+ // ── reads / queries / listings ──────────────────────────────────────────
73
+ status: "read",
74
+ list_wings: "read",
75
+ list_rooms: "read",
76
+ get_taxonomy: "read",
77
+ search: "read",
78
+ check_duplicate: "read",
79
+ get_aaak_spec: "read",
80
+ get_drawer: "read",
81
+ list_drawers: "read",
82
+ kg_query: "read",
83
+ kg_timeline: "read",
84
+ kg_stats: "read",
85
+ traverse: "read",
86
+ find_tunnels: "read",
87
+ graph_stats: "read",
88
+ list_tunnels: "read",
89
+ follow_tunnels: "read",
90
+ diary_read: "read",
91
+ hook_settings: "read",
92
+ memories_filed_away: "read",
93
+ reconnect: "read",
94
+ version: "read",
95
+ wake_up: "read",
96
+ wake_up_and_search: "read",
97
+ setup: "read", // intercepted upstream; never reaches the bridge mutex
98
+
99
+ // ── writes / mutations ──────────────────────────────────────────────────
100
+ add_drawer: "write",
101
+ update_drawer: "write",
102
+ delete_drawer: "write",
103
+ diary_write: "write",
104
+ kg_add: "write",
105
+ kg_invalidate: "write",
106
+ create_tunnel: "write",
107
+ delete_tunnel: "write",
108
+ // CLI actions that touch the palace on disk: init creates structure,
109
+ // mine writes drawers via the indexer, split rewrites mega-file sources
110
+ // under the palace tree, repair rebuilds the chroma index. All four must
111
+ // serialize against concurrent add_drawer/diary_write writers on the
112
+ // same palace.
113
+ init: "write",
114
+ mine: "write",
115
+ split: "write",
116
+ repair: "write",
117
+ } as const satisfies Record<MempalaceAction, "read" | "write">;
118
+
119
+ // Compile-time check: every MEMPALACE_ACTIONS entry must have a kind.
120
+ // If a new action is added without an ACTION_KIND entry, this errors.
121
+ type _ExhaustiveActionKind = MempalaceAction extends keyof typeof ACTION_KIND ? true : never;
122
+ const _exhaustiveActionKind: _ExhaustiveActionKind = true;
123
+ void _exhaustiveActionKind;
124
+ void MEMPALACE_ACTIONS;
125
+
126
+ function isWriteAction(action: MempalaceAction): boolean {
127
+ return ACTION_KIND[action] === "write";
128
+ }
129
+
130
+ // Subset of write actions where a single retry on transient bridge failures
131
+ // meaningfully improves durability (write-once payloads, low retry cost).
132
+ const RETRY_ON_TRANSIENT = new Set<MempalaceAction>([
133
+ "add_drawer", "diary_write", "kg_add", "kg_invalidate",
134
+ ]);
135
+
136
+ // Error codes that are transient and safe to retry. `bridge_timeout` is
137
+ // deliberately excluded: when the TS-side timeout fires we SIGKILL the python
138
+ // child, but the OS may not have reaped the writer yet — retrying could race a
139
+ // still-live writer on the same sqlite file. `bridge_process_failed` only
140
+ // fires after `child.on('close')`, so the child is definitely gone by then.
141
+ const TRANSIENT_ERROR_CODES = new Set(["bridge_process_failed"]);
142
+
143
+ // Per-palace write serialization queue. Keyed by the canonicalized palace
144
+ // path so that concurrent OMP workspaces (different palaces) do not block each
145
+ // other, and so callers passing `~/x` vs `/Users/me/x` collide on the same
146
+ // lock (they hit the same sqlite file). Entries are deleted in a settlement
147
+ // handler when they still point at the current tail, keeping the map bounded
148
+ // in long-lived OMP processes.
149
+ const palaceMutex = new Map<string, Promise<void>>();
150
+ const RELEASED = Promise.resolve<void>(undefined);
151
+
152
+ function canonicalPalaceKey(palacePath: string): string {
153
+ const trimmed = palacePath.trim();
154
+ if (trimmed === "~") return homedir();
155
+ const expanded = (trimmed.startsWith("~/") || trimmed.startsWith("~\\"))
156
+ ? path.join(homedir(), trimmed.slice(2))
157
+ : trimmed;
158
+ return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(expanded);
159
+ }
160
+
57
161
  function resolveToolTimeoutMs(params: MempalaceParams, bridgeTimeoutMs: number): number {
58
162
  if (typeof params.timeout !== "number") return bridgeTimeoutMs;
59
163
 
@@ -81,55 +185,117 @@ export function createMempalaceBridge(options: CreateMempalaceBridgeOptions): Me
81
185
  const venv = resolveManagedVenvPaths(options.config.managedVenvPath);
82
186
  const timeoutMs = resolveToolTimeoutMs(params, options.config.timeouts.bridgeMs);
83
187
  const palacePath = params.palace ?? options.config.palacePath;
84
- const runResult = await runBridge({
85
- pythonPath: venv.python,
86
- bridgeScriptPath: bridgePath.path,
87
- timeoutMs,
88
- request: {
89
- action: params.action,
90
- params: { ...params },
91
- options: {
92
- cwd: options.cwd,
93
- palacePath,
94
- agentName: options.config.defaultAgentName,
188
+
189
+ const invokeOnce = async (): Promise<BridgeDispatchResult> => {
190
+ const runResult = await runBridge({
191
+ pythonPath: venv.python,
192
+ bridgeScriptPath: bridgePath.path,
193
+ timeoutMs,
194
+ request: {
195
+ action: params.action,
196
+ params: { ...params },
197
+ options: {
198
+ cwd: options.cwd,
199
+ palacePath,
200
+ agentName: options.config.defaultAgentName,
201
+ },
95
202
  },
96
- },
97
- });
203
+ });
204
+
205
+ if (!runResult.ok) {
206
+ return {
207
+ result: {
208
+ ok: false,
209
+ action: params.action,
210
+ error: withSetupRemediation(runResult.error),
211
+ diagnostics: {
212
+ durationMs: runResult.durationMs,
213
+ stdoutPreview: runResult.stdoutPreview,
214
+ stderrTail: runResult.stderrTail,
215
+ },
216
+ },
217
+ release: runResult.completion ?? RELEASED,
218
+ };
219
+ }
220
+
221
+ if (!runResult.response.ok) {
222
+ return {
223
+ result: {
224
+ ok: false,
225
+ action: params.action,
226
+ error: withSetupRemediation(runResult.response.error),
227
+ diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
228
+ durationMs: runResult.durationMs,
229
+ stderr: runResult.stderr,
230
+ }),
231
+ },
232
+ release: RELEASED,
233
+ };
234
+ }
98
235
 
99
- if (!runResult.ok) {
100
236
  return {
101
- ok: false,
102
- action: params.action,
103
- error: withSetupRemediation(runResult.error),
104
- diagnostics: {
105
- durationMs: runResult.durationMs,
106
- stdoutPreview: runResult.stdoutPreview,
107
- stderrTail: runResult.stderrTail,
237
+ result: {
238
+ ok: true,
239
+ action: params.action,
240
+ result: runResult.response.result ?? {},
241
+ diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
242
+ durationMs: runResult.durationMs,
243
+ stderr: runResult.stderr,
244
+ }),
108
245
  },
246
+ release: RELEASED,
109
247
  };
110
- }
248
+ };
111
249
 
112
- if (!runResult.response.ok) {
113
- return {
114
- ok: false,
115
- action: params.action,
116
- error: withSetupRemediation(runResult.response.error),
117
- diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
118
- durationMs: runResult.durationMs,
119
- stderr: runResult.stderr,
120
- }),
250
+ // dispatch: run with one retry on transient errors for eligible actions.
251
+ const dispatch = async (): Promise<BridgeDispatchResult> => {
252
+ const first = await invokeOnce();
253
+ const withRetries = (r: BridgeDispatchResult, retries: number): BridgeDispatchResult => {
254
+ const diag = { ...r.result.diagnostics, retries };
255
+ return {
256
+ result: r.result.ok
257
+ ? { ok: true, action: r.result.action, result: r.result.result, diagnostics: diag }
258
+ : { ok: false, action: r.result.action, error: r.result.error, diagnostics: diag },
259
+ release: r.release,
260
+ };
121
261
  };
262
+ if (
263
+ !first.result.ok &&
264
+ RETRY_ON_TRANSIENT.has(params.action) &&
265
+ TRANSIENT_ERROR_CODES.has(first.result.error.code)
266
+ ) {
267
+ await first.release;
268
+ await new Promise<void>((res) => setTimeout(res, 150));
269
+ return withRetries(await invokeOnce(), 1);
270
+ }
271
+ return withRetries(first, 0);
272
+ };
273
+
274
+ // Serialize write actions per palace to avoid ChromaDB lock contention.
275
+ if (isWriteAction(params.action)) {
276
+ const mutexKey = canonicalPalaceKey(palacePath);
277
+ const tail = palaceMutex.get(mutexKey) ?? RELEASED;
278
+ const mine = tail.then(dispatch, dispatch);
279
+ // Chaining uses the runtime completion signal, not just the call
280
+ // result. A bridge_timeout can be returned to the caller before the
281
+ // killed Python child has fully exited; next writer must wait until
282
+ // that completion settles so sqlite/Chroma locks are released.
283
+ const settled = mine
284
+ .then(({ release }) => release, () => RELEASED)
285
+ .then(() => {}, () => {});
286
+ palaceMutex.set(mutexKey, settled);
287
+ // GC the entry once the release signal settles, if no later writer has
288
+ // stacked on top. Comparing identity preserves the chain when another
289
+ // writer enqueued between `set` above and settlement.
290
+ settled.then(() => {
291
+ if (palaceMutex.get(mutexKey) === settled) {
292
+ palaceMutex.delete(mutexKey);
293
+ }
294
+ });
295
+ return (await mine).result;
122
296
  }
123
297
 
124
- return {
125
- ok: true,
126
- action: params.action,
127
- result: runResult.response.result ?? {},
128
- diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
129
- durationMs: runResult.durationMs,
130
- stderr: runResult.stderr,
131
- }),
132
- };
298
+ return (await dispatch()).result;
133
299
  },
134
300
  };
135
301
  }
@@ -26,13 +26,19 @@ function expandUserPath(input: string, cwd: string): string {
26
26
  }
27
27
 
28
28
  function sanitizedWing(value: string): string {
29
+ // MemPalace's own normalize_wing_name canonicalizes wing slugs with
30
+ // underscores (`hyphens` and spaces are folded to `_`). We mirror that
31
+ // here so a project directory like `sij_mono` and a supipowers-resolved
32
+ // wing `sij-mono` collapse to the same slug; otherwise data ends up
33
+ // split across two wings (`sij_mono` vs `sij-mono`) and search/diary
34
+ // writes diverge between the CLI and the hook bridge.
29
35
  return value
30
36
  .trim()
31
37
  .toLowerCase()
32
- .replace(/[\s/\\]+/g, "-")
33
- .replace(/[^a-z0-9_-]+/g, "-")
34
- .replace(/[-_]+/g, "-")
35
- .replace(/^-+|-+$/g, "");
38
+ .replace(/[\s/\\-]+/g, "_")
39
+ .replace(/[^a-z0-9_]+/g, "_")
40
+ .replace(/_+/g, "_")
41
+ .replace(/^_+|_+$/g, "");
36
42
  }
37
43
 
38
44
  export function normalizeMempalaceWing(value: string): string {