@yugabytedb/meko-mcp 2.2.0 → 2.3.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 (63) hide show
  1. package/README.md +106 -38
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +131 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +95 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1237 -137
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +85 -18
  15. package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  40. package/bin/create-meko-setup.mjs +70 -7
  41. package/lib/capture-cleanup.mjs +63 -0
  42. package/lib/clients/claude-code.mjs +1 -0
  43. package/lib/clients/codex.mjs +80 -35
  44. package/lib/clients/common.mjs +7 -0
  45. package/lib/clients/index.mjs +2 -0
  46. package/lib/clients/kiro.mjs +5 -5
  47. package/lib/clients/opencode.mjs +532 -0
  48. package/lib/config.mjs +68 -1
  49. package/lib/installer.mjs +18 -2
  50. package/lib/migrate/adapters/claude-code.mjs +11 -2
  51. package/lib/migrate/adapters/codex.mjs +9 -2
  52. package/lib/migrate/adapters/memory-md.mjs +27 -8
  53. package/lib/migrate/adapters/opencode.mjs +148 -0
  54. package/lib/migrate/claude-parser.mjs +153 -9
  55. package/lib/migrate/id.mjs +195 -13
  56. package/lib/migrate/mcp-client.mjs +25 -1
  57. package/lib/migrate/opencode-normalizer.mjs +132 -0
  58. package/lib/migrate/orchestrator.mjs +293 -77
  59. package/lib/migrate/progress.mjs +239 -5
  60. package/lib/migrate/registry.mjs +72 -0
  61. package/lib/migrate.mjs +96 -8
  62. package/package.json +1 -1
  63. package/scripts/bundle-assets.mjs +4 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * CJS mirror of installer/lib/migrate/opencode-normalizer.mjs for capture handler.
