gm-skill 2.0.1477 → 2.0.1478

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/AGENTS.md CHANGED
@@ -34,11 +34,11 @@ Agents dispatch verbs by writing to `.gm/exec-spool/in/<verb>/<N>.txt` (request
34
34
 
35
35
  **Orchestrator verbs**: `instruction`, `transition`, `phase-status`, `mutable-resolve`, `memorize-fire`, `residual-scan`, `auto-recall`.
36
36
 
37
- **Wasm-direct verbs**: `fs_read`, `fs_write`, `fs_stat`, `fs_readdir`, `kv_get`, `kv_put`, `kv_query`, `fetch`, `exec_js`, `env_get`, `recall`, `codesearch`, `memorize`, `memorize-prune`, `health`, `filter`, `git_status`, `branch_status`, `git_push`.
37
+ **Wasm-direct verbs**: `fs_read`, `fs_write`, `fs_stat`, `fs_readdir`, `kv_get`, `kv_put`, `kv_query`, `fetch`, `exec_js`, `env_get`, `recall`, `codesearch`, `memorize`, `memorize-prune`, `health`, `filter`, `git_status`, `branch_status`, `git_push`, `git_add`, `git_commit`, `git_finalize`, `git_log`, `git_diff`, `git_show`, `git_fetch`, `git_branch`, `git_checkout`, `git_rm`, `git_revert`, `git_reset`.
38
38
 
39
39
  **memorize-prune verb**: deletes bad/superseded memories, pruning bad memory matters more than preserving good memory, since a wrong recall hit is worse than a miss. Two modes: explicit `{key}`/`{keys:[...]}` deletes exactly those mem keys (text + `-vec` embedding sibling) via the `host_kv_delete` host import; `{query}` returns review-only candidates (vector_top_k hits with keys) for the agent to judge, then re-dispatch with the stale `{keys:[...]}`. Query mode never auto-deletes by similarity, a blind similarity-delete is itself a bad-memory generator; the destructive step stays under agent judgment. Emits `memory.pruned` per deletion.
40
40
 
41
- **git verbs**: `git_status` returns `{dirty, modified, untracked, deleted, staged}` from `git status --porcelain`. `branch_status` returns `{branch, ahead, behind, remote}`, the `remote-pushed` witness. `git_push` is the ONLY admissible push surface, it gates on `git_porcelain()` non-empty (refuses dirty), emits `deviation.push-dirty` on attempt, and shells the push only when clean. A raw `git push` via Bash bypasses the gate and is itself a deviation; ccsniff `--git-discipline` flags it.
41
+ **git verbs**: git is a first-class spool surface, never a shell command. Inspect: `git_status` returns `{dirty, modified, untracked, deleted, staged}`; `branch_status` returns `{branch, ahead, behind, remote}` (the `remote-pushed` witness); `git_log {count}` → `{commits:[{sha,subject}]}`; `git_diff {staged?,path?}`; `git_show {ref,stat?}`; `git_branch` → `{current,branches}`. Mutate: `git_add {paths|default -A}`; `git_commit {message,allow_empty?}` (argv-array, no shell quoting, porcelain-pre-checked → `nothing_to_commit`); `git_checkout {ref,create?}`; `git_fetch {remote}`; `git_rm {paths,cached?}`; `git_revert {paths→discard | ref→revert-commit}`; `git_reset {ref,mode}` (hard guarded behind explicit `mode:hard`). Push: `git_push` is the ONLY admissible raw push, gates on `git_porcelain()` (refuses dirty, emits `deviation.push-dirty`), rebase-retries on remote-moved. **`git_finalize {message}` is the bundled COMPLETE-phase surface**: add-all → commit → porcelain-clean-gate `git_push`, emitting `git.commit`/`git.push` witnesses, in ONE dispatch instead of 4+ Bash events (token/turn conservation); nothing-to-commit still pushes unpushed commits. All git verbs route through the `host_git` import, which resolves `git`→`git.exe` (no `.cmd`-shim terminal flash on Windows) and accepts a JSON argv array (no commit-message shattering). A `bash`/`sh`/`shell`/`powershell` body whose command is git-dominant is gated at `check_dispatch` (rs-plugkit `gates.rs`) and mirrored in `lib/spool-dispatch.js`, emitting `deviation.bash-git-bypass` and naming the equivalent verb; raw git via Bash bypasses the porcelain gate and the witness ledger.
42
42
 
43
43
  **filter verb**: pure stdout → compact-stdout transformation. Body `{kind, input, ...opts}` where kind is one of `grep`, `ls`, `tree`, `json`, `diff`, `git-status`, `log`. Returns `{output, stats:{bytes_in, bytes_out, saved_pct, ...}}`. Pipe raw command output through filter before letting it enter context, in-wasm, no subprocess. The bootstrap fetches only `plugkit.wasm`, there is no separate filter/rtk binary download.
44
44
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-plugkit",
3
- "version": "2.0.1477",
3
+ "version": "2.0.1478",
4
4
  "description": "Bootstrap and daemon-spawn tool for gm plugkit binary. Downloads the correct platform binary, verifies SHA256, and starts the spool watcher daemon. Includes plugkit-wasm-wrapper for WASM-based spool watching.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1894,8 +1894,16 @@ function makeHostFunctions(instanceRef) {
1894
1894
  const args = readWasmStr(instanceRef.value, argsPtr, argsLen);
1895
1895
  const cwdStr = readWasmStr(instanceRef.value, cwdPtr, cwdLen);
1896
1896
  const cwd = cwdStr || process.cwd();
1897
- const argv = args.trim().split(/\s+/);
1898
- const result = _rawSpawnSync('git', argv, { encoding: 'utf-8', timeout: 30000, cwd, windowsHide: true });
1897
+ let argv;
1898
+ const trimmed = args.trim();
1899
+ if (trimmed.startsWith('[')) {
1900
+ try { argv = JSON.parse(trimmed); } catch { argv = trimmed.split(/\s+/); }
1901
+ if (!Array.isArray(argv)) argv = String(argv).split(/\s+/);
1902
+ } else {
1903
+ argv = trimmed.split(/\s+/);
1904
+ }
1905
+ const gitBin = resolveWindowsExeLocal('git');
1906
+ const result = _rawSpawnSync(gitBin, argv, { encoding: 'utf-8', timeout: 60000, cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
1899
1907
  return writeWasmJson(instanceRef.value, {
1900
1908
  stdout: result.stdout || '',
1901
1909
  stderr: result.stderr || '',
package/gm.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm",
3
- "version": "2.0.1477",
3
+ "version": "2.0.1478",
4
4
  "description": "Spool-dispatch orchestration engine with unified state machine, skills, and automated git enforcement",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
@@ -282,6 +282,19 @@ function checkDispatchGates(sessionId, operation, extra) {
282
282
  pending_step_id: pending.step_id,
283
283
  };
284
284
  }
285
+
286
+ if (['bash', 'sh', 'shell', 'zsh', 'powershell', 'ps1'].includes(extra.verb)) {
287
+ const cmd = String((extra.body && (extra.body.command || extra.body.code || extra.body.script)) || extra.command || '').trim();
288
+ const first = cmd.split(/\s+/)[0] || '';
289
+ const gitDominant = first === 'git' || /[\\/]git$/.test(first) || cmd.startsWith('git ');
290
+ if (gitDominant) {
291
+ logDeviation('deviation.bash-git-bypass', { verb: extra.verb, cmd: cmd.slice(0, 80) });
292
+ return {
293
+ allowed: false,
294
+ reason: `bash-git-bypass: a \`${extra.verb}\` verb invoking \`git\` is denied — git is a first-class spool surface, not a shell command. Use the git verb: git_status/git_log/git_diff/git_show/git_branch (inspect); git_add/git_commit/git_finalize/git_push (stage/commit/push); git_checkout/git_fetch/git_rm/git_revert/git_reset (mutate). git_finalize {message} bundles add→commit→porcelain-gate→push in ONE dispatch.`,
295
+ };
296
+ }
297
+ }
285
298
  }
286
299
 
287
300
  if (['stop', 'complete'].includes(operation)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-skill",
3
- "version": "2.0.1477",
3
+ "version": "2.0.1478",
4
4
  "description": "Canonical universal harness — AI-native software engineering via skill-driven orchestration; bootstraps plugkit for task execution and session isolation. Install in any AI coding agent host.",
5
5
  "author": "AnEntrypoint",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@ Every turn: dispatch `instruction` (you are the one dispatching it), read the re
26
26
 
27
27
  **Search routes through the spool, never a platform search agent.** For any code, file, or symbol search — whereabouts, "where is X defined", "what calls Y", grepping the tree — you dispatch the `codesearch` verb (`.gm/exec-spool/in/codesearch/<N>.txt` with `{"query":"..."}`), and for prior-knowledge you dispatch `recall`. You do NOT reach for the platform's Explore agent, a Task/general-purpose search subagent, raw `grep`/`Glob`, or any host-native code-search; those are not substitutes for `codesearch`, exactly as puppeteer is not a substitute for the `browser` verb. They bypass the spool, the committed code-search index, and the recall-grounded discipline — the search becomes invisible to gmsniff, ungrounded in what the project already learned, and non-portable across harnesses. The orient fan-out at PLAN is `recall` + `codesearch` in parallel; every ad-hoc lookup mid-EXECUTE is a `codesearch` dispatch too. Reaching outside the spool for search is the same drift as reaching outside it for the browser: the capability exists as a verb, so you use the verb.
28
28
 
29
- **This is one instance of a class rule: every platform-native capability that has a plugkit verb is forbidden in favor of the verb.** Your `allowed-tools` already blocks raw shell beyond the boot commands, but a harness can still offer the capability as its own first-class tool or subagent that slips past that restriction — a search/Explore agent, a web-fetch or web-search tool, a plan/architect subagent, a notebook editor. For each, the plugkit verb is the only admissible surface: code/file/symbol search → `codesearch`; prior knowledge → `recall`; fetching a URL or searching the web → the `fetch` verb (`.gm/exec-spool/in/fetch/`); running code → `exec_js` / the exec spool; a real browser → the `browser` verb; persisting memory → `memorize-fire`. The native tool is never the substitute, for the same three reasons every time: it bypasses the spool (invisible to the ledger), it bypasses the project's committed index and learned memory (ungrounded), and it is non-portable across harnesses (a different agent host has a different native tool, so a discipline built on the native tool does not transport — only the verb does). When you reach for any capability, the question is not "what tool does my platform give me" but "what verb does plugkit expose for this"; if a verb exists, the native tool is off-limits, and if no verb exists the gap is a missing verb to add, not a license to reach around the spool.
29
+ **This is one instance of a class rule: every platform-native capability that has a plugkit verb is forbidden in favor of the verb.** Your `allowed-tools` already blocks raw shell beyond the boot commands, but a harness can still offer the capability as its own first-class tool or subagent that slips past that restriction — a search/Explore agent, a web-fetch or web-search tool, a plan/architect subagent, a notebook editor. For each, the plugkit verb is the only admissible surface: code/file/symbol search → `codesearch`; prior knowledge → `recall`; fetching a URL or searching the web → the `fetch` verb (`.gm/exec-spool/in/fetch/`); running code → `exec_js` / the exec spool; a real browser → the `browser` verb; persisting memory → `memorize-fire`; **any git operation → the git verbs** (`git_status`/`git_log`/`git_diff`/`git_show`/`git_branch` to inspect, `git_add`/`git_commit`/`git_finalize`/`git_push` to stage-commit-push, `git_checkout`/`git_fetch`/`git_rm`/`git_revert`/`git_reset` to mutate) — `git_finalize {message}` bundles add→commit→porcelain-gate→push in ONE dispatch and is the COMPLETE-phase push surface, so you never shell `git` via Bash and never spend 4 tool-use events on what is one verb; a `bash`/`sh`/`powershell` body that invokes git is gated (`deviation.bash-git-bypass`). The native tool is never the substitute, for the same three reasons every time: it bypasses the spool (invisible to the ledger), it bypasses the project's committed index and learned memory (ungrounded), and it is non-portable across harnesses (a different agent host has a different native tool, so a discipline built on the native tool does not transport — only the verb does). When you reach for any capability, the question is not "what tool does my platform give me" but "what verb does plugkit expose for this"; if a verb exists, the native tool is off-limits, and if no verb exists the gap is a missing verb to add, not a license to reach around the spool.
30
30
 
31
31
  **Boot before dispatching. Always check first.** Writing to `.gm/exec-spool/in/instruction/N.txt` while the watcher is dead is the canonical cold-start failure, the request sits forever, you read no response, you fabricate the chain from memory of the prose. The spool directory's existence does NOT mean the watcher is alive; `.status.json` mtime within the last 15s does. The leftover `.status.json` from yesterday's dead watcher is the most common trap.
32
32
 
package/lib/git.js DELETED
@@ -1,331 +0,0 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
- const os = require('os');
4
-
5
- const GIT_USER = 'lanmower';
6
- const GIT_EMAIL = 'almagestfraternite@gmail.com';
7
-
8
- function emitGitEvent(severity, message, data = {}) {
9
- const logDir = path.join(os.homedir(), '.claude', 'gm-log', new Date().toISOString().split('T')[0]);
10
- if (!fs.existsSync(logDir)) {
11
- try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) {}
12
- }
13
- const logFile = path.join(logDir, 'git.jsonl');
14
- try {
15
- fs.appendFileSync(logFile, JSON.stringify({
16
- ts: new Date().toISOString(),
17
- subsystem: 'git',
18
- severity,
19
- message,
20
- ...data,
21
- }) + '\n');
22
- } catch (e) {}
23
- }
24
-
25
- function escapeShellArg(arg) {
26
- if (os.platform() === 'win32') {
27
- if (!arg) return '""';
28
- if (/^[a-zA-Z0-9._\-/:=\\]+$/.test(arg)) return arg;
29
- return '"' + arg.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`') + '"';
30
- }
31
- if (!arg) return "''";
32
- if (/^[a-zA-Z0-9._\-/:=]+$/.test(arg)) return arg;
33
- return "'" + arg.replace(/'/g, "'\"'\"'") + "'";
34
- }
35
-
36
- function parseGitStatus(porcelain) {
37
- const isDirty = porcelain.trim().length > 0;
38
- const lines = porcelain.trim().split('\n').filter(l => l.length > 0);
39
- const modified = lines.filter(l => /^[ AM][MD]/.test(l)).map(l => l.substring(3));
40
- const untracked = lines.filter(l => /^\?\?/.test(l)).map(l => l.substring(3));
41
- const deleted = lines.filter(l => /^[ A]D/.test(l)).map(l => l.substring(3));
42
- return { isDirty, modified, untracked, deleted, all: modified.concat(untracked, deleted) };
43
- }
44
-
45
- async function sendSpoolRequest(lang, code, timeoutMs = 30000) {
46
- const gmDir = path.join(process.cwd(), '.gm');
47
- const spoolIn = path.join(gmDir, 'exec-spool', 'in', lang);
48
- const spoolOut = path.join(gmDir, 'exec-spool', 'out');
49
-
50
- if (!fs.existsSync(spoolIn)) {
51
- try { fs.mkdirSync(spoolIn, { recursive: true }); } catch (e) {}
52
- }
53
-
54
- let taskId = '';
55
- try {
56
- taskId = fs.readdirSync(spoolOut).filter(f => f.endsWith('.json')).length + 1;
57
- } catch (e) {
58
- taskId = Math.random().toString(36).substring(7);
59
- }
60
-
61
- const ext = lang === 'bash' ? 'sh' : 'js';
62
- const inPath = path.join(spoolIn, `${taskId}.${ext}`);
63
- const outPath = path.join(spoolOut, `${taskId}.out`);
64
- const errPath = path.join(spoolOut, `${taskId}.err`);
65
- const jsonPath = path.join(spoolOut, `${taskId}.json`);
66
-
67
- try {
68
- fs.writeFileSync(inPath, code, 'utf8');
69
- } catch (e) {
70
- emitGitEvent('error', 'Could not write spool file', { path: inPath, error: e.message });
71
- throw new Error(`Could not write spool request: ${e.message}`);
72
- }
73
-
74
- const startTime = Date.now();
75
- const deadline = startTime + timeoutMs;
76
-
77
- while (Date.now() < deadline) {
78
- if (fs.existsSync(jsonPath)) {
79
- try {
80
- const metadata = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
81
- const stdout = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : '';
82
- const stderr = fs.existsSync(errPath) ? fs.readFileSync(errPath, 'utf8') : '';
83
- return { ok: !metadata.timedOut && metadata.exitCode === 0, stdout, stderr, exitCode: metadata.exitCode, durationMs: metadata.durationMs };
84
- } catch (e) {
85
- emitGitEvent('error', 'Could not parse spool response', { path: jsonPath, error: e.message });
86
- }
87
- }
88
- await new Promise(r => setTimeout(r, 100));
89
- }
90
-
91
- throw new Error(`Spool request timed out after ${timeoutMs}ms`);
92
- }
93
-
94
- async function commit(message, files = [], sessionId = 'unknown') {
95
- emitGitEvent('info', 'commit() called', { message: message.substring(0, 72), fileCount: files.length, sessionId });
96
-
97
- if (!message || message.trim().length === 0) {
98
- emitGitEvent('error', 'commit message required', { sessionId });
99
- throw new Error('Commit message required');
100
- }
101
-
102
- const summary = message.split('\n')[0];
103
- if (summary.length > 72) {
104
- emitGitEvent('warn', 'commit summary exceeds 72 chars', { length: summary.length, sessionId });
105
- }
106
-
107
- const filesToStage = files && files.length > 0 ? files : ['.'];
108
- const stageCmd = filesToStage.map(f => `git add ${escapeShellArg(f)}`).join(' && ');
109
- const commitMsg = message.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
110
- const commitCmd = `git -c user.name=${escapeShellArg(GIT_USER)} -c user.email=${escapeShellArg(GIT_EMAIL)} commit -m "${commitMsg}"`;
111
-
112
- const script = `${stageCmd} && ${commitCmd}`;
113
-
114
- try {
115
- const result = await sendSpoolRequest('bash', script, 30000);
116
- if (!result.ok) {
117
- const err = result.stderr || result.stdout || 'unknown error';
118
- emitGitEvent('error', 'commit failed', { error: err.substring(0, 200), sessionId });
119
- throw new Error(`Commit failed: ${err}`);
120
- }
121
- emitGitEvent('info', 'commit succeeded', { message: summary, fileCount: files.length, sessionId });
122
- return { ok: true, message: summary };
123
- } catch (e) {
124
- emitGitEvent('error', 'commit error', { error: e.message, sessionId });
125
- throw e;
126
- }
127
- }
128
-
129
- async function push(branch = 'main', sessionId = 'unknown') {
130
- emitGitEvent('info', 'push() called', { branch, sessionId });
131
-
132
- const pushCmd = `git push origin ${escapeShellArg(branch)} 2>&1`;
133
-
134
- try {
135
- const result = await sendSpoolRequest('bash', pushCmd, 60000);
136
- if (!result.ok || result.stderr.includes('fatal') || result.stdout.includes('fatal')) {
137
- const err = result.stderr || result.stdout || 'unknown error';
138
-
139
- if (err.includes('remote: error') || err.includes('Permission denied')) {
140
- emitGitEvent('error', 'push auth failed', { branch, sessionId });
141
- throw new Error(`Push authentication failed. Check credentials for branch: ${branch}\n${err}`);
142
- }
143
- if (err.includes('no changes added') || err.includes('nothing to commit')) {
144
- emitGitEvent('info', 'push: nothing to push', { branch, sessionId });
145
- return { ok: true, message: 'nothing to push' };
146
- }
147
- emitGitEvent('error', 'push failed', { error: err.substring(0, 200), branch, sessionId });
148
- throw new Error(`Push failed: ${err}`);
149
- }
150
- emitGitEvent('info', 'push succeeded', { branch, sessionId });
151
- return { ok: true, message: `pushed ${branch}` };
152
- } catch (e) {
153
- emitGitEvent('error', 'push error', { error: e.message, branch, sessionId });
154
- throw e;
155
- }
156
- }
157
-
158
- async function status(sessionId = 'unknown') {
159
- emitGitEvent('info', 'status() called', { sessionId });
160
-
161
- const statusCmd = 'git status --porcelain';
162
-
163
- try {
164
- const result = await sendSpoolRequest('bash', statusCmd, 10000);
165
- if (!result.ok) {
166
- emitGitEvent('error', 'status failed', { error: result.stderr, sessionId });
167
- throw new Error(`Status check failed: ${result.stderr}`);
168
- }
169
-
170
- const parsed = parseGitStatus(result.stdout);
171
- emitGitEvent('info', 'status retrieved', { isDirty: parsed.isDirty, modifiedCount: parsed.modified.length, sessionId });
172
- return { ok: true, ...parsed };
173
- } catch (e) {
174
- emitGitEvent('error', 'status error', { error: e.message, sessionId });
175
- throw e;
176
- }
177
- }
178
-
179
- async function diff(sessionId = 'unknown') {
180
- emitGitEvent('info', 'diff() called', { sessionId });
181
-
182
- const diffCmd = 'git diff HEAD';
183
-
184
- try {
185
- const result = await sendSpoolRequest('bash', diffCmd, 30000);
186
- if (!result.ok && !result.stdout) {
187
- emitGitEvent('error', 'diff failed', { error: result.stderr, sessionId });
188
- throw new Error(`Diff failed: ${result.stderr}`);
189
- }
190
-
191
- emitGitEvent('info', 'diff retrieved', { hasChanges: result.stdout.length > 0, sessionId });
192
- return { ok: true, diff: result.stdout };
193
- } catch (e) {
194
- emitGitEvent('error', 'diff error', { error: e.message, sessionId });
195
- throw e;
196
- }
197
- }
198
-
199
- async function log(sessionId = 'unknown', count = 10) {
200
- emitGitEvent('info', 'log() called', { count, sessionId });
201
-
202
- const logCmd = `git log -${count} --oneline`;
203
-
204
- try {
205
- const result = await sendSpoolRequest('bash', logCmd, 10000);
206
- if (!result.ok) {
207
- emitGitEvent('error', 'log failed', { error: result.stderr, sessionId });
208
- throw new Error(`Log failed: ${result.stderr}`);
209
- }
210
-
211
- const commits = result.stdout.trim().split('\n').filter(l => l.length > 0);
212
- emitGitEvent('info', 'log retrieved', { commitCount: commits.length, sessionId });
213
- return { ok: true, commits };
214
- } catch (e) {
215
- emitGitEvent('error', 'log error', { error: e.message, sessionId });
216
- throw e;
217
- }
218
- }
219
-
220
- async function checkout(branch, sessionId = 'unknown') {
221
- emitGitEvent('info', 'checkout() called', { branch, sessionId });
222
-
223
- if (!branch || branch.trim().length === 0) {
224
- emitGitEvent('error', 'branch name required', { sessionId });
225
- throw new Error('Branch name required');
226
- }
227
-
228
- const checkCmd = `git rev-parse --verify ${escapeShellArg(branch)} 2>&1`;
229
-
230
- try {
231
- const checkResult = await sendSpoolRequest('bash', checkCmd, 10000);
232
- if (!checkResult.ok || checkResult.stderr.includes('fatal') || checkResult.stdout.includes('fatal')) {
233
- emitGitEvent('error', 'checkout: branch not found', { branch, sessionId });
234
- throw new Error(`Branch not found: ${branch}`);
235
- }
236
-
237
- const checkoutCmd = `git checkout ${escapeShellArg(branch)}`;
238
- const result = await sendSpoolRequest('bash', checkoutCmd, 10000);
239
-
240
- if (!result.ok) {
241
- emitGitEvent('error', 'checkout failed', { error: result.stderr, branch, sessionId });
242
- throw new Error(`Checkout failed: ${result.stderr}`);
243
- }
244
-
245
- emitGitEvent('info', 'checkout succeeded', { branch, sessionId });
246
- return { ok: true, branch };
247
- } catch (e) {
248
- emitGitEvent('error', 'checkout error', { error: e.message, branch, sessionId });
249
- throw e;
250
- }
251
- }
252
-
253
- async function rebase(upstream, sessionId = 'unknown') {
254
- emitGitEvent('info', 'rebase() called', { upstream, sessionId });
255
-
256
- if (!upstream || upstream.trim().length === 0) {
257
- emitGitEvent('error', 'upstream branch required', { sessionId });
258
- throw new Error('Upstream branch required');
259
- }
260
-
261
- const rebaseCmd = `git rebase ${escapeShellArg(upstream)} 2>&1`;
262
-
263
- try {
264
- const result = await sendSpoolRequest('bash', rebaseCmd, 60000);
265
-
266
- if (result.stderr.includes('CONFLICT') || result.stdout.includes('CONFLICT')) {
267
- emitGitEvent('warn', 'rebase: conflicts detected', { upstream, sessionId });
268
- const statusCmd = 'git status --porcelain';
269
- const statusResult = await sendSpoolRequest('bash', statusCmd, 10000);
270
- const conflicts = statusResult.stdout.split('\n').filter(l => l.startsWith('UU') || l.startsWith('AA') || l.startsWith('DD'));
271
- return { ok: false, conflicts: true, conflictFiles: conflicts, message: 'Rebase halted due to conflicts. Resolve conflicts and run git rebase --continue' };
272
- }
273
-
274
- if (!result.ok) {
275
- emitGitEvent('error', 'rebase failed', { error: result.stderr, upstream, sessionId });
276
- throw new Error(`Rebase failed: ${result.stderr}`);
277
- }
278
-
279
- emitGitEvent('info', 'rebase succeeded', { upstream, sessionId });
280
- return { ok: true, upstream };
281
- } catch (e) {
282
- emitGitEvent('error', 'rebase error', { error: e.message, upstream, sessionId });
283
- throw e;
284
- }
285
- }
286
-
287
- async function cherryPick(commit, sessionId = 'unknown') {
288
- emitGitEvent('info', 'cherryPick() called', { commit, sessionId });
289
-
290
- if (!commit || commit.trim().length === 0) {
291
- emitGitEvent('error', 'commit hash required', { sessionId });
292
- throw new Error('Commit hash required');
293
- }
294
-
295
- const pickCmd = `git cherry-pick ${escapeShellArg(commit)} 2>&1`;
296
-
297
- try {
298
- const result = await sendSpoolRequest('bash', pickCmd, 60000);
299
-
300
- if (result.stderr.includes('CONFLICT') || result.stdout.includes('CONFLICT')) {
301
- emitGitEvent('warn', 'cherry-pick: conflicts detected', { commit, sessionId });
302
- return { ok: false, conflicts: true, message: 'Cherry-pick halted due to conflicts. Resolve conflicts and run git cherry-pick --continue' };
303
- }
304
-
305
- if (!result.ok) {
306
- emitGitEvent('error', 'cherry-pick failed', { error: result.stderr, commit, sessionId });
307
- throw new Error(`Cherry-pick failed: ${result.stderr}`);
308
- }
309
-
310
- emitGitEvent('info', 'cherry-pick succeeded', { commit, sessionId });
311
- return { ok: true, commit };
312
- } catch (e) {
313
- emitGitEvent('error', 'cherry-pick error', { error: e.message, commit, sessionId });
314
- throw e;
315
- }
316
- }
317
-
318
- module.exports = {
319
- commit,
320
- push,
321
- status,
322
- diff,
323
- log,
324
- checkout,
325
- rebase,
326
- cherryPick,
327
- escapeShellArg,
328
- parseGitStatus,
329
- GIT_USER,
330
- GIT_EMAIL,
331
- };