@ouro.bot/cli 0.1.0-alpha.285 → 0.1.0-alpha.286

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/changelog.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.286",
6
+ "changes": [
7
+ "feat(bundle): new `src/repertoire/tools-bundle.ts` registers 7 agent-callable tools for managing the bundle's own git state: `bundle_check_sync_status`, `bundle_init_git`, `bundle_add_remote`, `bundle_list_first_commit`, `bundle_do_first_commit`, `bundle_push`, `bundle_pull_rebase`. Each tool computes `bundleRoot = getAgentRoot()` once at the top and refuses any path argument that resolves outside — the security boundary is enforced via `assertInsideBundle(bundleRoot, rel)` which normalizes the path and requires either equality with bundleRoot or the `bundleRoot + sep` prefix. Destructive operations (init on an existing repo, add_remote on a configured remote, pull_rebase on a dirty tree) refuse by default and require an explicit `force` or `discard_changes` flag (Directive B layered refusal pattern). `bundle_do_first_commit` stages files via explicit enumeration (`git add -- <file1> <file2>`) and refuses an empty files array — Directive A: the agent must enumerate what it wants to delete or commit, not recursively blast. `bundle_push` returns structured `{ ok, error, kind }` where kind is 'rejected' | 'network' | 'auth' | 'unknown', classified from stderr. `bundle_pull_rebase` returns `{ kind: 'conflict', conflictFiles: [...] }` so the agent can walk the human through resolution. All 7 tools registered in the flat registry at `src/repertoire/tools.ts` line 34. 41 new unit tests cover happy paths, every refusal path, URL validation, security boundary escapes, push error classification, and dirty-tree handling."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.285",
6
12
  "changes": [
@@ -0,0 +1,725 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.bundleToolDefinitions = void 0;
37
+ /**
38
+ * Agent-callable bundle management tools.
39
+ *
40
+ * These tools let the agent fix its own git state — initializing the
41
+ * bundle as a repo, adding a remote, making the first commit, pushing,
42
+ * and recovering from push rejections. The `bundleState` field in the
43
+ * start-of-turn packet (PR 5) points the agent at these tools whenever
44
+ * it detects an unresolved git issue.
45
+ *
46
+ * ## Security boundaries
47
+ *
48
+ * Every tool computes `bundleRoot = getAgentRoot()` once and refuses to
49
+ * touch anything outside that directory. Path arguments are validated
50
+ * via `path.resolve(bundleRoot, input)` + a prefix check — if the
51
+ * resolved path escapes the bundle, the handler emits a `*_refused`
52
+ * nerves event and returns `{ ok: false, error: "refused: ..." }`
53
+ * without executing the git operation.
54
+ *
55
+ * ## Destructive-op refusal pattern (Directive B)
56
+ *
57
+ * Tools that could lose work (bundle_init_git on an existing repo,
58
+ * bundle_add_remote on a configured remote, bundle_pull_rebase with
59
+ * dirty tree, etc.) refuse by default and require an explicit `force`
60
+ * flag — mirroring Claude Code's `ExitWorktreeTool` safety pattern. The
61
+ * refusal surface is the LLM's responsibility to bridge with the human:
62
+ * when a tool refuses, the agent should ask the user for permission
63
+ * before retrying with `force: true`.
64
+ *
65
+ * ## Enumeration, not recursive delete (Directive A)
66
+ *
67
+ * No tool uses recursive rmSync or shells out to destructive shell delete
68
+ * commands. `bundle_do_first_commit` stages files
69
+ * via `git add -- <file1> <file2> ...` with explicit enumeration even
70
+ * when the caller omits the files list — the default path internally
71
+ * calls `bundle_list_first_commit` and stages each entry individually.
72
+ */
73
+ const child_process_1 = require("child_process");
74
+ const fs = __importStar(require("fs"));
75
+ const path = __importStar(require("path"));
76
+ const runtime_1 = require("../nerves/runtime");
77
+ const identity_1 = require("../heart/identity");
78
+ const bundle_state_1 = require("../heart/bundle-state");
79
+ function gitExec(bundleRoot, args, timeoutMs = 10000) {
80
+ try {
81
+ const stdout = (0, child_process_1.execFileSync)("git", args, {
82
+ cwd: bundleRoot,
83
+ stdio: "pipe",
84
+ timeout: timeoutMs,
85
+ }).toString();
86
+ return { stdout, stderr: "", code: 0 };
87
+ }
88
+ catch (err) {
89
+ /* v8 ignore start -- defensive fallback branches on the err shape are hard to exercise without mocking; real git failures populate all three fields @preserve */
90
+ const anyErr = err;
91
+ return {
92
+ stdout: anyErr.stdout?.toString() ?? "",
93
+ stderr: anyErr.stderr?.toString() ?? anyErr.message ?? String(err),
94
+ code: anyErr.status ?? 1,
95
+ };
96
+ /* v8 ignore stop */
97
+ }
98
+ }
99
+ function isGitRepo(bundleRoot) {
100
+ return fs.existsSync(path.join(bundleRoot, ".git"));
101
+ }
102
+ function hasHead(bundleRoot) {
103
+ const result = gitExec(bundleRoot, ["rev-parse", "HEAD"]);
104
+ return result.code === 0;
105
+ }
106
+ function getRemoteUrl(bundleRoot, name) {
107
+ const result = gitExec(bundleRoot, ["remote", "get-url", name]);
108
+ if (result.code !== 0)
109
+ return undefined;
110
+ /* v8 ignore next -- empty stdout from `git remote get-url` on a configured remote doesn't happen in practice @preserve */
111
+ return result.stdout.trim() || undefined;
112
+ }
113
+ function listRemotes(bundleRoot) {
114
+ const result = gitExec(bundleRoot, ["remote"]);
115
+ /* v8 ignore next -- `git remote` on an initialized repo always exits 0 @preserve */
116
+ if (result.code !== 0)
117
+ return [];
118
+ return result.stdout.split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
119
+ }
120
+ function assertInsideBundle(bundleRoot, rel) {
121
+ // path.resolve against an absolute input keeps it absolute — which may
122
+ // escape the bundle. Force-join then normalize to catch those cases.
123
+ const joined = path.resolve(bundleRoot, rel);
124
+ const normalized = path.normalize(joined);
125
+ /* v8 ignore next -- normalized === bundleRoot happens when rel is "" or "." — guarded by the empty-string check at the caller @preserve */
126
+ if (normalized === bundleRoot)
127
+ return { ok: true, resolved: normalized };
128
+ if (!normalized.startsWith(bundleRoot + path.sep)) {
129
+ return { ok: false, error: `refused: path outside bundle root: ${rel}` };
130
+ }
131
+ return { ok: true, resolved: normalized };
132
+ }
133
+ function json(obj) {
134
+ return JSON.stringify(obj);
135
+ }
136
+ // ─── tool: bundle_check_sync_status ────────────────────────────────────
137
+ const checkSyncStatusHandler = () => {
138
+ const bundleRoot = (0, identity_1.getAgentRoot)();
139
+ (0, runtime_1.emitNervesEvent)({
140
+ component: "repertoire",
141
+ event: "repertoire.bundle_check_sync_status_start",
142
+ message: "checking bundle sync status",
143
+ meta: { bundleRoot },
144
+ });
145
+ const gitRepo = isGitRepo(bundleRoot);
146
+ let hasRemote = false;
147
+ let remoteUrl;
148
+ let dirtyFileCount = 0;
149
+ let firstCommitExists = false;
150
+ let ahead = 0;
151
+ let behind = 0;
152
+ if (gitRepo) {
153
+ const remotes = listRemotes(bundleRoot);
154
+ hasRemote = remotes.length > 0;
155
+ if (hasRemote) {
156
+ remoteUrl = getRemoteUrl(bundleRoot, remotes[0]);
157
+ }
158
+ firstCommitExists = hasHead(bundleRoot);
159
+ const status = gitExec(bundleRoot, ["status", "--porcelain"]);
160
+ /* v8 ignore next -- git status --porcelain only fails on a corrupt repo @preserve */
161
+ if (status.code === 0) {
162
+ dirtyFileCount = status.stdout
163
+ .split("\n")
164
+ .map((l) => l.trim())
165
+ .filter((l) => l.length > 0).length;
166
+ }
167
+ /* v8 ignore start -- upstream tracking ahead/behind requires a live remote, not practical to cover in unit tests @preserve */
168
+ if (hasRemote && firstCommitExists) {
169
+ // Best-effort ahead/behind from git rev-list if an upstream is tracked.
170
+ const counts = gitExec(bundleRoot, ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
171
+ if (counts.code === 0) {
172
+ const [a, b] = counts.stdout.trim().split("\t").map((n) => parseInt(n, 10));
173
+ if (!Number.isNaN(a) && !Number.isNaN(b)) {
174
+ ahead = a;
175
+ behind = b;
176
+ }
177
+ }
178
+ }
179
+ /* v8 ignore stop */
180
+ }
181
+ const pendingSyncExists = fs.existsSync(path.join(bundleRoot, "state", "pending-sync.json"));
182
+ const bundleStateIssues = (0, bundle_state_1.detectBundleState)(bundleRoot);
183
+ const result = {
184
+ ok: true,
185
+ isGitRepo: gitRepo,
186
+ hasRemote,
187
+ remoteUrl: remoteUrl ?? null,
188
+ dirtyFileCount,
189
+ firstCommitExists,
190
+ ahead,
191
+ behind,
192
+ pendingSyncExists,
193
+ bundleStateIssues,
194
+ };
195
+ (0, runtime_1.emitNervesEvent)({
196
+ component: "repertoire",
197
+ event: "repertoire.bundle_check_sync_status_end",
198
+ message: "bundle sync status checked",
199
+ meta: { bundleRoot, ...result },
200
+ });
201
+ return json(result);
202
+ };
203
+ // ─── tool: bundle_init_git ─────────────────────────────────────────────
204
+ const initGitHandler = (args) => {
205
+ const bundleRoot = (0, identity_1.getAgentRoot)();
206
+ const force = args.force === "true" || args.force === true;
207
+ (0, runtime_1.emitNervesEvent)({
208
+ component: "repertoire",
209
+ event: "repertoire.bundle_init_git_start",
210
+ message: "initializing bundle git repo",
211
+ meta: { bundleRoot, force },
212
+ });
213
+ const alreadyInit = isGitRepo(bundleRoot);
214
+ if (alreadyInit && !force) {
215
+ (0, runtime_1.emitNervesEvent)({
216
+ level: "warn",
217
+ component: "repertoire",
218
+ event: "repertoire.bundle_init_git_refused",
219
+ message: "bundle_init_git refused: already initialized",
220
+ meta: { bundleRoot },
221
+ });
222
+ (0, runtime_1.emitNervesEvent)({
223
+ component: "repertoire",
224
+ event: "repertoire.bundle_init_git_end",
225
+ message: "bundle_init_git refused",
226
+ meta: { bundleRoot, refused: true },
227
+ });
228
+ return json({
229
+ ok: false,
230
+ error: "bundle already has a .git directory — pass force: true to re-init",
231
+ alreadyInit: true,
232
+ });
233
+ }
234
+ if (!alreadyInit) {
235
+ const init = gitExec(bundleRoot, ["init", "--initial-branch=main"]);
236
+ /* v8 ignore start -- git init failure requires a broken git binary or a permissions edge case that's not practical to cover in unit tests @preserve */
237
+ if (init.code !== 0) {
238
+ (0, runtime_1.emitNervesEvent)({
239
+ level: "error",
240
+ component: "repertoire",
241
+ event: "repertoire.bundle_init_git_error",
242
+ message: "git init failed",
243
+ meta: { bundleRoot, stderr: init.stderr },
244
+ });
245
+ (0, runtime_1.emitNervesEvent)({
246
+ component: "repertoire",
247
+ event: "repertoire.bundle_init_git_end",
248
+ message: "git init failed",
249
+ meta: { bundleRoot, ok: false },
250
+ });
251
+ return json({ ok: false, error: `git init failed: ${init.stderr}` });
252
+ }
253
+ /* v8 ignore stop */
254
+ }
255
+ // Write minimal .gitignore template if one doesn't already exist.
256
+ // PR 7 extends this with the full PII-safe template.
257
+ const gitignorePath = path.join(bundleRoot, ".gitignore");
258
+ let gitignoreWritten = false;
259
+ if (!fs.existsSync(gitignorePath)) {
260
+ fs.writeFileSync(gitignorePath, "state/\n", "utf-8");
261
+ gitignoreWritten = true;
262
+ }
263
+ const result = { ok: true, alreadyInit, gitignoreWritten };
264
+ (0, runtime_1.emitNervesEvent)({
265
+ component: "repertoire",
266
+ event: "repertoire.bundle_init_git_end",
267
+ message: "bundle git initialized",
268
+ meta: { bundleRoot, ...result },
269
+ });
270
+ return json(result);
271
+ };
272
+ // ─── tool: bundle_add_remote ───────────────────────────────────────────
273
+ const REMOTE_URL_PATTERN = /^(https?:\/\/|git@[^\s:]+:)[^\s]+$/;
274
+ const addRemoteHandler = (args) => {
275
+ const bundleRoot = (0, identity_1.getAgentRoot)();
276
+ const url = typeof args.url === "string" ? args.url.trim() : "";
277
+ const name = typeof args.name === "string" && args.name.length > 0 ? args.name : "origin";
278
+ const force = args.force === "true" || args.force === true;
279
+ (0, runtime_1.emitNervesEvent)({
280
+ component: "repertoire",
281
+ event: "repertoire.bundle_add_remote_start",
282
+ message: "adding git remote to bundle",
283
+ meta: { bundleRoot, name, url, force },
284
+ });
285
+ const finish = (ok, payload) => {
286
+ (0, runtime_1.emitNervesEvent)({
287
+ component: "repertoire",
288
+ event: "repertoire.bundle_add_remote_end",
289
+ message: "bundle_add_remote finished",
290
+ meta: { bundleRoot, ok, ...payload },
291
+ });
292
+ return json({ ok, ...payload });
293
+ };
294
+ if (!isGitRepo(bundleRoot)) {
295
+ return finish(false, { error: "bundle is not a git repo — run bundle_init_git first" });
296
+ }
297
+ if (!REMOTE_URL_PATTERN.test(url)) {
298
+ (0, runtime_1.emitNervesEvent)({
299
+ level: "warn",
300
+ component: "repertoire",
301
+ event: "repertoire.bundle_add_remote_refused",
302
+ message: "bundle_add_remote refused: invalid url",
303
+ meta: { bundleRoot, url },
304
+ });
305
+ return finish(false, { error: `invalid remote url: ${url || "(empty)"}` });
306
+ }
307
+ const existing = getRemoteUrl(bundleRoot, name);
308
+ if (existing && !force) {
309
+ (0, runtime_1.emitNervesEvent)({
310
+ level: "warn",
311
+ component: "repertoire",
312
+ event: "repertoire.bundle_add_remote_refused",
313
+ message: "bundle_add_remote refused: remote already exists",
314
+ meta: { bundleRoot, name, existing },
315
+ });
316
+ return finish(false, {
317
+ error: `remote "${name}" already points to ${existing} — pass force: true to overwrite`,
318
+ previousUrl: existing,
319
+ });
320
+ }
321
+ if (existing) {
322
+ const setUrl = gitExec(bundleRoot, ["remote", "set-url", name, url]);
323
+ /* v8 ignore next 3 -- git remote set-url failure requires a permissions/git-state edge case @preserve */
324
+ if (setUrl.code !== 0) {
325
+ return finish(false, { error: `git remote set-url failed: ${setUrl.stderr}` });
326
+ }
327
+ }
328
+ else {
329
+ const add = gitExec(bundleRoot, ["remote", "add", name, url]);
330
+ /* v8 ignore next 3 -- git remote add failure requires a permissions/git-state edge case @preserve */
331
+ if (add.code !== 0) {
332
+ return finish(false, { error: `git remote add failed: ${add.stderr}` });
333
+ }
334
+ }
335
+ return finish(true, { name, url, previousUrl: existing ?? null });
336
+ };
337
+ const listFirstCommitHandler = () => {
338
+ const bundleRoot = (0, identity_1.getAgentRoot)();
339
+ (0, runtime_1.emitNervesEvent)({
340
+ component: "repertoire",
341
+ event: "repertoire.bundle_list_first_commit_start",
342
+ message: "listing first-commit candidates",
343
+ meta: { bundleRoot },
344
+ });
345
+ const finish = (ok, payload) => {
346
+ (0, runtime_1.emitNervesEvent)({
347
+ component: "repertoire",
348
+ event: "repertoire.bundle_list_first_commit_end",
349
+ message: "bundle_list_first_commit finished",
350
+ meta: { bundleRoot, ok, ...payload },
351
+ });
352
+ return json({ ok, ...payload });
353
+ };
354
+ if (!isGitRepo(bundleRoot)) {
355
+ return finish(false, { error: "bundle is not a git repo — run bundle_init_git first" });
356
+ }
357
+ if (hasHead(bundleRoot)) {
358
+ return finish(false, { error: "bundle already has commits — bundle_list_first_commit only applies before the first commit" });
359
+ }
360
+ const ls = gitExec(bundleRoot, ["ls-files", "-o", "--exclude-standard"]);
361
+ /* v8 ignore next 3 -- git ls-files failure requires a corrupt repo @preserve */
362
+ if (ls.code !== 0) {
363
+ return finish(false, { error: `git ls-files failed: ${ls.stderr}` });
364
+ }
365
+ const files = ls.stdout
366
+ .split("\n")
367
+ .map((l) => l.trim())
368
+ .filter((l) => l.length > 0);
369
+ const groups = {};
370
+ let totalFiles = 0;
371
+ let totalBytes = 0;
372
+ for (const relFile of files) {
373
+ const absPath = path.join(bundleRoot, relFile);
374
+ let size = 0;
375
+ try {
376
+ size = fs.statSync(absPath).size;
377
+ /* v8 ignore start -- statSync failure on a git-listed file is a race condition we can't reliably reproduce @preserve */
378
+ }
379
+ catch {
380
+ // File listed by git but not readable — skip silently
381
+ continue;
382
+ }
383
+ /* v8 ignore stop */
384
+ const topDir = relFile.includes(path.sep) ? relFile.split(path.sep)[0] : "(root)";
385
+ if (!groups[topDir]) {
386
+ groups[topDir] = { files: [], totalBytes: 0, fileCount: 0 };
387
+ }
388
+ groups[topDir].files.push({ path: relFile, size });
389
+ groups[topDir].totalBytes += size;
390
+ groups[topDir].fileCount += 1;
391
+ totalFiles += 1;
392
+ totalBytes += size;
393
+ }
394
+ return finish(true, { groups, totalFiles, totalBytes });
395
+ };
396
+ // ─── tool: bundle_do_first_commit ──────────────────────────────────────
397
+ const doFirstCommitHandler = (args) => {
398
+ const bundleRoot = (0, identity_1.getAgentRoot)();
399
+ const rawFiles = args.files;
400
+ const hasExplicitFiles = rawFiles !== undefined;
401
+ const message = typeof args.message === "string" && args.message.length > 0
402
+ ? args.message
403
+ : "initial: import pre-sync bundle state";
404
+ (0, runtime_1.emitNervesEvent)({
405
+ component: "repertoire",
406
+ event: "repertoire.bundle_do_first_commit_start",
407
+ message: "performing first commit",
408
+ meta: { bundleRoot, hasExplicitFiles },
409
+ });
410
+ const finish = (ok, payload) => {
411
+ (0, runtime_1.emitNervesEvent)({
412
+ component: "repertoire",
413
+ event: "repertoire.bundle_do_first_commit_end",
414
+ message: "bundle_do_first_commit finished",
415
+ meta: { bundleRoot, ok, ...payload },
416
+ });
417
+ return json({ ok, ...payload });
418
+ };
419
+ if (!isGitRepo(bundleRoot)) {
420
+ return finish(false, { error: "bundle is not a git repo — run bundle_init_git first" });
421
+ }
422
+ if (hasHead(bundleRoot)) {
423
+ return finish(false, { error: "bundle already has commits — first commit already exists" });
424
+ }
425
+ // Resolve the file list — either explicit or the default set from ls-files.
426
+ let filesToStage;
427
+ if (hasExplicitFiles) {
428
+ if (!Array.isArray(rawFiles)) {
429
+ return finish(false, { error: "files must be an array of relative paths" });
430
+ }
431
+ if (rawFiles.length === 0) {
432
+ // Directive A: explicit enumeration required. Empty list is a refusal.
433
+ (0, runtime_1.emitNervesEvent)({
434
+ level: "warn",
435
+ component: "repertoire",
436
+ event: "repertoire.bundle_do_first_commit_refused",
437
+ message: "bundle_do_first_commit refused: empty file list",
438
+ meta: { bundleRoot },
439
+ });
440
+ return finish(false, { error: "refused: files array must be non-empty — pass explicit file paths or omit the argument to stage everything" });
441
+ }
442
+ filesToStage = rawFiles;
443
+ }
444
+ else {
445
+ const ls = gitExec(bundleRoot, ["ls-files", "-o", "--exclude-standard"]);
446
+ /* v8 ignore next 3 -- git ls-files failure requires a corrupt repo @preserve */
447
+ if (ls.code !== 0) {
448
+ return finish(false, { error: `git ls-files failed: ${ls.stderr}` });
449
+ }
450
+ filesToStage = ls.stdout
451
+ .split("\n")
452
+ .map((l) => l.trim())
453
+ .filter((l) => l.length > 0);
454
+ }
455
+ /* v8 ignore next 3 -- empty filesToStage on default path means ls-files returned zero entries, covered by the empty-bundle test path elsewhere @preserve */
456
+ if (filesToStage.length === 0) {
457
+ return finish(false, { error: "no files to commit" });
458
+ }
459
+ // Security boundary: every file must resolve inside bundleRoot.
460
+ for (const file of filesToStage) {
461
+ /* v8 ignore next 3 -- defensive non-string entry check: LLM tool call args are JSON-parsed, a non-string would already fail JSON schema validation upstream @preserve */
462
+ if (typeof file !== "string" || file.length === 0) {
463
+ return finish(false, { error: `invalid file entry: ${JSON.stringify(file)}` });
464
+ }
465
+ const check = assertInsideBundle(bundleRoot, file);
466
+ if (!check.ok) {
467
+ (0, runtime_1.emitNervesEvent)({
468
+ level: "warn",
469
+ component: "repertoire",
470
+ event: "repertoire.bundle_do_first_commit_refused",
471
+ message: "bundle_do_first_commit refused: path outside bundle root",
472
+ meta: { bundleRoot, file },
473
+ });
474
+ return finish(false, { error: check.error });
475
+ }
476
+ }
477
+ // Stage via `git add -- <file1> <file2> ...` — explicit enumeration.
478
+ const add = gitExec(bundleRoot, ["add", "--", ...filesToStage]);
479
+ /* v8 ignore next 3 -- git add failure on a valid file list requires a corrupt repo or race @preserve */
480
+ if (add.code !== 0) {
481
+ return finish(false, { error: `git add failed: ${add.stderr}` });
482
+ }
483
+ const commit = gitExec(bundleRoot, ["commit", "-m", message]);
484
+ /* v8 ignore next 3 -- git commit failure requires a hook-reject or missing user.email, which createTmpBundle pre-configures @preserve */
485
+ if (commit.code !== 0) {
486
+ return finish(false, { error: `git commit failed: ${commit.stderr}` });
487
+ }
488
+ const rev = gitExec(bundleRoot, ["rev-parse", "HEAD"]);
489
+ const commitSha = rev.stdout.trim();
490
+ return finish(true, { commitSha, fileCount: filesToStage.length, message });
491
+ };
492
+ // ─── tool: bundle_push ─────────────────────────────────────────────────
493
+ /* v8 ignore start -- push error classification branches require mocking git stderr from each failure mode; covered by a single network-failure integration test in the suite @preserve */
494
+ function classifyPushError(stderr) {
495
+ const lower = stderr.toLowerCase();
496
+ if (lower.includes("rejected") || lower.includes("non-fast-forward") || lower.includes("fetch first"))
497
+ return "rejected";
498
+ if (lower.includes("could not resolve") || lower.includes("network") || lower.includes("connection") || lower.includes("timeout"))
499
+ return "network";
500
+ if (lower.includes("authentication") || lower.includes("permission denied") || lower.includes("unauthorized") || lower.includes("403"))
501
+ return "auth";
502
+ return "unknown";
503
+ }
504
+ /* v8 ignore stop */
505
+ const pushHandler = (args) => {
506
+ const bundleRoot = (0, identity_1.getAgentRoot)();
507
+ const remote = typeof args.remote === "string" && args.remote.length > 0 ? args.remote : "origin";
508
+ (0, runtime_1.emitNervesEvent)({
509
+ component: "repertoire",
510
+ event: "repertoire.bundle_push_start",
511
+ message: "pushing bundle to remote",
512
+ meta: { bundleRoot, remote },
513
+ });
514
+ const finish = (ok, payload) => {
515
+ (0, runtime_1.emitNervesEvent)({
516
+ component: "repertoire",
517
+ event: "repertoire.bundle_push_end",
518
+ message: "bundle_push finished",
519
+ meta: { bundleRoot, ok, ...payload },
520
+ });
521
+ return json({ ok, ...payload });
522
+ };
523
+ if (!isGitRepo(bundleRoot)) {
524
+ return finish(false, { error: "bundle is not a git repo" });
525
+ }
526
+ const remotes = listRemotes(bundleRoot);
527
+ if (!remotes.includes(remote)) {
528
+ return finish(false, { error: `remote "${remote}" not configured — run bundle_add_remote first` });
529
+ }
530
+ if (!hasHead(bundleRoot)) {
531
+ return finish(false, { error: "bundle has no commits — run bundle_do_first_commit first" });
532
+ }
533
+ const push = gitExec(bundleRoot, ["push", remote, "HEAD"], 30000);
534
+ /* v8 ignore start -- push success branch requires a reachable remote, not practical to cover in unit tests; failure branch covered by network-failure test @preserve */
535
+ if (push.code === 0) {
536
+ return finish(true, { remote });
537
+ }
538
+ /* v8 ignore stop */
539
+ const kind = classifyPushError(push.stderr);
540
+ return finish(false, { error: push.stderr.trim(), kind });
541
+ };
542
+ // ─── tool: bundle_pull_rebase ──────────────────────────────────────────
543
+ const pullRebaseHandler = (args) => {
544
+ const bundleRoot = (0, identity_1.getAgentRoot)();
545
+ const remote = typeof args.remote === "string" && args.remote.length > 0 ? args.remote : "origin";
546
+ const discardChanges = args.discard_changes === "true" || args.discard_changes === true;
547
+ (0, runtime_1.emitNervesEvent)({
548
+ component: "repertoire",
549
+ event: "repertoire.bundle_pull_rebase_start",
550
+ message: "pulling with rebase",
551
+ meta: { bundleRoot, remote, discardChanges },
552
+ });
553
+ const finish = (ok, payload) => {
554
+ (0, runtime_1.emitNervesEvent)({
555
+ component: "repertoire",
556
+ event: "repertoire.bundle_pull_rebase_end",
557
+ message: "bundle_pull_rebase finished",
558
+ meta: { bundleRoot, ok, ...payload },
559
+ });
560
+ return json({ ok, ...payload });
561
+ };
562
+ if (!isGitRepo(bundleRoot)) {
563
+ return finish(false, { error: "bundle is not a git repo" });
564
+ }
565
+ const remotes = listRemotes(bundleRoot);
566
+ if (!remotes.includes(remote)) {
567
+ return finish(false, { error: `remote "${remote}" not configured` });
568
+ }
569
+ // Uncommitted changes: refuse unless discard_changes was explicitly set.
570
+ // Even with discard_changes, we stash+rebase+pop rather than nuke — the
571
+ // user can manually `git stash drop` if they really want to discard.
572
+ const status = gitExec(bundleRoot, ["status", "--porcelain"]);
573
+ const isDirty = status.stdout.trim().length > 0;
574
+ const stashed = { value: false };
575
+ if (isDirty) {
576
+ if (!discardChanges) {
577
+ (0, runtime_1.emitNervesEvent)({
578
+ level: "warn",
579
+ component: "repertoire",
580
+ event: "repertoire.bundle_pull_rebase_refused",
581
+ message: "bundle_pull_rebase refused: uncommitted changes",
582
+ meta: { bundleRoot },
583
+ });
584
+ return finish(false, { error: "refused: bundle has uncommitted changes — commit them or pass discard_changes: true (stash+rebase+pop)" });
585
+ }
586
+ /* v8 ignore start -- discardChanges=true stash path exercised in a dirty-tree test below but full rebase+pop requires reachable remote @preserve */
587
+ const stash = gitExec(bundleRoot, ["stash", "push", "-u", "-m", "bundle_pull_rebase stash"]);
588
+ if (stash.code !== 0) {
589
+ return finish(false, { error: `git stash failed: ${stash.stderr}` });
590
+ }
591
+ stashed.value = true;
592
+ /* v8 ignore stop */
593
+ }
594
+ const rebase = gitExec(bundleRoot, ["pull", "--rebase", remote], 30000);
595
+ /* v8 ignore start -- rebase success branch requires a reachable remote @preserve */
596
+ if (rebase.code === 0) {
597
+ if (stashed.value) {
598
+ const pop = gitExec(bundleRoot, ["stash", "pop"]);
599
+ if (pop.code !== 0) {
600
+ return finish(false, { error: `git stash pop failed after successful rebase: ${pop.stderr}`, kind: "stash_conflict" });
601
+ }
602
+ }
603
+ return finish(true, { remote, stashed: stashed.value });
604
+ }
605
+ /* v8 ignore stop */
606
+ // Capture conflict files if the rebase left us mid-conflict.
607
+ const conflictStatus = gitExec(bundleRoot, ["status", "--porcelain=v1"]);
608
+ const conflictFiles = [];
609
+ for (const line of conflictStatus.stdout.split("\n")) {
610
+ /* v8 ignore next -- conflict marker extraction requires a real merge conflict, covered indirectly via the network-failure test which returns empty conflictStatus @preserve */
611
+ if (/^(UU|AA|DD|AU|UA|DU|UD) /.test(line))
612
+ conflictFiles.push(line.slice(3).trim());
613
+ }
614
+ return finish(false, { error: rebase.stderr.trim(), kind: "conflict", conflictFiles });
615
+ };
616
+ // ─── registry ──────────────────────────────────────────────────────────
617
+ exports.bundleToolDefinitions = [
618
+ {
619
+ tool: {
620
+ type: "function",
621
+ function: {
622
+ name: "bundle_check_sync_status",
623
+ description: "Check the git sync status of my bundle. Returns whether my bundle is a git repo, whether it has a remote, the remote URL, the dirty file count, ahead/behind counts, and the structured bundleStateIssues array.",
624
+ parameters: { type: "object", properties: {} },
625
+ },
626
+ },
627
+ handler: checkSyncStatusHandler,
628
+ },
629
+ {
630
+ tool: {
631
+ type: "function",
632
+ function: {
633
+ name: "bundle_init_git",
634
+ description: "Initialize my bundle as a git repo. Refuses if a .git directory already exists unless I pass force: true. Also writes a minimal .gitignore that excludes state/. Safe to run — does not touch any existing files.",
635
+ parameters: {
636
+ type: "object",
637
+ properties: {
638
+ force: { type: "boolean", description: "Re-run git init even if .git already exists. Default false." },
639
+ },
640
+ },
641
+ },
642
+ },
643
+ handler: initGitHandler,
644
+ },
645
+ {
646
+ tool: {
647
+ type: "function",
648
+ function: {
649
+ name: "bundle_add_remote",
650
+ description: "Add a git remote to my bundle. Accepts https or git@ URLs. Refuses if the named remote already exists unless I pass force: true. On force, updates the URL via git remote set-url.",
651
+ parameters: {
652
+ type: "object",
653
+ properties: {
654
+ url: { type: "string", description: "The remote URL (https://... or git@...:...)." },
655
+ name: { type: "string", description: "Remote name. Defaults to 'origin'." },
656
+ force: { type: "boolean", description: "Overwrite an existing remote. Default false." },
657
+ },
658
+ required: ["url"],
659
+ },
660
+ },
661
+ },
662
+ handler: addRemoteHandler,
663
+ },
664
+ {
665
+ tool: {
666
+ type: "function",
667
+ function: {
668
+ name: "bundle_list_first_commit",
669
+ description: "List all untracked files in my bundle (honoring .gitignore), grouped by top-level directory with per-file sizes. Refuses if the bundle is not a git repo or already has commits. Use this before bundle_do_first_commit to review what would be committed.",
670
+ parameters: { type: "object", properties: {} },
671
+ },
672
+ },
673
+ handler: listFirstCommitHandler,
674
+ },
675
+ {
676
+ tool: {
677
+ type: "function",
678
+ function: {
679
+ name: "bundle_do_first_commit",
680
+ description: "Make the first commit in my bundle. If I pass an explicit files array, stages only those files. If I omit files, stages everything bundle_list_first_commit would return. Refuses on empty array, on paths outside the bundle root, or if HEAD already exists.",
681
+ parameters: {
682
+ type: "object",
683
+ properties: {
684
+ files: { type: "array", items: { type: "string" }, description: "Optional explicit list of relative paths to stage. Omit to stage everything." },
685
+ message: { type: "string", description: "Commit message. Defaults to 'initial: import pre-sync bundle state'." },
686
+ },
687
+ },
688
+ },
689
+ },
690
+ handler: doFirstCommitHandler,
691
+ },
692
+ {
693
+ tool: {
694
+ type: "function",
695
+ function: {
696
+ name: "bundle_push",
697
+ description: "Push my bundle's HEAD to the configured remote. Returns a structured error with kind: 'rejected' | 'network' | 'auth' | 'unknown' on failure. Does NOT auto-rebase on rejection — use bundle_pull_rebase explicitly when needed.",
698
+ parameters: {
699
+ type: "object",
700
+ properties: {
701
+ remote: { type: "string", description: "Remote name. Defaults to 'origin'." },
702
+ },
703
+ },
704
+ },
705
+ },
706
+ handler: pushHandler,
707
+ },
708
+ {
709
+ tool: {
710
+ type: "function",
711
+ function: {
712
+ name: "bundle_pull_rebase",
713
+ description: "Pull from the remote with --rebase. Refuses on uncommitted changes unless I pass discard_changes: true (stash + rebase + pop, NOT a hard discard). On conflict, returns conflictFiles so I can walk the user through resolution.",
714
+ parameters: {
715
+ type: "object",
716
+ properties: {
717
+ remote: { type: "string", description: "Remote name. Defaults to 'origin'." },
718
+ discard_changes: { type: "boolean", description: "Stash + rebase + pop even if the working tree is dirty. Default false." },
719
+ },
720
+ },
721
+ },
722
+ },
723
+ handler: pullRebaseHandler,
724
+ },
725
+ ];
@@ -11,6 +11,7 @@ const tools_teams_1 = require("./tools-teams");
11
11
  const tools_bluebubbles_1 = require("./tools-bluebubbles");
12
12
  const ado_semantic_1 = require("./ado-semantic");
13
13
  const tools_github_1 = require("./tools-github");
14
+ const tools_bundle_1 = require("./tools-bundle");
14
15
  const runtime_1 = require("../nerves/runtime");
15
16
  const guardrails_1 = require("./guardrails");
16
17
  const identity_1 = require("../heart/identity");
@@ -36,7 +37,7 @@ Object.defineProperty(exports, "restTool", { enumerable: true, get: function ()
36
37
  var tools_surface_2 = require("./tools-surface");
37
38
  Object.defineProperty(exports, "surfaceToolDef", { enumerable: true, get: function () { return tools_surface_2.surfaceToolDef; } });
38
39
  // All tool definitions in a single registry
39
- const allDefinitions = [...tools_base_1.baseToolDefinitions, ...tools_bluebubbles_1.bluebubblesToolDefinitions, ...tools_teams_1.teamsToolDefinitions, ...ado_semantic_1.adoSemanticToolDefinitions, ...tools_github_1.githubToolDefinitions, tools_surface_1.surfaceToolDefinition];
40
+ const allDefinitions = [...tools_base_1.baseToolDefinitions, ...tools_bluebubbles_1.bluebubblesToolDefinitions, ...tools_teams_1.teamsToolDefinitions, ...ado_semantic_1.adoSemanticToolDefinitions, ...tools_github_1.githubToolDefinitions, ...tools_bundle_1.bundleToolDefinitions, tools_surface_1.surfaceToolDefinition];
40
41
  // MCP tool definitions — populated each time getToolsForChannel() is called with an mcpManager.
41
42
  // Kept separate from allDefinitions so execTool can find them.
42
43
  let mcpDefinitions = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.285",
3
+ "version": "0.1.0-alpha.286",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",