3
+ * Keep logic in sync — golden tests assert byte-equivalent exchanges.
4
+ */
5
+
6
+ "use strict";
7
+
8
+ const SYNTHETIC_PART_TYPES = new Set(["compaction", "subtask", "continuation"]);
9
+ const EXCLUDED_PART_TYPES = new Set([
10
+ "tool", "tool-invocation", "tool-result", "tool_result", "reasoning",
11
+ "file", "attachment", "step", "snapshot", "patch",
12
+ ]);
13
+ const INTERRUPTED_MARKER =
14
+ "[Meko capture: assistant response was interrupted before completion.]";
15
+ function normalizeOpenCodeSession(exportShape, opts = {}) {
16
+ const root = exportShape && typeof exportShape === "object" ? exportShape : {};
17
+ const info = root.info && typeof root.info === "object" ? root.info : {};
18
+ const rawMessages = Array.isArray(root.messages) ? root.messages : [];
19
+ const sessionId = firstString(info.id, info.sessionID, info.sessionId, root.sessionId) || "unknown";
20
+ const cwd = firstString(info.cwd, info.directory, info.projectPath, root.cwd) || "";
21
+ const parentSessionId = firstString(info.parentID, info.parentId, info.parentSessionId) || null;
22
+ const title = firstString(info.title, info.name) || sessionId;
23
+ const startedAt = firstTimestamp(info.time?.created, info.created, info.startedAt, info.time);
24
+ const visibleMessages = rawMessages.map(normalizeMessage).filter(Boolean).sort(compareMessageOrder);
25
+ const exchanges = [];
26
+ let pendingUser = null;
27
+ let firstUserText = "";
28
+ for (const msg of visibleMessages) {
29
+ if (msg.role === "user") {
30
+ if (pendingUser) exchanges.push(makeExchange(pendingUser, null, { forceInterrupted: true }));
31
+ pendingUser = msg;
32
+ if (!firstUserText && msg.text) firstUserText = msg.text.slice(0, 240);
33
+ } else if (msg.role === "assistant" && pendingUser) {
34
+ exchanges.push(makeExchange(pendingUser, msg, {}));
35
+ pendingUser = null;
36
+ }
37
+ }
38
+ let partial = Boolean(pendingUser);
39
+ if (pendingUser && opts.emitPendingAsExchange) {
40
+ // Live-capture mode. Emit the trailing pending user with no assistant
41
+ // output; the reconciler holds it until a later snapshot completes it,
42
+ // a later user interrupts it, or MEKO_OPENCODE_SETTLE_MS expires.
43
+ exchanges.push(makeExchange(pendingUser, null, {}));
44
+ pendingUser = null;
45
+ partial = false;
46
+ } else if (pendingUser && (opts.terminalError || opts.interrupted)) {
47
+ exchanges.push(makeExchange(pendingUser, null, { ...opts, interrupted: true }));
48
+ pendingUser = null;
49
+ partial = false;
50
+ } else if (!pendingUser && (opts.terminalError || opts.interrupted) && exchanges.length) {
51
+ markInterrupted(exchanges[exchanges.length - 1]);
52
+ }
53
+ return {
54
+ sessionId, cwd, parentSessionId, title,
55
+ startedAt: startedAt || visibleMessages[0]?.timestamp || null,
56
+ firstUserText,
57
+ exchanges: exchanges.filter((ex) => ex.input || ex.output),
58
+ partial,
59
+ };
60
+ }
61
+ function normalizeMessage(raw) {
62
+ if (!raw || typeof raw !== "object") return null;
63
+ const info = raw.info && typeof raw.info === "object" ? raw.info : raw;
64
+ const role = firstString(info.role, raw.role)?.toLowerCase();
65
+ if (role !== "user" && role !== "assistant") return null;
66
+ const synthetic = Boolean(
67
+ info.synthetic || info.compaction || info.subtask || info.continuation ||
68
+ SYNTHETIC_PART_TYPES.has(String(info.type || "").toLowerCase()),
69
+ );
70
+ if (synthetic) return null;
71
+ const parts = Array.isArray(raw.parts) ? raw.parts : [];
72
+ const text = joinVisibleText(parts);
73
+ if (!text && role === "assistant") return null;
74
+ return {
75
+ role,
76
+ id: firstString(info.id, raw.id, raw.uuid) || "",
77
+ timestamp: firstTimestamp(info.time?.created, info.createdAt, info.timestamp, raw.timestamp) || "",
78
+ text,
79
+ order: Number.isFinite(Number(info.order)) ? Number(info.order) : null,
80
+ };
81
+ }
82
+ function joinVisibleText(parts) {
83
+ const chunks = [];
84
+ for (const part of parts) {
85
+ if (!part || typeof part !== "object") continue;
86
+ if (part.synthetic || part.ignored) continue;
87
+ const type = String(part.type || "text").toLowerCase();
88
+ if (EXCLUDED_PART_TYPES.has(type) || SYNTHETIC_PART_TYPES.has(type)) continue;
89
+ if (type !== "text") continue;
90
+ const text = typeof part.text === "string" ? part.text : "";
91
+ if (!text.trim()) continue;
92
+ chunks.push(text);
93
+ }
94
+ return chunks.join("\n");
95
+ }
96
+ function compareMessageOrder(a, b) {
97
+ const ao = a.order; const bo = b.order;
98
+ if (ao != null && bo != null && ao !== bo) return ao - bo;
99
+ if (a.timestamp && b.timestamp && a.timestamp !== b.timestamp) {
100
+ return a.timestamp < b.timestamp ? -1 : 1;
101
+ }
102
+ return String(a.id).localeCompare(String(b.id));
103
+ }
104
+ function makeExchange(userMsg, assistantMsg, opts) {
105
+ const interrupted = Boolean(opts.interrupted || (!assistantMsg && opts.forceInterrupted));
106
+ return {
107
+ userId: userMsg.id || `opencode-user-${hashish(userMsg.text)}`,
108
+ assistantId: assistantMsg?.id || null,
109
+ input: userMsg.text,
110
+ output: interrupted ? INTERRUPTED_MARKER : (assistantMsg?.text || ""),
111
+ timestamp: assistantMsg?.timestamp || userMsg.timestamp || "",
112
+ interrupted,
113
+ };
114
+ }
115
+ function markInterrupted(exchange) {
116
+ exchange.interrupted = true;
117
+ exchange.output = exchange.output
118
+ ? `${exchange.output}\n\n${INTERRUPTED_MARKER}`
119
+ : INTERRUPTED_MARKER;
120
+ }
121
+ function firstString(...values) {
122
+ for (const value of values) {
123
+ if (typeof value === "string" && value.trim()) return value.trim();
124
+ }
125
+ return "";
126
+ }
127
+ function firstTimestamp(...values) {
128
+ for (const value of values) {
129
+ if (typeof value === "string" && value.trim()) return value.trim();
130
+ if (typeof value === "number" && Number.isFinite(value)) {
131
+ const date = new Date(value);
132
+ if (!Number.isNaN(date.getTime())) return date.toISOString();
133
+ }
134
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
135
+ }
136
+ return "";
137
+ }
138
+ function hashish(text) {
139
+ let h = 0;
140
+ const s = String(text || "");
141
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
142
+ return Math.abs(h).toString(36);
143
+ }
144
+
145
+ module.exports = { normalizeOpenCodeSession, INTERRUPTED_MARKER };
@@ -359,7 +359,16 @@ async function runWorker(jobFile) {
359
359
  ? Math.max(0, dueMs - Date.now())
360
360
  : delayMs;
361
361
  const remaining = Math.max(0, WORKER_MAX_AGE_MS - (Date.now() - started));
362
- await sleep(Math.min(untilDue, remaining));
362
+ if (untilDue >= remaining) {
363
+ // The retry falls after this worker's age ceiling (for example a
364
+ // session parked by --capture-cleanup). Sleeping would only hold a
365
+ // process for the rest of the day; timer recovery owns the retry.
366
+ process.stderr.write(
367
+ `[meko-capture] SessionEnd worker: next retry ${result.next_retry_at} is past the worker ceiling; leaving outbox pending.\n`,
368
+ );
369
+ break;
370
+ }
371
+ await sleep(untilDue);
363
372
  delayMs = Math.min(delayMs * 2, 60_000);
364
373
  continue;
365
374
  }
