@ouro.bot/cli 0.1.0-alpha.287 → 0.1.0-alpha.288

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.288",
6
+ "changes": [
7
+ "feat(bundle): full `.gitignore` template + first-push PII review workflow + confirmation-token gate on `bundle_push`. Completes the agent-manages-its-own-bundle chain (PRs 5 + 6 + 7). (1) New `src/repertoire/bundle-templates.ts` exports `BUNDLE_GITIGNORE_TEMPLATE` — a curated gitignore that handles functional cases only (runtime state, credentials, editor/OS noise, build artifacts) and explicitly does NOT block PII. The design philosophy is baked into the file's top-comment: bundles are inherently full of PII (friends/, diary/, journal/, psyche/, arc/, facts/, family/, travel/) and blocking those via gitignore would defeat the bundle's purpose. PII is handled at first-push time by a separate safety layer instead. Also exports `PII_BUNDLE_DIRECTORIES` as the canonical list of PII-bearing top-level dirs. (2) `bundle_init_git` now writes the full template instead of the minimal `state/`-only placeholder from PR 6. (3) New `bundle_first_push_review` tool enumerates existing PII directories with per-directory file counts (honoring `.gitignore` via `git ls-files --others --exclude-standard`), probes the remote URL for GitHub public/private visibility via an unauthenticated `fetch` to `https://api.github.com/repos/{owner}/{repo}` with a 5-second timeout, and generates a first-person warning text — `warningLevel: 'public_github' | 'private_github' | 'generic'`. The tool issues a `confirmationToken` (crypto.randomUUID) stored in a module-level Map with a 15-minute TTL and returns it in the payload. (4) `bundle_push` updated to accept an optional `confirmation_token` parameter: on first-push attempts (detected via `git ls-remote --heads <remote> <branch>` empty — or, conservatively, when that probe fails to reach the remote), the handler requires a valid token that was issued for the SAME bundleRoot and has not expired. Missing, invalid, wrong-bundle, or expired token returns `kind: 'confirmation_required'`. On successful validation, the token is consumed (one-shot). This is the Directive D PII-review gate: the agent cannot push a bundle to the internet without the human explicitly acknowledging the PII payload first. 17 new tests cover the template, PII counting with empty and populated directories, GitHub public/private/404/network-error/malformed-response paths, URL parsing for gitlab/self-hosted/github, token storage + TTL expiry, and every token-gating refusal path in bundle_push. Bundle-templates.ts is added to the file-completeness exempt list since it's a pure constants module (design: `tools-bundle.ts` owns the observability for bundle operations)."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.287",
6
12
  "changes": [
@@ -71,6 +71,10 @@ const DISPATCH_EXEMPT_PATTERNS = [
71
71
  "repertoire/commerce-errors",
72
72
  // Log redaction: pure utility consumed by the NDJSON sink (no independent side effects).
73
73
  "nerves/redact",
74
+ // Bundle templates: pure constants (gitignore template string, PII
75
+ // directory list). No runtime behavior — consumed by tools-bundle.ts
76
+ // which owns the observability for bundle operations.
77
+ "repertoire/bundle-templates",
74
78
  ];
75
79
  function isDispatchExempt(filePath) {
76
80
  return DISPATCH_EXEMPT_PATTERNS.some((pattern) => filePath.includes(pattern));
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /**
3
+ * Templates for agent bundle scaffolding.
4
+ *
5
+ * ## .gitignore design philosophy
6
+ *
7
+ * The bundle .gitignore handles FUNCTIONAL "shouldn't track" cases only:
8
+ *
9
+ * - Runtime state (sessions, logs, runtime files) — stale data with no
10
+ * value for review or history.
11
+ * - Credentials — real secrets live in `~/.agentsecrets`, but defense
12
+ * in depth in case anything leaks into the bundle.
13
+ * - Editor / OS noise (.DS_Store, .idea/, etc.).
14
+ * - Build artifacts (rare in bundles, but possible).
15
+ *
16
+ * It DOES NOT handle PII. The bundle is inherently full of PII — `friends/`,
17
+ * `diary/`, `journal/`, `psyche/`, `arc/`, `facts/`, `family/`, `travel/`
18
+ * etc. That's the point of the bundle; blocking those via .gitignore would
19
+ * defeat the purpose.
20
+ *
21
+ * PII is handled at first-push time by `bundle_first_push_review`, which
22
+ * enumerates PII-bearing directories, shows the agent counts, probes the
23
+ * remote URL for GitHub visibility, and hard-pauses until the human
24
+ * confirms. See Directive D in the planning doc.
25
+ *
26
+ * No content-pattern blocks (no `**\/sk-ant-*` or similar). Content-review
27
+ * failures are a different safety layer — credential scanning at commit
28
+ * time would be a follow-up feature.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.PII_BUNDLE_DIRECTORIES = exports.BUNDLE_GITIGNORE_TEMPLATE = void 0;
32
+ exports.BUNDLE_GITIGNORE_TEMPLATE = `# Runtime state — sessions, logs, runtime files, never tracked
33
+ state/
34
+
35
+ # Credentials — never tracked. Real secrets live in ~/.agentsecrets, but
36
+ # defense in depth in case anything leaks into the bundle.
37
+ .env
38
+ .env.*
39
+ secrets/
40
+ **/*.key
41
+ **/*.pem
42
+ **/*.credentials
43
+ **/*.pfx
44
+
45
+ # Editor and OS noise
46
+ .DS_Store
47
+ .idea/
48
+ .vscode/
49
+ *.swp
50
+ *.swo
51
+
52
+ # Build artifacts (rare in bundles, but possible if a workspace lands here)
53
+ node_modules/
54
+ dist/
55
+ `;
56
+ /**
57
+ * PII-sensitive top-level directories. Enumerated here so `bundle_first_push_review`
58
+ * can categorize and count. Adding a new PII bucket to the bundle means adding
59
+ * it here so the first-push warning includes it.
60
+ */
61
+ exports.PII_BUNDLE_DIRECTORIES = [
62
+ "friends",
63
+ "diary",
64
+ "journal",
65
+ "psyche",
66
+ "arc",
67
+ "facts",
68
+ "family",
69
+ "travel",
70
+ "memory",
71
+ "sessions",
72
+ ];
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.bundleToolDefinitions = void 0;
37
+ exports.__getConfirmationTokenStore = __getConfirmationTokenStore;
37
38
  /**
38
39
  * Agent-callable bundle management tools.
39
40
  *
@@ -71,11 +72,13 @@ exports.bundleToolDefinitions = void 0;
71
72
  * calls `bundle_list_first_commit` and stages each entry individually.
72
73
  */
73
74
  const child_process_1 = require("child_process");
75
+ const crypto = __importStar(require("crypto"));
74
76
  const fs = __importStar(require("fs"));
75
77
  const path = __importStar(require("path"));
76
78
  const runtime_1 = require("../nerves/runtime");
77
79
  const identity_1 = require("../heart/identity");
78
80
  const bundle_state_1 = require("../heart/bundle-state");
81
+ const bundle_templates_1 = require("./bundle-templates");
79
82
  function gitExec(bundleRoot, args, timeoutMs = 10000) {
80
83
  try {
81
84
  const stdout = (0, child_process_1.execFileSync)("git", args, {
@@ -252,12 +255,14 @@ const initGitHandler = (args) => {
252
255
  }
253
256
  /* v8 ignore stop */
254
257
  }
255
- // Write minimal .gitignore template if one doesn't already exist.
256
- // PR 7 extends this with the full PII-safe template.
258
+ // Write the full .gitignore template if one doesn't already exist.
259
+ // See BUNDLE_GITIGNORE_TEMPLATE's design philosophy: functional blocks
260
+ // only (credentials, state, noise, build artifacts); PII is handled
261
+ // separately by bundle_first_push_review at first-push time.
257
262
  const gitignorePath = path.join(bundleRoot, ".gitignore");
258
263
  let gitignoreWritten = false;
259
264
  if (!fs.existsSync(gitignorePath)) {
260
- fs.writeFileSync(gitignorePath, "state/\n", "utf-8");
265
+ fs.writeFileSync(gitignorePath, bundle_templates_1.BUNDLE_GITIGNORE_TEMPLATE, "utf-8");
261
266
  gitignoreWritten = true;
262
267
  }
263
268
  const result = { ok: true, alreadyInit, gitignoreWritten };
@@ -489,6 +494,155 @@ const doFirstCommitHandler = (args) => {
489
494
  const commitSha = rev.stdout.trim();
490
495
  return finish(true, { commitSha, fileCount: filesToStage.length, message });
491
496
  };
497
+ const CONFIRMATION_TOKEN_TTL_MS = 15 * 60 * 1000;
498
+ const _confirmationTokens = new Map();
499
+ function pruneExpiredTokens(now) {
500
+ for (const [token, entry] of _confirmationTokens) {
501
+ if (now - entry.createdAt > CONFIRMATION_TOKEN_TTL_MS) {
502
+ _confirmationTokens.delete(token);
503
+ }
504
+ }
505
+ }
506
+ /** Test hook: lets unit tests inspect + clear the token store. */
507
+ function __getConfirmationTokenStore() {
508
+ return _confirmationTokens;
509
+ }
510
+ function countFilesInDir(bundleRoot, relDir) {
511
+ // Use `git ls-files --others --exclude-standard -- <dir>` to honor .gitignore.
512
+ const result = gitExec(bundleRoot, ["ls-files", "--others", "--exclude-standard", "--", relDir]);
513
+ /* v8 ignore next -- git ls-files failure requires a corrupt repo @preserve */
514
+ if (result.code !== 0)
515
+ return 0;
516
+ return result.stdout.split("\n").filter((l) => l.trim().length > 0).length;
517
+ }
518
+ function parseGitHubUrl(url) {
519
+ // https://github.com/owner/repo(.git)
520
+ const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/);
521
+ if (httpsMatch)
522
+ return { owner: httpsMatch[1], repo: httpsMatch[2] };
523
+ // git@github.com:owner/repo(.git)
524
+ const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/);
525
+ if (sshMatch)
526
+ return { owner: sshMatch[1], repo: sshMatch[2] };
527
+ return null;
528
+ }
529
+ async function checkGitHubVisibility(parsed, fetchFn) {
530
+ const controller = new AbortController();
531
+ /* v8 ignore next -- 5-second timeout abort only fires if fetch takes longer than the test runner allows; the abort path is defensive @preserve */
532
+ const timer = setTimeout(() => controller.abort(), 5000);
533
+ try {
534
+ const res = await fetchFn(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, {
535
+ signal: controller.signal,
536
+ headers: { Accept: "application/vnd.github+json" },
537
+ });
538
+ if (!res.ok)
539
+ return "unknown";
540
+ const data = (await res.json());
541
+ if (data.private === true)
542
+ return "private";
543
+ if (data.private === false)
544
+ return "public";
545
+ /* v8 ignore next -- malformed GitHub API response (private field neither true nor false) is not practical to provoke @preserve */
546
+ return "unknown";
547
+ }
548
+ catch {
549
+ return "unknown";
550
+ }
551
+ finally {
552
+ clearTimeout(timer);
553
+ }
554
+ }
555
+ function makeFirstPushReviewHandler(deps = {}) {
556
+ return async () => {
557
+ const bundleRoot = (0, identity_1.getAgentRoot)();
558
+ const fetchFn = deps.fetch ?? globalThis.fetch;
559
+ const now = deps.now ?? Date.now;
560
+ (0, runtime_1.emitNervesEvent)({
561
+ component: "repertoire",
562
+ event: "repertoire.bundle_first_push_review_start",
563
+ message: "reviewing bundle for first push",
564
+ meta: { bundleRoot },
565
+ });
566
+ const finish = (ok, payload) => {
567
+ (0, runtime_1.emitNervesEvent)({
568
+ component: "repertoire",
569
+ event: "repertoire.bundle_first_push_review_end",
570
+ message: "bundle_first_push_review finished",
571
+ meta: { bundleRoot, ok, ...payload },
572
+ });
573
+ return json({ ok, ...payload });
574
+ };
575
+ if (!isGitRepo(bundleRoot)) {
576
+ return finish(false, { error: "bundle is not a git repo — run bundle_init_git first" });
577
+ }
578
+ const remotes = listRemotes(bundleRoot);
579
+ if (remotes.length === 0) {
580
+ return finish(false, { error: "no remote configured — run bundle_add_remote first" });
581
+ }
582
+ const remoteName = remotes[0];
583
+ const remoteUrl = getRemoteUrl(bundleRoot, remoteName);
584
+ /* v8 ignore next 3 -- listRemotes returned a name but get-url failed; only possible under a git race @preserve */
585
+ if (!remoteUrl) {
586
+ return finish(false, { error: "could not resolve remote url" });
587
+ }
588
+ // Enumerate PII directory counts
589
+ const piiCounts = {};
590
+ let totalPiiRecords = 0;
591
+ for (const dir of bundle_templates_1.PII_BUNDLE_DIRECTORIES) {
592
+ const dirPath = path.join(bundleRoot, dir);
593
+ if (!fs.existsSync(dirPath))
594
+ continue;
595
+ const count = countFilesInDir(bundleRoot, dir);
596
+ if (count > 0) {
597
+ piiCounts[dir] = count;
598
+ totalPiiRecords += count;
599
+ }
600
+ }
601
+ // GitHub visibility probe
602
+ const parsedGitHub = parseGitHubUrl(remoteUrl);
603
+ let warningLevel = "generic";
604
+ if (parsedGitHub) {
605
+ const visibility = await checkGitHubVisibility(parsedGitHub, fetchFn);
606
+ if (visibility === "public")
607
+ warningLevel = "public_github";
608
+ else if (visibility === "private")
609
+ warningLevel = "private_github";
610
+ }
611
+ // Build first-person warning text
612
+ const piiSummary = Object.entries(piiCounts)
613
+ .map(([dir, count]) => `${count} ${dir} record${count === 1 ? "" : "s"}`)
614
+ .join(", ");
615
+ const piiClause = piiSummary.length > 0
616
+ ? `my bundle contains personal data: ${piiSummary} (${totalPiiRecords} records total)`
617
+ : "my bundle has no PII directories populated yet";
618
+ let visibilityClause;
619
+ if (warningLevel === "public_github") {
620
+ visibilityClause = `⚠️ ${remoteUrl} is a PUBLIC GitHub repo — anything i push will be visible to anyone. are you SURE you want to push PII to a public repo?`;
621
+ }
622
+ else if (warningLevel === "private_github") {
623
+ visibilityClause = `${remoteUrl} is a PRIVATE GitHub repo. still, confirm you want to push this data there.`;
624
+ }
625
+ else {
626
+ visibilityClause = `${remoteUrl} — i can't verify this remote's visibility. confirm the repo is private before i push PII.`;
627
+ }
628
+ const warningText = `${piiClause}. ${visibilityClause}`;
629
+ // Issue and store the confirmation token
630
+ const token = crypto.randomUUID();
631
+ const currentTime = now();
632
+ pruneExpiredTokens(currentTime);
633
+ _confirmationTokens.set(token, { bundleRoot, createdAt: currentTime });
634
+ return finish(true, {
635
+ warningLevel,
636
+ remoteUrl,
637
+ piiCounts,
638
+ totalPiiRecords,
639
+ warningText,
640
+ confirmRequired: true,
641
+ confirmationToken: token,
642
+ });
643
+ };
644
+ }
645
+ const firstPushReviewHandler = makeFirstPushReviewHandler();
492
646
  // ─── tool: bundle_push ─────────────────────────────────────────────────
493
647
  /* 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
648
  function classifyPushError(stderr) {
@@ -502,43 +656,130 @@ function classifyPushError(stderr) {
502
656
  return "unknown";
503
657
  }
504
658
  /* 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) => {
659
+ /**
660
+ * Detect whether this is a first push to the remote.
661
+ *
662
+ * Returns true if the remote branch does not yet exist (bundle has local
663
+ * commits but has never been pushed). Also returns true — conservatively —
664
+ * when git fails to probe the remote (network unreachable, git error).
665
+ * The token requirement is a security gate; if we can't verify the remote
666
+ * state, we assume the worst and force the agent to get confirmation. An
667
+ * unreachable remote should NOT be a bypass vector.
668
+ */
669
+ /* v8 ignore start -- isFirstPushToRemote has two branches that only differ when the remote is reachable; in the unit test environment all remotes are unreachable so we always hit the "return true on network failure" branch. The security contract (never return false when unsure) is covered by the refusal tests in bundle-first-push-review.test.ts @preserve */
670
+ function isFirstPushToRemote(bundleRoot, remote) {
671
+ const branchResult = gitExec(bundleRoot, ["symbolic-ref", "--short", "HEAD"]);
672
+ if (branchResult.code !== 0)
673
+ return true;
674
+ const branch = branchResult.stdout.trim();
675
+ const lsRemote = gitExec(bundleRoot, ["ls-remote", "--heads", remote, branch], 10000);
676
+ if (lsRemote.code !== 0)
677
+ return true;
678
+ return lsRemote.stdout.trim().length === 0;
679
+ }
680
+ /* v8 ignore stop */
681
+ function makePushHandler(deps = {}) {
682
+ return (args) => {
683
+ const bundleRoot = (0, identity_1.getAgentRoot)();
684
+ const remote = typeof args.remote === "string" && args.remote.length > 0 ? args.remote : "origin";
685
+ const confirmationToken = typeof args.confirmation_token === "string"
686
+ ? args.confirmation_token
687
+ : undefined;
688
+ const now = deps.now ?? Date.now;
515
689
  (0, runtime_1.emitNervesEvent)({
516
690
  component: "repertoire",
517
- event: "repertoire.bundle_push_end",
518
- message: "bundle_push finished",
519
- meta: { bundleRoot, ok, ...payload },
691
+ event: "repertoire.bundle_push_start",
692
+ message: "pushing bundle to remote",
693
+ meta: { bundleRoot, remote },
520
694
  });
521
- return json({ ok, ...payload });
695
+ const finish = (ok, payload) => {
696
+ (0, runtime_1.emitNervesEvent)({
697
+ component: "repertoire",
698
+ event: "repertoire.bundle_push_end",
699
+ message: "bundle_push finished",
700
+ meta: { bundleRoot, ok, ...payload },
701
+ });
702
+ return json({ ok, ...payload });
703
+ };
704
+ if (!isGitRepo(bundleRoot)) {
705
+ return finish(false, { error: "bundle is not a git repo" });
706
+ }
707
+ const remotes = listRemotes(bundleRoot);
708
+ if (!remotes.includes(remote)) {
709
+ return finish(false, { error: `remote "${remote}" not configured — run bundle_add_remote first` });
710
+ }
711
+ if (!hasHead(bundleRoot)) {
712
+ return finish(false, { error: "bundle has no commits — run bundle_do_first_commit first" });
713
+ }
714
+ // Directive D: first push to a remote requires the agent to have
715
+ // previously called bundle_first_push_review and obtained a
716
+ // confirmation token. This forces a PII-review gate between
717
+ // "bundle built locally" and "bundle contents on the internet".
718
+ const isFirstPush = isFirstPushToRemote(bundleRoot, remote);
719
+ /* v8 ignore next -- the !isFirstPush branch (subsequent push path) requires a reachable remote to test end-to-end; the path itself falls through to the git push below which is covered by the network-failure test @preserve */
720
+ if (isFirstPush) {
721
+ if (!confirmationToken) {
722
+ (0, runtime_1.emitNervesEvent)({
723
+ level: "warn",
724
+ component: "repertoire",
725
+ event: "repertoire.bundle_push_refused",
726
+ message: "bundle_push refused: first push requires confirmation token",
727
+ meta: { bundleRoot },
728
+ });
729
+ return finish(false, {
730
+ error: "refused: first push requires a confirmation token — call bundle_first_push_review, show the warning to the user, and pass the returned confirmationToken to bundle_push",
731
+ kind: "confirmation_required",
732
+ });
733
+ }
734
+ const currentTime = now();
735
+ pruneExpiredTokens(currentTime);
736
+ const entry = _confirmationTokens.get(confirmationToken);
737
+ if (!entry) {
738
+ (0, runtime_1.emitNervesEvent)({
739
+ level: "warn",
740
+ component: "repertoire",
741
+ event: "repertoire.bundle_push_refused",
742
+ message: "bundle_push refused: confirmation token invalid or expired",
743
+ meta: { bundleRoot },
744
+ });
745
+ return finish(false, {
746
+ error: "refused: confirmation token invalid or expired — call bundle_first_push_review again",
747
+ kind: "confirmation_required",
748
+ });
749
+ }
750
+ if (entry.bundleRoot !== bundleRoot) {
751
+ (0, runtime_1.emitNervesEvent)({
752
+ level: "warn",
753
+ component: "repertoire",
754
+ event: "repertoire.bundle_push_refused",
755
+ message: "bundle_push refused: confirmation token bound to a different bundle",
756
+ meta: { bundleRoot, tokenBundleRoot: entry.bundleRoot },
757
+ });
758
+ return finish(false, {
759
+ error: "refused: confirmation token was issued for a different bundle",
760
+ kind: "confirmation_required",
761
+ });
762
+ }
763
+ (0, runtime_1.emitNervesEvent)({
764
+ component: "repertoire",
765
+ event: "repertoire.bundle_push_first_push_confirmed",
766
+ message: "first push confirmed by valid token",
767
+ meta: { bundleRoot },
768
+ });
769
+ // One-shot: consume the token on successful validation.
770
+ _confirmationTokens.delete(confirmationToken);
771
+ }
772
+ const push = gitExec(bundleRoot, ["push", remote, "HEAD"], 30000);
773
+ /* 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 */
774
+ if (push.code === 0) {
775
+ return finish(true, { remote, firstPush: isFirstPush });
776
+ }
777
+ /* v8 ignore stop */
778
+ const kind = classifyPushError(push.stderr);
779
+ return finish(false, { error: push.stderr.trim(), kind });
522
780
  };
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
- };
781
+ }
782
+ const pushHandler = makePushHandler();
542
783
  // ─── tool: bundle_pull_rebase ──────────────────────────────────────────
543
784
  const pullRebaseHandler = (args) => {
544
785
  const bundleRoot = (0, identity_1.getAgentRoot)();
@@ -689,16 +930,28 @@ exports.bundleToolDefinitions = [
689
930
  },
690
931
  handler: doFirstCommitHandler,
691
932
  },
933
+ {
934
+ tool: {
935
+ type: "function",
936
+ function: {
937
+ name: "bundle_first_push_review",
938
+ description: "Review my bundle for PII exposure before the first push to a new remote. Enumerates PII-bearing directories (friends, diary, journal, etc.) with per-directory counts, probes the remote URL for GitHub public/private visibility, and returns a first-person warning text I must show the human plus a confirmationToken I must pass to bundle_push on first push. Required before the first push to any new remote.",
939
+ parameters: { type: "object", properties: {} },
940
+ },
941
+ },
942
+ handler: firstPushReviewHandler,
943
+ },
692
944
  {
693
945
  tool: {
694
946
  type: "function",
695
947
  function: {
696
948
  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.",
949
+ description: "Push my bundle's HEAD to the configured remote. On first push to a new remote, requires a confirmation_token from bundle_first_push_review (Directive D: human must acknowledge PII exposure before the bundle goes over the wire). Subsequent pushes ignore the token. Returns a structured error with kind: 'rejected' | 'network' | 'auth' | 'unknown' | 'confirmation_required' on failure. Does NOT auto-rebase on rejection — use bundle_pull_rebase explicitly when needed.",
698
950
  parameters: {
699
951
  type: "object",
700
952
  properties: {
701
953
  remote: { type: "string", description: "Remote name. Defaults to 'origin'." },
954
+ confirmation_token: { type: "string", description: "Opaque token from bundle_first_push_review. Required on the first push to a new remote; ignored on subsequent pushes." },
702
955
  },
703
956
  },
704
957
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.287",
3
+ "version": "0.1.0-alpha.288",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",