@@ -0,0 +1,213 @@
1
+ // Managed by meko installer; reinstall rather than editing.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { randomBytes } from "node:crypto";
6
+ import { spawn } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
9
+ const MEKO_ROOT = path.join(PLUGIN_DIR, "..", "meko");
10
+ const ENV_PATH = path.join(MEKO_ROOT, "opencode-hook-env.json");
11
+ const HANDLER_PATH = path.join(MEKO_ROOT, "hooks-handlers", "lib", "opencode-handler.js");
12
+ const MANAGED_MARKER = "Managed by meko installer";
13
+ function readEnv() {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(ENV_PATH, "utf8"));
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+ // MEKO-635: staged snapshots live under the durable Capture v2 watermark root
21
+ // (matches skills/hooks-handlers/lib/capture-state.js). This keeps them
22
+ // outside installer-owned runtime and lets uninstall preserve them.
23
+ function resolveStagingDir(env) {
24
+ const watermarkDir =
25
+ (typeof env.MEKO_WATERMARK_DIR === "string" && env.MEKO_WATERMARK_DIR) ||
26
+ path.join(os.homedir(), ".claude", "meko-capture");
27
+ return path.join(watermarkDir, "opencode-snapshots");
28
+ }
29
+ function safeSegment(value) {
30
+ return String(value || "unknown").replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 120);
31
+ }
32
+ function sanitizeSnapshot(session, messages) {
33
+ return {
34
+ info: {
35
+ id: session?.id,
36
+ title: session?.title,
37
+ directory: session?.directory,
38
+ parentID: session?.parentID,
39
+ time: session?.time,
40
+ },
41
+ messages: (Array.isArray(messages) ? messages : []).map((message) => ({
42
+ info: {
43
+ id: message?.info?.id,
44
+ role: message?.info?.role,
45
+ type: message?.info?.type,
46
+ order: message?.info?.order,
47
+ synthetic: message?.info?.synthetic,
48
+ compaction: message?.info?.compaction,
49
+ subtask: message?.info?.subtask,
50
+ continuation: message?.info?.continuation,
51
+ time: message?.info?.time,
52
+ createdAt: message?.info?.createdAt,
53
+ timestamp: message?.info?.timestamp,
54
+ },
55
+ parts: (Array.isArray(message?.parts) ? message.parts : [])
56
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
57
+ .map((part) => ({
58
+ type: "text",
59
+ text: part.text,
60
+ synthetic: Boolean(part.synthetic),
61
+ ignored: Boolean(part.ignored),
62
+ })),
63
+ })),
64
+ };
65
+ }
66
+ function stageSnapshot(sessionId, snapshot, terminalError, stagingDir) {
67
+ fs.mkdirSync(stagingDir, { recursive: true, mode: 0o700 });
68
+ // MEKO-635: two events in the same PID and millisecond can race. The 8-byte
69
+ // random suffix makes both the target and the temp path collision-proof so a
70
+ // session.error and a session.idle firing back-to-back cannot overwrite each
71
+ // other (nor let a late pending snapshot clobber one the reconciler is
72
+ // about to read).
73
+ const unique = randomBytes(8).toString("hex");
74
+ const target = path.join(
75
+ stagingDir,
76
+ `${safeSegment(sessionId)}.${process.pid}.${Date.now()}.${unique}.json`,
77
+ );
78
+ const tmp = `${target}.tmp`;
79
+ fs.writeFileSync(
80
+ tmp,
81
+ JSON.stringify({
82
+ schema_version: 1,
83
+ terminalError: Boolean(terminalError),
84
+ export: snapshot,
85
+ }),
86
+ { encoding: "utf8", mode: 0o600 },
87
+ );
88
+ fs.renameSync(tmp, target);
89
+ try { fs.chmodSync(target, 0o600); } catch { /* best effort */ }
90
+ return target;
91
+ }
92
+ function launchHandler(stagedPath, nodeExecutable, spawnProcess) {
93
+ if (!nodeExecutable) {
94
+ throw new Error("MEKO_NODE_EXECUTABLE is missing; reinstall Meko for OpenCode");
95
+ }
96
+ const child = spawnProcess(nodeExecutable, [HANDLER_PATH], {
97
+ detached: false,
98
+ stdio: ["pipe", "ignore", "ignore"],
99
+ env: {
100
+ ...process.env,
101
+ MEKO_OPENCODE_ENV_FILE: ENV_PATH,
102
+ MEKO_HOOK_CLIENT: "opencode",
103
+ },
104
+ });
105
+ child.once?.("error", (err) => {
106
+ console.error(`[meko-opencode] handler launch failed: ${err.message}`);
107
+ });
108
+ child.stdin?.on?.("error", () => {});
109
+ child.stdin?.end?.(JSON.stringify({ snapshotPath: stagedPath }));
110
+ return new Promise((resolve) => {
111
+ if (!child || typeof child.once !== "function") {
112
+ resolve();
113
+ return;
114
+ }
115
+ const done = () => resolve();
116
+ child.once("exit", done);
117
+ child.once("close", done);
118
+ });
119
+ }
120
+ async function captureSession(
121
+ client,
122
+ sessionID,
123
+ terminalError,
124
+ nodeExecutable,
125
+ spawnProcess,
126
+ stagingDir,
127
+ ) {
128
+ if (!sessionID) return;
129
+ let staged = "";
130
+ try {
131
+ const [sessionResponse, messagesResponse] = await Promise.all([
132
+ client.session.get({ path: { id: sessionID } }),
133
+ client.session.messages({ path: { id: sessionID } }),
134
+ ]);
135
+ const session = sessionResponse?.data;
136
+ const messages = messagesResponse?.data;
137
+ if (!session || !Array.isArray(messages)) {
138
+ throw new Error("OpenCode SDK returned an incomplete session snapshot");
139
+ }
140
+ staged = stageSnapshot(
141
+ session.id || sessionID,
142
+ sanitizeSnapshot(session, messages),
143
+ terminalError,
144
+ stagingDir,
145
+ );
146
+ await launchHandler(staged, nodeExecutable, spawnProcess);
147
+ } catch (err) {
148
+ console.error(`[meko-opencode] capture failed: ${err.message}`);
149
+ }
150
+ }
151
+ export default async function MekoPlugin({ client }, options = {}) {
152
+ const runtimeEnv = readEnv();
153
+ const nodeExecutable = runtimeEnv.MEKO_NODE_EXECUTABLE;
154
+ const stagingDir = resolveStagingDir(runtimeEnv);
155
+ const spawnProcess =
156
+ typeof options.__mekoSpawn === "function" ? options.__mekoSpawn : spawn;
157
+ const sessionTails = new Map();
158
+ function enqueue(sessionID, task) {
159
+ const key = sessionID || "_";
160
+ const next = (sessionTails.get(key) || Promise.resolve()).then(task, task);
161
+ sessionTails.set(key, next);
162
+ return next;
163
+ }
164
+ if (process.env.MEKO_OPENCODE_DISABLE_RECOVERY !== "1") {
165
+ try {
166
+ if (!nodeExecutable) {
167
+ throw new Error("MEKO_NODE_EXECUTABLE is missing; reinstall Meko for OpenCode");
168
+ }
169
+ spawnProcess(nodeExecutable, [HANDLER_PATH, "--recovery-scan"], {
170
+ detached: true,
171
+ windowsHide: true,
172
+ stdio: "ignore",
173
+ env: {
174
+ ...process.env,
175
+ MEKO_OPENCODE_ENV_FILE: ENV_PATH,
176
+ MEKO_HOOK_CLIENT: "opencode",
177
+ },
178
+ }).unref();
179
+ } catch (err) {
180
+ console.error(`[meko-opencode] recovery scan failed: ${err.message}`);
181
+ }
182
+ }
183
+
184
+ return {
185
+ async config(config) {
186
+ const env = readEnv();
187
+ const serverName = env.MEKO_MCP_SERVER_NAME || "meko";
188
+ if (!env.MEKO_MCP_URL) return;
189
+ const headers = {
190
+ "User-Agent": env.MEKO_MCP_USER_AGENT || "meko-mcp-installer/1.0",
191
+ };
192
+ if (env.MEKO_API_KEY) headers.Authorization = `Bearer ${env.MEKO_API_KEY}`;
193
+ config.mcp ??= {};
194
+ config.mcp[serverName] = {
195
+ type: "remote",
196
+ url: env.MEKO_MCP_URL,
197
+ enabled: true,
198
+ oauth: false,
199
+ headers,
200
+ };
201
+ },
202
+ async event({ event }) {
203
+ const sessionID = event.properties?.sessionID;
204
+ if (event?.type === "session.idle") {
205
+ await enqueue(sessionID, () =>
206
+ captureSession(client, sessionID, false, nodeExecutable, spawnProcess, stagingDir));
207
+ } else if (event?.type === "session.error") {
208
+ await enqueue(sessionID, () =>
209
+ captureSession(client, sessionID, true, nodeExecutable, spawnProcess, stagingDir));
210
+ }
211
+ },
212
+ };
213
+ }
@@ -46,12 +46,18 @@ fi
46
46
 
47
47
  # --- Derive agent_id from cwd ---
48
48
  #
49
- # Mirrors deriveAgentId() in skills/hooks-handlers/lib/capture.js:51-65:
50
- # - basename of cwd, lowercased
49
+ # Mirrors deriveAgentId() + resolveRepoRoot() in
50
+ # skills/hooks-handlers/lib/capture.js:
51
+ # - walk parents of cwd for a '.git' entry (dir OR file, for worktrees and
52
+ # submodules); the walk stops at $HOME from below, so a git-tracked home
53
+ # does not claim every directory beneath it
54
+ # - basename of that repo root, lowercased
51
55
  # - non-[a-z0-9-]+ → '-'
52
56
  # - trim leading/trailing '-'
53
57
  # - max 64 chars
54
58
  # - prefix with 'claude_code:'
59
+ # - no repo root found → bare 'claude_code' (NOT 'claude_code:<leaf>'), so
60
+ # this segment reads the same pin file the hook writes
55
61
  #
56
62
  # Then mirrors datapackPinSlug() in capture.js:341-348:
57
63
  # - non-[A-Za-z0-9._-]+ → '_'
@@ -74,19 +80,72 @@ if [[ -n "$STDIN_PAYLOAD" ]]; then
74
80
  fi
75
81
  fi
76
82
 
83
+ if [[ -n "${HOME:-}" ]]; then
84
+ USER_HOME="$(cd -L -- "$HOME" 2>/dev/null && pwd -L)"
85
+ else
86
+ USER_HOME="$(cd -L -- ~ 2>/dev/null && pwd -L)"
87
+ fi
88
+
77
89
  # MEKO_AGENT_ID overrides the cwd-derived id, matching resolveSessionAgentId() in
78
90
  # capture.js (the env override wins verbatim when non-empty).
79
91
  if [[ -n "${MEKO_AGENT_ID:-}" ]]; then
80
92
  AGENT_ID="$MEKO_AGENT_ID"
93
+ LEGACY_AGENT_ID="$MEKO_AGENT_ID"
81
94
  else
82
- BASENAME="$(basename "$CWD")"
83
- PROJECT_SLUG="$(printf '%s' "$BASENAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+|-+$//g')"
84
- PROJECT_SLUG="${PROJECT_SLUG:0:64}"
95
+ # Walk up from $CWD for a '.git' entry. $HOME bounds the walk from below.
96
+ REPO_ROOT=""
97
+ # Normalize relative paths and '..' before the textual $HOME comparison,
98
+ # matching path.resolve() for live directories. Unlike JavaScript's lexical
99
+ # resolution, cd/pwd requires the directory to exist; statusline cwd does.
100
+ START_DIR="$(cd -L -- "$CWD" 2>/dev/null && pwd -L)"
101
+ if [[ -z "$START_DIR" ]]; then
102
+ START_DIR="${CWD%/}"
103
+ [[ -z "$START_DIR" ]] && START_DIR="$CWD"
104
+ fi
105
+ WALK_DIR="$START_DIR"
106
+ HOME_BOUND="$USER_HOME"
107
+ while [[ -n "$WALK_DIR" ]]; do
108
+ # A directory below a git-tracked home must not inherit home's .git.
109
+ if [[ -n "$HOME_BOUND" && "$WALK_DIR" == "$HOME_BOUND" && "$WALK_DIR" != "$START_DIR" ]]; then
110
+ break
111
+ fi
112
+ if [[ -e "$WALK_DIR/.git" ]]; then
113
+ REPO_ROOT="$WALK_DIR"
114
+ break
115
+ fi
116
+ # Check home itself for .git, then stop. A non-git home must not inherit
117
+ # repository state from /Users, /home, or /.
118
+ if [[ -n "$HOME_BOUND" && "$WALK_DIR" == "$HOME_BOUND" ]]; then
119
+ break
120
+ fi
121
+ PARENT="$(dirname "$WALK_DIR")"
122
+ [[ "$PARENT" == "$WALK_DIR" ]] && break
123
+ WALK_DIR="$PARENT"
124
+ done
85
125
 
86
- if [[ -z "$PROJECT_SLUG" || "$PROJECT_SLUG" == "-" ]]; then
126
+ if [[ -z "$REPO_ROOT" ]]; then
127
+ # Outside any repo: bare client name, matching deriveAgentId().
87
128
  AGENT_ID="claude_code"
88
129
  else
89
- AGENT_ID="claude_code:$PROJECT_SLUG"
130
+ BASENAME="$(basename "$REPO_ROOT")"
131
+ PROJECT_SLUG="$(printf '%s' "$BASENAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+|-+$//g')"
132
+ PROJECT_SLUG="${PROJECT_SLUG:0:64}"
133
+
134
+ if [[ -z "$PROJECT_SLUG" || "$PROJECT_SLUG" == "-" ]]; then
135
+ AGENT_ID="claude_code"
136
+ else
137
+ AGENT_ID="claude_code:$PROJECT_SLUG"
138
+ fi
139
+ fi
140
+
141
+ # Read-only compatibility alias for pins written before MEKO-590.
142
+ LEGACY_BASENAME="$(basename "$CWD")"
143
+ LEGACY_SLUG="$(printf '%s' "$LEGACY_BASENAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+|-+$//g')"
144
+ LEGACY_SLUG="${LEGACY_SLUG:0:64}"
145
+ if [[ -n "$LEGACY_SLUG" && "$LEGACY_SLUG" != "-" ]]; then
146
+ LEGACY_AGENT_ID="claude_code:$LEGACY_SLUG"
147
+ else
148
+ LEGACY_AGENT_ID="claude_code"
90
149
  fi
91
150
  fi
92
151
 
@@ -96,7 +155,11 @@ PIN_SLUG="$(printf '%s' "$AGENT_ID" | sed -E 's/[^A-Za-z0-9._-]+/_/g; s/^_+|_+$/
96
155
  if [[ -z "$PIN_SLUG" ]]; then
97
156
  PIN_SLUG="meko_agent"
98
157
  fi
99
- PIN_FILE="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}/pin-${PIN_SLUG}.json"
158
+ PIN_DIR="${MEKO_WATERMARK_DIR:-$USER_HOME/.claude/meko-capture}"
159
+ PIN_FILE="${PIN_DIR}/pin-${PIN_SLUG}.json"
160
+ LEGACY_PIN_SLUG="$(printf '%s' "$LEGACY_AGENT_ID" | sed -E 's/[^A-Za-z0-9._-]+/_/g; s/^_+|_+$//g')"
161
+ [[ -z "$LEGACY_PIN_SLUG" ]] && LEGACY_PIN_SLUG="meko_agent"
162
+ LEGACY_PIN_FILE="${PIN_DIR}/pin-${LEGACY_PIN_SLUG}.json"
100
163
 
101
164
  # --- Read the pin file and append the suffix if a valid pin is present ---
102
165
 
@@ -105,20 +168,24 @@ if [[ -n "${INNER_OUT:-}" ]]; then
105
168
  HAVE_SEGMENT=1
106
169
  fi
107
170
 
171
+ PIN_NAME=""
108
172
  if [[ -f "$PIN_FILE" ]]; then
109
173
  # grep -oE isolates the exact "datapack_name":"…" pair before extracting, so a
110
174
  # greedy `.*` can't grab a different key that merely ends in datapack_name.
111
175
  PIN_NAME="$(grep -oE '"datapack_name"[[:space:]]*:[[:space:]]*"[^"]+"' "$PIN_FILE" | sed -E 's/.*"([^"]+)"$/\1/' | head -n 1)"
112
- if [[ -n "$PIN_NAME" ]]; then
113
- SUFFIX="Meko: $PIN_NAME"
114
- # Add a separator only if the inner command produced output.
115
- if [[ "$HAVE_SEGMENT" -eq 1 ]]; then
116
- printf ' \xe2\x80\xa2 %s' "$SUFFIX"
117
- else
118
- printf '%s' "$SUFFIX"
119
- fi
120
- HAVE_SEGMENT=1
176
+ fi
177
+ if [[ -z "$PIN_NAME" && "$LEGACY_PIN_FILE" != "$PIN_FILE" && -f "$LEGACY_PIN_FILE" ]]; then
178
+ PIN_NAME="$(grep -oE '"datapack_name"[[:space:]]*:[[:space:]]*"[^"]+"' "$LEGACY_PIN_FILE" | sed -E 's/.*"([^"]+)"$/\1/' | head -n 1)"
179
+ fi
180
+ if [[ -n "$PIN_NAME" ]]; then
181
+ SUFFIX="Meko: $PIN_NAME"
182
+ # Add a separator only if the inner command produced output.
183
+ if [[ "$HAVE_SEGMENT" -eq 1 ]]; then
184
+ printf ' \xe2\x80\xa2 %s' "$SUFFIX"
185
+ else
186
+ printf '%s' "$SUFFIX"
121
187
  fi
188
+ HAVE_SEGMENT=1
122
189
  fi
123
190
 
124
191
  # --- Capture health suffix (Capture V2) ---
@@ -127,7 +194,7 @@ fi
127
194
  # nothing. Reads rebuildable capture-health.json (no full watermark scan).
128
195
  # Format mirrors statuslineSuffixFromHealth() in capture-notices.js.
129
196
 
130
- WATERMARK_DIR="${MEKO_WATERMARK_DIR:-$HOME/.claude/meko-capture}"
197
+ WATERMARK_DIR="$PIN_DIR"
131
198
  HEALTH_FILE="${WATERMARK_DIR}/capture-health.json"
132
199
  CAPTURE_SUFFIX=""
133
200
  if [[ -f "$HEALTH_FILE" ]]; then